feat(sts)!: fail closed on empty trust fields, check token type, require exp, log successes - #146
Conversation
…require exp, log successes An empty required_audiences accepted any audience and an empty subject_conditions skipped the subject check, so a role that omitted either — and both default to empty — trusted everything its issuer signed. Both now accept nothing; "any subject" is written "*", and static-config validation rejects a role with either list empty so the omission is reported at load rather than discovered at exchange. A token whose header carries typ must say JWT: access tokens (at+jwt) and other typed tokens are not identity tokens, whoever signed them. A token must carry exp, unless its issuer is listed in the new RoleConfig.allow_missing_exp_from — for a host's own long-lived tokens whose validity it tracks itself. A third-party token with no expiry is an indefinitely replayable credential its issuer never meant to issue. Successful exchanges are logged at info with issuer, subject, role and duration; before only failures were. Claim validation is factored out of verify_token into validate_claims, taking the clock, so each rule is tested without a key pair. BREAKING CHANGE: a role must set required_audiences and subject_conditions (use ["*"] to accept any subject); RoleConfig gains allow_missing_exp_from, which struct-literal constructors must add. Refs #143 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
…n lacks resolve_scopes substituted an empty string for a missing or non-string claim. For a bucket that fails safe; for a prefix an empty string matches every key, so a missing claim silently granted the whole bucket — the opposite of what the doc comment promised. An unresolvable template is now an error at mint time, and mint_temporary_credentials returns Result. Closes #143 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
|
Claude finished @alukach's task in 1m 39s —— View job Review complete
✅ No blocking issues — safe to merge. Traced the full flow (
Minor, non-blocking observations:
Simplify (ponytail) Nothing to flag — no new dependencies, no speculative abstractions; 💰 Estimated review cost: $0.62 · 1m39s · 30 turns |
|
📖 Docs preview deployed to https://multistore-docs-pr-146.development-seed.workers.dev
|
Config validation now rejects roles without required_audiences, so the worker failed to start on the preview deploy. GitHub roles use sts.amazonaws.com (what the smoke/integration tests request); the source.coop role uses https://data.staging.source.coop. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
🚀 Latest commit deployed to https://multistore-proxy-pr-146.development-seed.workers.dev
|
Since #146 a role with no required_audiences fails config validation at startup, because it could never accept a token. The role this PR adds for the configure-aws-credentials test had none, so wrangler dev refused the whole PROXY_CONFIG and every integration test failed. It now requires sts.amazonaws.com, the action's default audience and the one the other GitHub Actions roles use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
…tion test (#126) * test(integration): assert configure-aws-credentials grants write access PR #112 made /.sts a drop-in AssumeRoleWithWebIdentity target for AWS SDK STS clients, which send parameters in a form-encoded POST body rather than the query string. aws-actions/configure-aws-credentials relies on that path. The integration suite only exercised the query-string GET, so a regression in form-body parsing would go unnoticed. - Extract XML->creds parsing into `_parse_sts_credentials`, shared by both the existing query GET helper and the new form-POST helper. - Add `assume_role_form_post`, reproducing the exact wire request the action emits (application/x-www-form-urlencoded body via requests `data=`). - Add `test_configure_aws_credentials_action_grants_write_access`: assume the github-actions role that way and prove a PUT/GET round-trip on private-uploads succeeds. Runs in the OIDC-gated class (id-token: write CI). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sts): implement GetCallerIdentity for drop-in STS compatibility `aws-actions/configure-aws-credentials` (and other AWS tooling) validates freshly assumed credentials by issuing an unconditional, SigV4-signed `GetCallerIdentity` call before exporting them. multistore-sts only parsed `AssumeRoleWithWebIdentity`, so the action could never succeed against the proxy — it would retry GetCallerIdentity 12 times and then fail the step. Closes #127. - **`caller_identity`** (new module) — `handle_get_caller_identity` authenticates the call against the sealed session token: it recovers the minted credentials from `x-amz-security-token`, checks the auth-header access key matches, and verifies the SigV4 signature with the proxy's own `verify_sigv4_signature` over the recovered secret. AWS SDKs sign STS POSTs over the SHA-256 of the form body, usually without an `x-amz-content-sha256` header, so the payload hash is taken from that header when present and recomputed from the collected body otherwise. Verification runs over the raw signing path so the trailing slash AWS SDK JS v3 appends (`/.sts` -> `/.sts/`) is honored, not normalized. - **`request::is_get_caller_identity`** — detects the action in a query string or form body (the two places AWS SDKs put STS parameters). - **`responses`** — `build_caller_identity_response` emits STS-shaped `GetCallerIdentityResponse` XML. `Account` is the fabricated `SYNTHETIC_ACCOUNT_ID` (`000000000000`) — the proxy has no real AWS account — used consistently in the assumed-role ARN so the identity is coherent. `build_sts_error_response` now maps `SignatureDoesNotMatch`/`ExpiredCredentials` to STS-shaped 403s instead of a generic 500. - **`route_handler`** — `StsHandler` dispatches GetCallerIdentity (authenticated, needs the full request) ahead of the unauthenticated assume-role exchange. `with_sts` now registers both `/.sts` and `/.sts/` (adding a `Clone` bound on the config) so SDK-JS callers, which hit the trailing-slash path, reach the handler. - **docs** — `auth/proxy-auth.md` documents GetCallerIdentity, the synthetic account, the trailing-slash rule, and a `configure-aws-credentials` workflow example; `reference/operations.md` and `architecture/crate-layout.md` updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(integration): exercise the real configure-aws-credentials action The Python suite only reproduced the action's wire requests. Now that the proxy serves GetCallerIdentity (#127), run the actual action end to end and prove the credentials it exports can write. - **ci.yml** — after pytest, the `integration` job runs `aws-actions/configure-aws-credentials@v6` (SHA-pinned) with `sts-endpoint` pointed at the proxy, then `aws s3 cp` uploads/downloads/deletes an object in `private-uploads` using the exported credentials. The action's own mandatory GetCallerIdentity validation must pass for the step to succeed, so this covers the full assume-role -> validate -> use flow. - **wrangler.integration.toml** — adds a role keyed by the full ARN `arn:aws:iam::000000000000:role/github-actions` (write scope on private-uploads). The action sends `role-to-assume` verbatim as the STS RoleArn and `get_role` matches `role_id` exactly, so the config key must be the full ARN; account `000000000000` matches the synthetic GetCallerIdentity account. - **test_integration.py** — adds `test_get_caller_identity` (botocore signs a real GetCallerIdentity over the assumed creds; asserts the synthetic account and ARN) and `test_get_caller_identity_rejects_static_credentials` (no session token -> 403). Rescopes the earlier form-POST test to `test_sdk_form_post_assume_role_grants_write_access`, since the headline "action works" claim is now proven by the real action in CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(integration): give the configure-aws-credentials role an audience Since #146 a role with no required_audiences fails config validation at startup, because it could never accept a token. The role this PR adds for the configure-aws-credentials test had none, so wrangler dev refused the whole PROXY_CONFIG and every integration test failed. It now requires sts.amazonaws.com, the action's default audience and the one the other GitHub Actions roles use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd * refactor(sts): compare GetCallerIdentity access key in constant time Align with the access-key check in auth/identity.rs by exporting constant_time_eq from multistore::auth. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…he platform (#234) One commit on `main`, now that #232 (ADR-014) is merged. It revises ADR-013 in place, since ADR-013 was never implemented, and it replaces an earlier draft that added an ADR-015 and then folded it back. Part of source-cooperative/source.coop#491. Records the decision that replaces #233, developmentseed/multistore#147 and the proxy half of source-cooperative/source.coop#570. ## What it records **ADR-013, revised: API keys are opaque secrets resolved by the platform.** A key is `sck_` + 32 random bytes (a fixed 47 characters, `^sck_[A-Za-z0-9_-]{43}$`), stored as a sha256 hash on the key record in source.coop and shown once. Nothing signs it. At `/.sts` the proxy accepts it as `WebIdentityToken` from a POST body only, trims and format-checks it locally, hashes it, and asks `POST /api/v1/service-account-keys/exchanges` for `{account_id, key_id, active}`, cached 60s positive and negative, failing closed; then mints session credentials through the same code as every other exchange. The cache-miss path is rate-limited by client IP. A stock AWS SDK does the exchange and the refresh itself from `AWS_WEB_IDENTITY_TOKEN_FILE`; the emergency stop for a leaked key is disabling the service account. **Why the first Decision was withdrawn** is recorded under Context, each point checkable against #233 and source.coop#570: the revocation lookup was never optional, so the signature verified what the lookup restates; a Worker cannot fetch its own JWKS, so the "no new path" benefit was gone; minting was a source.coop→Ory→proxy→source.coop cycle whose `/.keys` would sign any `jti` for a manager; `exp` in the token overrode the editable record; and non-expiring keys died on the second rotation of a signing key shared with outbound federation. The ADR's original Context and its rejected alternatives stand. **Amendments**, in ADR-014's house form: ADR-005 gains the one proxy-as-itself route, with the sentinel subject `urn:source:data-proxy` that fails both account-id grammars; ADR-014's first two bullets amending ADR-013 lose their `sub`/`jti` wording. The RFC index gains ADR-014's row, which #232 omitted. It also fixes a sentence in ADR-014's "How it authenticates" section, which the automated review caught: it said a key's subject is the service account's id, and now says a key names its account on the key record. **Alternatives** rejected with reasons: the proxy-signed JWT (the first Decision), source.coop-signed JWTs verified via a source.coop JWKS, the two-hop exchange (opaque key → short-lived Ory ID token → `/.sts`), the proxy's `get_credential` slot, Ory-native tokens, and long-lived Ory refresh tokens. ## The two-hop spike the ADR cites Run on 2026-09-25 against the staging Ory project, driving the headless authorization-code flow source.coop already uses for a person's proxy credentials (`getOryIdToken` on `main`) with a service-account-shaped subject and, as a control, a random UUID that matches no identity. Both returned an RS256 ID token from `https://auth.staging.source.coop` whose `sub` was exactly the subject given, with the default 3600s lifetime. The run used a throwaway confidential OAuth2 client created and deleted for the purpose. Conclusion: Ory Network will mint for a subject with no identity record, so the two-hop design needs no proxy change and remains available; the ADR rejects it for the first release on the client-side cost to HPC and VM users, not on feasibility. ## Review Five targeted reviews of the plan and the decision text (security, proxy implementer, source.coop implementer, ops and client experience, ADR and issue hygiene), each returning "approve with changes"; all changes are folded in. The ones that moved a decision: keys refused in the query string (invocation logs are on; GDAL sends STS as a GET and is routed through the CLI); the sentinel is a URN, not a slug; unknown keys answer `active: false` rather than 404 so the existing cache code applies; rate limiting is per IP on misses and ships with the branch; the revocation numbers distinguish writes (60s) from restricted reads (300s) per the proxy's caches; ADR-005 is amended, not merely depended on. ## What follows #235, the proxy PR replacing #233 (its exchange branch survives; minting, self-verification and the multistore pin do not), source.coop#570 reworked in place, multistore#147 closed, #231 rewritten, and the epic's Phase 5 items edited. ## PR Checklist - [x] This PR has **no** breaking changes. (Documentation only.) - [x] I have updated or added new tests to cover the changes in this PR. (None apply; no code.) - [x] This PR affects the [Source Cooperative Frontend & API](https://github.com/source-cooperative/source.coop), and I have opened issue/PR source-cooperative/source.coop#570 to track the change. (Existing PR, to be reworked to this ADR.) ## Related Issues #230, #231, #232, #233, #235; developmentseed/multistore#146, developmentseed/multistore#147; source-cooperative/source.coop#491, source-cooperative/source.coop#548, source-cooperative/source.coop#561, source-cooperative/source.coop#570, source-cooperative/source.coop#580. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
) > [!IMPORTANT] > - **On `main`**, now that #235 and #236 are merged. This PR's own CI runs the integration tests that need CI's GitHub token. > - **Blocked externally for `aws-actions/configure-aws-credentials`.** That action, which source.coop's settings page hands out, checks the credentials it exports with `GetCallerIdentity`. The proxy cannot answer that call until developmentseed/multistore#126 lands, so the action fails after a successful exchange. An AWS SDK's own web-identity provider works now, as does any direct `AssumeRoleWithWebIdentity` call. > - **No multistore bump.** multistore 0.7.2 is enough: the one fail-closed check this needs, a required `exp`, is made here. See "Decisions to flag". ## What I'm changing A token from a **platform issuer**, GitHub Actions to begin with, can now be exchanged at `/.sts`. It acts as the service account its `RoleArn` names, `arn:aws:iam::<owner>--<name>:role/FullAccess`, and only if that account trusts the token's issuer and subject (ADR-014). The path runs ahead of the STS route, next to the API-key exchange. Both share one parse of the request, and the token is trimmed, so a token file ending in a newline works. 1. **Route by issuer.** The token's `iss` is read unverified. If it is in `PLATFORM_ISSUERS`, this path takes the token; otherwise the STS route does, unchanged. 2. **Body only.** As with API keys, the token must come in the form body. One in the URL is refused with `InvalidParameterValue`, because Cloudflare logs URLs and a logged token could be replayed until it expires. 3. **Check the request locally.** The Role must be one the proxy serves (#236), and `RoleArn` must name an account. That account must be a **service account**, `{owner}--{name}` as source.coop's `SERVICE_ACCOUNT_ID_REGEX` defines it (82 characters at most). Any other account is refused as an untrusting one is, before the token is verified or anything is signed as it. 4. **Verify the token** against the issuer's JWKS: signature, issuer, **that issuer's own audiences**, `nbf`, and a **required** `exp`. A key id missing from the cached keys is looked up once more, so a key GitHub has just rotated in works at once. A failed or timed-out key fetch is a retryable `InternalError`, not `InvalidIdentityToken`. 5. **Ask the account.** `POST {SOURCE_API_URL}/api/v1/accounts/{account}/trusts/exchanges` with the verified `{issuer, subject}`, authenticated as the account, which is the contract on source.coop `main` (source-cooperative/source.coop#566). Per account, issuer and subject, a yes is cached for 60 seconds and a no for 10. Only a lookup the cache can't answer is charged to `STS_EXCHANGE_LIMIT`, 100 a minute per client address, shared with API-key exchanges. 6. **Mint** under the named Role, with the **account as the principal**, never the token's subject. Every refusal of the trust reads `AccessDenied: Not authorized to perform sts:AssumeRoleWithWebIdentity (request id …)`, and the proxy logs the account, issuer and subject at WARN. Nothing unverified reaches the Source API: a token that fails steps 2 to 4 costs no lookup. Every refusal at `/.sts` from the API-key and platform paths now carries the request id, and its message is XML-escaped (#246): it can echo `RoleArn` or a token's `kid`. The person route's errors still come from multistore's unescaped builder (developmentseed/multistore#160). **Configuration (#223).** `PLATFORM_ISSUERS` is a JSON object from issuer to the audiences its tokens must carry, written as a string or as a TOML table. The audiences are per issuer, so one issuer's audience never admits another's token (ADR-009). An issuer with no audience is refused, and a value that doesn't parse trusts none. An entry for `AUTH_ISSUER` is dropped at load with an error, since it would take every person token away from the STS route. `AUTH_ISSUER` and `AUTH_AUDIENCE` still configure the person issuer, whose tokens act as their own subject and ignore `RoleArn`'s account. An empty `AUTH_AUDIENCE` now disables only the person route with 501; API keys and platform tokens still exchange. | Config | Person issuer | Platform issuers | | --- | --- | --- | | production (`wrangler.toml`) | Ory, as before | GitHub, audience `https://data.source.coop` | | staging (`[env.staging]`) | staging Ory, as before | GitHub, audience `https://data.staging.source.coop` | | previews (`wrangler.preview.toml`) | staging Ory, as before | GitHub, audience `https://data.staging.source.coop` | | CI, main worker (`ci.yml`'s `.dev.vars`) | `https://auth.example.invalid`, which mints nothing, audience `not-the-data-proxy` | GitHub, audience `source-data-proxy-ci` | | CI, second worker (port 8788) | GitHub, audience `source-data-proxy-ci` | none: the GitHub entry is dropped as `AUTH_ISSUER` | GitHub's audience is the proxy's origin because source.coop's workflow snippet mints the token with `audience: <proxy origin>`. A preview's hostname changes per PR, so previews accept staging's origin, as they already accept staging's Ory clients. **Why one PR for #222 and #223.** The trust check (#222) is reachable only once a second issuer is configured (#223). The configuration is safe only with the trust check: without it, a GitHub token would act as its own subject under an unlimited Role, the risk ADR-009's important note describes. Neither is useful or safe alone. **#222's issue body is superseded by its later comment**, the source-cooperative/source.coop#566 contract. The body asked for an issuer-qualified principal. Under the comment's contract, a platform token's subject never becomes a principal at all: the principal is the account that trusts it. The trust answer is cached per issuer, so two issuers' identical subjects cannot share an entry, which is what #222's "done when" asks. **Decisions to flag** - **Only a service account can be named.** The minted principal is the account segment as given, and source.coop resolves a principal as an Ory identity first. An Ory UUID fits the person and organisation handle grammar, so if a trust ever existed on such an account, platform credentials would act as a person. source.coop writes trusts only to service accounts today, so this is defense in depth, and it matches ADR-014. The `000000000000` placeholder from `_default`'s ARN is refused here too. - **A yes is cached for 60 seconds, a no for 10, under one key.** `cached_fetch` caches 200s only, and the route says no with a 403, or a 401 for an account it can't resolve. That refusal is stored as `{"trusted": false}` under the same key, through a `cache_put` extracted from `cached_fetch`. So replaying one token costs about one lookup per 10 seconds per account, issuer and subject in each data center, and a trust just added works within 10 seconds. - **The body is read as well as the status.** A 200 whose body says `trusted: false` is refused and mints nothing. It is cached like any 200, for 60 seconds; the route never sends one. - **The rate limit is charged only on a cache miss.** Anyone can mint a GitHub token for this audience, so a lookup is bounded like a flood of junk keys. #235's `KEY_EXCHANGE_LIMIT` becomes `STS_EXCHANGE_LIMIT`, one budget shared by both kinds of exchange, with the same namespace ids. A cached answer costs the Source API nothing and isn't charged, so a job matrix behind one NAT address sharing a subject stays under it. Naming a different account each time still costs a lookup and is charged. - **Key fetches are bounded and retried, not refused.** The fetch races `STS_REQUEST_TIMEOUT` (10s). An unknown key id is looked up again through a second key cache held a minute, so a forged key id costs GitHub at most one fetch a minute per isolate. After a failed fetch, multistore backs off for 30 seconds and keeps no older keys, so a GitHub outage returns 500s for that long. - **A 404 from the trusts route is a 500, not a refusal.** The route answers for any account (401 when it doesn't exist), so a 404 means the API doesn't serve the route at all. That is a deployment mismatch, the same for every account, so it reveals nothing about any one account. - **401 and 403 read the same to the caller.** source.coop answers 401 for an account that doesn't exist, and also when it can't authenticate the proxy. Both are refused like "not trusted" and cached for 10 seconds. The WARN line has the account, issuer and subject, and source.coop's log says which case it was. - **The proxy vouches for the account before it is established.** To ask the question, it signs its usual on-behalf-of assertion as the account the caller names, and sends it only to that account's trusts route. This is ADR-014's design. ADR-005 describes such assertions only for callers the proxy has already authenticated (see Docs and ADRs). - **`exp` is required here, and multistore is not bumped.** multistore 0.7.2's `verify_token` checks `exp` only when present, the exposure ADR-004's warning says must be closed before other issuers are admitted. Upstream closes it in developmentseed/multistore#146, released as 1.0.0 by developmentseed/multistore#153, not yet merged. This path needs only that one check (`platform::subject`). Bumping would also bring multistore `main`'s `BucketConfig::backend_type` enum (developmentseed/multistore#155) into the registry for nothing this PR needs. The person path keeps 0.7.2's behaviour, which is harmless while Ory always sets `exp`. Drop the check when the bump happens. - **CI covers both routes with GitHub's token.** On the main worker the token takes the platform path, as in production. A second worker names GitHub as `AUTH_ISSUER`, so the same token is a person token there. The main worker's `AUTH_AUDIENCE` is the audience of the wrong-audience token CI already mints: a platform path that checked the person audiences instead of GitHub's own would accept that token and refuse the real one. - **Successful exchanges are logged at INFO**, which production drops. A cached yes never reaches source.coop, so nothing durable records which workflow minted a set of credentials. Not addressed here. ## How I did it - `src/platform.rs` (new, wasm-free): - `parse_issuers`, from a JSON string or the table's object; - `unverified`, the header and claims, for routing and the key id; - `verify`, multistore-sts's `find_key` and `verify_token` against keys it is given; - `subject`, which requires `exp` and a non-empty `sub`. - `src/sts.rs`: `account(role_arn)`, the ARN's account segment, and `is_service_account_id`, source.coop's grammar without a regex crate. - `src/source_api/cache.rs`: - `get_or_fetch_trust`, through `cached_fetch` as the account, with the 10-second refusal entry; - `cached_trust`, the cache read alone, so the rate limit is charged only on a miss; - `cache_put`, extracted from `cached_fetch`. - `src/lib.rs`: - **Dispatch:** `/.sts` parses its parameters once, trims the token, and passes them to `api_key_exchange` and `platform_exchange`. The 501 for an empty `AUTH_AUDIENCE` comes after both. - **Platform path:** `platform_exchange` is the whole platform path. `platform_keys` and `fetch_keys` add the timeout and the second key cache, through `futures-util` (already in the lockfile through worker) and `worker::Delay`. - **Errors:** `sts_refusal` and `sts_error_xml` build every exchange error, escaped and with the request id. - **Rate limit:** `STS_EXCHANGE_LIMIT` replaces `KEY_EXCHANGE_LIMIT`. - `src/config.rs`: `PLATFORM_ISSUERS`, read with `var` and, for a table, `object_var`, without `AUTH_ISSUER`. `Cargo.toml`: `base64` (already in the lockfile through multistore-sts) and `futures-util`. - **Wrangler config:** - `wrangler.toml` (production and staging) and `wrangler.preview.toml` get `PLATFORM_ISSUERS` and the renamed rate-limit binding. - The binding keeps the `[[ratelimits]]` form #245 moved it to for wrangler 4, with the same namespace ids. - **README:** the variable, the binding, and a "Platform identity providers" section with the `GetCallerIdentity` caveat. - `.github/workflows/ci.yml`: - `.dev.vars` as in the table above. - The mint step exports the token's `sub` as `CI_TRUSTED_SUBJECT`. - A second `wrangler dev` runs on 8788, with its own `--host`, because `wrangler.toml`'s dev host is `localhost:8787` and SigV4 is verified against it. - `.github/workflows/staging.yml`: the dormant federation smoke test mints its token only when both `FEDERATION_TEST_AUDIENCE` and `FEDERATION_TEST_TRUST_ACCOUNT` are set. The latter is the staging service account the token acts as, which `tests/test_writes.py` reads as `CI_TRUST_ACCOUNT`. - `tests/stub_api.py`: the trusts route. - It says yes only for exactly `CI_TRUSTED_SUBJECT` on `ci-tests--github-ci`, as source.coop matches a trust exactly. - `ci-tests--says-no-with-200` answers 200 `{"trusted": false}`. - It refuses an assertion made as anyone but the account, though it reads the assertion without verifying its signature. - It also keeps a per-account lookup counter and records who each product was looked up as. ## How to test it - `cargo test`: all suites pass. - `tests/platform.rs`: - audiences kept per issuer; - the table form read like the string; - an issuer without an audience dropped; - unparseable config trusting none; - a JWT read unverified, and non-JWTs (an API key among them) not read; - a verified token's subject, with a missing `exp`, a missing `sub` and an empty `sub` each refused. - `tests/sts.rs`, the account segment: none for a bare name, an empty account or a truncated ARN. - `tests/sts.rs`, the service-account grammar: ids accepted up to 82 characters, and refused for handles, an Ory UUID, the placeholder, uppercase, underscores, one-character halves, a triple hyphen, a second separator, edge hyphens and 83 characters. - `cargo fmt --check`, `cargo clippy --target wasm32-unknown-unknown -- -D warnings` and `cargo check --target wasm32-unknown-unknown`, through the pre-commit hook. - `pytest tests/ --ignore=tests/test_contract.py --ignore=tests/test_federation.py` against `wrangler@4 dev` and the stub, with CI's `.dev.vars`: **42 passed, 20 skipped**. These ran without a real token: - A GitHub-issuer token whose `RoleArn` names no account is refused with `InvalidParameterValue`. - A person handle, an Ory UUID or the placeholder named as the account is refused with the untrusted `AccessDenied`. The token is forged, so this proves no trusts lookup happened. - A forged GitHub token naming a service account is refused as `InvalidIdentityToken`, with the request id and no trusts lookup: the worker fetched GitHub's real JWKS, twice, and found no such key. - A platform token in the URL is refused before any trusts lookup. - A `RoleArn` containing markup and `&` comes back escaped, in a body that parses. - With `PLATFORM_ISSUERS` written as a TOML table in `wrangler.toml`, the platform tests pass as with the string. - With `AUTH_AUDIENCE` blank, a person token gets 501 and a platform token is still verified. - **With a real GitHub token**, in this PR's CI at `dc8b141`: **61 passed, 4 skipped**. Beyond `test_writes.py`'s credentialed tier, which goes through the platform path, these need the token: - A trusted workflow's credentials act as the account: the stub records the account, not the GitHub subject, as the product lookup's subject. - A token file's trailing newline is accepted. - An account that doesn't trust the workflow refuses it with the request id, and a replay within 10 seconds costs no second lookup. A 200 saying no is refused too. - A yes is cached. - On the second worker the token is a person token. It exchanges at `_default`, and the credentials sign a list. A wrong audience and a tampered signature are refused. - **End to end, after deploy**, against staging or this PR's preview, both of which accept staging's audience: give a staging service account a trust for a workflow's subject in source.coop, then run in that workflow: ```yaml permissions: { id-token: write } steps: - run: | curl -sSf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://data.staging.source.coop" | jq -r .value > "$RUNNER_TEMP/token" - env: AWS_WEB_IDENTITY_TOKEN_FILE: ${{ runner.temp }}/token AWS_ROLE_ARN: arn:aws:iam::<service-account-id>:role/FullAccess AWS_ENDPOINT_URL_STS: https://<proxy>/.sts AWS_ENDPOINT_URL_S3: https://<proxy> AWS_REGION: us-west-2 run: aws s3 ls s3://<owner>/<product>/ ``` Then remove the trust, and within a minute the next exchange reads `AccessDenied … (request id …)`. ## Docs and ADRs - **ADR-009** (platform IdPs): its decision holds (platform issuers, per-issuer audiences, fail closed per issuer), and this implements it. Its migration step 3 and its important note are superseded by ADR-014: platform issuers are not added to `_default`, and a platform token acts only as an account that trusts it. A note there now says so, and its status says implemented in part. ADR-014 (#232) does not list ADR-009 under **Amends**; #232 may want to. - **ADR-004** trusted "exactly one OIDC issuer" and warned that `exp` must be enforced before other issuers are admitted. A note under its trust model now points to platform issuers and ADR-014. Its `exp` warning now says platform tokens must carry `exp` and that the proxy checks it. It also says `alg` is checked before the key fetch, which is not so on the platform path: a forged header still costs one cached key lookup before `verify_token` rejects its algorithm. - **ADR-001** said `source_identity` is "the original OIDC `sub` — the caller's Ory identity". It now says it is the account an API key or a trusted platform token names, which also covers #235's keys. - **ADR-005** says the proxy signs lookups as a caller it has authenticated. The trusts lookup is signed as the account a caller names, before that account is established, and only for that one route. ADR-014 (#232) records this, and `ApiCaller::Account`'s doc now says so. ADR-005's text does not, so it is for #232 or #234 to amend. - **ADR-014** (#232), "the token path is then…", is what this implements, and it still holds. - **docs.source.coop**: no page on `main` covers GitHub Actions against the proxy. The unattended-workflow guide, source-cooperative/docs.source.coop#34, should give the token-file workflow above and the `configure-aws-credentials` caveat until developmentseed/multistore#126. - **source.coop**: `src/lib/services/github-workflow.ts` on `main` hands out `configure-aws-credentials` with `audience: <proxy origin>` and `role/FullAccess`. The audience matches `PLATFORM_ISSUERS` in production and staging, and the Role resolves since #236. The action itself waits on developmentseed/multistore#126. ## PR Checklist - [x] This PR has **no** breaking changes. Person-issuer tokens and API keys behave as before; GitHub tokens, refused until now as an untrusted issuer, take the new path. An empty `AUTH_AUDIENCE` no longer disables API-key exchange. - [x] I have updated or added new tests to cover the changes in this PR. - [x] This PR affects the [Source Cooperative Frontend & API](https://github.com/source-cooperative/source.coop): it calls the trusts route from source-cooperative/source.coop#566, already on `main`. ## Related Issues Closes #222. Part of #223: this is the proxy side, and #223's "done when", a workflow in an unrelated repository writing with only its ambient token, also needs a deployment and, for the action source.coop hands out, developmentseed/multistore#126. Builds on #235 and #236, both merged. Includes #246 and #247. ADRs: #232 (ADR-014). Upstream: developmentseed/multistore#126, developmentseed/multistore#146, developmentseed/multistore#153, developmentseed/multistore#160. Epic: source-cooperative/source.coop#491. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
## What I'm changing Bumps multistore from 0.7.2 to 0.8.0 (developmentseed/multistore#153), which answers `GetCallerIdentity`. `aws-actions/configure-aws-credentials`, the step source.coop's settings page hands out, makes that call after the exchange to check the credentials it exports, so until now the step failed after a successful exchange. The call needs no new routing here: it is answered by the STS handler this Worker already mounts with `with_sts("/.sts", …)`, which in 0.8.0 serves `/.sts/` too and verifies SigV4 over the path the client signed, so the existing `/.sts/` → `/.sts` rewrite doesn't break the signature. What 0.8.0 asked of this repo: - **`RoleConfig.subject_conditions` is `["*"]`.** In 0.8.0 an empty list accepts no subject (developmentseed/multistore#146), which would refuse every person. Any subject is right here: a person's token names the person, and a platform token acts only as an account that trusts its subject (ADR-014). - **`RoleConfig.allow_missing_exp_from` is empty**, so every issuer must set `exp`. Ory always does; API keys never reach `verify_token`. - `mint_temporary_credentials` returns a `Result`, and `BucketConfig::backend_type` is an enum (developmentseed/multistore#155), parsed from the backend string `backend_options` already produces (`s3`/`az`/`gcs`, which its `FromStr` accepts). The README's paragraph saying the action fails now says it works. ## Decisions to flag - **#237's own `exp` check in `platform::subject` stays.** #237 said to drop it with this bump, since multistore now requires `exp` for every issuer this Role trusts. It is redundant but harmless, and removing a security check plus its test is better done as its own reviewed change than inside a dependency bump. - **`GetCallerIdentity`'s `Account` is multistore's fixed synthetic id**, not the service account that #223's 2026-09-23 comment asked for. `Arn` and `UserId` do carry the Role and the account (the credentials' source identity). The action only needs the call to succeed, so this doesn't block the workflow; making `Account` the service account is an upstream change in multistore if we want the action's `aws-account-id` output to mean something. ## Testing - `cargo fmt --check`, `cargo clippy --target wasm32-unknown-unknown -- -D warnings`, `cargo check --target wasm32-unknown-unknown` and `cargo test` pass locally (the pre-push hook). - Not run locally: the Python integration tests and a real `configure-aws-credentials` run. CI's integration job runs the former on this PR's preview; the latter is the functional test for #223 once this deploys. ## Docs and ADRs ADR-014 says the proxy answers `GetCallerIdentity` "once developmentseed/multistore#126 lands"; this PR is that landing and implements the decision, so the ADR still holds. ADR-004 already lists `configure-aws-credentials` as a supported client. docs.source.coop: the GitHub Actions section of the automated-access guide (source-cooperative/docs.source.coop#37) is waiting on this. Part of #223: it is done once a workflow in an unrelated repository writes with only its ambient token, against a deployment. Upstream: developmentseed/multistore#126, developmentseed/multistore#146, developmentseed/multistore#153. Epic: source-cooperative/source.coop#491. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Closes #143. Two commits; each compiles and tests on its own.
What I'm changing
Four things in
multistore-stswere unreachable for a host serving one hardcoded Role and become reachable the moment Roles are user-authored or a second issuer is trusted — which source-cooperative/source.coop#491 is about to do:required_audiencesaccepted any audience, and an emptysubject_conditionsskipped the subject check. Both fields default to empty, so a Role that omitted either trusted everything its issuer signed. Both now accept nothing; "any subject" is written"*"; andstatic-configvalidation rejects a Role with either list empty, so the omission is reported at load rather than discovered at exchange time.typmust sayJWT. Access tokens (at+jwt) and other typed tokens are not identity tokens, whoever signed them.expwere accepted. A third-party token with no expiry is an indefinitely replayable credential its issuer never meant to issue.expis now required — except from issuers listed in the newRoleConfig.allow_missing_exp_from, which exists for a host's own long-lived tokens whose validity it tracks itself (Source Cooperative's API keys, with server-side revocation, are the case the issue's caveat describes). The exemption is an explicit field, not a side effect of configuration.info, with issuer, subject, role and duration.And the item the issue marks as worth fixing here:
resolve_scopessubstituted an empty string for a missing or non-string claim. For abucketthat fails safe; for aprefix, an empty string matches every key, so a missing claim silently granted the whole bucket — the opposite of what the doc comment promised. An unresolvable template is now an error at mint time.Breaking: a Role must set
required_audiencesandsubject_conditions;RoleConfiggainsallow_missing_exp_from, which struct-literal constructors must add;mint_temporary_credentialsreturnsResult. The example server config and the docs are updated. Forsource-cooperative/data.source.coop, the hardcoded_defaultRole currently setssubject_conditions: vec![]and must becomevec!["*"]on bump.How I did it
crates/sts/src/jwks.rs—audience_allowedreturns false for an empty accepted set. Claim validation moves out ofverify_tokenintovalidate_claims(header, claims, issuer, role, now), which takes the clock so every rule is tested without a key pair:typ, issuer, audience,exp(required unless exempt),nbf.verify_tokenverifies the signature and calls it.crates/sts/src/lib.rs— the subject check becomessubject_allowed(subject, &conditions), which isanyover the list with no empty-list bypass; the success log;?on mint.crates/sts/src/sts.rs—resolve_templateandresolve_scopesreturnResult;mint_temporary_credentialspropagates it. The doc comment now says what actually happens.crates/core/src/types.rs—RoleConfig.allow_missing_exp_from, and the field docs say "accepts nothing" where they said "unrestricted".crates/static-config/src/lib.rs—validaterejects a Role with emptyrequired_audiencesorsubject_conditions, alongside the existing no-issuers check; the fixture Role gets both.docs/configuration/roles.md,docs/auth/proxy-auth.md,docs/reference/config-example.md,crates/sts/README.md,examples/server/config.toml— the new semantics, the new field, and audiences on every example Role (the integration tests mint forsts.amazonaws.com).Test plan
cargo fmtcargo test --workspace— all green; new tests: empty accepted set denies; well-formed token passes with and withouttyp;at+jwtrejected; a Role with no audience accepts nothing;exprequired unless the issuer is exempt; expiry honours the 60s skew; empty subject conditions allow no subject; missing/non-string template claim is an error; static-config rejects empty audiences and subjects.cargo clippy --all-targets— only the pre-existing 11-argument warning incrates/core/src/auth/tests.rs.cargo checkandcargo check -p multistore-cf-workers --target wasm32-unknown-unknown.make test-integration— not run here (needs docker); the example config's roles now carry the audience the tests mint for.🤖 Generated with Claude Code
https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z