feat(oauth): Sign in with Apple + generic config-driven OIDC provider#269
feat(oauth): Sign in with Apple + generic config-driven OIDC provider#269iarunsaragadam wants to merge 1 commit into
Conversation
Add Sign in with Apple as a first-class OAuth provider and a generic config-driven OIDC exchanger so operators can enable any standards- compliant IdP (Okta, Slack, enterprise OIDC) via env config alone. - pkg/oauth/apple.go: Apple Exchanger reusing the shared OIDC discovery and JWKS verification. Mints a short-lived ES256 client_secret JWT from the team id / key id / PKCS#8 private key, verifies the ES256-signed id_token (issuer/audience/expiry), enforces verified email, and decodes Apple's bool-or-string email_verified. First-login display name is captured from the form_post 'user' field via context. - pkg/oauth/oidc_generic.go: NewOIDC(GenericOIDCConfig) resolves all endpoints from <issuer>/.well-known/openid-configuration, exchanges the code, verifies the id_token (RS256/ES256) against the discovered JWKS, and falls back to userinfo for email/name. - Generalize verifyJWS into verifyJWSWithAlgs so ES256 is accepted for Apple/OIDC while existing Google/Microsoft callers stay RS256-only. - Hosted callback now accepts POST (Apple response_mode=form_post), parses the size-bound form, and threads the captured name into the exchange context. GET providers are unchanged. - Config: GATEWAY_APPLE_* and GATEWAY_OIDC_PROVIDERS + per-key GATEWAY_OIDC_<KEY>_* env wiring; both registered additively in buildOAuthRegistry behind credential presence (same gating as github). - Tests across pkg/oauth (happy path, unverified-email, bad-audience, alg-substitution rejection, missing-discovery, userinfo fallback), config parsing, and an end-to-end hosted form_post name-capture test. - Docs updated for Apple and generic OIDC setup. No proto change: 'apple' and generic keys flow through the existing OAuth RPCs, registry, hosted and headless paths. Closes #259.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ceea2fe1b0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if o.jwks == nil || o.jwks.url != doc.JWKSURI { | ||
| o.jwks = newJWKSCache(doc.JWKSURI, o.cfg.JWKSCacheTTL, o.client) |
There was a problem hiding this comment.
Guard the generic OIDC JWKS cache initialization
When a generic OIDC provider handles concurrent callbacks, the registry reuses this single oidcExchanger instance for all requests, so the lazy o.jwks read/write here races on a cold cache (or if discovery returns a different JWKS URI). In Go this is an unsafe data race during ordinary simultaneous logins; initialize the cache behind a mutex or otherwise avoid mutating this shared field from Exchange.
Useful? React with 👍 / 👎.
| if client == nil { | ||
| client = defaultHTTPClient() | ||
| } | ||
| priv, _ := parseECPrivateKeyPEM(cfg.PrivateKeyPEM) |
There was a problem hiding this comment.
Reject Apple starts when the signing key is invalid
When GATEWAY_APPLE_PRIVATE_KEY is non-empty but malformed, buildOAuthRegistry still registers Apple and this parse error is discarded, so AuthorizationURL can still redirect users through Apple and only fail after the callback when Exchange sees a.priv == nil. Preserve the parse failure or check a.priv before starting the flow so a bad Apple config does not advertise a provider that cannot complete login.
Useful? React with 👍 / 👎.
iarunsaragadam
left a comment
There was a problem hiding this comment.
PR review gate: ❌ BLOCKED
| Reviewer | Effective verdict | Surviving blockers |
|---|---|---|
| Product | APPROVE | 0 |
| Security | APPROVE | 0 |
| Performance | APPROVE | 0 |
| Maintainability | APPROVE | 0 |
| Correctness | REQUEST_CHANGES | 1 |
Conditional reviewers: Contract & Migration — skipped (no proto/API/schema/migration changes); Accessibility & UX — skipped (no UI changes).
Confirmed blockers
Correctness — Data race on oidcExchanger.jwks (shared singleton mutated in Exchange without synchronization)
- Location:
pkg/oauth/oidc_generic.go:149-150(write) and:253,260,261(read). buildOAuthRegistry(internal/app/oauth.go) registers exactly oneoidcExchangerper provider key, and the registry returns that same*oidcExchangerpointer for every login (registry.goGet).Exchangeruns on the concurrent login path (internal/service/auth_login.go:541, driven by the hosted/headless HTTP handlers), so multiple goroutines callExchangeon the same receiver simultaneously. Inside, lines 149-150 doif o.jwks == nil || o.jwks.url != doc.JWKSURI { o.jwks = newJWKSCache(...) }— an unguarded read-then-write ofo.jwks— andverifyIDTokenlater readso.jwks. Two concurrent first logins to the same generic OIDC provider race on that field. This is a textbook Go data race (undefined behavior);go test -race(the repo's standardmake testinvocation) will flag it. Apple is not affected becauseNewApplesetsa.jwksonce and never reassigns it; only the generic path defers JWKS-cache creation to the firstExchangebecause the JWKS URI is discovery-derived.- Fix: Resolve discovery (or at least the JWKS URI) eagerly in
NewOIDCand build thejwksCacheonce, mirroringNewApple; or guard the lazy-init/read ofo.jwkswith async.Mutex(orsync.Oncekeyed by JWKS URI). ThejwksCacheis already internally locked, so once the pointer is stable the rest is safe — the race is purely the field assignment. Add a-racetest that runsExchangeconcurrently for a generic OIDC provider to lock in the fix.
Refuted (raised but verification cleared them)
- Maintainability — id_token validation flow duplicated 4×, extract a shared verify helper (
pkg/oauth/apple.go ~L300,oidc_generic.go ~L240vsgoogle.go:277-330,microsoft.go:230): real DRY improvement and worth doing, but verification did not treat it as a merge-blocking structural gap — downgraded to a strong non-blocking recommendation below.
Non-blocking findings
- Product (major): Apple first-login display name is capturable only on the hosted callback (
oauth.WithAppleNameviaappleNameFromForm); the headless RPC path has no field to carry Apple's one-timeuserblob, so native-SDK clients drop the name on first login. Document the limitation indocs/oauth.mdand track an additive headless name field as a follow-up. - Product / Maintainability (minor/nit): A generic OIDC provider key (
GATEWAY_OIDC_PROVIDERS=apple|google|...) silently overrides a built-in exchanger via last-write-winsRegistry.Register(registry.go:27). Reserve built-in keys and skip-with-warning on collision inloadGenericOIDCProviders/buildOAuthRegistry, plus a doc note. - Security (minor): Generic OIDC validates
issagainstdoc.Issuer(the discovery doc's own value) rather than the operator-configuredcfg.IssuerURL, making the check a tautology. AssertTrimRight(doc.Issuer,"/") == TrimRight(cfg.IssuerURL,"/")after discovery and add a rejecting test. - Security (nit):
appleBoolString.UnmarshalJSONfails closed on unexpected values (also used foris_private_email); acceptable, confirm intent. - Performance (major, non-blocking): Generic OIDC refetches the static discovery doc on every
AuthorizationURLandExchange(two uncached IdP RTTs per login), inconsistent with the 1h-TTL JWKS cache. Cache the parsed discovery doc behind a mutex with a TTL. - Maintainability (recommended): Extract the duplicated id_token validation flow (JWKS get → verify-with-invalidate-retry → iss/aud/exp/iat-with-2min-skew → unmarshal) into one shared helper (e.g.
verifyOIDCIDToken) and migrate all four exchangers, before the fourth copy drifts. - Maintainability (nit): Stale comment at
pkg/oauth/jwks.go:15-16still says the cache is used only by Google and Microsoft; update to include Apple and generic OIDC.
Recommendation
Block on the one confirmed concurrency defect: the unsynchronized o.jwks assignment in the shared generic-OIDC exchanger is a genuine data race on the request path and will trip go test -race. Fix it by building the jwksCache once in NewOIDC (eager discovery/JWKS-URI resolution, mirroring NewApple) or by guarding the field, and add a concurrent -race test. While in this code, strongly consider the recommended DRY extraction of the now-quadrupled id_token validation flow and caching the static discovery document (which also removes the per-request o.jwks churn that causes the race). The remaining product/security/maintainability items are non-blocking polish; the feature is otherwise additive, well-tested, and security-sound.
Generated by the PR review gate (AGENTS.md §11) — 5 always-on reviewers + conditional Contract/Accessibility, with adversarial verification.
Closes #259.
What
Adds Sign in with Apple as a first-class OAuth provider and a generic config-driven OIDC exchanger, so the iOS clients can satisfy App Store Guideline 4.8 (SiwA required when another social login is offered) and operators can enable any standards-compliant OIDC IdP (Okta, Slack, enterprise) purely via env — no code release.
Approach (backward-compatible / additive only)
pkg/oauth/apple.go— AppleExchangerbuilt on the existing OIDC discovery + JWKS helpers. Mints a short-lived ES256client_secretJWT fromGATEWAY_APPLE_TEAM_ID/KEY_ID/PRIVATE_KEY(PKCS#8), verifies the ES256-signedid_token(iss/aud/exp), enforces verified email, leniently decodes Apple's bool-or-stringemail_verified. First-login display name is captured from theform_postuserfield via context.pkg/oauth/oidc_generic.go—NewOIDC(GenericOIDCConfig{IssuerURL,ClientID,ClientSecret,Scopes})resolves endpoints from<issuer>/.well-known/openid-configuration, exchanges the code, verifies the id_token (RS256/ES256) against the discovered JWKS, falls back to userinfo for email/name.verifyJWS→verifyJWSWithAlgs; Google/Microsoft stay RS256-only, Apple/OIDC accept ES256. Prevents alg-substitution (covered by a test).POST(Appleresponse_mode=form_post), parses the size-bound form, and threads the name into the exchange context. GET providers unchanged.GATEWAY_APPLE_*andGATEWAY_OIDC_PROVIDERS+ per-keyGATEWAY_OIDC_<KEY>_*; both registered additively inbuildOAuthRegistrybehind credential presence (same gating as github).appleand generic keys flow through the existing OAuth RPCs, registry, hosted and headless paths.Acceptance criteria
GATEWAY_APPLE_*set, registry includesapple; hosted/oauth/start?provider=appleand headless OAuth complete end-to-end (stubbed token endpoint in tests); first-login name captured; Google/MS/GitHub unchanged. ✅Tests
pkg/oauth: Apple happy path (name from context), email_verified bool/string, unverified-email rejection, bad-audience, RS256-token rejection, form_post AuthorizationURL, bad private key, token-endpoint error; generic OIDC happy path, userinfo email-verified fallback, unverified-email rejection, missing-discovery, authorization URL.internal/config: Apple + multi OIDC provider parsing (dedupe, disabled-without-creds dropped, scopes).internal/app: end-to-end hosted form_post Apple name-capture;callbackParamsPOST/GET;appleNameFromForm.go build ./..., affectedgo test,gofumpt,go vet, andgolangci-linton changed packages all clean;go mod tidyis a no-op.Deferred