Files
WRNexusJS/packages/core/src/auth.ts
T
2026-07-12 15:55:18 +05:30

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");
}