Skip to content

Commit 02827d7

Browse files
authored
feat: account linking (#130)
* feat(auth): add change email and OAuth account linking * fix(next): reject protocol-relative URLs in OAuth redirect target * fix(auth): address review findings for OAuth, change email, and docs - Docs: callback routes enum (github,facebook,twitter), link-authorize-url session-only - Change email request: try/catch with verification cleanup on render/send error - OAuth link: session validation for oauth_link_state, pass Bearer from callback - Durable rate limit: consumedAt on verification, update instead of delete for oauth_link_state - Session user: deduplicate linkedAccounts by providerId - Change email block: try/catch for request/verify, toast on error - Change email callback: onTokensRefreshed to capture refreshed tokens - use-change-email: await onVerifySuccess, propagate requestChange errors - use-oauth-link: throwOnError for API client - Extract validateAndConsumeOAuthState helper, remove max-lines override - linked-accounts-section: derive providers from providerLabels, Google coming soon - Twitter exchange: validate linkUserId before consume * fix(fastify): harden auth flows and add accessibility - oauth-exchange-state: atomic consume with consumedAt IS NULL to prevent double-use - account/email/change: add EMAIL_NOT_CHANGED guard when new email equals current - github exchange: use findOrCreateUserByEmail for idempotent user creation - change-email-block: add id and aria-label to verification code and new-email inputs - docu: align link-authorize-url auth wording (session/JWT, no API key)
1 parent d9cc19c commit 02827d7

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+5305
-892
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
title: Account linking
3+
description: Change email vs link email, OAuth provider linking, provider trust, and guardrails.
4+
---
5+
6+
## Overview
7+
8+
Account linking lets users attach additional sign-in methods (email, OAuth providers, wallets) to an existing account. Two primary patterns exist: **change email** (replace primary email) and **link email / OAuth** (add methods without removing existing ones).
9+
10+
## Change email vs link email
11+
12+
| Capability | Change email | Link email |
13+
|------------|--------------|------------|
14+
| Effect | Replaces `users.email` | Adds a linked method; primary email unchanged |
15+
| Flow | Request → verify code/link → update | Request → verify link → link account |
16+
| Use case | User changes email address | User adds email sign-in to OAuth-only account |
17+
18+
Change email uses `change_email` verification type; link email uses `link_email`. Both use the same 6-digit + link UX and 15-minute TTL.
19+
20+
## OAuth provider linking
21+
22+
Logged-in users can link additional OAuth providers from Profile (Settings). The backend:
23+
24+
- Uses `oauth_link_state` (distinct from `oauth_state` for sign-in)
25+
- Binds the link to `meta.userId` so the exchange attaches to the correct account
26+
- Returns 409 `PROVIDER_ALREADY_LINKED` when the provider is linked to another user
27+
- Returns `redirectTo` so the client can return the user to the settings page after success
28+
29+
## Provider trust
30+
31+
OAuth providers are trusted for identity on first link. Subsequent links require re-authorization; existing tokens are replaced on each successful link.
32+
33+
## Guardrail: last sign-in method
34+
35+
Users must keep at least one sign-in method. Unlinking (wallet, OAuth, or other methods) fails with 400 `LAST_SIGN_IN_METHOD` if it would leave no way to sign in.
36+
37+
## Verify modes (change email)
38+
39+
Verification accepts exactly one of:
40+
41+
- `{ token, email }` — code entry flow; email used as identifier
42+
- `{ token, verificationId }` — link-click flow; verificationId from callback URL
43+
44+
See [Authentication](/docs/architecture/authentication#change-email-implemented) for full flow details.

apps/docu/content/docs/architecture/authentication.mdx

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,21 @@ Magic link sign-in issues a **6-digit login code** over email, then exchanges it
9393
3. **Option A (manual):** User types the 6-digit code in the login form → `POST /auth/magiclink/verify` with `{ token: code }` → JWTs returned
9494
4. **Option B (link):** User clicks the button in email → callback page receives `?token={code}` → verify → cookies set, redirect
9595

96+
### Change email (implemented)
97+
98+
Change email follows the same 6-digit + link pattern as magic link. User requests change, receives code and link in email, verifies via code entry or link click.
99+
100+
**Flow:**
101+
1. User enters new email → `POST /account/email/change/request` (Bearer, body: `{ email, callbackUrl }`) → rate limited 3/hour per user
102+
2. Email contains 6-digit code and link; identifier `userId:newEmail`; 15 min TTL
103+
3. **Code entry:** `POST /account/email/change/verify` with `{ token, email }` (Bearer)
104+
4. **Link click:** User hits `/auth/callback/change-email?token=&verificationId=` → callback verifies with Bearer, sets cookies, redirects `/settings?email_changed=ok`
105+
5. On success: `users.email` updated, session rotated, old-email notification sent (fire-and-forget)
106+
107+
**Verify payload modes:** Exactly one of `{ token, email }` or `{ token, verificationId }`. Auth attempts type `change_email` (5 fail → 15 min lock).
108+
109+
**Callback without session:** Redirect to `/auth/login?redirect=...`; after login, user returns to callback with session and verifies.
110+
96111
### OAuth (GitHub)
97112

98113
OAuth sign-in uses provider authorization + callback flows and issues access + refresh JWTs. Provider accounts are stored in `account` (provider id, account id, and encrypted tokens at rest).
@@ -116,6 +131,17 @@ OAuth sign-in uses provider authorization + callback flows and issues access + r
116131

117132
When a provider is not configured (503 `OAUTH_NOT_CONFIGURED`), the client shows a Sonner toast instead of redirecting.
118133

134+
### OAuth account linking (implemented)
135+
136+
Logged-in users can link additional OAuth providers from Profile (Settings). Flow: `GET /auth/oauth/{provider}/link-authorize-url` (session/JWT required, no API key) → redirect to provider → callback → exchange with `oauth_link_state` → link account, return `{ token, refreshToken, redirectTo: "/settings?linked=ok" }`.
137+
138+
- **Link-authorize-url:** Session/JWT required, no API key; rate limit 10/hour per user. Stores `oauth_link_state` with `meta.userId`.
139+
- **Exchange:** State lookup branches on `oauth_link_state` vs `oauth_state`. Link mode uses `meta.userId`; if provider already linked to another user → 409 `PROVIDER_ALREADY_LINKED`.
140+
- **Unlink:** `DELETE /account/link/oauth/:providerId` (Bearer). Guardrail: cannot unlink if it would leave no sign-in method (400 `LAST_SIGN_IN_METHOD`).
141+
- **Session user:** `GET /auth/session/user` returns `linkedAccounts: [{ providerId }]`.
142+
143+
See [Account linking](/docs/architecture/account-linking) for change email vs link email, provider trust, and guardrails.
144+
119145
### Web3 (SIWE / SIWS)
120146

121147
Web3 sign-in follows a **nonce → sign → verify** flow and returns access + refresh JWTs. Nonce and verify requests go **directly to Fastify**; the **callback** (token → cookie exchange) goes through the Next.js backend when cookies are needed.
@@ -290,13 +316,18 @@ All endpoints below are served by `apps/fastify`. The API does not set cookies;
290316
- `GET /auth/web3/nonce``{ nonce }` (query: `chain`, `address`)
291317
- `POST /auth/web3/:chain/verify``{ token, refreshToken }`
292318
- `:chain` is `eip155` or `solana`
293-
- **Account linking** (Bearer required — JWT or API key)
319+
- **Change email** (Bearer required)
320+
- `POST /account/email/change/request``{ ok }` (body: `email`, `callbackUrl`), rate limit 3/hour
321+
- `POST /account/email/change/verify``{ token, refreshToken }` (body: `{ token, email }` or `{ token, verificationId }`)
322+
- **Account linking** (Bearer required — JWT or API key, except link-authorize-url)
294323
- `POST /account/link/wallet/verify``{ ok }` (body: `chain`, `message`, `signature`)
295324
- `DELETE /account/link/wallet/:id``204` (unlink wallet by id from `linkedWallets`)
296325
- `POST /account/link/email/request``{ ok }` (body: `email`, `callbackUrl`)
297326
- `POST /account/link/email/verify``{ token, refreshToken }` (body: `token`)
327+
- `GET /auth/oauth/{github,facebook,twitter}/link-authorize-url``{ redirectUrl }` (Session required — cookie/JWT session, no API key; rate limit 10/hour)
328+
- `DELETE /account/link/oauth/:providerId``204` (unlink OAuth provider)
298329

299-
**Account link error codes**: `WALLET_ALREADY_LINKED`, `EMAIL_ALREADY_IN_USE`
330+
**Account link error codes**: `WALLET_ALREADY_LINKED`, `EMAIL_ALREADY_IN_USE`, `PROVIDER_ALREADY_LINKED`, `LAST_SIGN_IN_METHOD`
300331

301332
- **API keys** (Bearer required — JWT or API key)
302333
- `POST /account/apikeys``{ id, name, key, prefix, createdAt }` (key shown once)
@@ -321,13 +352,13 @@ All endpoints below are served by `apps/fastify`. The API does not set cookies;
321352
- `POST /auth/passkey/exchange``{ token, refreshToken }` (body: `{ code }`)
322353

323354
- **Session**
324-
- `GET /auth/session/user` (Bearer) → `{ user }` (includes optional `wallet`, `linkedWallets` with `{ id, chain, address }` for unlink, `totpEnabled`, `passkeys` with `{ id, name, createdAt }`)
355+
- `GET /auth/session/user` (Bearer) → `{ user }` (includes optional `wallet`, `linkedWallets`, `linkedAccounts` with `{ providerId }`, `totpEnabled`, `passkeys` with `{ id, name, createdAt }`)
325356
- `POST /auth/session/logout` (Bearer) → `204`
326357
- `POST /auth/session/refresh``{ token, refreshToken }`
327358

328359
## Next.js Auth (web)
329360

330-
**Callback pages** (`/auth/callback/magiclink`, `/auth/callback/oauth/github`, `/auth/callback/web3`, `/auth/callback/passkey`) exchange credentials with Fastify, set cookies, and redirect. **Logout** (`/auth/logout`) calls Fastify to revoke the session and clears cookies.
361+
**Callback pages** (`/auth/callback/magiclink`, `/auth/callback/change-email`, `/auth/callback/oauth/github`, `/auth/callback/oauth/facebook`, `/auth/callback/oauth/twitter`, `/auth/callback/web3`, `/auth/callback/passkey`) exchange credentials with Fastify, set cookies, and redirect. **Logout** (`/auth/logout`) calls Fastify to revoke the session and clears cookies.
331362

332363
**API routes** (cookie updates only):
333364
- `POST /api/auth/update-tokens`: accepts `{ token, refreshToken }`, sets single cookie. Used after 401 refresh (core's `onTokensRefreshed`) and after link email or profile edit.
@@ -443,7 +474,7 @@ sequenceDiagram
443474
The auth system relies on these tables:
444475

445476
- **`users`**: user identity (email-based for magic link)
446-
- **`verification`**: single-use tokens (stored hashed, with TTL); `type` = `magic_link` | `link_email` | `oauth_state` (link_email reserved for Phase 1b)
477+
- **`verification`**: single-use tokens (stored hashed, with TTL); `type` = `magic_link` | `link_email` | `oauth_state` | `change_email` | `oauth_link_state`
447478
- **`sessions`**: server-side session state (stores hashed refresh `jti`, expiry, optional `wallet_chain`/`wallet_address` for wallet sessions)
448479
- **`account`**: OAuth provider accounts (provider id + encrypted tokens at rest)
449480
- **`wallet_identities`**: Web3 identities (`eip155`/`solana` + normalized address)

apps/docu/content/docs/architecture/index.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ sequenceDiagram
3636
- **[Monorepo Structure](/docs/architecture/monorepo)** - Turborepo organization and package architecture
3737
- **[API Architecture](/docs/architecture/api)** - Node.js, Fastify, PostgreSQL, Drizzle ORM
3838
- **[Authentication](/docs/architecture/authentication)** - Magic link, OAuth and Web3 wallet authentication
39+
- **[Account linking](/docs/architecture/account-linking)** - Change email, OAuth linking, guardrails
3940
- **[Frontend Architecture](/docs/architecture/frontend)** - Next.js, Shadcn/ui, Tailwind CSS
4041
- **[Frontend Stack](/docs/architecture/frontend-stack)** - Core libraries, patterns, Web3 wallet hooks
4142
- **[ESM & TypeScript Strategy](/docs/architecture/esm-strategy)** - ESM module system architecture and TypeScript configuration

apps/docu/content/docs/architecture/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"monorepo",
66
"api",
77
"authentication",
8+
"account-linking",
89
"frontend",
910
"portability",
1011
"esm-strategy",

apps/fastify/eslint.config.mjs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ import { config } from '@repo/eslint-config/base'
33
export default [
44
...config,
55
{
6-
files: ['src/routes/auth/oauth/twitter/exchange.ts'],
7-
rules: {
8-
'max-lines': ['error', { max: 400, skipBlankLines: true, skipComments: true }],
9-
},
6+
files: [
7+
'src/routes/auth/oauth/twitter/exchange.ts',
8+
'src/routes/auth/oauth/github/exchange.ts',
9+
'src/routes/auth/oauth/facebook/exchange.ts',
10+
],
11+
rules: { complexity: 'off' },
1012
},
1113
{
1214
files: ['src/routes/reference/template.ts'],

0 commit comments

Comments
 (0)