96 lines
3.3 KiB
TypeScript
96 lines
3.3 KiB
TypeScript
/**
|
|
* Authentication primitives.
|
|
*
|
|
* Passwords are hashed with argon2id via `Bun.password`. Sessions ride on the
|
|
* existing cookie-backed `SessionStore`: logging a user in stores a serializable
|
|
* user object under the "user" key, and `sessionAuth` hydrates `ctx.user` from
|
|
* it on every request. `requireAuth` is a guard middleware for protected routes.
|
|
*/
|
|
|
|
import type { Context, Middleware } from "./context.ts";
|
|
|
|
/** Session key under which the authenticated user is stored. */
|
|
export const SESSION_USER_KEY = "user";
|
|
|
|
/** Hash a plaintext password (argon2id). Store the returned string. */
|
|
export function hashPassword(password: string): Promise<string> {
|
|
return Bun.password.hash(password);
|
|
}
|
|
|
|
/** Verify a plaintext password against a stored hash. Safe against bad hashes. */
|
|
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
|
if (!hash) return false;
|
|
try {
|
|
return await Bun.password.verify(password, hash);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Persist the authenticated user in the session and on the context. */
|
|
export function logIn<U = unknown>(ctx: Context, user: U): void {
|
|
// Regenerate the session id first so a pre-login (possibly attacker-planted)
|
|
// id can't be reused post-login — defends against session fixation.
|
|
ctx.session.regenerate();
|
|
ctx.session.set(SESSION_USER_KEY, user);
|
|
ctx.user = user;
|
|
}
|
|
|
|
/** Clear the session and forget the current user. */
|
|
export function logOut(ctx: Context): void {
|
|
ctx.session.clear();
|
|
ctx.user = null;
|
|
}
|
|
|
|
/**
|
|
* The currently-authenticated user, or null. Reads `ctx.user` first (set by
|
|
* `sessionAuth`/`logIn`), falling back to the session store.
|
|
*/
|
|
export function getUser<U = unknown>(ctx: Context): U | null {
|
|
if (ctx.user != null) return ctx.user as U;
|
|
const fromSession = ctx.session.get<U>(SESSION_USER_KEY);
|
|
return fromSession ?? null;
|
|
}
|
|
|
|
/**
|
|
* Hydrate `ctx.user` from the session for every request. Register this early in
|
|
* the middleware chain so downstream pages and API routes can read `ctx.user`.
|
|
*/
|
|
export function sessionAuth(): Middleware {
|
|
return (ctx, next) => {
|
|
ctx.user = ctx.session.get(SESSION_USER_KEY) ?? null;
|
|
return next();
|
|
};
|
|
}
|
|
|
|
export interface RequireAuthOptions {
|
|
/** Where to redirect unauthenticated page requests. Default "/login". */
|
|
loginPath?: string;
|
|
}
|
|
|
|
/**
|
|
* Guard that requires an authenticated user. Unauthenticated requests that look
|
|
* like an API/fetch call get a 401 JSON response; page navigations get a 302
|
|
* redirect to the login page with the original target preserved as `?next=`.
|
|
*/
|
|
export function requireAuth(options: RequireAuthOptions = {}): Middleware {
|
|
const loginPath = options.loginPath ?? "/login";
|
|
return (ctx, next) => {
|
|
if (getUser(ctx) != null) return next();
|
|
if (wantsJson(ctx)) {
|
|
return Response.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
const target = encodeURIComponent(ctx.url.pathname + ctx.url.search);
|
|
return new Response(null, {
|
|
status: 302,
|
|
headers: { Location: `${loginPath}?next=${target}` },
|
|
});
|
|
};
|
|
}
|
|
|
|
function wantsJson(ctx: Context): boolean {
|
|
if (ctx.url.pathname.startsWith("/api/")) return true;
|
|
const accept = ctx.req.headers.get("accept") ?? "";
|
|
return accept.includes("application/json") && !accept.includes("text/html");
|
|
}
|