Skip to content

feat(backend-federation): outbound OIDC→backend STS exchange (oidc-provider delegates to it)#57

Merged
alukach merged 6 commits into
mainfrom
feat/backend-federation
Jun 3, 2026
Merged

feat(backend-federation): outbound OIDC→backend STS exchange (oidc-provider delegates to it)#57
alukach merged 6 commits into
mainfrom
feat/backend-federation

Conversation

@alukach
Copy link
Copy Markdown
Member

@alukach alukach commented Jun 3, 2026

Refs #56 (roadmap item 1: the federation exchange primitive).

What

Adds multistore-backend-federation — the runtime-agnostic outbound STS-exchange primitive: the proxy presents its own OIDC identity to a backend cloud, assumes a role there (AWS AssumeRoleWithWebIdentity), and signs backend requests with the temporary credentials, so the operator never holds long-lived backend keys.

It is the client-side counterpart to the existing multistore-sts (the inbound AssumeRoleWithWebIdentity server, which mints proxy credentials for callers). The naming now reflects that symmetry: sts = inbound server, backend-federation = outbound client.

Relationship to multistore-oidc-provider (important)

multistore-oidc-provider already performed the outbound AWS exchange (in exchange/aws.rs), with its own caching (CredentialCache) and AwsBackendAuth middleware wired into dispatch. Rather than ship a second, parallel implementation, this PR makes oidc-provider delegate its AWS exchange to backend-federation:

  • AwsExchange::exchange now builds the request via backend-federation's AssumeRoleWithWebIdentity::form_pairs() and parses it via parse_response; the hand-rolled extract_xml_value XML scanner is deleted.
  • All wired-in behavior is preserved — OidcCredentialProvider::get_credentials (mint → exchange → cache) and AwsBackendAuth/MaybeOidcAuth middleware are unchanged and still pass their existing tests.
  • oidc-provider keeps its real responsibilities: minting the proxy's identity (JWT signing, JWKS, discovery), caching, and middleware.

So oidc-provider is the batteries-included orchestrator; backend-federation is the dependency-light mechanism it (and any "bring-your-own-token" caller) builds on. Dependency direction: oidc-provider → backend-federation → multistore (no cycles; verified for native and wasm).

Contents

  • aws — runtime-agnostic AssumeRoleWithWebIdentity request builder. form_pairs() returns unencoded key/value pairs (for HTTP layers that urlencode themselves, e.g. reqwest .form()); body() returns a ready-to-POST urlencoded string. parse_response parses the XML; an AWS <ErrorResponse> becomes a typed FederationError::Sts { code, message }.
  • FederatedCredentials::apply_to(&mut BucketConfig) — injects access_key_id / secret_access_key / token and clears skip_signature. Debug redacts the secret + session token.
  • Improvements folded into the delegated path: optional DurationSeconds (omitted when None), inline session_policy, the typed StsError, and a secret-redacting Debug on oidc-provider's CloudCredentials (it previously derived Debug and could log secrets).

cf-workers example

The example already wires OIDC backend auth (MaybeOidcAuth + AwsBackendAuth + a FetchHttpExchange), so it exercises this crate transitively. This PR adds a documented federated-private bucket to wrangler.toml (auth_type=oidc + oidc_role_arn) showing the setup, and the wasm build is verified.

Functional tests

  • tests/live_sts.rs — an env-gated functional test that runs the full primitive against real AWS STS (build request → exchange a real OIDC token → parse) and then proves the credentials work by reading the private bucket via object_store, exactly how multistore signs backend requests. It self-skips when MULTISTORE_TEST_ROLE_ARN is unset, so ordinary cargo test is a no-op.
  • New CI job live-federation runs it with GitHub Actions OIDC (id-token: write); it activates once the repo Actions variables MULTISTORE_TEST_ROLE_ARN / MULTISTORE_TEST_BUCKET / MULTISTORE_TEST_REGION are set and the role's trust policy trusts this repo's GitHub OIDC subject (audience sts.amazonaws.com).

cargo test -p multistore-backend-federation — 11 unit tests + 1 doctest + the (skipping) live test. Existing oidc-provider tests all pass against the delegated path.

Unit tests

Response parse, STS error path, secret redaction (creds + applied BucketConfig), request shape (incl. session policy, DurationSeconds present/omitted, unencoded form_pairs), regional endpoint, and BucketConfig injection.

Notes

  • Bare "federation" was overloaded (the inbound sts+oidc-provider pair is also "federation"); the crate is named backend-federation and its docs reserve bare "federation" for the concept.
  • Cargo.lock is updated to reflect the crate rename and the new oidc-provider → backend-federation edge (required for --locked CI).

Follow-ups (tracked in #56)

  • Azure/GCS bearer credential source: oidc-provider has feature-gated Azure/GCP exchange stubs today; extracting them into backend-federation (and modelling bearer-only credentials) is future work.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 3, 2026

🚀 Latest commit deployed to https://multistore-proxy-pr-57.development-seed.workers.dev

  • Date: 2026-06-03T16:21:39Z
  • Commit: 6da078f

…backend STS

New `multistore-federation` crate: the symmetric outbound half of multistore's
existing inbound federation (multistore-sts / multistore-oidc-provider). Lets the
proxy present its own OIDC identity to a backend cloud and assume a role there,
so it can serve a private bucket without the operator holding long-lived keys.

- aws: runtime-agnostic AssumeRoleWithWebIdentity request builder + XML response
  parser (caller owns HTTP), surfacing AWS <ErrorResponse> as a typed error.
- FederatedCredentials::apply_to(&mut BucketConfig): inject temp creds
  (access_key_id/secret_access_key/token), clear skip_signature, disable
  anonymous_access. Debug redacts secret + session token.

No core changes; additive crate only. Tests cover parse, error path, redaction,
request shape, and BucketConfig integration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…c-provider delegation

Resolves the duplication between this crate and multistore-oidc-provider's
existing (wired-in) outbound exchange.

- Rename multistore-federation -> multistore-backend-federation: the outbound
  AssumeRoleWithWebIdentity *client*, conceptual peer to multistore-sts's
  inbound *server*.
- Make multistore-oidc-provider delegate its AWS STS exchange to this crate
  (request build via form_pairs() + parse_response), deleting its hand-rolled
  extract_xml_value parser. Wired-in get_credentials + AwsBackendAuth
  middleware behavior is preserved (all existing tests pass).
- Carry the primitive's improvements through delegation: DurationSeconds (now
  Option, omitted when None), inline session policy, typed StsError
  {code,message}, and secret-redacting Debug on CloudCredentials.
- Fix apply_to to leave inbound anonymous_access untouched (it governs only
  outbound backend signing).
- Add an env-gated live functional test against real AWS STS + S3
  (tests/live_sts.rs) plus a CI job to run it, a documented federation bucket
  in the cf-workers wrangler.toml, and a crate README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alukach alukach changed the title feat(federation): add multistore-federation crate for outbound OIDC→backend STS feat(backend-federation): outbound OIDC→backend STS exchange (oidc-provider delegates to it) Jun 3, 2026
… not green)

The live-federation job was reporting success while the test self-skipped
(MULTISTORE_TEST_ROLE_ARN unset), giving a misleading green check that implied
the real AWS STS path was exercised when it wasn't.

- Gate the live-federation job with `if: vars.MULTISTORE_TEST_ROLE_ARN != ''`
  so it shows as *skipped* (neutral), not *passed*, when unconfigured.
- Mark the test `#[ignore]` so the ordinary unit-test job reports it as
  *ignored* rather than *passed*; the gated job runs it with `-- --ignored`.

The crate's encoding/parsing/redaction logic remains covered by the unit tests;
only the real network round-trip is gated on a configured role + bucket.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…onfigured

Per request: the live-federation job must fail — not skip — when
MULTISTORE_TEST_ROLE_ARN is unset, so a green checks list genuinely means the
real AWS STS path was exercised.

- Drop the job's `if:` guard so it always runs.
- The test now panics (instead of returning early) on missing
  MULTISTORE_TEST_ROLE_ARN, failing the `-- --ignored` run.
- Kept `#[ignore]` so ordinary `cargo test` (and the unit-test CI job, and
  local dev) does not run it and is unaffected.

CI's live-federation check will be RED until the repo Actions variables
MULTISTORE_TEST_ROLE_ARN / MULTISTORE_TEST_BUCKET / MULTISTORE_TEST_REGION are
set and the IAM role trusts this repo's GitHub OIDC subject.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s and staging

Adds an end-to-end test of the outbound federation path (proxy mints its own
OIDC assertion -> AssumeRoleWithWebIdentity -> SigV4 read of a private bucket),
run by the shared smoke-test job on every preview (PR) and staging deploy.

To avoid creating/tearing down a per-PR AWS identity provider + role, PR
previews reuse the STABLE staging OIDC issuer: all deployments share
OIDC_PROVIDER_KEY, so a preview mints tokens whose `iss` is the staging URL and
AWS validates them against the single, already-registered staging IAM OIDC
provider. preview.yml and staging.yml both set oidc_issuer_override to
${{ vars.STAGING_OIDC_ISSUER }}; the rationale is commented in both workflows.

- tests/smoke/test_federation.py: anonymous GET of the `federated-test` bucket
  must return the private object (no silent skip — fails if federation is
  unconfigured).
- wrangler.deploy.toml: `federated-test` bucket (auth_type=oidc) with
  placeholder role ARN / bucket to replace with real test resources.
- deploy.yml: pass FEDERATION_TEST_KEY to the smoke-test job.

Requires (one-time, no per-PR IAM): set the STAGING_OIDC_ISSUER repo variable,
register an AWS IAM OIDC provider for it, and create a role trusting it
(sub=multistore, aud=sts.amazonaws.com) with read on the private bucket.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alukach alukach merged commit 40fb24c into main Jun 3, 2026
11 of 13 checks passed
@alukach alukach deleted the feat/backend-federation branch June 3, 2026 21:35
alukach added a commit that referenced this pull request Jun 3, 2026
The backend-federation crate (added in #57) was missing from the
release-please extra-files list, so its $.workspace.dependencies entry
wasn't bumped in lockstep with the workspace version. On the 0.5.0
release PR this left the dep pinned at ^0.4.0 while the crate built as
0.5.0, breaking workspace resolution and failing every CI job.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
alukach added a commit that referenced this pull request Jun 4, 2026
…ive refresh)

Roadmap item 2 of #56. Caches short-lived FederatedCredentials per credential
identity so a proxy doesn't re-mint on every request:

- proactive refresh once within a configurable lead window of expiry (so a
  credential never expires mid-use),
- single-flight: concurrent callers for the same key await one in-flight fetch
  via a per-key futures::lock::Mutex,
- runtime-agnostic: the caller passes `now` (no clock dep) and no async runtime
  is required.

Closure-based get_or_fetch(key, now, fetch) keeps it flexible; invalidate(key)
supports drop-and-refetch on backend rejection. Tested: miss/hit/refresh/
invalidate/key-isolation and concurrent single-flight.

Rebased onto main: the federation crate landed on main as backend-federation
(#57), so this folds the cache into that crate instead of re-adding a duplicate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant