Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions apps/docu/content/docs/architecture/account-linking.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
title: Account linking
description: Change email vs link email, OAuth provider linking, provider trust, and guardrails.
---

## Overview

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).

## Change email vs link email

| Capability | Change email | Link email |
|------------|--------------|------------|
| Effect | Replaces `users.email` | Adds a linked method; primary email unchanged |
| Flow | Request → verify code/link → update | Request → verify link → link account |
| Use case | User changes email address | User adds email sign-in to OAuth-only account |

Change email uses `change_email` verification type; link email uses `link_email`. Both use the same 6-digit + link UX and 15-minute TTL.

## OAuth provider linking

Logged-in users can link additional OAuth providers from Profile (Settings). The backend:

- Uses `oauth_link_state` (distinct from `oauth_state` for sign-in)
- Binds the link to `meta.userId` so the exchange attaches to the correct account
- Returns 409 `PROVIDER_ALREADY_LINKED` when the provider is linked to another user
- Returns `redirectTo` so the client can return the user to the settings page after success

## Provider trust

OAuth providers are trusted for identity on first link. Subsequent links require re-authorization; existing tokens are replaced on each successful link.

## Guardrail: last sign-in method

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.

## Verify modes (change email)

Verification accepts exactly one of:

- `{ token, email }` — code entry flow; email used as identifier
- `{ token, verificationId }` — link-click flow; verificationId from callback URL

See [Authentication](/docs/architecture/authentication#change-email-implemented) for full flow details.
41 changes: 36 additions & 5 deletions apps/docu/content/docs/architecture/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,21 @@ Magic link sign-in issues a **6-digit login code** over email, then exchanges it
3. **Option A (manual):** User types the 6-digit code in the login form → `POST /auth/magiclink/verify` with `{ token: code }` → JWTs returned
4. **Option B (link):** User clicks the button in email → callback page receives `?token={code}` → verify → cookies set, redirect

### Change email (implemented)

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.

**Flow:**
1. User enters new email → `POST /account/email/change/request` (Bearer, body: `{ email, callbackUrl }`) → rate limited 3/hour per user
2. Email contains 6-digit code and link; identifier `userId:newEmail`; 15 min TTL
3. **Code entry:** `POST /account/email/change/verify` with `{ token, email }` (Bearer)
4. **Link click:** User hits `/auth/callback/change-email?token=&verificationId=` → callback verifies with Bearer, sets cookies, redirects `/settings?email_changed=ok`
5. On success: `users.email` updated, session rotated, old-email notification sent (fire-and-forget)

**Verify payload modes:** Exactly one of `{ token, email }` or `{ token, verificationId }`. Auth attempts type `change_email` (5 fail → 15 min lock).

**Callback without session:** Redirect to `/auth/login?redirect=...`; after login, user returns to callback with session and verifies.

### OAuth (GitHub)

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).
Expand All @@ -116,6 +131,17 @@ OAuth sign-in uses provider authorization + callback flows and issues access + r

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

### OAuth account linking (implemented)

Logged-in users can link additional OAuth providers from Profile (Settings). Flow: `GET /auth/oauth/{provider}/link-authorize-url` (Bearer) → redirect to provider → callback → exchange with `oauth_link_state` → link account, return `{ token, refreshToken, redirectTo: "/settings?linked=ok" }`.

- **Link-authorize-url:** Bearer required, rate limit 10/hour per user. Stores `oauth_link_state` with `meta.userId`.
- **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`.
- **Unlink:** `DELETE /account/link/oauth/:providerId` (Bearer). Guardrail: cannot unlink if it would leave no sign-in method (400 `LAST_SIGN_IN_METHOD`).
- **Session user:** `GET /auth/session/user` returns `linkedAccounts: [{ providerId }]`.

See [Account linking](/docs/architecture/account-linking) for change email vs link email, provider trust, and guardrails.

### Web3 (SIWE / SIWS)

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.
Expand Down Expand Up @@ -290,13 +316,18 @@ All endpoints below are served by `apps/fastify`. The API does not set cookies;
- `GET /auth/web3/nonce` → `{ nonce }` (query: `chain`, `address`)
- `POST /auth/web3/:chain/verify` → `{ token, refreshToken }`
- `:chain` is `eip155` or `solana`
- **Account linking** (Bearer required — JWT or API key)
- **Change email** (Bearer required)
- `POST /account/email/change/request` → `{ ok }` (body: `email`, `callbackUrl`), rate limit 3/hour
- `POST /account/email/change/verify` → `{ token, refreshToken }` (body: `{ token, email }` or `{ token, verificationId }`)
- **Account linking** (Bearer required — JWT or API key, except link-authorize-url)
- `POST /account/link/wallet/verify` → `{ ok }` (body: `chain`, `message`, `signature`)
- `DELETE /account/link/wallet/:id` → `204` (unlink wallet by id from `linkedWallets`)
- `POST /account/link/email/request` → `{ ok }` (body: `email`, `callbackUrl`)
- `POST /account/link/email/verify` → `{ token, refreshToken }` (body: `token`)
- `GET /auth/oauth/{github,facebook,twitter}/link-authorize-url` → `{ redirectUrl }` (Session required — cookie/JWT session, no API key; rate limit 10/hour)
- `DELETE /account/link/oauth/:providerId` → `204` (unlink OAuth provider)

**Account link error codes**: `WALLET_ALREADY_LINKED`, `EMAIL_ALREADY_IN_USE`
**Account link error codes**: `WALLET_ALREADY_LINKED`, `EMAIL_ALREADY_IN_USE`, `PROVIDER_ALREADY_LINKED`, `LAST_SIGN_IN_METHOD`

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

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

## Next.js Auth (web)

**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.
**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.

**API routes** (cookie updates only):
- `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.
Expand Down Expand Up @@ -443,7 +474,7 @@ sequenceDiagram
The auth system relies on these tables:

- **`users`**: user identity (email-based for magic link)
- **`verification`**: single-use tokens (stored hashed, with TTL); `type` = `magic_link` | `link_email` | `oauth_state` (link_email reserved for Phase 1b)
- **`verification`**: single-use tokens (stored hashed, with TTL); `type` = `magic_link` | `link_email` | `oauth_state` | `change_email` | `oauth_link_state`
- **`sessions`**: server-side session state (stores hashed refresh `jti`, expiry, optional `wallet_chain`/`wallet_address` for wallet sessions)
- **`account`**: OAuth provider accounts (provider id + encrypted tokens at rest)
- **`wallet_identities`**: Web3 identities (`eip155`/`solana` + normalized address)
Expand Down
1 change: 1 addition & 0 deletions apps/docu/content/docs/architecture/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ sequenceDiagram
- **[Monorepo Structure](/docs/architecture/monorepo)** - Turborepo organization and package architecture
- **[API Architecture](/docs/architecture/api)** - Node.js, Fastify, PostgreSQL, Drizzle ORM
- **[Authentication](/docs/architecture/authentication)** - Magic link, OAuth and Web3 wallet authentication
- **[Account linking](/docs/architecture/account-linking)** - Change email, OAuth linking, guardrails
- **[Frontend Architecture](/docs/architecture/frontend)** - Next.js, Shadcn/ui, Tailwind CSS
- **[Frontend Stack](/docs/architecture/frontend-stack)** - Core libraries, patterns, Web3 wallet hooks
- **[ESM & TypeScript Strategy](/docs/architecture/esm-strategy)** - ESM module system architecture and TypeScript configuration
Expand Down
1 change: 1 addition & 0 deletions apps/docu/content/docs/architecture/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"monorepo",
"api",
"authentication",
"account-linking",
"frontend",
"portability",
"esm-strategy",
Expand Down
10 changes: 6 additions & 4 deletions apps/fastify/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { config } from '@repo/eslint-config/base'
export default [
...config,
{
files: ['src/routes/auth/oauth/twitter/exchange.ts'],
rules: {
'max-lines': ['error', { max: 400, skipBlankLines: true, skipComments: true }],
},
files: [
'src/routes/auth/oauth/twitter/exchange.ts',
'src/routes/auth/oauth/github/exchange.ts',
'src/routes/auth/oauth/facebook/exchange.ts',
],
rules: { complexity: 'off' },
},
{
files: ['src/routes/reference/template.ts'],
Expand Down
Loading
Loading