feat(api+client): link-session token for native OAuth linking (#245) #262
No reviewers
Labels
No labels
area:auth
area:ci
area:db
area:infra
area:native
area:pwa
area:service
epic
feature
foundation
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
james/carol!262
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "245-native-oauth-link-session"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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/accounttherefore 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:What landed
014_link_session_tokens.ts(FK → users, ON DELETE CASCADE, UNIQUE token_hash, single-use viaconsumed_at). Migration015_oauth_inits_intent.tsaddsintentcolumn tooauth_inits("signin"default,"link"for this flow).link_session_tokensis added to the postgres teardown drop list in dependency order — closes the recurring #259 trap for new FK-holders.POST /api/account/identities/link-session— bearer or cookie auth, mints{ token, expiresAt, deepLinkOnComplete }, token returned once.GET /api/auth/oauth/startextended withlink_sessionquery param. Precedence: session cookie wins; otherwiselink_session→ mint OAuth init withintent: "link",user_id: <bound>.GET /api/auth/oauth/callback/{provider}branches onoauth_inits.intent. Link path runs the existing linking decision tree against the bound user and redirects tocarol://account/identities?linked=1(success) /?error=<code>(failure). No completion-token mint; no/api/auth/tokenexchange.openapi-routes.ts; drift gate + coverage green.@carol/api-client.useStartIdentityLinkmutation inaccount-identities.ts. Typed client regenerated; drift gate green.apps/client/app/(app)/account.tsx—ConnectProviderRowmints a link-session, builds the OAuth start URL, opens viaLinking.openURL.SignInMethodsCardsubscribes to link-complete deep-links; success force-refetchesaccount.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.account.errors.linkSession.*(expired,consumed,invalid,emailInUse,providerMismatch,unknown).docs/api-conventions.mdcovering the flow + threat model. Same flow diagram as above.Security notes
/api/auth/oauth/startunder a "still null"consumed_atpredicate; theoauth_initsrow 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).oauth_inits.user_idis 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.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
intentcolumn onoauth_initsis the discriminator (rather than a separatelink_oauth_initstable). The sign-in and link flows share state binding, PKCE, nonce, mix-up + RFC 9207 checks, deep-link redirect plumbing — onlycurrentUserIdsource 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.POST /api/auth/tokenlink sibling. The ticket asks the agent to decide whether to add alinkgrant 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.Test plan
pnpm install --frozen-lockfilepnpm -F @carol/api typecheck/lintpnpm -F @carol/api test— 650 passed (51 files, including newaccount-link-session.test.tswith 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/checkpnpm -F @carol/client typecheck/lint/test(15 files, 99 cases, including newlinkCompleteDeepLink.test.tscovering parser + fan-out queue)pnpm -F @carol/client export:web(web build green; web flow unchanged — same-origin redirect)Related
Closes #245.
📊 Test coverage
Patch coverage: no testable lines changed.
Overall (
app/,lib/,db/, excluding UI per ADR-0019):Soft thresholds per ADR-0019. Coverage is informational and does not block merge.