# Implementation Plan: Approach A — Anonymous + Custom Recovery Layer (v3) **Status:** Revised after Stage 1 cookie probe surfaced a blocker (GoTrue rejects HS256 JWTs whose `session_id` is not in `auth.sessions`). Both compliance and security agents recommended **Option 3**: replace every `supabase.auth.getUser()` call with a local JWT-decode wrapper. v3 bakes that decision in. See `research/COOKIE-SHAPE-DECISION.md` for the Stage 1 finding and `research/AUTH-APPROACHES.md` for the original landscape. ## Context Current recovery design (synthetic `@moviedice.invalid` email + HKDF password + GoTrue `admin.updateUserById` "promotion") is structurally fighting GoTrue. It hit bug #2013, required a CHECK constraint that broke the flow, and security review demanded Kong-level denylists once `EMAIL_ENABLED=true`. The product never needed permanent-account "promotion" — only "given a valid recovery code, give the same user a fresh session." Approach A delivers exactly that. **Outcome:** - Onboarding still uses `signInAnonymously()` (unchanged). - Recovery generate inserts `argon2id(code)` into a custom `recovery_codes` table. - Recovery claim verifies the code, atomically consumes it, mints a fresh JWT bound to the same `sub`, persists a `user_sessions` row for revocation, writes the `@supabase/ssr` cookie. - All users stay `is_anonymous=true` in `auth.users`. That flag becomes meaningless metadata. - `auth.uid()` resolves natively → all RLS policies unchanged. - Browser Realtime keeps working (HS256 JWTs signed with `JWT_SECRET` are accepted). - GoTrue config: `EMAIL_ENABLED` stays `false`. No Kong denylists needed. ## Decisions settled (no longer open questions) - **JWT lifecycle: path (a)** — hand-mint short-lived access tokens, periodic re-mint via `/api/auth/touch`. Per security review #4 (v2 round). - **Argon2 lookup: peppered HMAC prefix index from day one**, not a follow-up. - **Atomic claim: `DELETE ... RETURNING` is the commit point**, not a separate scan-then-delete. - **Session revocation: `user_sessions` table** with `revoked_at`. - **Absolute session cap: 30 days** via `iat_original` JWT claim. - **Trust boundary: local JWT decode + `user_sessions` lookup, NOT `supabase.auth.getUser()`** (NEW v3 decision). Per Stage 1: GoTrue rejects HS256 JWTs whose `session_id` is not in `auth.sessions`. Reusing/inserting that row is a forgery + GC + coupling hazard. Instead, replace every `getUser()` call with a thin server wrapper `requireUser()` that does local `jwtVerify` + `user_sessions.revoked_at IS NULL` check. PostgREST + Realtime continue to accept the JWT via signature alone — only GoTrue's `/auth/v1/user` rejects, and after Option 3 nothing in the app calls it. - **Browser uses `useCurrentUser()` hook** that reads `/api/auth/me` (server-side `requireUser()`); browser never calls `supabase.auth.getUser()` directly. - **CI grep-guard** rejects raw `supabase.auth.getUser()` outside the `current-user.ts` wrapper module. ## Scope ### In scope 1. ✅ **DONE Stage 1** — Cookie-shape decision in `research/COOKIE-SHAPE-DECISION.md`. Option (X): `base64-` + base64url(JSON object) with `{access_token, token_type:"bearer", expires_in, expires_at, refresh_token:"", user:null}`. 2. **Migration `00004_recovery_and_sessions.sql`**: drop `users.recovery_code` column; create `recovery_codes` (with peppered prefix index column); create `user_sessions`. 3. **Module `src/lib/auth/jwt.ts`**: HS256 mint + verify with `kid: "v1"`, `iat_original` claim, `setNotBefore(-10s)`, `setIssuer("moviedice")`, `setAudience("authenticated")`. `session_id` is generated UUID, persisted in `user_sessions`. **Does NOT need to satisfy GoTrue's `getUser()` — see v3 trust-boundary decision.** 4. **Module `src/lib/auth/cookies.ts`**: write per Stage 1 Option (X). 5. **Module `src/lib/auth/sessions.ts`**: create/lookup/revoke `user_sessions` rows. 6. **Module `src/lib/auth/recovery-prefix.ts`**: HMAC-SHA256 with `RECOVERY_CODE_PEPPER` for the prefix index. 7. **NEW Module `src/lib/auth/current-user.ts`** (Option 3 wrapper): `requireUser(req)` does local `verifyAccessToken` + `user_sessions.revoked_at IS NULL` check + 30d cap. `getCurrentUser(req)` non-throwing variant. **The ONLY place in the codebase that decodes the JWT or queries `user_sessions`.** Enforced by CI grep-guard. 8. **NEW Route `src/app/api/auth/me/route.ts`**: returns `{id, isAnonymous: true}` via `requireUser()`. Backs the browser hook. 9. **NEW Hook `src/hooks/use-current-user.ts`**: TanStack Query against `/api/auth/me` with appropriate `staleTime`. Replaces ALL browser `supabase.auth.getUser()` / `getSession()` calls. 10. **Rewrite `src/app/api/auth/recovery/generate/route.ts`** — uses `requireUser()` for auth. 11. **Rewrite `src/app/api/auth/recovery/claim/route.ts`** — atomic `DELETE...RETURNING`, constant-time padding, mints JWT with generated `session_id`, inserts `user_sessions` row. 12. **NEW `src/app/api/auth/touch/route.ts`** with absolute-cap enforcement (uses `requireUser()` then re-mints). 13. **Migrate `src/middleware.ts`** (line 33): replace `supabase.auth.getUser()` with `requireUser(req)`. 14. **Migrate 14 API route handlers**: replace inline `supabase.auth.getUser()` with `requireUser(req)`. Files identified by `grep -rn "auth.getUser\|auth.getSession" src/app/api/`. Programmer must list and migrate each. 15. **Migrate 5 browser hooks**: `use-realtime-movies`, `use-add-movie`, `use-delete-movie`, `use-toggle-watched`, `use-all-user-movies`, `use-user-groups`. Replace inline `supabase.auth.getUser()` with `useCurrentUser()`. 16. **Migrate `src/app/(auth)/recovery/page.tsx`**: replace `supabase.auth.getSession()` with `useCurrentUser()`. 17. **Update `src/lib/supabase/client.ts`**: `auth: { autoRefreshToken: false, persistSession: true, detectSessionInUrl: false }`. Cookie encoding stays default (Stage 1 Option X). 18. **Layout-level token-age check + touch-failure handler**: in `(app)` layout. On touch 401: `queryClient.clear()` + hard-redirect to `/`. 19. **Update `src/lib/supabase/admin.ts`**: drop the `EMAIL_ENABLED !== false` assertion (rationale obsolete). 20. **Delete `src/lib/auth/synthetic.ts`** (after grep-verify zero importers post-rewrites). 21. **Tests**: delete `synthetic.test.ts`, `promotion-regression.test.ts`. Rewrite `recovery-generate.test.ts`, `recovery-claim.test.ts`. Add per "Tests" section below. 22. **Update `src/types/database.ts`**: drop `users.recovery_code`; add `recovery_codes`, `user_sessions`. 23. **Update `src/env.ts`**: ensure `JWT_SECRET` and add `RECOVERY_CODE_PEPPER` in the server schema (never client). 24. **docker-compose.yml**: verify `JWT_SECRET` is exposed to the Next.js app container; add `RECOVERY_CODE_PEPPER` (32+ random chars). 25. **Sentry `beforeSend`**: drop events whose stringified payload contains `JWT_SECRET` or `RECOVERY_CODE_PEPPER` literal value. 26. **CI grep-guard test**: fail if `supabase.auth.getUser()` or `supabase.auth.getSession()` appears anywhere in `src/` except `src/lib/auth/current-user.ts` and `src/hooks/use-current-user.ts`. Fail if `auth.jwt()->>'is_anonymous'` appears in any new migration. 27. **Update `CLAUDE.md`**: replace synthetic-identity / GoTrue #2013 / `EMAIL_ENABLED` / Kong denylist paragraphs. Document `requireUser()` / `useCurrentUser()` as the only auth boundary. Note `is_anonymous` is meaningless and `getUser()` is forbidden outside the wrapper. ### Out of scope - Removing GoTrue from docker-compose. `signInAnonymously()` is fine to keep delegated. - Approach B (custom JWT, no GoTrue for users) — landscape-only. - Migration of existing `users.recovery_code` data (dev-only per project state). - Admin TOTP path (untouched). - CSP `script-src` hardening (security #10) — flagged as separate follow-up. ## Detailed steps ### Step 0: Cookie-shape probe (blocking) Before writing any code: run a one-shot script in dev to capture what `signInAnonymously()` writes today. This determines whether `setSessionCookie` writes: - (X) `base64-` prefix + base64url(JSON.stringify(session-object)) - (Y) raw JSON array `[access, refresh, ...]` - (Z) chunked `${name}.0`, `${name}.1` for large values Decision tree: - If (X): match it exactly using `stringToBase64URL` from `@supabase/ssr` internals OR construct both clients with `cookieOptions: { cookieEncoding: "raw" }` and use raw JSON object. - If (Z) for our token size (~1.5 KB): must implement chunking OR force `raw` encoding. **Recommended path**: pass `cookieOptions: { cookieEncoding: "raw" }` to BOTH `createBrowserClient` and `createServerClient` (they accept it per `node_modules/@supabase/ssr/dist/main/createServerClient.js:13`). Then write a plain JSON object matching the modern Supabase `Session` shape: `{ access_token, refresh_token, expires_at, expires_in, token_type, user }`. For path (a), `refresh_token` is a non-empty placeholder (a constant like `"NO_REFRESH"`) since the browser client has `autoRefreshToken: false` and won't try to use it. Verify this doesn't trip `getUser()` validation. **Acceptance**: a Vitest integration test that calls `setSessionCookie(mintedToken)` then constructs a `createServerClient` against the same cookie store and asserts `getUser()` returns the expected `sub`. **Cannot ship without this test passing** — security #8 / compliance #1. ### Step 1: Migration `00004_recovery_and_sessions.sql` Code-first deploy ordering: this migration runs AFTER the new app code is deployed and BEFORE the old `users.recovery_code` column reads are removed. (Per compliance #4.) Concretely: deploy app code that tolerates either schema first, then run migration, then remove tolerance. For dev (no production yet), simpler: stop dev server, apply migration, deploy code, restart. ```sql -- 00004_recovery_and_sessions.sql ALTER TABLE public.users DROP COLUMN IF EXISTS recovery_code; CREATE TABLE public.recovery_codes ( user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, argon2_hash text NOT NULL, prefix_hmac bytea NOT NULL, -- HMAC-SHA256(pepper, code) truncated to first 8 bytes created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX idx_recovery_codes_prefix ON public.recovery_codes(prefix_hmac); ALTER TABLE public.recovery_codes ENABLE ROW LEVEL SECURITY; -- No policies; service role only. COMMENT ON TABLE public.recovery_codes IS 'Argon2id-hashed recovery codes with HMAC prefix index. Service role only. Single-use; deleted on claim atomically via DELETE ... RETURNING.'; CREATE TABLE public.user_sessions ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, iat_original timestamptz NOT NULL DEFAULT now(), last_seen_at timestamptz NOT NULL DEFAULT now(), revoked_at timestamptz ); CREATE INDEX idx_user_sessions_user_active ON public.user_sessions(user_id) WHERE revoked_at IS NULL; ALTER TABLE public.user_sessions ENABLE ROW LEVEL SECURITY; -- No policies; service role only. COMMENT ON TABLE public.user_sessions IS 'Server-side session records. JWT carries session_id; touch endpoint validates revoked_at IS NULL and now() - iat_original < 30d.'; ``` **Note on `is_anonymous`**: under this design `auth.jwt()->>''is_anonymous''` returns true for every real user. `grep -rn "is_anonymous" supabase/migrations/` to verify no policy depends on it. CLAUDE.md gets a warning line. ### Step 2: `src/lib/auth/jwt.ts` ```ts import { SignJWT, jwtVerify } from "jose"; const ACCESS_EXP_SECONDS = 60 * 60; // 1h const ABSOLUTE_CAP_SECONDS = 30 * 24 * 60 * 60; // 30d const NBF_SKEW_SECONDS = 10; interface AccessClaims { sub: string; session_id: string; iat_original: number; // unix seconds, set on first mint, preserved across touches } export async function mintAccessToken(claims: AccessClaims): Promise { const secret = new TextEncoder().encode(process.env.JWT_SECRET); return await new SignJWT({ sub: claims.sub, role: "authenticated", is_anonymous: true, session_id: claims.session_id, iat_original: claims.iat_original, }) .setProtectedHeader({ alg: "HS256", typ: "JWT", kid: "v1" }) .setIssuedAt() .setNotBefore(`${-NBF_SKEW_SECONDS}s`) .setExpirationTime(`${ACCESS_EXP_SECONDS}s`) .setIssuer("moviedice") .setAudience("authenticated") .sign(secret); } export async function verifyAccessToken(token: string): Promise { try { const secret = new TextEncoder().encode(process.env.JWT_SECRET); const { payload } = await jwtVerify(token, secret, { issuer: "moviedice", audience: "authenticated", }); return { sub: payload.sub as string, session_id: payload.session_id as string, iat_original: payload.iat_original as number, }; } catch { return null; } } export const ACCESS_TOKEN_TTL_SECONDS = ACCESS_EXP_SECONDS; export const ABSOLUTE_SESSION_CAP_SECONDS = ABSOLUTE_CAP_SECONDS; ``` `session_id` is generated at claim time and persisted (Step 4); not a per-mint random. ### Step 3: `src/lib/auth/recovery-prefix.ts` ```ts import { createHmac } from "node:crypto"; export function prefixHmac(code: string): Buffer { const pepper = process.env.RECOVERY_CODE_PEPPER; if (!pepper) throw new Error("RECOVERY_CODE_PEPPER not set"); return createHmac("sha256", pepper).update(code).digest().subarray(0, 8); } ``` Pepper lives in env (32+ random chars), never in code, never client-exposed. ### Step 4: `src/lib/auth/sessions.ts` ```ts import { getSupabaseAdminClient } from "@/lib/supabase/admin"; import { ABSOLUTE_SESSION_CAP_SECONDS } from "@/lib/auth/jwt"; export async function createSession(userId: string): Promise<{ id: string; iat_original: number }> { const admin = getSupabaseAdminClient(); const { data, error } = await admin .from("user_sessions") .insert({ user_id: userId }) .select("id, iat_original") .single(); if (error || !data) throw new Error("Failed to create session"); return { id: data.id, iat_original: Math.floor(new Date(data.iat_original).getTime() / 1000) }; } export async function isSessionLive(sessionId: string, iatOriginal: number): Promise { const admin = getSupabaseAdminClient(); const { data } = await admin .from("user_sessions") .select("revoked_at") .eq("id", sessionId) .maybeSingle(); if (!data || data.revoked_at) return false; if (Math.floor(Date.now() / 1000) - iatOriginal > ABSOLUTE_SESSION_CAP_SECONDS) return false; // touch last_seen_at (fire-and-forget) void admin .from("user_sessions") .update({ last_seen_at: new Date().toISOString() }) .eq("id", sessionId); return true; } export async function revokeSession(sessionId: string): Promise { const admin = getSupabaseAdminClient(); await admin .from("user_sessions") .update({ revoked_at: new Date().toISOString() }) .eq("id", sessionId); } ``` ### Step 5: `src/lib/auth/cookies.ts` Final shape determined in Step 0. Sketched assuming `cookieEncoding: "raw"` works: ```ts import { cookies } from "next/headers"; import { getSupabaseCookieName } from "@/lib/supabase/cookie-name"; import { ACCESS_TOKEN_TTL_SECONDS } from "@/lib/auth/jwt"; export async function setSessionCookie(accessToken: string): Promise { const name = `${getSupabaseCookieName()}-auth-token`; const expiresAt = Math.floor(Date.now() / 1000) + ACCESS_TOKEN_TTL_SECONDS; const session = { access_token: accessToken, refresh_token: "NO_REFRESH", // placeholder; client has autoRefreshToken: false expires_at: expiresAt, expires_in: ACCESS_TOKEN_TTL_SECONDS, token_type: "bearer", user: null, // verify in Step 0 probe whether this can be null }; (await cookies()).set(name, JSON.stringify(session), { httpOnly: false, // @supabase/ssr browser client must read it; matches GoTrue's cookie posture secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", maxAge: ACCESS_TOKEN_TTL_SECONDS, }); } export async function clearSessionCookie(): Promise { const name = `${getSupabaseCookieName()}-auth-token`; (await cookies()).delete(name); } ``` ### Step 6: Rewrite `src/app/api/auth/recovery/generate/route.ts` ```ts import { NextRequest, NextResponse } from "next/server"; import { getSupabaseServerClient } from "@/lib/supabase/server"; import { getSupabaseAdminClient } from "@/lib/supabase/admin"; import { generateRecoveryCode, hashRecoveryCode } from "@/lib/auth/recovery"; import { prefixHmac } from "@/lib/auth/recovery-prefix"; import { checkRateLimit } from "@/lib/auth/rate-limit"; const PER_UID_WINDOW_MS = 60 * 60 * 1000; const PER_UID_MAX = 3; export async function POST(request: NextRequest) { const authHeader = request.headers.get("authorization"); const bearerToken = authHeader?.toLowerCase().startsWith("bearer ") ? authHeader.slice(7).trim() : null; const admin = getSupabaseAdminClient(); let uid: string | null = null; let authPath: "bearer" | "cookie" | null = null; if (bearerToken) { const { data, error } = await admin.auth.getUser(bearerToken); if (!error && data.user) { uid = data.user.id; authPath = "bearer"; } } if (!uid) { const supabase = await getSupabaseServerClient(); const { data: { user }, } = await supabase.auth.getUser(); if (user) { uid = user.id; authPath = "cookie"; } } if (!uid) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const limit = checkRateLimit(`recovery-generate:${uid}`, { windowMs: PER_UID_WINDOW_MS, maxAttempts: PER_UID_MAX, }); if (!limit.allowed) { return NextResponse.json( { error: "Too many attempts." }, { status: 429, headers: { "Retry-After": String(Math.ceil(limit.retryAfterMs / 1000)) }, }, ); } const { data: existing } = await admin .from("recovery_codes") .select("user_id") .eq("user_id", uid) .maybeSingle(); if (existing) { return NextResponse.json({ error: "Recovery code already exists" }, { status: 409 }); } const code = generateRecoveryCode(); const argonHash = await hashRecoveryCode(code); const prefix = prefixHmac(code); const { error } = await admin .from("recovery_codes") .insert({ user_id: uid, argon2_hash: argonHash, prefix_hmac: prefix }); if (error) { return NextResponse.json({ error: "Failed to store recovery code" }, { status: 500 }); } console.log(`[recovery/generate] uid=${uid} authPath=${authPath}`); return NextResponse.json({ code }); } ``` ### Step 7: Rewrite `src/app/api/auth/recovery/claim/route.ts` ```ts import { NextRequest, NextResponse } from "next/server"; import { getSupabaseAdminClient } from "@/lib/supabase/admin"; import { verifyRecoveryCode } from "@/lib/auth/recovery"; import { prefixHmac } from "@/lib/auth/recovery-prefix"; import { checkRateLimit, getClientIp } from "@/lib/auth/rate-limit"; import { mintAccessToken } from "@/lib/auth/jwt"; import { createSession } from "@/lib/auth/sessions"; import { setSessionCookie } from "@/lib/auth/cookies"; const IP_WINDOW_MS = 15 * 60 * 1000; const IP_MAX = 5; const TARGET_RESPONSE_MS = 200; // pad to defeat timing channel export async function POST(request: NextRequest) { const start = Date.now(); const ip = getClientIp(request); const limit = checkRateLimit(`recovery-claim:${ip}`, { windowMs: IP_WINDOW_MS, maxAttempts: IP_MAX, }); if (!limit.allowed) { return NextResponse.json( { error: "Too many attempts." }, { status: 429, headers: { "Retry-After": String(Math.ceil(limit.retryAfterMs / 1000)) }, }, ); } const body = await request.json().catch(() => null); const code = typeof body?.code === "string" ? body.code.trim() : null; if (!code) return NextResponse.json({ error: "Invalid code" }, { status: 400 }); const admin = getSupabaseAdminClient(); const prefix = prefixHmac(code); // Prefix-narrowed lookup (typically 0-1 rows; collision possible but negligible at 8-byte HMAC). const { data: candidates } = await admin .from("recovery_codes") .select("user_id, argon2_hash") .eq("prefix_hmac", prefix); let matchedUid: string | null = null; for (const row of candidates ?? []) { if (await verifyRecoveryCode(code, row.argon2_hash)) { matchedUid = row.user_id; break; } } if (!matchedUid) { await padResponse(start); return NextResponse.json({ error: "Invalid recovery code" }, { status: 401 }); } // Atomic single-use: DELETE ... RETURNING. If rowCount === 0, someone else won the race. const { data: deleted, error: deleteError } = await admin .from("recovery_codes") .delete() .eq("user_id", matchedUid) .select("user_id"); if (deleteError || !deleted || deleted.length === 0) { await padResponse(start); return NextResponse.json({ error: "Invalid recovery code" }, { status: 401 }); } const session = await createSession(matchedUid); const access = await mintAccessToken({ sub: matchedUid, session_id: session.id, iat_original: session.iat_original, }); await setSessionCookie(access); await padResponse(start); return NextResponse.json({ ok: true }); } async function padResponse(startMs: number): Promise { const elapsed = Date.now() - startMs; const remaining = TARGET_RESPONSE_MS - elapsed; if (remaining > 0) await new Promise((r) => setTimeout(r, remaining)); } ``` ### Step 8: `src/app/api/auth/touch/route.ts` ```ts import { NextRequest, NextResponse } from "next/server"; import { cookies } from "next/headers"; import { getSupabaseCookieName } from "@/lib/supabase/cookie-name"; import { mintAccessToken, verifyAccessToken, ABSOLUTE_SESSION_CAP_SECONDS } from "@/lib/auth/jwt"; import { isSessionLive } from "@/lib/auth/sessions"; import { setSessionCookie } from "@/lib/auth/cookies"; export async function POST(request: NextRequest) { // Origin check (CSRF). Per security #16. const origin = request.headers.get("origin"); const expectedOrigin = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"; if (origin && origin !== expectedOrigin) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } const cookieStore = await cookies(); const cookieName = `${getSupabaseCookieName()}-auth-token`; const raw = cookieStore.get(cookieName)?.value; if (!raw) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); let accessToken: string; try { const session = JSON.parse(raw); accessToken = session.access_token; } catch { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const claims = await verifyAccessToken(accessToken); if (!claims) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); // Absolute cap: refuse re-mint if iat_original is older than 30 days. if (Math.floor(Date.now() / 1000) - claims.iat_original > ABSOLUTE_SESSION_CAP_SECONDS) { return NextResponse.json({ error: "Session expired" }, { status: 401 }); } // Session-table check: revoked? still within cap? if (!(await isSessionLive(claims.session_id, claims.iat_original))) { return NextResponse.json({ error: "Session revoked" }, { status: 401 }); } const fresh = await mintAccessToken({ sub: claims.sub, session_id: claims.session_id, iat_original: claims.iat_original, // preserved across touches }); await setSessionCookie(fresh); return NextResponse.json({ ok: true }); } ``` ### Step 9: Browser client adjustments `src/lib/supabase/client.ts`: ```ts createBrowserClient(url, key, { cookieOptions: { name: getSupabaseCookieName(), cookieEncoding: "raw" }, // pin encoding auth: { autoRefreshToken: false, persistSession: true, detectSessionInUrl: false }, }); ``` Same `cookieEncoding: "raw"` on `createServerClient` everywhere it's constructed. ### Step 10: Layout-level token-age check + touch-failure handler In the `(app)` layout (or a small client component mounted in it), on mount and on a 5-min interval: ```ts const session = await supabase.auth.getSession(); if (session.data.session) { const expiresAt = session.data.session.expires_at ?? 0; if (expiresAt - Math.floor(Date.now() / 1000) < 5 * 60) { const res = await fetch("/api/auth/touch", { method: "POST" }); if (!res.ok) { queryClient.clear(); // hard-clear TanStack cache (security #14) window.location.assign("/"); // redirect to landing } } } ``` ### Step 11: `src/lib/supabase/admin.ts` assertion Currently asserts `GOTRUE_EXTERNAL_EMAIL_ENABLED !== "false"` with rationale "synthetic-recovery requires admin-API-only email identity creation." That rationale is now obsolete. **Drop the assertion entirely** — the new design has no constraint on this flag. Update CLAUDE.md accordingly. ### Step 12: Sentry `beforeSend` filter `src/sentry.config.ts` (or wherever `beforeSend` lives): in addition to current UUID stripping, drop any event whose JSON-stringified payload contains the literal value of `JWT_SECRET` or `RECOVERY_CODE_PEPPER`. Compare in constant time. ### Step 13: Delete `src/lib/auth/synthetic.ts` Pre-step: `grep -rn "from \"@/lib/auth/synthetic\"" src/` and `grep -rn "synthetic" src/` to confirm zero importers (after the route rewrites). Delete the file. ### Step 14: Test updates (in scope, NOT follow-up) - **Delete** `src/__tests__/auth/synthetic.test.ts`, `src/__tests__/auth/promotion-regression.test.ts`. - **Rewrite** `src/__tests__/api/recovery-generate.test.ts`: - 401 unauthorized, 429 rate-limited, 409 already-exists, 200 happy path (verifies row inserted with both `argon2_hash` and `prefix_hmac`). - **Rewrite** `src/__tests__/api/recovery-claim.test.ts`: - 400 missing code, 429 rate-limited, 401 invalid (with constant-time padding), 200 happy path (cookie set, row deleted, session row created). - Concurrency test: two parallel claims of the same code — exactly one returns 200. - **New** `src/__tests__/auth/jwt.test.ts`: mint/verify roundtrip; `kid`/`iss`/`aud` correct; expired/nbf rejected; `iat_original` preserved across touches. - **New** `src/__tests__/auth/cookies.test.ts`: write cookie via `setSessionCookie`, read via `createServerClient` instance, assert `getUser()` returns expected `sub`. **Catches the cookie-shape blocker (security #8 / compliance #1) directly.** - **New** `src/__tests__/api/touch.test.ts`: 401 missing/invalid token, 401 absolute-cap exceeded, 401 revoked session, 200 happy path with `iat_original` preserved. - **New** `src/__tests__/integration/recovery-flow.test.ts` (gated on `RUN_INTEGRATION=1`): generate → claim → cookie set → `auth.uid()` matches → touch → revoke → touch fails. Real GoTrue + Postgres. - **New** `src/__tests__/integration/realtime-smoke.test.ts` (also gated): minted JWT can subscribe to a `movies` channel — validates the load-bearing claim that "Realtime keeps working." - **New** `src/__tests__/api/recovery-ci.test.ts`: grep-style CI guard that `auth.jwt()->>'is_anonymous'` does not appear in any new migration (security #9). ### Step 15: Update `CLAUDE.md` Replace the entire "Auth → Recovery" section with: > Recovery code is stored Argon2id-hashed in `public.recovery_codes` with an HMAC-prefix index (peppered with `RECOVERY_CODE_PEPPER`) for O(1) lookup. Generate is server-side, rate-limited per uid, idempotent (409 if already present). Claim atomically deletes via `DELETE ... RETURNING`, mints a fresh HS256 JWT bound to the same `sub` (signed with `JWT_SECRET`, `kid: "v1"`, `iss: "moviedice"`, `aud: "authenticated"`, `nbf: -10s`), creates a `user_sessions` row, writes the `@supabase/ssr` cookie. Sessions have a 1h access TTL with re-mint via `POST /api/auth/touch`, capped absolutely at 30 days from the original `iat_original`. Revocation: flip `user_sessions.revoked_at`; touch refuses to re-mint. **All users remain `is_anonymous=true` in `auth.users` — that flag has NO semantic meaning under this design; do not gate any policy or UI on it.** Remove all "Synthetic-identity-at-generate", `00003_synthetic_email_constraint`, GoTrue #2013 workaround, and Kong-denylist lines. ## Critical files **New:** - `supabase/migrations/00004_recovery_and_sessions.sql` - `src/lib/auth/jwt.ts` - `src/lib/auth/cookies.ts` - `src/lib/auth/sessions.ts` - `src/lib/auth/recovery-prefix.ts` - `src/app/api/auth/touch/route.ts` - Tests per Step 14 **Rewrite:** - `src/app/api/auth/recovery/generate/route.ts` - `src/app/api/auth/recovery/claim/route.ts` **Modify:** - `src/lib/supabase/client.ts` (add `autoRefreshToken: false`, `cookieEncoding: "raw"`) - `src/lib/supabase/server.ts` (verify `cookieEncoding: "raw"` if needed) - `src/lib/supabase/admin.ts` (drop `EMAIL_ENABLED` assertion) - `src/types/database.ts` (drop `users.recovery_code`, add new tables) - `src/env.ts` (ensure `JWT_SECRET` and add `RECOVERY_CODE_PEPPER`, both server-only) - `src/sentry.config.ts` (or wherever `beforeSend` lives — secret-leak filter) - `docker-compose.yml` (verify `JWT_SECRET` exposed to app container; add `RECOVERY_CODE_PEPPER`) - `(app)` layout — token-age check + touch-failure handler - `CLAUDE.md` **Delete:** - `src/lib/auth/synthetic.ts` - `src/__tests__/auth/synthetic.test.ts` - `src/__tests__/auth/promotion-regression.test.ts` ## Dependencies - `jose` — verify with `npm ls jose`. Likely already present (admin TOTP path may use it). Install if missing. - No other new deps. ## Verification 1. `npm run build` clean. 2. `tsc --noEmit` clean (catches Compliance #2: synthetic test files importing dead modules). 3. `npm test` — all unit + integration suites pass. 4. Manual probe at `http://localhost:3000`: - Fresh incognito → anon signin → home renders. - `/recovery` → code displayed. - Second incognito → `/recover` → paste code → hard-nav to `/` → original user's session restored. - Re-claim same code → 401 (single-use enforced). - Wait >1h or force-expire token → trigger touch → cookie re-minted, no UI break. - Run `UPDATE user_sessions SET revoked_at = now() WHERE user_id = ...` → trigger touch → 401, hard-clear, redirect. 5. SQL spot-check: `SELECT count(*) FROM recovery_codes` = 0 after a successful claim. `SELECT * FROM user_sessions WHERE user_id = ...` shows the session. 6. RLS spot-check: log in as recovered user, `SELECT auth.uid()` returns original UID. 7. Realtime smoke: subscribe to `movies` channel from browser console after recovery — confirm WebSocket auth succeeds. ## Rollback - Migration `00004` is forward-only. If app code regresses post-deploy, redeploy old code; the new tables sit unused. Do NOT roll back the migration unless the new app code is fully out of production — old code reads `users.recovery_code` which is gone. - For dev: `psql -c "ALTER TABLE public.users ADD COLUMN recovery_code text; DROP TABLE public.recovery_codes; DROP TABLE public.user_sessions;"` if needed. ## Follow-ups (post-merge) - PM agent updates `PROJECT_SCOPE.md` to reflect the new recovery model. - CSP `script-src` hardening review (security #10) — separate work. - Audit `getClientIp` "unknown" fallback — global rate-limit bucket collision (compliance #10) — separate work. - If `JWT_SECRET` ever needs rotation: leverage `kid: "v1"` to dual-mint during transition.