first commit
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
Reference in New Issue
Block a user