Skip to content

feat: OIDC provider#132

Merged
alukach merged 59 commits into
mainfrom
feat/oidc-provider
Jun 16, 2026
Merged

feat: OIDC provider#132
alukach merged 59 commits into
mainfrom
feat/oidc-provider

Conversation

@alukach

@alukach alukach commented Apr 2, 2026

Copy link
Copy Markdown
Contributor

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

  • OIDC provider — the proxy now mints and signs RSA JWTs and publishes them at GET /.well-known/openid-configuration and GET /.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.
  • Per-user API auth — replaces the single static api_secret with a flexible ApiAuth that 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.
  • STS token exchange — new /.sts endpoint with a _default role that trusts the auth provider, letting an authenticated user obtain temporary credentials.

Also included

  • New config module parses keys/secrets once per isolate (RSA PEM parsed a single time).
  • Object writes now return an S3 405 MethodNotAllowed instead of a misleading 404 (the proxy is read-only).
  • Upstream auth failures (401/403) now surface as S3 403 AccessDenied rather than a 500.
  • Cache keys are identity-scoped and URL-safe (hex-encoded subject).
  • Bumps multistore to 0.4.0, refreshes dependencies / cargo audit allowlist, 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

  1. Access the accompanying frontend PR (feat: OIDC auth source.coop#283) preview: https://source-cooperative-git-feat-oidc-auth-radiantearth.vercel.app/

  2. Authenticate & access Restricted product (e.g. https://source-cooperative-git-feat-oidc-auth-radiantearth.vercel.app/alukach/alukach-experimentation). Verify ****

  3. Set the env vars/secrets above (the README has a one-liner to generate an RSA key).

  4. Discovery: curl https://<host>/.well-known/openid-configuration and /.well-known/jwks.json return the issuer and the active (+ previous, during rotation) keys.

  5. Token exchange: exchange an OIDC token at /.sts and confirm temporary credentials are returned.

  6. Behavior: a signed object download still streams; a PUT/POST/DELETE returns 405; a request the API rejects returns 403 AccessDenied (not 500).

PR Checklist

  • This PR has no breaking changes.
  • I have updated or added new tests to cover the changes in this PR.
  • This PR affects the Source Cooperative Frontend & API,
    and I have opened issue/PR #XXX to track the change.

Related Issues

alukach and others added 5 commits April 1, 2026 23:40
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>
@alukach alukach changed the title Feat/OIDC provider feat: OIDC provider Apr 2, 2026
@github-actions

github-actions Bot commented Apr 2, 2026

Copy link
Copy Markdown

🚀 Latest commit deployed to https://source-data-proxy-pr-132.source-coop.workers.dev

  • Date: 2026-06-15T16:48:51Z
  • Commit: fef5e7b

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>
alukach and others added 3 commits June 10, 2026 13:50
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>
claude[bot]

This comment was marked as outdated.

@claude claude Bot dismissed their stale review June 14, 2026 07:12

Superseded — reposting with inline comments.

Comment thread src/config.rs Outdated
Comment thread src/auth.rs
Comment on lines +23 to +26
Err(e) => {
tracing::error!("failed to sign API auth JWT: {}", e);
None
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread Cargo.toml
claude[bot]

This comment was marked as outdated.

alukach and others added 3 commits June 14, 2026 22:53
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 claude Bot dismissed their stale review June 15, 2026 05:54

Superseded by a newer Claude review.

claude[bot]

This comment was marked as outdated.

Comment thread src/config.rs Outdated
Comment thread wrangler.toml Outdated
@claude

claude Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

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:

  • Key rotation (src/config.rs:43–52): OIDC_PROVIDER_KEY_PREVIOUS set without OIDC_PROVIDER_KID_PREVIOUS now emits tracing::warn! and returns None explicitly — no more silent drop.
  • authorization_header downgrade (src/auth.rs:31–33): signing failure now logs tracing::error! and the fall-through-to-anonymous behaviour is documented in the function comment.
  • auth_audience docs: doc comment, README table row, and wrangler.toml comment all now correctly state that unset disables /.sts (returns 501) rather than the previous "any audience accepted" wording.

Two non-blocking items worth a quick check before merge:

  • wrangler.toml orphaned comment (wrangler.toml:32): # Recommended vars: is a leftover stub — the lines below it are actually "Required vars". Harmless but reads confusingly.
  • JWT TTL of 60 s (src/config.rs:35): the API-auth JWT lives only 60 seconds. If the Source Cooperative API verifier enforces exp without any clock-skew leeway, requests near the window boundary will intermittently 403. Worth confirming the verifier allows ≥ 5 s of leeway, or bumping the TTL to 120 s.

alukach and others added 2 commits June 14, 2026 23:47
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>
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.

2 participants