import { sha256Hex } from "./crypto"; import { accountDb } from "./scoped-db"; import { forgetLocator, locate, registerLocator } from "./scoped-db/locators"; /** Tokens issued before anyone is signed in (a passkey sign-in challenge) live in this pseudo-account. */ export const ANONYMOUS_AUTH_SCOPE = "_anonymous"; async function tokenScope(tokenHash: string) { const scopeId = await locate("auth_token", tokenHash); return scopeId ? accountDb(scopeId) : null; } export type AuthTokenKind = | "email-verify" | "email-verify-spent" | "password-reset" | "webauthn" | "pending-login" | "editor-session" | "editor-preview" | "mcp-authz"; type Row = { user_id: string | null; payload: string; created_at: number }; export type ConsumedToken = { userId: string | null; payload: string; createdAt: number }; /** Stores single-use state keyed by the hash of a secret token. */ export async function storeAuthToken( kind: AuthTokenKind, token: string, userId: string | null, payload: string, ttlSeconds: number, ): Promise { const now = Date.now(); const tokenHash = await sha256Hex(token); const scopeId = userId ?? ANONYMOUS_AUTH_SCOPE; await accountDb(scopeId).run( "INSERT INTO auth_tokens (token_hash, kind, user_id, payload, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)", tokenHash, kind, userId, payload, now, now + ttlSeconds * 1000, ); await registerLocator("auth_token", tokenHash, scopeId); } /** Atomically consumes a token: concurrent callers can't both succeed. */ export async function consumeAuthToken(kind: AuthTokenKind, token: string): Promise { const tokenHash = await sha256Hex(token); const db = await tokenScope(tokenHash); if (!db) return null; const row = await db.first( "DELETE FROM auth_tokens WHERE token_hash = ? AND kind = ? AND expires_at > ? RETURNING user_id, payload, created_at", tokenHash, kind, Date.now(), ); if (row) await forgetLocator("auth_token", tokenHash); return row ? { userId: row.user_id, payload: row.payload, createdAt: row.created_at } : null; } /** Counts an attempt against a token, returning it only while under `maxAttempts`. */ export async function attemptAuthToken( kind: AuthTokenKind, token: string, maxAttempts: number, ): Promise { const tokenHash = await sha256Hex(token); const db = await tokenScope(tokenHash); if (!db) return null; const row = await db.first( `UPDATE auth_tokens SET attempts = attempts + 1 WHERE token_hash = ? AND kind = ? AND expires_at > ? AND attempts < ? RETURNING user_id, payload, created_at`, tokenHash, kind, Date.now(), maxAttempts, ); return row ? { userId: row.user_id, payload: row.payload, createdAt: row.created_at } : null; } export async function peekAuthToken( kind: AuthTokenKind, token: string, maxAttempts: number, ): Promise { const tokenHash = await sha256Hex(token); const db = await tokenScope(tokenHash); if (!db) return null; const row = await db.first( "SELECT user_id, payload, created_at FROM auth_tokens WHERE token_hash = ? AND kind = ? AND expires_at > ? AND attempts < ?", tokenHash, kind, Date.now(), maxAttempts, ); return row ? { userId: row.user_id, payload: row.payload, createdAt: row.created_at } : null; } export async function deleteAuthToken(kind: AuthTokenKind, token: string): Promise { const tokenHash = await sha256Hex(token); const db = await tokenScope(tokenHash); if (!db) return; const result = await db.run("DELETE FROM auth_tokens WHERE token_hash = ? AND kind = ?", tokenHash, kind); if (result.meta.changes > 0) await forgetLocator("auth_token", tokenHash); } /** Reads a token without counting an attempt or consuming it. */ export async function findAuthToken(kind: AuthTokenKind, token: string): Promise { const tokenHash = await sha256Hex(token); const db = await tokenScope(tokenHash); if (!db) return null; const row = await db.first( "SELECT user_id, payload, created_at FROM auth_tokens WHERE token_hash = ? AND kind = ? AND expires_at > ?", tokenHash, kind, Date.now(), ); return row ? { userId: row.user_id, payload: row.payload, createdAt: row.created_at } : null; }