feat: OIDC provider#132
Conversation
Add multistore-oidc-provider dependency and wire up OIDC configuration loading from environment variables (OIDC_PROVIDER_KEY, OIDC_PROVIDER_KID, OIDC_PROVIDER_ISSUER) with optional key rotation support. Serve /.well-known/openid-configuration and /.well-known/jwks.json endpoints when OIDC is configured. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Introduce ApiAuth enum that supports OIDC JWT bearer tokens, static shared secrets (legacy), or no authentication. When OIDC config is present, the proxy signs a fresh JWT for each API request instead of using a static secret. This enables service-to-service auth via the OIDC provider added in previous commits. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ng modules Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
🚀 Latest commit deployed to https://source-data-proxy-pr-132.source-coop.workers.dev
|
The advisory covers a timing side-channel in RSA decryption. We only use RSA for signing JWTs, never decryption, so the vulnerable code path is never reached. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Without an audience restriction, /.sts accepted an ID token minted for ANY OAuth client of AUTH_ISSUER and exchanged it for that user's credentials — a confused-deputy cross-client impersonation. AUTH_AUDIENCE is currently configured in no environment, so the endpoint was live and unrestricted. Fail closed: only mount /.sts when an audience restriction is configured, and short-circuit it with a 501 NotImplemented otherwise, so an unrestricted exchanger is never registered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
account/product path segments are percent-decoded before being split out of the request path, then interpolated raw into the upstream API URL. A decoded `?`/`#`/`&` therefore injected query parameters into the secret-authenticated Source API call, and — combined with the ?subject=<hex> cache key — let an anonymous request forge a key colliding with an authenticated user's, poisoning their product resolution. Percent-encode account/product with a path-segment set (unreserved chars pass through unchanged, so ordinary slugs are byte-identical). Also drop the release-compiled-out debug_assert in cache_key_with_subject for a real separator choice, so a query-bearing URL can never silently forge a colliding key. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An unset GitHub secret evaluated to empty, jq emitted an empty value, `secret bulk` succeeded, and the job went green — while every request to the worker panicked in load_config. Fail loudly if OIDC_PROVIDER_KEY or SESSION_TOKEN_KEY is empty, and add a post-deploy smoke test that curls / and /.well-known/jwks.json so a worker that can't boot turns the job red instead of shipping silently. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Superseded — reposting with inline comments.
| Err(e) => { | ||
| tracing::error!("failed to sign API auth JWT: {}", e); | ||
| None | ||
| } |
There was a problem hiding this comment.
Silent downgrade on JWT signing failure sends an unauthenticated request.
Returning None causes cached_fetch to omit the Authorization header and issue an anonymous API call. An authenticated user then sees AccessDenied (403) rather than a 500, hiding the real cause from both the user and operator logs.
The practical risk is low — JwtSigner::from_pem is called at startup and panics on a bad key, so runtime signing failures are very unlikely. A brief comment noting that None intentionally falls through to an anonymous request (which surfaces as AccessDenied) would make this non-obvious behaviour clear to future readers.
When OIDC_PROVIDER_KEY_PREVIOUS is set but OIDC_PROVIDER_KID_PREVIOUS is missing, the previous key was silently dropped from JWKS via `ok()?`, leaving no trace in the worker logs. Tokens signed with the old key then fail verification at relying parties with no diagnostic. Replace the silent `?` with an explicit warning, matching the existing warn-on-error branch for from_pem failures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signing failures return None, causing the caller to issue an unauthenticated request that the API surfaces as AccessDenied (403) rather than a 500. The signing key is validated at startup so this is very unlikely at runtime; document the intentional fall-through so the non-obvious behaviour is clear to future readers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`default-features = false` with no feature list looked fragile (the review flagged it as relying on transitive features from multistore-sts). Document that on wasm32-unknown-unknown reqwest selects its fetch backend by target arch rather than a cargo feature, and that the methods we use (Client::new/send/text) are not feature-gated -- so no explicit list is needed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @alukach's task in 2m 31s —— View job ✅ No blocking issues — safe to merge. All three blockers from the previous review are resolved in this sync:
Two non-blocking items worth a quick check before merge:
|
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
The env-var table claimed that leaving AUTH_AUDIENCE unset lets tokens for any client of AUTH_ISSUER work. The code fails closed instead: /.sts is disabled and returns 501 until AUTH_AUDIENCE is set (src/config.rs:87-92). Align the README with the actual behavior, as already documented in config.rs and wrangler.toml. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
What I'm changing
Turns the data proxy into an OIDC provider so it can authenticate to the Source Cooperative API on behalf of each user, and adds an STS token-exchange endpoint so clients can trade an OIDC token for temporary S3-style credentials.
Highlights
GET /.well-known/openid-configurationandGET /.well-known/jwks.json, so the Source API (and other relying parties) can verify them. Supports zero-downtime key rotation by serving both the active and previous key in the JWKS.api_secretwith a flexibleApiAuththat signs a short-lived JWT scoped to the calling principal. Upstream API calls are now made as that user instead of with a shared secret./.stsendpoint with a_defaultrole that trusts the auth provider, letting an authenticated user obtain temporary credentials.Also included
configmodule parses keys/secrets once per isolate (RSA PEM parsed a single time).405 MethodNotAllowedinstead of a misleading404(the proxy is read-only).401/403) now surface as S3403 AccessDeniedrather than a500.multistoreto0.4.0, refreshes dependencies /cargo auditallowlist, and updates CI + deploy workflows to forward the new secrets.Warning
this adds required env vars/secrets —
OIDC_PROVIDER_KEY,OIDC_PROVIDER_KID,OIDC_PROVIDER_ISSUER,SESSION_TOKEN_KEY,AUTH_ISSUER. The worker will not boot without them. See the README → OIDC Provider for setup and the key-rotation procedure.How to test it
Access the accompanying frontend PR (feat: OIDC auth source.coop#283) preview: https://source-cooperative-git-feat-oidc-auth-radiantearth.vercel.app/
Authenticate & access Restricted product (e.g. https://source-cooperative-git-feat-oidc-auth-radiantearth.vercel.app/alukach/alukach-experimentation). Verify ****
Set the env vars/secrets above (the README has a one-liner to generate an RSA key).
Discovery:
curl https://<host>/.well-known/openid-configurationand/.well-known/jwks.jsonreturn the issuer and the active (+ previous, during rotation) keys.Token exchange: exchange an OIDC token at
/.stsand confirm temporary credentials are returned.Behavior: a signed object download still streams; a
PUT/POST/DELETEreturns405; a request the API rejects returns403 AccessDenied(not500).PR Checklist
and I have opened issue/PR #XXX to track the change.
Related Issues