feat(api+client): OAuth callback → bearer token handoff for native (#215) #249

Merged
james merged 1 commit from 215-oauth-native-bearer into main 2026-06-23 14:04:41 +00:00
Owner

Closes #215.

Summary

Adds a three-step OAuth sign-in flow for native clients (Android, Flatpak) that can't hold the existing cookie-based session across the system-browser handoff. The web cookie flow is preserved unchanged — the callback routes by "state hashes to a row" vs. "no row, fall through to cookies".

┌──────────────┐                ┌──────────────┐               ┌────────────┐
│ Native app   │                │ Carol API    │               │  IdP       │
└──────────────┘                └──────────────┘               └────────────┘
       │                                │                              │
       │ POST /api/auth/oauth/native/init                              │
       │ { provider }                   │                              │
       ├───────────────────────────────►│                              │
       │   {initId, providerStartUrl,                                  │
       │    deepLinkOnComplete, expiresAt}                             │
       │◄───────────────────────────────┤                              │
       │ openURL(providerStartUrl) → system browser ─────────────────► │
       │                                                               │
       │ user authenticates with IdP                                   │
       │                                                               │
       │ system browser hits   ◄─ 302 carol://auth/oauth/complete?init=…&code=…
       │ /api/auth/oauth/callback/{provider}?state=…&code=…  │         │
       │                                ├──────────────────► token exch│
       │                                │◄──────────────────  profile  │
       │                                │ stores hash on init row      │
       │                                │ 302 → carol://…              │
       │ deep-link arrives                                             │
       │                                                               │
       │ POST /api/auth/token           │                              │
       │ { grantType: "authorization_code", initId, code }             │
       ├───────────────────────────────►│                              │
       │   {accessToken, refreshToken, …}                              │
       │◄───────────────────────────────┤                              │
       │ stash in SecureStore                                          │

Route table

Route Method Auth Notes
/api/auth/oauth/native/init POST public Mints an oauth_inits row; returns { initId, providerStartUrl, deepLinkOnComplete, expiresAt }.
/api/auth/oauth/callback/{provider} GET public Extended: when the received state hashes to an init row, runs the native completion variant (attach completion-token hash, 302 to carol://auth/oauth/complete). Otherwise keeps the cookie path unchanged.
/api/auth/token POST public Discriminated grantType gains "authorization_code" ({ initId, code }); reuses mintTokenPair.
/api/auth/providers GET public Unauthenticated provider list for the login screen's "Sign in with " buttons.
  • State / CSRF. Init row stores SHA-256(state) under a UNIQUE constraint; tampered states miss the row entirely.
  • PKCE. Verifier lives in the row, single-use via consumed_at.
  • OIDC nonce. Stored on the row, validated against the id_token claim exactly as the cookie flow does.
  • Mix-up / RFC 9207. Row binds provider at init time; the callback compares the URL provider param. OIDC instances also check ?iss= when present.
  • Replay. Completion token is one-shot — markConsumed is a single transition guarded by a "still null" predicate. A replayed (initId, code) returns 401 invalid_authorization_code.
  • Expiry. Rows expire 5 minutes after creation.
  • Deep-link squatting. The completion-token's secret never reaches the device until the legitimate app's listener fires; an attacker who registered the same carol:// scheme can only race the legitimate exchange on the network round-trip after seeing the URL.

Test plan

  • Native client init returns initId + GitHub authorize URL + deep-link prefix
  • Init row persists state_hash (not raw state), nonce, PKCE verifier
  • Unknown provider / malformed body → 400
  • Callback happy path: signs up a new user, attaches completion token, redirects to carol://auth/oauth/complete?init=…&code=…
  • Mix-up defence: provider-mismatch on init row → 400
  • Tampered state → no matching row → cookie fallback → 400
  • Expired init row → deep link with ?error=oauth_expired
  • Second callback for the same state → ?error=oauth_already_completed
  • email_in_use flows back through the deep link (per ADR-0015 — no auto-merge by email)
  • POST /api/auth/token { grantType: "authorization_code" } happy path mints cat_* + crt_*
  • Replay (same code twice) → 401 invalid_authorization_code
  • Wrong code for right initId → 401 (no oracle — constant-time compare)
  • Unknown initId → 401
  • Incomplete init row (no token attached) → 401
  • Expired completion row → 401 even with the right code
  • Password grant still works (regression baseline for the discriminated union)
  • Web cookie OAuth flow still works (no init row → cookie path)
  • GET /api/auth/providers returns the configured provider list
  • Client runOauthNativeSignIn orchestrates init → openURL → deep-link → exchange with mocked callbacks
  • Client init-mismatch / deep-link-error / exchange-failure surface stable error codes
  • Client deep-link queue delivers a pushed URL to a subsequent await + times out
  • pnpm -F @carol/api typecheck / lint / test / openapi:check / openapi:coverage green
  • pnpm -F @carol/api-client typecheck / lint / check green
  • pnpm -F @carol/client typecheck / lint / test / export:web green

See also

  • #215 — this ticket.
  • #245 — sibling ticket: native OAuth linking flow (authenticated, different plumbing).
  • ADR-0015 — "don't auto-merge by email" rule the native callback follows for sign-in.
  • ADR-0027 §6 — cookie-vs-bearer split.

🤖 Generated with Claude Code

Closes #215. ## Summary Adds a three-step OAuth sign-in flow for native clients (Android, Flatpak) that can't hold the existing cookie-based session across the system-browser handoff. The web cookie flow is preserved unchanged — the callback routes by "state hashes to a row" vs. "no row, fall through to cookies". ```text ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │ Native app │ │ Carol API │ │ IdP │ └──────────────┘ └──────────────┘ └────────────┘ │ │ │ │ POST /api/auth/oauth/native/init │ │ { provider } │ │ ├───────────────────────────────►│ │ │ {initId, providerStartUrl, │ │ deepLinkOnComplete, expiresAt} │ │◄───────────────────────────────┤ │ │ openURL(providerStartUrl) → system browser ─────────────────► │ │ │ │ user authenticates with IdP │ │ │ │ system browser hits ◄─ 302 carol://auth/oauth/complete?init=…&code=… │ /api/auth/oauth/callback/{provider}?state=…&code=… │ │ │ ├──────────────────► token exch│ │ │◄────────────────── profile │ │ │ stores hash on init row │ │ │ 302 → carol://… │ │ deep-link arrives │ │ │ │ POST /api/auth/token │ │ │ { grantType: "authorization_code", initId, code } │ ├───────────────────────────────►│ │ │ {accessToken, refreshToken, …} │ │◄───────────────────────────────┤ │ │ stash in SecureStore │ ``` ## Route table | Route | Method | Auth | Notes | |---|---|---|---| | `/api/auth/oauth/native/init` | POST | _public_ | Mints an `oauth_inits` row; returns `{ initId, providerStartUrl, deepLinkOnComplete, expiresAt }`. | | `/api/auth/oauth/callback/{provider}` | GET | _public_ | Extended: when the received `state` hashes to an init row, runs the native completion variant (attach completion-token hash, 302 to `carol://auth/oauth/complete`). Otherwise keeps the cookie path unchanged. | | `/api/auth/token` | POST | _public_ | Discriminated `grantType` gains `"authorization_code"` (`{ initId, code }`); reuses `mintTokenPair`. | | `/api/auth/providers` | GET | _public_ | Unauthenticated provider list for the login screen's "Sign in with <Provider>" buttons. | ## Threat model (every cookie-flow defence preserved 1:1) - **State / CSRF.** Init row stores SHA-256(state) under a UNIQUE constraint; tampered states miss the row entirely. - **PKCE.** Verifier lives in the row, single-use via `consumed_at`. - **OIDC nonce.** Stored on the row, validated against the id_token claim exactly as the cookie flow does. - **Mix-up / RFC 9207.** Row binds `provider` at init time; the callback compares the URL provider param. OIDC instances also check `?iss=` when present. - **Replay.** Completion token is one-shot — `markConsumed` is a single transition guarded by a "still null" predicate. A replayed `(initId, code)` returns 401 `invalid_authorization_code`. - **Expiry.** Rows expire 5 minutes after creation. - **Deep-link squatting.** The completion-token's secret never reaches the device until the legitimate app's listener fires; an attacker who registered the same `carol://` scheme can only race the legitimate exchange on the network round-trip after seeing the URL. ## Test plan - [x] Native client init returns initId + GitHub authorize URL + deep-link prefix - [x] Init row persists `state_hash` (not raw state), nonce, PKCE verifier - [x] Unknown provider / malformed body → 400 - [x] Callback happy path: signs up a new user, attaches completion token, redirects to `carol://auth/oauth/complete?init=…&code=…` - [x] Mix-up defence: provider-mismatch on init row → 400 - [x] Tampered state → no matching row → cookie fallback → 400 - [x] Expired init row → deep link with `?error=oauth_expired` - [x] Second callback for the same state → `?error=oauth_already_completed` - [x] `email_in_use` flows back through the deep link (per ADR-0015 — no auto-merge by email) - [x] `POST /api/auth/token { grantType: "authorization_code" }` happy path mints `cat_*` + `crt_*` - [x] Replay (same code twice) → 401 `invalid_authorization_code` - [x] Wrong code for right initId → 401 (no oracle — constant-time compare) - [x] Unknown initId → 401 - [x] Incomplete init row (no token attached) → 401 - [x] Expired completion row → 401 even with the right code - [x] Password grant still works (regression baseline for the discriminated union) - [x] Web cookie OAuth flow still works (no init row → cookie path) - [x] `GET /api/auth/providers` returns the configured provider list - [x] Client `runOauthNativeSignIn` orchestrates init → openURL → deep-link → exchange with mocked callbacks - [x] Client init-mismatch / deep-link-error / exchange-failure surface stable error codes - [x] Client deep-link queue delivers a pushed URL to a subsequent await + times out - [x] `pnpm -F @carol/api typecheck / lint / test / openapi:check / openapi:coverage` green - [x] `pnpm -F @carol/api-client typecheck / lint / check` green - [x] `pnpm -F @carol/client typecheck / lint / test / export:web` green ## See also - [#215](https://forge.wynning.tech/james/carol/issues/215) — this ticket. - [#245](https://forge.wynning.tech/james/carol/issues/245) — sibling ticket: native OAuth *linking* flow (authenticated, different plumbing). - [ADR-0015](docs/adr/0015-oauth-account-linking.md) — "don't auto-merge by email" rule the native callback follows for sign-in. - [ADR-0027](docs/adr/0027-frontend-backend-split-and-universal-client.md) §6 — cookie-vs-bearer split. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(api+client): OAuth callback → bearer token handoff for native (#215)
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 8s
PR / OSV-Scanner (pull_request) Successful in 1m38s
PR / Static analysis (pull_request) Successful in 1m56s
PR / OpenAPI (pull_request) Successful in 2m12s
PR / Client (web export smoke) (pull_request) Successful in 2m22s
PR / Typecheck (pull_request) Successful in 2m23s
PR / Build (pull_request) Successful in 2m32s
PR / pnpm audit (pull_request) Successful in 2m43s
PR / Package age policy (soft) (pull_request) Successful in 33s
Secrets / gitleaks (pull_request) Successful in 25s
PR / Lint (pull_request) Successful in 2m56s
PR / Test (sqlite) (pull_request) Successful in 3m14s
PR / Test (postgres) (pull_request) Failing after 3m14s
PR / Coverage (soft) (pull_request) Successful in 1m29s
PR / Trivy (image) (pull_request) Successful in 2m0s
08f177d3bf
Adds a three-step OAuth sign-in flow for native clients that can't
hold the existing cookie-based session across the system-browser
handoff:

  1. POST /api/auth/oauth/native/init mints an `oauth_inits` row
     carrying state hash + PKCE verifier + (OIDC) nonce, returns
     `{ initId, providerStartUrl, deepLinkOnComplete, expiresAt }`.
  2. The native client opens `providerStartUrl` in the system browser
     via `expo-linking.openURL`. The user authenticates, the IdP
     redirects to `/api/auth/oauth/callback/{provider}`.
  3. The callback hashes the received state, finds the init row,
     runs the existing linking decision tree, attaches a one-time
     completion-token hash to the row, and 302s to
     `carol://auth/oauth/complete?init=…&code=…`.
  4. The native app catches the deep link via `expo-linking.addEventListener`,
     POSTs `{ grantType: "authorization_code", initId, code }` to
     /api/auth/token, and stashes the returned bearer pair in
     SecureStore.

State, PKCE, nonce, and RFC 9207 issuer checks carry over 1:1 from
the cookie flow; the init row's `state_hash` UNIQUE constraint
replaces the cookie-equality check. Replay is blocked by a
single-shot `consumed_at` transition; rows expire after 5 minutes.
The web cookie flow is preserved unchanged — the callback routes by
"state hashes to a row" vs. "no row, fall through to cookies".

Also adds GET /api/auth/providers (unauthenticated provider list)
for the login screen's "Sign in with <Provider>" buttons.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

📊 Test coverage

Patch coverage: no testable lines changed.

Overall (app/, lib/, db/, excluding UI per ADR-0019):

Metric Value Soft target
Lines 83.3% ≥ 50%
Branches 75.7% ≥ 75%
Functions 91.9% informational

Soft thresholds per ADR-0019. Coverage is informational and does not block merge.

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** no testable lines changed. **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 83.3% ✅ | ≥ 50% | | Branches | 75.7% ✅ | ≥ 75% | | Functions | 91.9% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
james merged commit 55f24fb024 into main 2026-06-23 14:04:41 +00:00
james deleted branch 215-oauth-native-bearer 2026-06-23 14:04:42 +00:00
Sign in to join this conversation.
No description provided.