feat(auth): link-session token so native bearer-authed clients can link OAuth identities #245
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
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
james/carol#245
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
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=linkonly flips to link mode when it sees a session cookie viagetSession(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.openURLopens 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
Approach
A one-shot link-session-mint endpoint:
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 newlink_session_tokenstable (mirrorrefresh_tokens' shape:id,user_id,token_hash,expires_at,consumed_at).expo-linking.openURL("<api>/api/auth/oauth/start?provider=<id>&intent=link&link_session=<token>")./api/auth/oauth/startis taught a third auth path: iflink_sessionquery 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.carol://account/identities?linked=1). Android picks this up via the existingexpo-linkingplumbing → the SignInMethodsCard refetches → new identity shows up.carol://account/identities?error=<code>with a user-facing error catalog key.Scope
link_session_tokens(forward-only, dual-engine).LinkSessionTokensRepositorywith the same lifecycle methods refresh tokens have.POST /api/account/identities/link-sessionroute + zod DTO + OpenAPI registration./api/auth/oauth/startto acceptlink_sessionquery param; document precedence (session cookie wins, then link_session, then anonymous → sign-in)./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).expo-linkingin the universal client to handle thecarol://account/identitiesdeep link path (add toLinking.createURLlisteners inapps/client/app/_layout.tsxor wherever the listener lives).errors.account.link_session_expired,errors.account.link_session_consumed,errors.account.link_session_provider_mismatch).Acceptance criteria
link_session.Out of scope
Composes with
Part of
#176 (epic just closed but the lineage applies).