feat(auth): link-session token so native bearer-authed clients can link OAuth identities #245

Closed
opened 2026-06-23 13:14:17 +00:00 by james · 0 comments
Owner

Context

PR #244 (closing #216) shipped the "Sign-in methods" panel on the Account screen. Listing + unlink work everywhere; "Connect another" doesn't actually link on native, only on web.

The root cause: /api/auth/oauth/start?intent=link only flips to link mode when it sees a session cookie via getSession(req). Per ADR-0027, native (Android, Tauri Flatpak) authenticates via short-lived bearer access tokens — no cookies. So when a native user taps "Connect another," expo-linking.openURL opens the system browser at the OAuth start URL, but the server can't tell who the user is and falls through to a regular sign-in / sign-up flow.

PR #244 ships with a hint string documenting that a user who's web-signed-in on the same browser can complete the link. That's an honest workaround, not a solution.

Source

  • PR #244 "two notable calls" section, sub-bullet 2.
  • ADR-0027 §3 (bearer auth for native, cookie auth for web).
  • ADR-0015 (OAuth account linking).

Approach

A one-shot link-session-mint endpoint:

  1. POST /api/account/identities/link-session (new). Bearer- or cookie-authed. Returns a short-lived (e.g. 5-minute) opaque token bound to the authenticated user, persisted in a new link_session_tokens table (mirror refresh_tokens' shape: id, user_id, token_hash, expires_at, consumed_at).
  2. Native client calls that endpoint right before opening the system browser. Includes the token as a query param: expo-linking.openURL("<api>/api/auth/oauth/start?provider=<id>&intent=link&link_session=<token>").
  3. /api/auth/oauth/start is taught a third auth path: if link_session query param is present, look it up (server-side, by hash), validate freshness + unconsumed state, and treat the request as if the bound user is signed in. Mark consumed on use.
  4. On callback success, the server links the new OAuth identity to the bound user and redirects back to a configured deep link (e.g. carol://account/identities?linked=1). Android picks this up via the existing expo-linking plumbing → the SignInMethodsCard refetches → new identity shows up.
  5. Failure modes (token expired, already consumed, mismatched provider) redirect to carol://account/identities?error=<code> with a user-facing error catalog key.

Scope

  • DB migration adding link_session_tokens (forward-only, dual-engine).
  • LinkSessionTokensRepository with the same lifecycle methods refresh tokens have.
  • POST /api/account/identities/link-session route + zod DTO + OpenAPI registration.
  • Extend /api/auth/oauth/start to accept link_session query param; document precedence (session cookie wins, then link_session, then anonymous → sign-in).
  • Update /api/auth/oauth/callback/[provider] to use the bound user when the start was link-session-driven (the existing OAuth-state cookie can carry the user id forward).
  • Extend expo-linking in the universal client to handle the carol://account/identities deep link path (add to Linking.createURL listeners in apps/client/app/_layout.tsx or wherever the listener lives).
  • Update the SignInMethodsCard's "Connect another" handler on native: mint the token, then openURL with it.
  • Add the new error catalog keys (errors.account.link_session_expired, errors.account.link_session_consumed, errors.account.link_session_provider_mismatch).
  • Tests: mint happy path, expired, consumed, cross-user 404, end-to-end link via a stubbed OAuth provider.
  • ADR-0015 amendment (or new ADR) recording the link-session token shape.

Acceptance criteria

  • A native user tapping "Connect another" on the Account screen successfully links a new OAuth identity. The new identity appears in the list after the deep-link callback fires.
  • Link-session tokens expire after 5 minutes (configurable via env var if useful — list in README per the env-var convention).
  • A token cannot be reused: consumed-state is enforced server-side.
  • Cross-user link attempts return 404 (don't leak identity existence).
  • Cookie-authed callers (web) keep working unchanged — the existing flow takes precedence over link_session.
  • Tests cover mint, valid use, expired, consumed, provider mismatch, cross-user.
  • OpenAPI spec includes the new endpoint; drift gate green.

Out of scope

  • Email-verified linking guard (#72 covers that).
  • Linking from the login screen.
  • Adding more providers — that's the OIDC env-var surface, unchanged.

Composes with

  • #216 / PR #244 — the panel that surfaces the gap.
  • ADR-0015 — OAuth + linking.
  • ADR-0027 — bearer vs cookie auth split.

Part of

#176 (epic just closed but the lineage applies).

## Context PR [#244](https://forge.wynning.tech/james/carol/pulls/244) (closing [#216](https://forge.wynning.tech/james/carol/issues/216)) shipped the "Sign-in methods" panel on the Account screen. Listing + unlink work everywhere; **"Connect another" doesn't actually link on native**, only on web. The root cause: `/api/auth/oauth/start?intent=link` only flips to link mode when it sees a session cookie via `getSession(req)`. Per [ADR-0027](docs/adr/0027-frontend-backend-split-and-universal-client.md), native (Android, Tauri Flatpak) authenticates via short-lived bearer access tokens — no cookies. So when a native user taps "Connect another," `expo-linking.openURL` opens the system browser at the OAuth start URL, but the server can't tell who the user is and falls through to a regular sign-in / sign-up flow. PR #244 ships with a hint string documenting that a user who's web-signed-in on the same browser can complete the link. That's an honest workaround, not a solution. ## Source - PR [#244](https://forge.wynning.tech/james/carol/pulls/244) "two notable calls" section, sub-bullet 2. - [ADR-0027](docs/adr/0027-frontend-backend-split-and-universal-client.md) §3 (bearer auth for native, cookie auth for web). - [ADR-0015](docs/adr/0015-oauth2-auth.md) (OAuth account linking). ## Approach A one-shot **link-session-mint** endpoint: 1. **`POST /api/account/identities/link-session`** (new). Bearer- or cookie-authed. Returns a short-lived (e.g. 5-minute) opaque token bound to the authenticated user, persisted in a new `link_session_tokens` table (mirror `refresh_tokens`' shape: `id`, `user_id`, `token_hash`, `expires_at`, `consumed_at`). 2. **Native client** calls that endpoint right before opening the system browser. Includes the token as a query param: `expo-linking.openURL("<api>/api/auth/oauth/start?provider=<id>&intent=link&link_session=<token>")`. 3. **`/api/auth/oauth/start`** is taught a third auth path: if `link_session` query param is present, look it up (server-side, by hash), validate freshness + unconsumed state, and treat the request as if the bound user is signed in. Mark consumed on use. 4. On callback success, the server links the new OAuth identity to the bound user and redirects back to a configured deep link (e.g. `carol://account/identities?linked=1`). Android picks this up via the existing `expo-linking` plumbing → the SignInMethodsCard refetches → new identity shows up. 5. **Failure modes** (token expired, already consumed, mismatched provider) redirect to `carol://account/identities?error=<code>` with a user-facing error catalog key. ## Scope - DB migration adding `link_session_tokens` (forward-only, dual-engine). - `LinkSessionTokensRepository` with the same lifecycle methods refresh tokens have. - `POST /api/account/identities/link-session` route + zod DTO + OpenAPI registration. - Extend `/api/auth/oauth/start` to accept `link_session` query param; document precedence (session cookie wins, then link_session, then anonymous → sign-in). - Update `/api/auth/oauth/callback/[provider]` to use the bound user when the start was link-session-driven (the existing OAuth-state cookie can carry the user id forward). - Extend `expo-linking` in the universal client to handle the `carol://account/identities` deep link path (add to `Linking.createURL` listeners in `apps/client/app/_layout.tsx` or wherever the listener lives). - Update the SignInMethodsCard's "Connect another" handler on native: mint the token, then openURL with it. - Add the new error catalog keys (`errors.account.link_session_expired`, `errors.account.link_session_consumed`, `errors.account.link_session_provider_mismatch`). - Tests: mint happy path, expired, consumed, cross-user 404, end-to-end link via a stubbed OAuth provider. - ADR-0015 amendment (or new ADR) recording the link-session token shape. ## Acceptance criteria - [ ] A native user tapping "Connect another" on the Account screen successfully links a new OAuth identity. The new identity appears in the list after the deep-link callback fires. - [ ] Link-session tokens expire after 5 minutes (configurable via env var if useful — list in README per the env-var convention). - [ ] A token cannot be reused: consumed-state is enforced server-side. - [ ] Cross-user link attempts return 404 (don't leak identity existence). - [ ] Cookie-authed callers (web) keep working unchanged — the existing flow takes precedence over `link_session`. - [ ] Tests cover mint, valid use, expired, consumed, provider mismatch, cross-user. - [ ] OpenAPI spec includes the new endpoint; drift gate green. ## Out of scope - Email-verified linking guard ([#72](https://forge.wynning.tech/james/carol/issues/72) covers that). - Linking from the login screen. - Adding more providers — that's the OIDC env-var surface, unchanged. ## Composes with - [#216](https://forge.wynning.tech/james/carol/issues/216) / PR [#244](https://forge.wynning.tech/james/carol/pulls/244) — the panel that surfaces the gap. - [ADR-0015](docs/adr/0015-oauth2-auth.md) — OAuth + linking. - [ADR-0027](docs/adr/0027-frontend-backend-split-and-universal-client.md) — bearer vs cookie auth split. ## Part of [#176](https://forge.wynning.tech/james/carol/issues/176) (epic just closed but the lineage applies).
james closed this issue 2026-06-23 20:37:43 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
james/carol#245
No description provided.