Files
au_whisper_web/src/server/auth.ts
T
2026-07-10 05:26:54 +02:00

292 lines
11 KiB
TypeScript

/**
* Auth: Passport.js local strategy behind a connect-compat adapter over
* Bun.serve (Decisions.md D-008), with users + sessions in bun:sqlite
* (D-009).
*
* Passport expects connect/express-style middleware, which Bun.serve does not
* have. The adapter here is deliberately minimal and honest: it shims only
* what `passport.authenticate("local", callback)` actually touches — the
* strategy reads `req.body`/`req.query` and, because we pass a custom
* callback with `session: false`, never writes to `res`. Session issuance,
* cookies, and serialization are driven by us around that call rather than by
* passport's own connect middleware.
*
* Routes implemented (wired in src/index.ts, shared by dev + production):
* POST /api/auth/register → 201 {user} + Set-Cookie | 400 | 409
* POST /api/auth/login → 200 {user} + Set-Cookie | 400 | 401
* POST /api/auth/logout → 204 (always) + cookie cleared
* GET /api/auth/me → 200 {user} | 401
*
* Cookie contract: name `sid`; HttpOnly; SameSite=Lax; Path=/; Max-Age 7 days;
* Secure when NODE_ENV=production. Expiry is enforced server-side via
* sessions.expires_at — a replayed cookie past that instant is rejected even
* though the browser would still send it.
*/
import passport from "passport";
import { Strategy as LocalStrategy } from "passport-local";
import { type AppDb, type User, getDb } from "./db.ts";
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/** The safe-to-serialize projection of a user (never the password hash). */
export interface SessionUser {
id: number;
username: string;
}
export interface AuthHandlers {
register(request: Request): Promise<Response>;
login(request: Request): Promise<Response>;
logout(request: Request): Promise<Response>;
me(request: Request): Promise<Response>;
/**
* Resolves the user behind a request's `sid` cookie, or null. Exported so
* the M5 transcribe endpoint can guard itself with the same session logic.
*/
getSessionUser(request: Request): SessionUser | null;
}
// ---------------------------------------------------------------------------
// Constants + small helpers
// ---------------------------------------------------------------------------
const COOKIE_NAME = "sid";
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const USERNAME_RE = /^[a-zA-Z0-9_.-]{3,32}$/;
const MIN_PASSWORD_LENGTH = 8;
function json(body: unknown, status: number, headers?: HeadersInit): Response {
const h = new Headers(headers);
h.set("Content-Type", "application/json");
return new Response(JSON.stringify(body), { status, headers: h });
}
/** Serializes the session cookie per the contract in the module doc comment. */
function sessionCookie(sid: string, maxAgeSeconds: number): string {
const attributes = [
`${COOKIE_NAME}=${sid}`,
"HttpOnly",
"SameSite=Lax",
"Path=/",
`Max-Age=${maxAgeSeconds}`,
];
// Checked per-call (not at module load) so tests can flip NODE_ENV.
if (process.env.NODE_ENV === "production") attributes.push("Secure");
return attributes.join("; ");
}
/** Max-Age=0 tells the browser to drop the cookie immediately. */
const clearedCookie = (): string => sessionCookie("", 0);
/** Extracts the `sid` cookie value from a request, if any. */
function readSessionId(request: Request): string | null {
const header = request.headers.get("cookie");
if (!header) return null;
for (const part of header.split(";")) {
const eq = part.indexOf("=");
if (eq === -1) continue;
if (part.slice(0, eq).trim() === COOKIE_NAME) {
const value = part.slice(eq + 1).trim();
return value.length > 0 ? value : null;
}
}
return null;
}
const toSessionUser = (user: User): SessionUser => ({
id: user.id,
username: user.username,
});
interface Credentials {
username: string;
password: string;
}
/**
* Parses a JSON `{username, password}` body. Returns null for anything that
* is not that shape (bad JSON, wrong types) — callers answer 400.
*/
async function parseCredentials(request: Request): Promise<Credentials | null> {
let body: unknown;
try {
body = await request.json();
} catch {
return null;
}
if (typeof body !== "object" || body === null) return null;
const { username, password } = body as Record<string, unknown>;
if (typeof username !== "string" || typeof password !== "string") return null;
return { username, password };
}
/**
* Argon2id hash used to burn comparable time when the username does not
* exist, so login latency doesn't reveal which usernames are registered.
* Computed lazily (hashing is deliberately slow) and cached.
*/
let dummyHash: Promise<string> | undefined;
const getDummyHash = (): Promise<string> =>
(dummyHash ??= Bun.password.hash("dummy-password-for-constant-time"));
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
/**
* Builds the four route handlers plus `getSessionUser` against a database
* source. Taking a getter (not an instance) keeps the default export lazy:
* importing this module never opens ./data/app.db, and tests inject
* `() => createDb(":memory:")` databases without touching disk.
*/
export function createAuth(getDatabase: () => AppDb): AuthHandlers {
// A private Authenticator instance (not the module-level singleton) so two
// createAuth() calls — e.g. app + tests — can't clobber each other's
// strategy registration.
const authenticator = new passport.Passport();
authenticator.use(
new LocalStrategy((username, password, done) => {
// passport-local's verify is callback-style; bridge to async/await and
// route thrown errors into done() so they surface as 500s, not hangs.
void (async () => {
const user = getDatabase().findUserByUsername(username);
if (!user) {
await Bun.password.verify(password, await getDummyHash());
return done(null, false, { message: "invalid credentials" });
}
const ok = await Bun.password.verify(password, user.passwordHash);
return ok
? done(null, user)
: done(null, false, { message: "invalid credentials" });
})().catch(done);
}),
);
/**
* The connect-compat adapter (D-008): run passport's authenticate chain
* against a shim of exactly the surface passport-local reads. With
* `session: false` and a custom callback, passport calls back with
* (err, user|false) and never touches `res`, so an empty object suffices.
*/
function authenticateLocal(credentials: Credentials): Promise<User | false> {
return new Promise((resolve, reject) => {
const req = {
body: credentials,
query: {},
headers: {},
method: "POST",
url: "/api/auth/login",
};
const middleware = authenticator.authenticate(
"local",
{ session: false },
(err: unknown, user: unknown) =>
err ? reject(err) : resolve((user as User | false) || false),
) as (req: unknown, res: unknown, next: (err?: unknown) => void) => void;
// next() only fires on internal errors here (the callback intercepts
// success and failure), so treat any call as a failure path.
middleware(req, {}, (err?: unknown) =>
err ? reject(err) : resolve(false),
);
});
}
/** Issues a fresh session row + Set-Cookie header for `user`. */
function issueSession(user: User): { user: SessionUser; cookie: string } {
const session = getDatabase().createSession(user.id, Date.now() + SESSION_TTL_MS);
return {
user: toSessionUser(user),
cookie: sessionCookie(session.id, SESSION_TTL_MS / 1000),
};
}
function getSessionUser(request: Request): SessionUser | null {
const sid = readSessionId(request);
if (!sid) return null;
const db = getDatabase();
const session = db.findValidSession(sid);
if (!session) return null;
const user = db.findUserById(session.userId);
return user ? toSessionUser(user) : null;
}
return {
async register(request) {
const credentials = await parseCredentials(request);
if (!credentials) {
return json({ error: "expected JSON body with username and password" }, 400);
}
if (!USERNAME_RE.test(credentials.username)) {
return json(
{ error: "username must be 3-32 characters (letters, digits, _ . -)" },
400,
);
}
if (credentials.password.length < MIN_PASSWORD_LENGTH) {
return json(
{ error: `password must be at least ${MIN_PASSWORD_LENGTH} characters` },
400,
);
}
// Argon2id, Bun's default — memory-hard, per D-009 / WorkBreakdown M4.
const passwordHash = await Bun.password.hash(credentials.password);
const user = getDatabase().createUser(credentials.username, passwordHash);
// createUser lets the UNIQUE constraint arbitrate, so concurrent
// registrations of the same name can't both succeed.
if (!user) return json({ error: "username taken" }, 409);
const issued = issueSession(user);
return json({ user: issued.user }, 201, { "Set-Cookie": issued.cookie });
},
async login(request) {
const credentials = await parseCredentials(request);
if (!credentials) {
return json({ error: "expected JSON body with username and password" }, 400);
}
const user = await authenticateLocal(credentials);
// One message for both unknown-user and wrong-password: no username
// enumeration via response text (timing handled by the dummy hash).
if (!user) return json({ error: "invalid credentials" }, 401);
const issued = issueSession(user);
return json({ user: issued.user }, 200, { "Set-Cookie": issued.cookie });
},
async logout(request) {
const sid = readSessionId(request);
if (sid) getDatabase().deleteSession(sid);
// Idempotent by contract: 204 whether or not a session existed. No body
// on 204, but always clear the cookie so stale clients converge.
return new Response(null, {
status: 204,
headers: { "Set-Cookie": clearedCookie() },
});
},
async me(request) {
const user = getSessionUser(request);
return user
? json({ user }, 200)
: json({ error: "not authenticated" }, 401);
},
getSessionUser,
};
}
// ---------------------------------------------------------------------------
// Default instance (the real app database) + convenience re-export for M5
// ---------------------------------------------------------------------------
export const auth: AuthHandlers = createAuth(getDb);
/** Session lookup against the app database — see AuthHandlers.getSessionUser. */
export function getSessionUser(request: Request): SessionUser | null {
return auth.getSessionUser(request);
}