import { all, batch, first, run, stmt } from "./db"; import { accountDb, repoDb, type ScopedValue } from "./scoped-db"; import { forgetLocator, type LocatorKind } from "./scoped-db/locators"; import { revokeAccessTokensWhere } from "./access-token-store"; import { revokeAllDeployTokensCreatedBy } from "./deploy-token-index"; import { forEachScope } from "./account-scope"; import { invitationOrgs } from "./invitation-index"; import { hashPassword, passwordNeedsRehash, randomToken, verifyPassword } from "./crypto"; import { consumeAuthToken, findAuthToken, storeAuthToken } from "./auth-tokens"; import { deleteUserSessions, sessionCreatedAt } from "./auth"; import { enforceHourlyLimit, enforceRateLimit, HOURLY } from "./limits"; import { emailTemplates, queueEmail } from "./email"; import { publicUrl } from "./events/emit"; import { badRequest, conflict } from "./errors"; import { promoteAdminEmailOnVerify, reclaimUnverifiedEmail } from "./accounts"; import { invalidateUserSignatures } from "./security/signatures"; import { dropMcpAccess } from "./access-revocation"; import { recordAdminAction } from "./admin-audit"; import { userChanged } from "./user-row"; const VERIFY_TTL_SECONDS = 60 * 60 * 24; const RESET_TTL_SECONDS = 60 * 60; type VerifyPayload = { email: string }; type SpentReason = "used" | "superseded"; /** `viaPrimary` is false when the link went to one of the user's other verified addresses. */ type ResetPayload = { passwordHash: string | null; viaPrimary: boolean }; function parseVerify(raw: string): VerifyPayload { const parsed: unknown = JSON.parse(raw); const email = typeof parsed === "object" && parsed !== null && "email" in parsed ? parsed.email : null; if (typeof email !== "string") throw badRequest("This verification link is invalid"); return { email }; } function parseReset(raw: string): ResetPayload { const parsed: unknown = JSON.parse(raw); const hash = typeof parsed === "object" && parsed !== null && "passwordHash" in parsed ? parsed.passwordHash : null; const viaPrimary = typeof parsed === "object" && parsed !== null && "viaPrimary" in parsed ? parsed.viaPrimary !== false : true; return { passwordHash: typeof hash === "string" ? hash : null, viaPrimary }; } async function displayNameFor(userId: string): Promise { return (await first<{ name: string }>("SELECT name FROM accounts WHERE id = ?", userId))?.name ?? "there"; } /** Sends a single-use verification link for the primary address or one of the user's other addresses. */ export async function sendEmailVerification(userId: string, email: string): Promise { const token = randomToken("verify"); await accountDb(userId).run( `UPDATE auth_tokens SET kind = 'email-verify-spent', payload = json_set(payload, '$.reason', 'superseded') WHERE kind = 'email-verify' AND user_id = ? AND json_extract(payload, '$.email') = ? COLLATE NOCASE`, userId, email, ); const payload: VerifyPayload = { email }; await storeAuthToken("email-verify", token, userId, JSON.stringify(payload), VERIFY_TTL_SECONDS); const url = publicUrl(`/verify-email?token=${encodeURIComponent(token)}`); const primary = await first<{ email: string }>("SELECT email FROM users WHERE account_id = ?", userId); const isPrimary = primary?.email.toLowerCase() === email.toLowerCase(); await queueEmail( email, emailTemplates.verifyEmail(await displayNameFor(userId), url, isPrimary ? "primary" : "added"), ); } /** Proving the inbox beats an unverified claim elsewhere only once that claim's reservation has lapsed. */ async function assertEmailClaimable(userId: string, email: string): Promise { const primaryOwner = await first<{ account_id: string }>("SELECT account_id FROM users WHERE email = ?", email); if (primaryOwner && primaryOwner.account_id !== userId && !(await reclaimUnverifiedEmail(email))) { throw conflict("That email address is already in use"); } const verifiedOwner = await first<{ user_id: string }>( "SELECT user_id FROM user_emails WHERE email = ? AND verified_at IS NOT NULL", email, ); if (verifiedOwner && verifiedOwner.user_id !== userId) throw conflict("That email address is already in use"); } async function isEmailVerified(userId: string, email: string): Promise { const row = await first<{ verified: number }>( `SELECT 1 AS verified FROM users WHERE account_id = ?1 AND email = ?2 COLLATE NOCASE AND email_verified_at IS NOT NULL UNION ALL SELECT 1 FROM user_emails WHERE user_id = ?1 AND email = ?2 COLLATE NOCASE AND verified_at IS NOT NULL`, userId, email, ); return row !== null; } /** Old links stay recognisable after use or resend so the page can say why they stopped working. */ async function explainSpentVerification(token: string): Promise<{ userId: string; email: string }> { const spent = await findAuthToken("email-verify-spent", token); if (!spent?.userId) { throw badRequest("This verification link is invalid or has expired. Request a new one from your settings."); } const { email } = parseVerify(spent.payload); if (await isEmailVerified(spent.userId, email)) return { userId: spent.userId, email }; const parsed: unknown = JSON.parse(spent.payload); const reason = typeof parsed === "object" && parsed !== null && "reason" in parsed ? parsed.reason : null; if (reason === "superseded") { throw badRequest(`A newer verification link was sent to ${email}. Use the link in the most recent email.`); } throw badRequest("This verification link was already used. Request a new one from your settings."); } export async function confirmEmailVerification(token: string): Promise<{ userId: string; email: string }> { const consumed = await consumeAuthToken("email-verify", token); if (!consumed?.userId) return explainSpentVerification(token); const userId = consumed.userId; const email = parseVerify(consumed.payload).email; const user = await first<{ email: string }>("SELECT email FROM users WHERE account_id = ?", userId); if (!user) throw badRequest("This verification link is invalid or has expired"); const now = Date.now(); if (user.email.toLowerCase() === email.toLowerCase()) { await run("UPDATE users SET email_verified_at = COALESCE(email_verified_at, ?) WHERE account_id = ?", now, userId); await userChanged(userId); await promoteAdminEmailOnVerify(userId); } else { const pending = await first<{ email: string }>( "SELECT email FROM user_emails WHERE user_id = ? AND email = ?", userId, email, ); if (!pending) throw badRequest("That address was removed from your account"); await assertEmailClaimable(userId, email); await batch([ stmt("DELETE FROM user_emails WHERE email = ? AND user_id != ? AND verified_at IS NULL", email, userId), stmt( "UPDATE user_emails SET verified_at = COALESCE(verified_at, ?) WHERE user_id = ? AND email = ?", now, userId, email, ), ]); } await invalidateUserSignatures(userId); const spentPayload: VerifyPayload & { reason: SpentReason } = { email, reason: "used" }; await storeAuthToken("email-verify-spent", token, userId, JSON.stringify(spentPayload), VERIFY_TTL_SECONDS); return { userId, email }; } /** Always succeeds from the caller's view so the endpoint can't be used to probe which emails exist. */ export async function requestPasswordReset(email: string): Promise { const user = await first<{ account_id: string; password_hash: string | null; email: string; via_primary: number; }>( `SELECT account_id, password_hash, email, 1 AS via_primary FROM users WHERE email = ?1 UNION ALL SELECT u.account_id, u.password_hash, e.email, 0 AS via_primary FROM user_emails e JOIN users u ON u.account_id = e.user_id WHERE e.email = ?1 AND e.verified_at IS NOT NULL LIMIT 1`, email, ); if (!user) return; const token = randomToken("reset"); const payload: ResetPayload = { passwordHash: user.password_hash, viaPrimary: user.via_primary === 1 }; await storeAuthToken("password-reset", token, user.account_id, JSON.stringify(payload), RESET_TTL_SECONDS); const url = publicUrl(`/reset-password?token=${encodeURIComponent(token)}`); await queueEmail(user.email, emailTemplates.passwordReset(await displayNameFor(user.account_id), url)); } const RESET_NOTICE = "your password was reset and your personal access and deploy tokens were revoked"; const HANDOVER_NOTICE = "your password was reset on an account whose email was never verified, so everything its creator set up was removed: sessions, personal access, OAuth and agent tokens with their refresh tokens and codes, agents, deploy tokens it created, two-factor methods, passkeys, recovery codes, signing and SSH keys, webhooks and integrations, self-hosted runners and runner registration tokens, its AI provider keys and those of its repositories, other email addresses, organization invitations to or from it, its organization memberships and its collaborator access to other repositories. Its repositories are now yours; review their collaborators and settings"; /** What a handover (AUTH-A2) clears in the D1 directory beyond a normal reset. */ function handoverDirectoryStatements(userId: string) { return [ stmt( "UPDATE users SET totp_secret = NULL, totp_iv = NULL, totp_enabled_at = NULL, totp_last_step = NULL, totp_failures = 0, totp_locked_until = NULL, github_id = NULL WHERE account_id = ?", userId, ), stmt( "DELETE FROM integrations WHERE created_by = ?1 OR (scope_type = 'repo' AND scope_id IN (SELECT id FROM repos WHERE owner_id = ?1))", userId, ), stmt("DELETE FROM user_emails WHERE user_id = ?", userId), stmt("DELETE FROM org_members WHERE user_id = ?", userId), stmt("DELETE FROM collaborators WHERE user_id = ?", userId), ]; } /** Deletes rows from a scoped table and drops the locator registered for each (`column` is the locator key). */ async function deleteLocated( db: ReturnType, table: string, column: string, kind: LocatorKind, where: string, ...params: ScopedValue[] ): Promise { const rows = await db.all>( `DELETE FROM ${table} WHERE ${where} RETURNING ${column} AS k`, ...params, ); await Promise.all(rows.map((row) => forgetLocator(kind, row.k))); } /** * What a handover clears in scoped objects: everything the unverified creator set up in the account's own * AccountState, the webhooks and AI keys of its repositories, and its invitations and runner registrations * held by organizations. `orgIds` are the organizations it belonged to (read before memberships are removed). */ async function clearHandoverScopes(userId: string, repoIds: string[], orgIds: string[]): Promise { const db = accountDb(userId); await deleteLocated(db, "passkeys", "id", "passkey", "user_id = ?", userId); await db.run("DELETE FROM recovery_codes WHERE user_id = ?", userId); await db.run("DELETE FROM signing_keys WHERE user_id = ?", userId); await deleteLocated(db, "oauth_refresh_tokens", "token_hash", "oauth_refresh", "user_id = ?", userId); await deleteLocated(db, "oauth_codes", "code_hash", "oauth_code", "user_id = ?", userId); // Every access token (agent tokens included) was revoked by the reset itself. await db.run("DELETE FROM agents WHERE owner_id = ?", userId); // The account's own runners include those of its repositories (runners live with the repository owner). const repoList = repoIds.length > 0 ? repoIds.map(() => "?").join(", ") : "NULL"; await deleteLocated( db, "runner_registration_tokens", "token_hash", "runner_registration", `created_by = ? OR owner_id = ? OR repo_id IN (${repoList})`, userId, userId, ...repoIds, ); const runners = await db.all<{ id: string; token_hash: string; previous_token_hash: string | null }>( `DELETE FROM runners WHERE owner_id = ? OR repo_id IN (${repoList}) RETURNING id, token_hash, previous_token_hash`, userId, ...repoIds, ); await Promise.all( runners.flatMap((runner) => [ forgetLocator("runner", runner.id), forgetLocator("runner_token", runner.token_hash), ...(runner.previous_token_hash ? [forgetLocator("runner_token", runner.previous_token_hash)] : []), ]), ); await db.run("DELETE FROM ai_endpoints WHERE scope = 'account' AND owner_id = ?", userId); for (const repoId of repoIds) { await deleteLocated(repoDb(repoId), "webhooks", "id", "webhook", "repo_id = ?", repoId); // Repository AI keys live in the AccountState named by the repository (see ai/endpoint.ts). await accountDb(repoId).run("DELETE FROM ai_endpoints WHERE scope = 'repo' AND owner_id = ?", repoId); } await forEachScope([userId, ...(await invitationOrgs(userId, orgIds))], async (accountId) => { await deleteLocated( accountDb(accountId), "org_invitations", "id", "org_invitation", "user_id = ?1 OR invited_by = ?1", userId, ); }); await forEachScope(orgIds, async (orgId) => { await deleteLocated( accountDb(orgId), "runner_registration_tokens", "token_hash", "runner_registration", "created_by = ?", userId, ); }); } export async function completePasswordReset(token: string, password: string): Promise { const consumed = await consumeAuthToken("password-reset", token); if (!consumed?.userId) throw badRequest("This reset link is invalid or has expired"); const payload = { userId: consumed.userId, ...parseReset(consumed.payload) }; const current = await first<{ password_hash: string | null; email: string; email_verified_at: number | null }>( "SELECT password_hash, email, email_verified_at FROM users WHERE account_id = ?", payload.userId, ); if (!current || current.password_hash !== payload.passwordHash) { throw badRequest("This reset link is no longer valid"); } const now = Date.now(); const statements = [ stmt( "UPDATE users SET password_hash = ?, sessions_revoked_at = ?, email_verified_at = COALESCE(email_verified_at, ?) WHERE account_id = ?", await hashPassword(password), now, payload.viaPrimary ? now : null, payload.userId, ), ]; // AUTH-A2: the inbox owner is taking over a row they never verified, which someone else created (and // could have enrolled 2FA, passkeys or keys on, or wired to agents, webhooks and orgs). Everything the // creator set up goes; repositories stay, since the inbox owner inherits them. const handover = current.email_verified_at === null && payload.viaPrimary; const ownedRepos = handover ? await all<{ id: string }>("SELECT id FROM repos WHERE owner_id = ?", payload.userId) : []; const orgIds = handover ? (await all<{ org_id: string }>("SELECT org_id FROM org_members WHERE user_id = ?", payload.userId)).map( (row) => row.org_id, ) : []; if (handover) statements.push(...handoverDirectoryStatements(payload.userId)); // The password and sessions_revoked_at land first: they are what locks the old holder out. await batch(statements); await userChanged(payload.userId); const db = accountDb(payload.userId); await deleteLocated( db, "auth_tokens", "token_hash", "auth_token", "kind = 'password-reset' AND user_id = ?", payload.userId, ); await deleteUserSessions(payload.userId); await revokeAccessTokensWhere(payload.userId, "1 = 1"); await revokeAllDeployTokensCreatedBy(payload.userId); if (handover) { await clearHandoverScopes( payload.userId, ownedRepos.map((repo) => repo.id), orgIds, ); await invalidateUserSignatures(payload.userId); for (const repo of ownedRepos) await dropMcpAccess(repo.id); await recordAdminAction({ actorId: payload.userId, action: "account.handover", targetKind: "user", targetId: payload.userId, reason: "Password reset verified a never-verified primary email; the creator's credentials, agents, webhooks, runners, AI keys, emails, invitations, org memberships and collaborator access were removed", }); } await notifySecurityEvent(payload.userId, handover ? HANDOVER_NOTICE : RESET_NOTICE); await db.run("DELETE FROM notification_unsubscribe_tokens WHERE user_id = ?", payload.userId); return payload.userId; } /** Signs out every existing session for the user (sessions created after this instant stay valid). */ export async function revokeAllSessions(userId: string): Promise { await run("UPDATE users SET sessions_revoked_at = ? WHERE account_id = ?", Date.now() - 1, userId); await userChanged(userId); await deleteUserSessions(userId); } /** Account security notices always go out (no email budget) and never fail the action that triggered them. */ export async function notifySecurityEvent( userId: string, event: string, extraRecipients: string[] = [], ): Promise { try { const user = await first<{ email: string; email_verified_at: number | null }>( "SELECT email, email_verified_at FROM users WHERE account_id = ?", userId, ); if (!user) return; const recipients = new Map(extraRecipients.map((address) => [address.toLowerCase(), address])); if (user.email_verified_at) recipients.set(user.email.toLowerCase(), user.email); const content = emailTemplates.securityNotice(await displayNameFor(userId), event); for (const address of recipients.values()) await queueEmail(address, content); } catch (error) { console.error("Security notice failed", error); } } const RECENT_LOGIN_MS = 15 * 60 * 1000; /** Re-authentication for sensitive changes; password-less (OAuth-only) accounts need a fresh sign-in instead. */ export async function confirmPassword(userId: string, password: string): Promise { await enforceRateLimit("AUTH_LIMITER", `sudo:${userId}`); await enforceHourlyLimit(`sudo:${userId}`, HOURLY.confirmPassword); const user = await first<{ password_hash: string | null }>( "SELECT password_hash FROM users WHERE account_id = ?", userId, ); if (!user) throw badRequest("Account not found"); if (!user.password_hash) { const createdAt = await sessionCreatedAt(); if (createdAt === null || Date.now() - createdAt > RECENT_LOGIN_MS) { throw badRequest("Sign out and sign in again to confirm this change"); } return; } if (!(await verifyPassword(password, user.password_hash))) throw badRequest("Password is incorrect"); await rehashIfStale(userId, password, user.password_hash); } /** * Upgrades a hash that was made at a lower PBKDF2 cost now that the plaintext is in hand (AUTH-A4). * Conditional on the old hash so a concurrent password change is never clobbered. */ export async function rehashIfStale(userId: string, password: string, storedHash: string): Promise { if (!passwordNeedsRehash(storedHash)) return; await run( "UPDATE users SET password_hash = ? WHERE account_id = ? AND password_hash = ?", await hashPassword(password), userId, storedHash, ); }