OAuth2 authentication + account linking (#12) #73
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
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
james/carol!73
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "12-oauth"
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?
Closes #12.
Summary
lib/auth/oauth.ts) for the linking decision tree; provider definitions inlib/auth/oauth-providers.ts; cookie helpers inlib/auth/oauth-cookies.ts. ADR-0014 records the deviation from the CLAUDE.md Auth.js default and enumerates the threat model the implementation handles (state, PKCE, mix-up / RFC 9207, replay via in-same-response cookie clear, redirect-URI lock-in, no token storage).GITHUB_CLIENT_IDandGITHUB_CLIENT_SECRETare set. Adding a provider is one registry entry + afetchProfilefunction + tests.GET /api/auth/oauth/startsets three short-lived cookies (state / PKCE verifier / signed meta) and 302s to the provider;GET /api/auth/oauth/callback/[provider]validates, exchanges, fetches profile, runs the decision tree, applies the result. Every callback exit clears the OAuth cookies in the same response — captured callback URLs can't be replayed.login_existing/signup_new/linked_to_current/refused_email_in_use/refused_belongs_to_other. Each branch is unit-tested./login?error=email_in_use). The published account-takeover attack against several Sign-In-With-X integrations is exactly auto-link-by-email; we don't ship it.fetchProfilethrowsNoVerifiedEmailErrorif the provider can't produce a verified primary email. GitHub's/user/emailsreturns the{primary, verified}flag per address.oauth_identitiestable (migration 005). UNIQUE(provider, provider_user_id)is the durable lookup key; INDEX(user_id)backs the /account listing. FK + cascade matches every other per-user table./loginand/registermount<OAuthButtons>below the password form with a divider. Both pages also surface?error=email_in_useand?error=no_verified_emailas a banner above the form./accountpage lives under the authed shell. Lists the user's local + OAuth identities, with "Unlink" form-buttons (disabled when removing the row would leave the user with zero sign-in methods) and a "Connect another" section showing OAuth providers that aren't yet linked.unlinkOAuthIdentityActionserver action mirrors the logout-action pattern from PR #71: ownership check + last-method invariant + redirect-in-same-response.safeNextat ingress./api/auth/oauth/startrunssafeNext(rawNext)before storing in the meta cookie, so a tampered?next=can't smuggle an off-origin redirect into the callback.lib/auth/public-routes.ts's "Auth.js callback routes" comment replaced with a reference to ADR-0014.email_in_uselockout. Without password reset, a user who forgot their password and tries OAuth sign-in is locked out; #72 builds the magic-link path that resolves it. Documented in ADR-0014.Build / route summary
/and/offlinestaying○ (Static)is the load-bearing thing: ADR-0008's precache invariant is preserved.Test plan
npm run typecheck,npm run lint— clean.npm test— 173 passed / 38 skipped (211 total). 49 new tests:tests/db/oauth-identities.test.ts— dual-engine, 6 tests (12 with the skipped pg leg). UNIQUE + cascade + delete-bounded-by-ownership.tests/auth/oauth-providers.test.ts— 11 tests. Registry shape, env-driven enablement, GitHubfetchProfileround-trip + the no-verified-email throw + non-200 paths.tests/auth/oauth-decision.test.ts— 7 tests. Every branch ofresolveOAuthIdentityincluding first-user-is-admin.tests/api/oauth.test.ts— 12 tests. /start parameter validation + cookie set,safeNextat ingress, every callback branch (signin-new, signin-existing-identity, signin-email-conflict, link-success, link-conflict, no-verified-email), and the replay defence (cookies cleared on bail-out responses).npm run build— succeeds. Routes as expected above.GITHUB_CLIENT_ID+GITHUB_CLIENT_SECRETand an OAuth app configured with callbackhttp://localhost:3000/api/auth/oauth/callback/github):What didn't change
proxy.tspolicy (ADR-0013). The new routes ride under the existing/api/auth/public prefix; the proxy's HTML/API split is untouched.registerUser,attemptLogin,createSession). OAuth callback layers on top.lib/dto/user.ts's hand-rolled parsers stay; zod migration is the follow-up the ADR-0012 note already names.ADR
ADR-0014 lives at
docs/adr/0014-oauth-account-linking.md. Notable: it explicitly enumerates the threats handled — without that enumeration, "rolled minimal OAuth" reads as a security shortcut instead of a considered choice.🤖 Generated with Claude Code
8dbedc47e886f77d3143