// server/supabase/functions/account-delete/recent-auth.ts // Step-up authentication policy for destructive account operations. // // The access token `iat` is NOT an authentication time: GoTrue mints a fresh // access token (new `iat`) on every refresh_token grant, so anyone holding a // refresh token can make `iat` "recent" without re-entering credentials. // The `amr` claim entries carry the time each authentication method was last // completed for the session and survive refresh, so the most recent `amr` // timestamp is the session's real authentication time. export const RECENT_AUTH_SECONDS = 10 * 60 /** Tolerated clock skew for timestamps slightly in the future. */ export const CLOCK_SKEW_SECONDS = 60 export interface AmrEntry { method: string timestamp: number } export interface SessionAuthClaims { amr: AmrEntry[] } export function extractBearerToken(authorization: string | null): string | null { const token = authorization?.replace(/^Bearer\s+/i, '').trim() return token ? token : null } /** * Decodes (without verifying) the JWT payload. Callers must verify the token * first (requireUser -> auth.getUser()) before trusting these claims. */ export function decodeJwtPayload(token: string | null): Record | null { if (!token) return null const payloadPart = token.split('.')[1] if (!payloadPart) return null try { const normalized = payloadPart.replace(/-/g, '+').replace(/_/g, '/') const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=') const payload: unknown = JSON.parse(atob(padded)) if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null return payload as Record } catch { return null } } export function parseSessionAuthClaims(payload: Record | null): SessionAuthClaims | null { if (!payload || !Array.isArray(payload.amr)) return null const amr: AmrEntry[] = [] for (const entry of payload.amr as unknown[]) { if (!entry || typeof entry !== 'object') continue const { method, timestamp } = entry as { method?: unknown; timestamp?: unknown } if (typeof method !== 'string' || typeof timestamp !== 'number' || !Number.isFinite(timestamp)) continue amr.push({ method, timestamp }) } return { amr } } /** The most recent time the session completed any authentication method, or null. */ export function resolveAuthenticatedAt(claims: SessionAuthClaims | null): number | null { if (!claims || claims.amr.length === 0) return null return Math.max(...claims.amr.map((entry) => entry.timestamp)) } export function isRecentlyAuthenticated( authenticatedAt: number | null, nowSeconds: number, windowSeconds: number = RECENT_AUTH_SECONDS, ): boolean { if (authenticatedAt === null) return false if (authenticatedAt > nowSeconds + CLOCK_SKEW_SECONDS) return false return nowSeconds - authenticatedAt <= windowSeconds } /** Composes the policy from a raw Authorization header. */ export function hasRecentAuthentication(authorization: string | null, nowSeconds: number): boolean { const claims = parseSessionAuthClaims(decodeJwtPayload(extractBearerToken(authorization))) return isRecentlyAuthenticated(resolveAuthenticatedAt(claims), nowSeconds) }