import "server-only"; import { cache } from "react"; 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 { 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 and starts a session. Returns the user, or null. */ export async function signIn(login: string, password: string) { const auth = await authenticateUser(login, password); if (!auth) return null; await setSessionCookie(auth.token); return auth.user; } /** 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 () => { const token = (await cookies()).get(SESSION_COOKIE)?.value; 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 : "/"; }