Skip to content

feat(oauth): Sign in with Apple + generic config-driven OIDC provider#269

Open
iarunsaragadam wants to merge 1 commit into
mainfrom
feat/issue-259-apple-generic-oidc
Open

feat(oauth): Sign in with Apple + generic config-driven OIDC provider#269
iarunsaragadam wants to merge 1 commit into
mainfrom
feat/issue-259-apple-generic-oidc

Conversation

@iarunsaragadam

Copy link
Copy Markdown
Contributor

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 — Apple Exchanger built on the existing OIDC discovery + JWKS helpers. Mints a short-lived ES256 client_secret JWT from GATEWAY_APPLE_TEAM_ID / KEY_ID / PRIVATE_KEY (PKCS#8), verifies the ES256-signed id_token (iss/aud/exp), enforces verified email, leniently 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.goNewOIDC(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.
  • Generalized verifyJWSverifyJWSWithAlgs; Google/Microsoft stay RS256-only, Apple/OIDC accept ES256. Prevents alg-substitution (covered by a test).
  • Hosted callback now accepts POST (Apple response_mode=form_post), parses the size-bound form, and threads the name into the exchange context. GET providers unchanged.
  • Config: GATEWAY_APPLE_* and GATEWAY_OIDC_PROVIDERS + per-key GATEWAY_OIDC_<KEY>_*; both registered additively in buildOAuthRegistry behind credential presence (same gating as github).
  • No proto changeapple and generic keys flow through the existing OAuth RPCs, registry, hosted and headless paths.

Acceptance criteria

  • With GATEWAY_APPLE_* set, registry includes apple; hosted /oauth/start?provider=apple and headless OAuth complete end-to-end (stubbed token endpoint in tests); first-login name captured; Google/MS/GitHub unchanged. ✅
  • Operator enables an arbitrary OIDC provider purely via env; generic exchanger validates discovery, exchanges code, fetches userinfo, links via existing path; pkg/oauth tests green incl. happy path + missing-discovery error. ✅

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; callbackParams POST/GET; appleNameFromForm.

go build ./..., affected go test, gofumpt, go vet, and golangci-lint on changed packages all clean; go mod tidy is a no-op.

Deferred

  • Per-tenant (vs server-wide) provider credentials remain out of scope.

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread pkg/oauth/oidc_generic.go
Comment on lines +149 to +150
if o.jwks == nil || o.jwks.url != doc.JWKSURI {
o.jwks = newJWKSCache(doc.JWKSURI, o.cfg.JWKSCacheTTL, o.client)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment thread pkg/oauth/apple.go
if client == nil {
client = defaultHTTPClient()
}
priv, _ := parseECPrivateKeyPEM(cfg.PrivateKeyPEM)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 iarunsaragadam left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 one oidcExchanger per provider key, and the registry returns that same *oidcExchanger pointer for every login (registry.go Get). Exchange runs on the concurrent login path (internal/service/auth_login.go:541, driven by the hosted/headless HTTP handlers), so multiple goroutines call Exchange on the same receiver simultaneously. Inside, lines 149-150 do if o.jwks == nil || o.jwks.url != doc.JWKSURI { o.jwks = newJWKSCache(...) } — an unguarded read-then-write of o.jwks — and verifyIDToken later reads o.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 standard make test invocation) will flag it. Apple is not affected because NewApple sets a.jwks once and never reassigns it; only the generic path defers JWKS-cache creation to the first Exchange because the JWKS URI is discovery-derived.
  • Fix: Resolve discovery (or at least the JWKS URI) eagerly in NewOIDC and build the jwksCache once, mirroring NewApple; or guard the lazy-init/read of o.jwks with a sync.Mutex (or sync.Once keyed by JWKS URI). The jwksCache is already internally locked, so once the pointer is stable the rest is safe — the race is purely the field assignment. Add a -race test that runs Exchange concurrently 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 ~L240 vs google.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.WithAppleName via appleNameFromForm); the headless RPC path has no field to carry Apple's one-time user blob, so native-SDK clients drop the name on first login. Document the limitation in docs/oauth.md and 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-wins Registry.Register (registry.go:27). Reserve built-in keys and skip-with-warning on collision in loadGenericOIDCProviders/buildOAuthRegistry, plus a doc note.
  • Security (minor): Generic OIDC validates iss against doc.Issuer (the discovery doc's own value) rather than the operator-configured cfg.IssuerURL, making the check a tautology. Assert TrimRight(doc.Issuer,"/") == TrimRight(cfg.IssuerURL,"/") after discovery and add a rejecting test.
  • Security (nit): appleBoolString.UnmarshalJSON fails closed on unexpected values (also used for is_private_email); acceptable, confirm intent.
  • Performance (major, non-blocking): Generic OIDC refetches the static discovery doc on every AuthorizationURL and Exchange (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-16 still 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sign in with Apple + generic configurable OIDC social provider

1 participant