From ceb24595e69da10fec227a874875b98c5b51fc6c Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 27 May 2026 14:54:19 +0800 Subject: [PATCH] fix(auth): race-safe first-run setup + rolling cookie max-age refresh Co-Authored-By: Claude Sonnet 4.6 --- src/data/users.test.ts | 20 ++++++++++++++++++++ src/data/users.ts | 21 +++++++++++++++++++++ src/web/api/session.ts | 15 ++++++++------- src/web/auth/validateSession.ts | 5 +++++ src/web/middleware/requireAuth.test.ts | 12 ++++++++++++ src/web/middleware/requireAuth.ts | 17 ++++++++++++++++- 6 files changed, 82 insertions(+), 8 deletions(-) diff --git a/src/data/users.test.ts b/src/data/users.test.ts index 65fc7f1..e89e52e 100644 --- a/src/data/users.test.ts +++ b/src/data/users.test.ts @@ -72,4 +72,24 @@ describe("UserStore", () => { expect(users.countUsers()).toBe(0); expect(users.deleteUser("not-a-real-id")).toBe(false); }); + + it("createFirstUser succeeds on empty db, returns null when a user already exists", async () => { + const a = await users.createFirstUser("alice", "pw-alice"); + expect(a).not.toBeNull(); + expect(a!.username).toBe("alice"); + const b = await users.createFirstUser("bob", "pw-bob-bob"); + expect(b).toBeNull(); + expect(users.countUsers()).toBe(1); + }); + + it("createFirstUser is race-safe: concurrent calls produce exactly one user", async () => { + const [a, b, c] = await Promise.all([ + users.createFirstUser("alice", "pw-alice"), + users.createFirstUser("bob", "pw-bob-bob"), + users.createFirstUser("charlie", "pw-charlie-pw"), + ]); + const created = [a, b, c].filter((u) => u !== null); + expect(created).toHaveLength(1); + expect(users.countUsers()).toBe(1); + }); }); diff --git a/src/data/users.ts b/src/data/users.ts index 42e9450..c7becc7 100644 --- a/src/data/users.ts +++ b/src/data/users.ts @@ -15,6 +15,7 @@ export interface UserRow { export interface UserStore { countUsers(): number; createUser(username: string, password: string): Promise; + createFirstUser(username: string, password: string): Promise; findByUsername(username: string): UserRow | null; findById(id: string): UserRow | null; verifyPassword(plain: string, hash: string): Promise; @@ -69,6 +70,26 @@ export function createUserStore(db: Database.Database): UserStore { return { id, username, passwordHash: hash, createdAt: now, updatedAt: now }; }, + async createFirstUser(username, password) { + const hash = await bcrypt.hash(password, BCRYPT_ROUNDS); + const id = randomUUID(); + const now = Date.now(); + const run = db.transaction(() => { + const count = (countStmt.get() as { n: number }).n; + if (count !== 0) return null; + try { + insertStmt.run(id, username, hash, now, now); + } catch (err) { + if (err && typeof err === "object" && (err as { code?: string }).code === "SQLITE_CONSTRAINT_UNIQUE") { + return null; + } + throw err; + } + return { id, username, passwordHash: hash, createdAt: now, updatedAt: now } as UserRow; + }); + return run(); + }, + findByUsername(username) { return (findByUsernameStmt.get(username) as UserRow | undefined) ?? null; }, diff --git a/src/web/api/session.ts b/src/web/api/session.ts index 9f302ce..5c00bbb 100644 --- a/src/web/api/session.ts +++ b/src/web/api/session.ts @@ -2,10 +2,9 @@ import { Router } from "express"; import type { Request, Response, NextFunction } from "express"; import type { Logger } from "../../logger.js"; import type { UserStore } from "../../data/users.js"; -import { UsernameTakenError } from "../../data/users.js"; import type { SessionStore } from "../../data/sessions.js"; import { SESSION_TTL_MS } from "../../data/sessions.js"; -import { SESSION_COOKIE_NAME, validateSessionFromHeaders } from "../auth/validateSession.js"; +import { SESSION_COOKIE_NAME, validateSessionFromHeaders, extractSessionToken } from "../auth/validateSession.js"; const FAILED_LOGIN_DELAY_MS = 250; @@ -60,6 +59,8 @@ export function createSessionRouter( return; } req.user = { id: result.userId, username: result.username }; + const token = extractSessionToken(req.headers.cookie); + if (token) setSessionCookie(res, token); next(); }; @@ -78,16 +79,16 @@ export function createSessionRouter( return; } try { - const user = await users.createUser(username, password); + const user = await users.createFirstUser(username, password); + if (!user) { + res.status(409).json({ error: "already initialized" }); + return; + } const { token } = sessions.createSession(user.id); setSessionCookie(res, token); logger.info({ userId: user.id, username }, "First admin created"); res.json({ id: user.id, username: user.username }); } catch (err) { - if (err instanceof UsernameTakenError) { - res.status(409).json({ error: "already initialized" }); - return; - } logger.error({ err }, "setup failed"); res.status(500).json({ error: "internal" }); } diff --git a/src/web/auth/validateSession.ts b/src/web/auth/validateSession.ts index 4a00cb2..0d50850 100644 --- a/src/web/auth/validateSession.ts +++ b/src/web/auth/validateSession.ts @@ -17,6 +17,11 @@ export function validateSessionFromHeaders( return sessions.validateAndTouch(token); } +export function extractSessionToken(rawCookieHeader: string | undefined): string | null { + if (!rawCookieHeader) return null; + return parseCookie(rawCookieHeader, SESSION_COOKIE_NAME); +} + function parseCookie(header: string, name: string): string | null { for (const part of header.split(";")) { const trimmed = part.trim(); diff --git a/src/web/middleware/requireAuth.test.ts b/src/web/middleware/requireAuth.test.ts index 644ecb3..c5c37a1 100644 --- a/src/web/middleware/requireAuth.test.ts +++ b/src/web/middleware/requireAuth.test.ts @@ -53,4 +53,16 @@ describe("requireAuth middleware", () => { expect(res.body.ok).toBe(true); expect(res.body.user.username).toBe("alice"); }); + + it("rolls the cookie max-age forward on successful auth", async () => { + const res = await request(app) + .get("/protected") + .set("Cookie", `${SESSION_COOKIE_NAME}=${validToken}`); + expect(res.status).toBe(200); + const setCookieHeaders = res.headers["set-cookie"]; + const arr = Array.isArray(setCookieHeaders) ? setCookieHeaders : setCookieHeaders ? [setCookieHeaders] : []; + const refreshed = arr.find((c) => c.startsWith(`${SESSION_COOKIE_NAME}=`)); + expect(refreshed).toBeDefined(); + expect(refreshed!).toMatch(/Max-Age=\d+/); + }); }); diff --git a/src/web/middleware/requireAuth.ts b/src/web/middleware/requireAuth.ts index e674a28..8ec67bd 100644 --- a/src/web/middleware/requireAuth.ts +++ b/src/web/middleware/requireAuth.ts @@ -1,6 +1,11 @@ import type { Request, Response, NextFunction, RequestHandler } from "express"; import type { SessionStore } from "../../data/sessions.js"; -import { validateSessionFromHeaders, SESSION_COOKIE_NAME } from "../auth/validateSession.js"; +import { SESSION_TTL_MS } from "../../data/sessions.js"; +import { + validateSessionFromHeaders, + extractSessionToken, + SESSION_COOKIE_NAME, +} from "../auth/validateSession.js"; declare module "express-serve-static-core" { interface Request { @@ -17,6 +22,16 @@ export function createRequireAuth(sessions: SessionStore): RequestHandler { return; } req.user = { id: result.userId, username: result.username }; + const token = extractSessionToken(req.headers.cookie); + if (token) { + res.cookie(SESSION_COOKIE_NAME, token, { + httpOnly: true, + sameSite: "lax", + secure: req.secure, + path: "/", + maxAge: SESSION_TTL_MS, + }); + } next(); }; }