Repository Public
1import { randomBytes } from "node:crypto";2import { cookies } from "next/headers";3import {4 generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse,5 type AuthenticationResponseJSON, type AuthenticatorTransport, type RegistrationResponseJSON,6} from "@simplewebauthn/server";7import { sql } from "./db";8import { env } from "./env";9import { ApiError, clientIp } from "./http";10import { randomToken, safeEqual, sha256 } from "./crypto";11import { audit } from "./audit";1213export const SESSION_COOKIE = "spcal_session";14const SESSION_DAYS = 30;15const RP_NAME = "SP Calendar";1617export const rpId = () => new URL(env.appUrl).hostname;18export const origin = () => new URL(env.appUrl).origin;1920export interface AdminContext { userId: string; email: string; displayName: string; sessionId: string; csrfToken: string }2122// ── Sessions ────────────────────────────────────────────────────────────────────────────────────────2324export async function createSession(userId: string, method: "passkey" | "recovery_code" | "setup", req: Request): Promise<void> {25 const token = randomToken(32);26 const csrf = randomToken(24);27 const expires = new Date(Date.now() + SESSION_DAYS * 86_400_000);28 await sql`INSERT INTO sessions (user_id, token_hash, csrf_token, method, user_agent, ip, expires_at)29 VALUES (${userId}, ${sha256(token)}, ${csrf}, ${method}, ${(req.headers.get("user-agent") ?? "").slice(0, 300)}, ${clientIp(req)}, ${expires})`;30 const jar = await cookies();31 jar.set(SESSION_COOKIE, token, { httpOnly: true, secure: env.isProd, sameSite: "lax", path: "/", expires });32}3334export async function currentAdmin(): Promise<AdminContext | null> {35 const jar = await cookies();36 const token = jar.get(SESSION_COOKIE)?.value;37 if (!token) return null;38 const [row] = await sql<{ id: string; user_id: string; csrf_token: string; email: string; display_name: string; last_seen_at: Date }[]>`39 SELECT s.id, s.user_id, s.csrf_token, u.email, u.display_name, s.last_seen_at40 FROM sessions s JOIN users u ON u.id = s.user_id41 WHERE s.token_hash = ${sha256(token)} AND s.revoked_at IS NULL AND s.expires_at > now()`;42 if (!row) return null;43 if (Date.now() - new Date(row.last_seen_at).getTime() > 60_000) await sql`UPDATE sessions SET last_seen_at = now() WHERE id = ${row.id}`;44 return { userId: row.user_id, email: row.email, displayName: row.display_name, sessionId: row.id, csrfToken: row.csrf_token };45}4647/**48 * Guard of every admin API route. State-changing requests must come from our own origin AND carry the session's49 * CSRF token in a header (a cross-site form cannot set it; SameSite=Lax already withholds the cookie on cross-site POST).50 */51export async function requireAdmin(req: Request): Promise<AdminContext> {52 const admin = await currentAdmin();53 if (!admin) throw new ApiError(401, "unauthenticated", "Sign in required");54 if (req.method !== "GET" && req.method !== "HEAD") {55 assertSameOrigin(req);56 const sent = req.headers.get("x-csrf-token") ?? "";57 if (!safeEqual(sent, admin.csrfToken)) throw new ApiError(403, "csrf", "Invalid CSRF token");58 }59 return admin;60}6162export function assertSameOrigin(req: Request): void {63 const o = req.headers.get("origin");64 if (o && o !== origin()) throw new ApiError(403, "bad_origin", "Cross-origin request refused");65 const site = req.headers.get("sec-fetch-site");66 if (site && site !== "same-origin" && site !== "none") throw new ApiError(403, "bad_origin", "Cross-site request refused");67}6869export async function revokeSession(sessionId: string, userId: string): Promise<void> {70 await sql`UPDATE sessions SET revoked_at = now() WHERE id = ${sessionId} AND user_id = ${userId} AND revoked_at IS NULL`;71}7273export async function clearSessionCookie(): Promise<void> {74 (await cookies()).set(SESSION_COOKIE, "", { httpOnly: true, secure: env.isProd, sameSite: "lax", path: "/", maxAge: 0 });75}7677// ── Owner and enrolment tokens ──────────────────────────────────────────────────────────────────────7879export async function ownerUser(): Promise<{ id: string; email: string; display_name: string } | null> {80 const [u] = await sql<{ id: string; email: string; display_name: string }[]>`SELECT id, email, display_name FROM users ORDER BY created_at LIMIT 1`;81 return u ?? null;82}8384export async function passkeyCount(userId: string): Promise<number> {85 const [r] = await sql<{ n: number }[]>`SELECT count(*)::int AS n FROM authenticators WHERE user_id = ${userId}`;86 return r.n;87}8889async function validSetupToken(raw: string) {90 const [t] = await sql<{ id: string; user_id: string; purpose: string }[]>`91 SELECT id, user_id, purpose FROM setup_tokens WHERE token_hash = ${sha256(raw)} AND used_at IS NULL AND expires_at > now()`;92 return t ?? null;93}9495// ── WebAuthn registration ───────────────────────────────────────────────────────────────────────────9697/**98 * A passkey can be enrolled in two cases only: with a one-time token issued from the server's shell (first setup,99 * recovery), or from an authenticated session (adding a second device).100 */101export async function registrationOptions(input: { setupToken?: string; admin?: AdminContext | null }) {102 let userId: string;103 if (input.admin) userId = input.admin.userId;104 else {105 const t = input.setupToken ? await validSetupToken(input.setupToken) : null;106 if (!t) throw new ApiError(403, "invalid_setup_token", "This enrolment link is invalid or has expired");107 userId = t.user_id;108 }109 const [user] = await sql<{ id: string; email: string; display_name: string }[]>`SELECT id, email, display_name FROM users WHERE id = ${userId}`;110 const existing = await sql<{ credential_id: string; transports: string[] }[]>`SELECT credential_id, transports FROM authenticators WHERE user_id = ${userId}`;111 const options = await generateRegistrationOptions({112 rpName: RP_NAME, rpID: rpId(),113 userName: user.email, userDisplayName: user.display_name,114 userID: new TextEncoder().encode(user.id),115 attestationType: "none",116 excludeCredentials: existing.map((c) => ({ id: c.credential_id, transports: c.transports as AuthenticatorTransport[] })),117 authenticatorSelection: { residentKey: "required", userVerification: "required" },118 });119 const [ch] = await sql<{ id: string }[]>`INSERT INTO webauthn_challenges (challenge, kind, user_id, expires_at)120 VALUES (${options.challenge}, 'registration', ${userId}, now() + interval '5 minutes') RETURNING id`;121 return { challengeId: ch.id, options };122}123124export async function verifyRegistration(input: { challengeId: string; response: RegistrationResponseJSON; name?: string; setupToken?: string; admin?: AdminContext | null }, req: Request) {125 const [ch] = await sql<{ challenge: string; user_id: string }[]>`126 DELETE FROM webauthn_challenges WHERE id = ${input.challengeId} AND kind = 'registration' AND expires_at > now() RETURNING challenge, user_id`;127 if (!ch) throw new ApiError(400, "challenge_expired", "The enrolment challenge has expired, start again");128 let token: { id: string; user_id: string; purpose: string } | null = null;129 if (input.admin) { if (input.admin.userId !== ch.user_id) throw new ApiError(403, "forbidden"); }130 else {131 token = input.setupToken ? await validSetupToken(input.setupToken) : null;132 if (!token || token.user_id !== ch.user_id) throw new ApiError(403, "invalid_setup_token", "This enrolment link is invalid or has expired");133 }134 let verification;135 try {136 verification = await verifyRegistrationResponse({ response: input.response, expectedChallenge: ch.challenge, expectedOrigin: origin(), expectedRPID: rpId(), requireUserVerification: true });137 } catch { throw new ApiError(400, "registration_failed", "The passkey could not be verified"); }138 if (!verification.verified || !verification.registrationInfo) throw new ApiError(400, "registration_failed", "The passkey could not be verified");139 const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;140 const name = (input.name ?? "").trim().slice(0, 60) || "Passkey";141142 let recoveryCodes: string[] | null = null;143 await sql.begin(async (tx) => {144 if (token) {145 const used = await tx`UPDATE setup_tokens SET used_at = now() WHERE id = ${token.id} AND used_at IS NULL RETURNING id`;146 if (used.length === 0) throw new ApiError(403, "invalid_setup_token", "This enrolment link was already used");147 if (token.purpose === "recovery") {148 // Recovery replaces every credential and signs out every device.149 await tx`DELETE FROM authenticators WHERE user_id = ${ch.user_id}`;150 await tx`UPDATE sessions SET revoked_at = now() WHERE user_id = ${ch.user_id} AND revoked_at IS NULL`;151 }152 }153 await tx`INSERT INTO authenticators (user_id, credential_id, public_key, counter, transports, device_type, backed_up, name)154 VALUES (${ch.user_id}, ${credential.id}, ${Buffer.from(credential.publicKey)}, ${credential.counter}, ${credential.transports ?? []}, ${credentialDeviceType}, ${credentialBackedUp}, ${name})`;155 if (token) {156 await tx`DELETE FROM recovery_codes WHERE user_id = ${ch.user_id}`;157 recoveryCodes = Array.from({ length: 8 }, () => randomBytes(6).toString("hex").replace(/(.{4})(.{4})(.{4})/, "$1-$2-$3"));158 for (const c of recoveryCodes) await tx`INSERT INTO recovery_codes (user_id, code_hash) VALUES (${ch.user_id}, ${sha256(c)})`;159 }160 });161 await audit({ userId: ch.user_id, actor: "admin", action: token ? `passkey.enrolled.${token.purpose}` : "passkey.added", ip: clientIp(req) });162 if (token) await createSession(ch.user_id, "setup", req);163 return { ok: true, recoveryCodes };164}165166// ── WebAuthn authentication ─────────────────────────────────────────────────────────────────────────167168export async function authenticationOptions() {169 // Discoverable credentials: no allowCredentials list, so the response says nothing about which accounts exist.170 const options = await generateAuthenticationOptions({ rpID: rpId(), userVerification: "required" });171 const [ch] = await sql<{ id: string }[]>`INSERT INTO webauthn_challenges (challenge, kind, expires_at)172 VALUES (${options.challenge}, 'authentication', now() + interval '5 minutes') RETURNING id`;173 return { challengeId: ch.id, options };174}175176export async function verifyAuthentication(input: { challengeId: string; response: AuthenticationResponseJSON }, req: Request) {177 const [ch] = await sql<{ challenge: string }[]>`178 DELETE FROM webauthn_challenges WHERE id = ${input.challengeId} AND kind = 'authentication' AND expires_at > now() RETURNING challenge`;179 const denied = new ApiError(401, "login_failed", "Sign-in failed");180 if (!ch) throw denied;181 const [cred] = await sql<{ id: string; user_id: string; credential_id: string; public_key: Buffer; counter: string; transports: string[] }[]>`182 SELECT id, user_id, credential_id, public_key, counter, transports FROM authenticators WHERE credential_id = ${input.response.id}`;183 if (!cred) { await audit({ actor: "system", action: "login.failed", meta: { why: "unknown_credential" }, ip: clientIp(req) }); throw denied; }184 let v;185 try {186 v = await verifyAuthenticationResponse({187 response: input.response, expectedChallenge: ch.challenge, expectedOrigin: origin(), expectedRPID: rpId(), requireUserVerification: true,188 credential: { id: cred.credential_id, publicKey: new Uint8Array(cred.public_key), counter: Number(cred.counter), transports: cred.transports as AuthenticatorTransport[] },189 });190 } catch { v = null; }191 if (!v?.verified) { await audit({ userId: cred.user_id, actor: "system", action: "login.failed", meta: { why: "bad_assertion" }, ip: clientIp(req) }); throw denied; }192 await sql`UPDATE authenticators SET counter = ${v.authenticationInfo.newCounter}, last_used_at = now(), backed_up = ${v.authenticationInfo.credentialBackedUp} WHERE id = ${cred.id}`;193 await createSession(cred.user_id, "passkey", req);194 await audit({ userId: cred.user_id, actor: "admin", action: "login.passkey", ip: clientIp(req) });195 return { ok: true };196}197198/** Recovery: a one-use code opens a session, from which a new passkey can be enrolled. */199export async function loginWithRecoveryCode(code: string, req: Request) {200 const normalized = code.trim().toLowerCase();201 const [row] = await sql<{ id: string; user_id: string }[]>`202 UPDATE recovery_codes SET used_at = now() WHERE code_hash = ${sha256(normalized)} AND used_at IS NULL RETURNING id, user_id`;203 if (!row) { await audit({ actor: "system", action: "login.failed", meta: { why: "bad_recovery_code" }, ip: clientIp(req) }); throw new ApiError(401, "login_failed", "Sign-in failed"); }204 await createSession(row.user_id, "recovery_code", req);205 await audit({ userId: row.user_id, actor: "admin", action: "login.recovery_code", ip: clientIp(req) });206 return { ok: true };207}208209/** Issued from the shell only (scripts/setup-token.ts). Returns the raw token once. */210export async function issueSetupToken(userId: string, purpose: "setup" | "recovery", ttlMinutes = 60): Promise<string> {211 const raw = randomToken(32);212 await sql`UPDATE setup_tokens SET used_at = now() WHERE user_id = ${userId} AND used_at IS NULL`;213 await sql`INSERT INTO setup_tokens (user_id, token_hash, purpose, expires_at) VALUES (${userId}, ${sha256(raw)}, ${purpose}, now() + ${ttlMinutes} * interval '1 minute')`;214 await audit({ userId, actor: "system", action: `setup_token.issued.${purpose}` });215 return raw;216}217