|
|
@@ -0,0 +1,636 @@
|
|
|
+# 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 `<uid>@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<string> {
|
|
|
+ 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<AccessClaims | null> {
|
|
|
+ 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<boolean> {
|
|
|
+ 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<void> {
|
|
|
+ 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<void> {
|
|
|
+ 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<void> {
|
|
|
+ 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<void> {
|
|
|
+ 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.
|