import "server-only"; import { cache } from "react"; import { createHash, randomBytes } from "node:crypto"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import { ClientResponseError, getTokenPayload, isTokenExpired } from "pocketbase"; import { client, db } from "./db"; import { SECURE_COOKIES } from "./request-origin"; import { hasTwoFactor, verifySecondFactor } from "./two-factor"; import { authenticateUser, toUser } from "./users"; // Sessions are PocketBase auth tokens, kept in an httpOnly cookie. They expire after 30 days (the // users collection's token duration) and stop working when the password or email changes. const SESSION_COOKIE = "session"; const SESSION_SECONDS = 30 * 86400; async function setSessionCookie(token: string) { (await cookies()).set(SESSION_COOKIE, token, { httpOnly: true, sameSite: "lax", secure: SECURE_COOKIES, path: "/", expires: new Date(getTokenPayload(token).exp * 1000), }); } /** * Checks a username or email and password. Starts a session, or with two-factor authentication on, * a sign-in challenge that `completeSignIn` finishes with a code. Returns null for a wrong password. */ export async function signIn(login: string, password: string) { const auth = await authenticateUser(login, password); if (!auth) return null; if (await hasTwoFactor(auth.user.id)) { await startChallenge(auth.user.id, auth.token); return { user: auth.user, needsCode: true }; } await setSessionCookie(auth.token); return { user: auth.user, needsCode: false }; } /* * Sign-in challenges: after the password, the session token waits on the server for a code, for 5 * minutes and 5 tries. The browser holds a random id for it in a cookie. They live in memory, as * one Node.js process serves all requests (see withLock); a restart only means signing in again. * Wrong codes also count per user, so new challenges don't give more guesses: after 10 in 15 * minutes, codes for that account are refused until the 15 minutes are over. */ const CHALLENGE_COOKIE = "signin_challenge"; const CHALLENGE_MS = 5 * 60_000; const CHALLENGE_TRIES = 5; const FAILURE_WINDOW_MS = 15 * 60_000; const FAILURE_LIMIT = 10; type Challenge = { userId: string; token: string; expires: number; tries: number }; const g = globalThis as unknown as { __signinChallenges?: Map; __signinFailures?: Map; }; const challenges = (g.__signinChallenges ??= new Map()); const failures = (g.__signinFailures ??= new Map()); const challengeKey = (id: string) => createHash("sha256").update(id).digest("hex"); async function startChallenge(userId: string, token: string) { const now = Date.now(); for (const [key, c] of challenges) if (c.expires < now) challenges.delete(key); const id = randomBytes(32).toString("base64url"); challenges.set(challengeKey(id), { userId, token, expires: now + CHALLENGE_MS, tries: 0 }); (await cookies()).set(CHALLENGE_COOKIE, id, { httpOnly: true, sameSite: "lax", secure: SECURE_COOKIES, path: "/login", maxAge: CHALLENGE_MS / 1000, }); } async function currentChallenge() { const id = (await cookies()).get(CHALLENGE_COOKIE)?.value; if (!id) return null; const key = challengeKey(id); const challenge = challenges.get(key); if (!challenge || challenge.expires < Date.now()) { challenges.delete(key); return null; } return { key, challenge }; } /** Whether this browser has entered a password and still needs to enter a code. */ export async function hasSignInChallenge() { return !!(await currentChallenge()); } /** * Checks a code from the authenticator app or a recovery code, counting wrong ones per user. The * mobile app's sign-in (lib/app-api.ts) uses this too. */ export async function checkSecondFactor(userId: string, code: string): Promise<"ok" | "invalid" | "locked"> { const now = Date.now(); const failed = failures.get(userId); if (failed && now - failed.since > FAILURE_WINDOW_MS) failures.delete(userId); if ((failures.get(userId)?.count ?? 0) >= FAILURE_LIMIT) return "locked"; if (!(await verifySecondFactor(userId, code))) { const f = failures.get(userId) ?? { count: 0, since: now }; failures.set(userId, { ...f, count: f.count + 1 }); return "invalid"; } failures.delete(userId); return "ok"; } /** Finishes signing in with a code from the authenticator app or a recovery code. */ export async function completeSignIn(code: string): Promise<"ok" | "invalid" | "expired" | "locked"> { const current = await currentChallenge(); if (!current) return "expired"; const { key, challenge } = current; const result = await checkSecondFactor(challenge.userId, code); if (result === "locked") return "locked"; if (result === "invalid") { if (++challenge.tries >= CHALLENGE_TRIES) { challenges.delete(key); await endChallenge(); return "expired"; } return "invalid"; } challenges.delete(key); await endChallenge(); await setSessionCookie(challenge.token); return "ok"; } async function endChallenge() { (await cookies()).delete({ name: CHALLENGE_COOKIE, path: "/login" }); } /** A new session for a signed-in user whose old token stopped working (after an email change). */ export async function renewSession(userId: string) { const impersonated = await (await db()).collection("users").impersonate(userId, SESSION_SECONDS); await setSessionCookie(impersonated.authStore.token); } export async function endSession() { (await cookies()).delete(SESSION_COOKIE); } /** The signed-in user for this request, or null. */ export const getViewer = cache(async () => userForSession((await cookies()).get(SESSION_COOKIE)?.value)); /** The user a session token belongs to, or null if it's expired or no longer valid. The mobile app sends these as bearer tokens (lib/app-api.ts). */ export async function userForSession(token: string | undefined) { if (!token || isTokenExpired(token)) return null; const { id, type } = getTokenPayload(token); if (type !== "auth" || typeof id !== "string") return null; await db(); // makes sure PocketBase is running // Reading the record with the user's own token is what checks the token. const pb = client(); pb.authStore.save(token); try { return toUser(await pb.collection("users").getOne(id)); } catch (e) { if (e instanceof ClientResponseError && [401, 403, 404].includes(e.status)) return null; throw e; } } /** Returns the signed-in user, or redirects to the login page. */ export async function requireViewer(returnTo?: string) { const viewer = await getViewer(); if (!viewer) redirect(returnTo ? `/login?next=${encodeURIComponent(returnTo)}` : "/login"); return viewer; } /** Only allow same-site relative redirects after login. */ export function safeNext(next: unknown) { return typeof next === "string" && next.startsWith("/") && !next.startsWith("//") ? next : "/"; }