feat(api+client): link-session token for native OAuth linking (#245) #262

Merged
james merged 1 commit from 245-native-oauth-link-session into main 2026-06-23 20:37:42 +00:00
Owner

Scope

Native (Android, Linux Flatpak) clients sign in via bearer token, which the system browser can't carry across expo-linking.openURL. The "Connect another" affordance on /account therefore had no way to tell the OAuth start route who's linking. This is the link companion to #215 / PR #249 (native sign-in).

The shape — a one-shot, server-minted token bound to the authenticated user, consumed at /api/auth/oauth/start:

┌──────────────┐                ┌──────────────┐               ┌────────────┐
│ Native app   │                │ Carol API    │               │  IdP       │
└──────────────┘                └──────────────┘               └────────────┘
       │ POST /api/account/identities/link-session  (bearer or cookie)
       ├───────────────────────────────►│
       │   {token, expiresAt, deepLinkOnComplete}
       │◄───────────────────────────────┤
       │ openURL(<api>/api/auth/oauth/start
       │   ?provider=…&intent=link&link_session=<token>) ────────────► │
       │                                │ consumes link_session,        │
       │                                │ writes oauth_inits with       │
       │                                │ intent=link + bound user_id   │
       │                                │ 302 → IdP authorize           │
       │ user authenticates with IdP                                    │
       │ system browser hits  ◄─ 302 carol://account/identities?linked=1
       │ /api/auth/oauth/callback/{provider}?state=…&code=…  │          │
       │                                ├──────────────────► token exch │
       │                                │◄──────────────────  profile   │
       │                                │ linking decision tree against │
       │                                │ bound user                    │
       │                                │ 302 → carol://…               │
       │ deep-link arrives → SignInMethodsCard refetches identities     │

What landed

  • DB. Migration 014_link_session_tokens.ts (FK → users, ON DELETE CASCADE, UNIQUE token_hash, single-use via consumed_at). Migration 015_oauth_inits_intent.ts adds intent column to oauth_inits ("signin" default, "link" for this flow). link_session_tokens is added to the postgres teardown drop list in dependency order — closes the recurring #259 trap for new FK-holders.
  • API.
    • POST /api/account/identities/link-session — bearer or cookie auth, mints { token, expiresAt, deepLinkOnComplete }, token returned once.
    • GET /api/auth/oauth/start extended with link_session query param. Precedence: session cookie wins; otherwise link_session → mint OAuth init with intent: "link", user_id: <bound>.
    • GET /api/auth/oauth/callback/{provider} branches on oauth_inits.intent. Link path runs the existing linking decision tree against the bound user and redirects to carol://account/identities?linked=1 (success) / ?error=<code> (failure). No completion-token mint; no /api/auth/token exchange.
    • Registered in openapi-routes.ts; drift gate + coverage green.
  • @carol/api-client. useStartIdentityLink mutation in account-identities.ts. Typed client regenerated; drift gate green.
  • Universal client.
    • apps/client/app/(app)/account.tsxConnectProviderRow mints a link-session, builds the OAuth start URL, opens via Linking.openURL. SignInMethodsCard subscribes to link-complete deep-links; success force-refetches account.identities, failure renders a translated error catalog string.
    • apps/client/app/_layout.tsx — deep-link listener now dispatches through both the sign-in queue and the link-complete subscriber.
    • New error catalog keys under account.errors.linkSession.* (expired, consumed, invalid, emailInUse, providerMismatch, unknown).
  • Docs. New section in docs/api-conventions.md covering the flow + threat model. Same flow diagram as above.

Security notes

  • Token hashing. The DB stores SHA-256(token) under a UNIQUE constraint. The token is delivered once in the mint response and never readable from the row again. SHA-256 (vs argon2id) suffices because the secret is 256-bit, single-use, and expires in 5 minutes — brute-force is infeasible regardless of hash cost.
  • Two-stage single-use. Link-session token is consumed at /api/auth/oauth/start under a "still null" consumed_at predicate; the oauth_inits row created from it is consumed at the callback under the same predicate. A replay of either fails closed (deep-link ?error=consumed / ?error=link_session_consumed).
  • Bound user binding. oauth_inits.user_id is set at init time from the consumed link-session token — never from a query param the off-origin browser carries. The linking decision tree resolves OAuth identities against the bound user, not against whoever happens to control the system browser.
  • Failure modes redirect to the deep link. Token expired, consumed, invalid → carol://account/identities?error=<code>. The user is already running the app; firing the deep link sends control back to them instead of stranding the system browser on a JSON 400.

Surprises / shape calls

  • intent column on oauth_inits is the discriminator (rather than a separate link_oauth_inits table). The sign-in and link flows share state binding, PKCE, nonce, mix-up + RFC 9207 checks, deep-link redirect plumbing — only currentUserId source and post-success target differ. One column + one branch keeps the callback under one roof; ADR-0015's "one greppable file for the decision tree" stays true.
  • No POST /api/auth/token link sibling. The ticket asks the agent to decide whether to add a link grant or reuse the callback's deep-link redirect. The native client is already bearer-authed when "Connect another" fires; the only thing it needs to learn is "the link succeeded." Deep-link redirect is the natural channel. Adding a token endpoint would introduce a second single-use exchange without any token to issue.
  • Fan-out subscriber (not a queue) for link-complete payloads — the consumer (Account screen) is mounted by the time the OAuth callback fires (the user tapped Connect from there). The sign-in flow's queue is needed because Android cold-start can deliver the deep link before any consumer exists; the link flow's consumer is always running, so we use a subscriber list with a one-entry buffer for the edge case.

Test plan

  • pnpm install --frozen-lockfile
  • pnpm -F @carol/api typecheck / lint
  • pnpm -F @carol/api test — 650 passed (51 files, including new account-link-session.test.ts with 14 cases covering mint, start consumption, expiry, replay, deep-link error mapping, callback link path happy/refusal/expired/consumed/idempotent-reconnect)
  • pnpm -F @carol/api openapi:check / openapi:coverage (65 pairs)
  • pnpm -F @carol/api-client typecheck / lint / test / check
  • pnpm -F @carol/client typecheck / lint / test (15 files, 99 cases, including new linkCompleteDeepLink.test.ts covering parser + fan-out queue)
  • pnpm -F @carol/client export:web (web build green; web flow unchanged — same-origin redirect)
  • Manual Android smoke (bearer sign-in → tap Connect another → OAuth in system browser → return via deep link → SignInMethodsCard refetches). Could not run on a device in this worktree; documenting honestly.
  • Sibling sign-in flow: #215 / PR #249
  • ADR-0015 (OAuth account linking), ADR-0017 (generic OIDC), ADR-0027 (frontend/backend split & universal client)

Closes #245.

## Scope Native (Android, Linux Flatpak) clients sign in via bearer token, which the system browser can't carry across `expo-linking.openURL`. The "Connect another" affordance on `/account` therefore had no way to tell the OAuth start route who's linking. This is the **link** companion to [#215](https://forge.wynning.tech/james/carol/issues/215) / [PR #249](https://forge.wynning.tech/james/carol/pulls/249) (native sign-in). The shape — a one-shot, server-minted token bound to the authenticated user, consumed at `/api/auth/oauth/start`: ```text ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │ Native app │ │ Carol API │ │ IdP │ └──────────────┘ └──────────────┘ └────────────┘ │ POST /api/account/identities/link-session (bearer or cookie) ├───────────────────────────────►│ │ {token, expiresAt, deepLinkOnComplete} │◄───────────────────────────────┤ │ openURL(<api>/api/auth/oauth/start │ ?provider=…&intent=link&link_session=<token>) ────────────► │ │ │ consumes link_session, │ │ │ writes oauth_inits with │ │ │ intent=link + bound user_id │ │ │ 302 → IdP authorize │ │ user authenticates with IdP │ │ system browser hits ◄─ 302 carol://account/identities?linked=1 │ /api/auth/oauth/callback/{provider}?state=…&code=… │ │ │ ├──────────────────► token exch │ │ │◄────────────────── profile │ │ │ linking decision tree against │ │ │ bound user │ │ │ 302 → carol://… │ │ deep-link arrives → SignInMethodsCard refetches identities │ ``` ## What landed - **DB.** Migration `014_link_session_tokens.ts` (FK → users, ON DELETE CASCADE, UNIQUE token_hash, single-use via `consumed_at`). Migration `015_oauth_inits_intent.ts` adds `intent` column to `oauth_inits` (`"signin"` default, `"link"` for this flow). `link_session_tokens` is added to the postgres teardown drop list in dependency order — closes the recurring [#259](https://forge.wynning.tech/james/carol/issues/259) trap for new FK-holders. - **API.** - `POST /api/account/identities/link-session` — bearer or cookie auth, mints `{ token, expiresAt, deepLinkOnComplete }`, token returned once. - `GET /api/auth/oauth/start` extended with `link_session` query param. Precedence: session cookie wins; otherwise `link_session` → mint OAuth init with `intent: "link"`, `user_id: <bound>`. - `GET /api/auth/oauth/callback/{provider}` branches on `oauth_inits.intent`. Link path runs the existing linking decision tree against the bound user and redirects to `carol://account/identities?linked=1` (success) / `?error=<code>` (failure). No completion-token mint; no `/api/auth/token` exchange. - Registered in `openapi-routes.ts`; drift gate + coverage green. - **`@carol/api-client`.** `useStartIdentityLink` mutation in `account-identities.ts`. Typed client regenerated; drift gate green. - **Universal client.** - `apps/client/app/(app)/account.tsx` — `ConnectProviderRow` mints a link-session, builds the OAuth start URL, opens via `Linking.openURL`. `SignInMethodsCard` subscribes to link-complete deep-links; success force-refetches `account.identities`, failure renders a translated error catalog string. - `apps/client/app/_layout.tsx` — deep-link listener now dispatches through both the sign-in queue and the link-complete subscriber. - New error catalog keys under `account.errors.linkSession.*` (`expired`, `consumed`, `invalid`, `emailInUse`, `providerMismatch`, `unknown`). - **Docs.** New section in `docs/api-conventions.md` covering the flow + threat model. Same flow diagram as above. ## Security notes - **Token hashing.** The DB stores SHA-256(token) under a UNIQUE constraint. The token is delivered once in the mint response and never readable from the row again. SHA-256 (vs argon2id) suffices because the secret is 256-bit, single-use, and expires in 5 minutes — brute-force is infeasible regardless of hash cost. - **Two-stage single-use.** Link-session token is consumed at `/api/auth/oauth/start` under a "still null" `consumed_at` predicate; the `oauth_inits` row created from it is consumed at the callback under the same predicate. A replay of either fails closed (deep-link `?error=consumed` / `?error=link_session_consumed`). - **Bound user binding.** `oauth_inits.user_id` is set at init time from the consumed link-session token — never from a query param the off-origin browser carries. The linking decision tree resolves OAuth identities against the bound user, not against whoever happens to control the system browser. - **Failure modes redirect to the deep link.** Token expired, consumed, invalid → `carol://account/identities?error=<code>`. The user is already running the app; firing the deep link sends control back to them instead of stranding the system browser on a JSON 400. ## Surprises / shape calls - **`intent` column on `oauth_inits`** is the discriminator (rather than a separate `link_oauth_inits` table). The sign-in and link flows share state binding, PKCE, nonce, mix-up + RFC 9207 checks, deep-link redirect plumbing — only `currentUserId` source and post-success target differ. One column + one branch keeps the callback under one roof; ADR-0015's "one greppable file for the decision tree" stays true. - **No `POST /api/auth/token` link sibling.** The ticket asks the agent to decide whether to add a `link` grant or reuse the callback's deep-link redirect. The native client is already bearer-authed when "Connect another" fires; the only thing it needs to learn is "the link succeeded." Deep-link redirect is the natural channel. Adding a token endpoint would introduce a second single-use exchange without any token to issue. - **Fan-out subscriber (not a queue) for link-complete payloads** — the consumer (Account screen) is mounted by the time the OAuth callback fires (the user tapped Connect from there). The sign-in flow's queue is needed because Android cold-start can deliver the deep link before any consumer exists; the link flow's consumer is always running, so we use a subscriber list with a one-entry buffer for the edge case. ## Test plan - [x] `pnpm install --frozen-lockfile` - [x] `pnpm -F @carol/api typecheck` / `lint` - [x] `pnpm -F @carol/api test` — 650 passed (51 files, including new `account-link-session.test.ts` with 14 cases covering mint, start consumption, expiry, replay, deep-link error mapping, callback link path happy/refusal/expired/consumed/idempotent-reconnect) - [x] `pnpm -F @carol/api openapi:check` / `openapi:coverage` (65 pairs) - [x] `pnpm -F @carol/api-client typecheck` / `lint` / `test` / `check` - [x] `pnpm -F @carol/client typecheck` / `lint` / `test` (15 files, 99 cases, including new `linkCompleteDeepLink.test.ts` covering parser + fan-out queue) - [x] `pnpm -F @carol/client export:web` (web build green; web flow unchanged — same-origin redirect) - [ ] Manual Android smoke (bearer sign-in → tap Connect another → OAuth in system browser → return via deep link → SignInMethodsCard refetches). Could not run on a device in this worktree; documenting honestly. ## Related - Sibling sign-in flow: [#215](https://forge.wynning.tech/james/carol/issues/215) / PR [#249](https://forge.wynning.tech/james/carol/pulls/249) - ADR-0015 (OAuth account linking), ADR-0017 (generic OIDC), ADR-0027 (frontend/backend split & universal client) Closes #245.
feat(api+client): link-session token for native OAuth linking (#245)
All checks were successful
Commits / Conventional Commits (pull_request) Successful in 7s
PR / OSV-Scanner (pull_request) Successful in 1m34s
PR / pnpm audit (pull_request) Successful in 2m0s
PR / Typecheck (pull_request) Successful in 2m16s
PR / Static analysis (pull_request) Successful in 2m17s
PR / OpenAPI (pull_request) Successful in 2m33s
PR / Lint (pull_request) Successful in 2m49s
PR / Client (web export smoke) (pull_request) Successful in 2m54s
PR / Package age policy (soft) (pull_request) Successful in 36s
Secrets / gitleaks (pull_request) Successful in 37s
PR / Test (postgres) (pull_request) Successful in 3m3s
PR / Build (pull_request) Successful in 3m10s
PR / Test (sqlite) (pull_request) Successful in 3m15s
PR / Coverage (soft) (pull_request) Successful in 1m35s
PR / Trivy (image) (pull_request) Successful in 2m6s
be40c6fe39
Native (Android, Linux Flatpak) clients sign in via bearer token,
which the system browser can't carry across the `expo-linking.openURL`
hop to /api/auth/oauth/start. The "Connect another" affordance on
/account therefore had no way to identify the linking user. #245
closes that gap with a one-shot, server-minted link-session token:

  - POST /api/account/identities/link-session (bearer or cookie)
    mints `{ token, expiresAt, deepLinkOnComplete }`. The DB row
    stores SHA-256(token), bound to the authenticated user,
    expires in 5 minutes, single-use.
  - GET /api/auth/oauth/start gains a `link_session` query param.
    When present and valid, the route consumes the token, persists
    an `oauth_inits` row with `intent: "link"` and the bound
    `user_id`, and redirects to the IdP. On token failure it
    redirects to `carol://account/identities?error=<code>` so the
    native app surfaces the error.
  - The OAuth callback branches on `oauth_inits.intent`. The link
    path runs the existing linking decision tree against the bound
    user and redirects to `carol://account/identities?linked=1` or
    `?error=<code>`. No completion-token mint; no
    /api/auth/token exchange.
  - @carol/api-client gains `useStartIdentityLink`. The Account
    screen mints, opens the OAuth URL via expo-linking, and a
    deep-link subscriber refetches the identities query on success
    or shows a translated error on failure.

Adds two forward-only migrations (`link_session_tokens` table,
`oauth_inits.intent` column) and adds `link_session_tokens` to the
postgres teardown drop list in dependency order (FK → users).

Closes #245

📊 Test coverage

Patch coverage: no testable lines changed.

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

Metric Value Soft target
Lines 82.7% ≥ 50%
Branches 75.1% ≥ 75%
Functions 91.7% 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 | 82.7% ✅ | ≥ 50% | | Branches | 75.1% ✅ | ≥ 75% | | Functions | 91.7% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
james merged commit 0b350acf27 into main 2026-06-23 20:37:42 +00:00
james deleted branch 245-native-oauth-link-session 2026-06-23 20:37:43 +00:00
Sign in to join this conversation.
No description provided.