From fe3c9e4881e91253735a8bfc4e4e2e4a3a0ecd9f Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 15:56:36 +0100 Subject: [PATCH] fix(core): serialise first-user registration, equalise login timing Co-Authored-By: Claude Sonnet 5.5 --- packages/core/src/auth.ts | 21 ++++++++++++--- packages/core/test/auth.test.ts | 46 +++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index 4de2eb4..b10f1f6 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -2,6 +2,7 @@ import type { TokenScope } from "@sttracker/db/schema"; import type { Db } from "@sttracker/db/db"; import { newId, nowIso } from "@sttracker/db/ids"; import { fromJson, toJson } from "@sttracker/db/json"; +import { sql } from "kysely"; import { AuthError } from "./errors.ts"; export type User = { id: string; username: string; displayName: string; role: "admin" | "member" }; @@ -14,6 +15,12 @@ function sha256Hex(s: string): string { return new Bun.CryptoHasher("sha256").update(s).digest("hex"); } +let dummy: Promise | undefined; +/** Fixed argon2id hash, computed once, so unknown-user logins cost the same as real ones. */ +function dummyHash(): Promise { + return (dummy ??= Bun.password.hash("sttracker-dummy-password", { algorithm: "argon2id" })); +} + function randomToken(bytes = 32): string { return crypto.getRandomValues(new Uint8Array(bytes)).toBase64({ alphabet: "base64url", omitPadding: true }); } @@ -31,15 +38,19 @@ export async function registerUser( const passwordHash = await Bun.password.hash(input.password, { algorithm: "argon2id" }); return db.k.transaction().execute(async (tx) => { + // Serialise registrations on Postgres so two first-time sign-ups cannot both become admin. + // SQLite is already serialised by its single connection. + if (db.dialect === "postgres") await sql`select pg_advisory_xact_lock(727401)`.execute(tx); + + const anyUser = await tx.selectFrom("users").select("id").limit(1).executeTakeFirst(); + if (anyUser && !input.inviteCode) throw new AuthError("invite_required"); + const existing = await tx.selectFrom("users").select("id").where("username", "=", username).executeTakeFirst(); if (existing) throw new AuthError("username_taken"); - const anyUser = await tx.selectFrom("users").select("id").limit(1).executeTakeFirst(); const id = newId(); const now = nowIso(); - if (anyUser && !input.inviteCode) throw new AuthError("invite_required"); - const role = anyUser ? "member" : "admin"; const display_name = input.displayName ?? username; await tx @@ -81,7 +92,9 @@ export async function login( .selectAll() .where("username", "=", username.toLowerCase()) .executeTakeFirst(); - if (!row || !(await Bun.password.verify(password, row.password_hash))) { + const hash = row?.password_hash ?? (await dummyHash()); + const ok = await Bun.password.verify(password, hash); + if (!row || !ok) { throw new AuthError("invalid_credentials"); } const sessionToken = randomToken(); diff --git a/packages/core/test/auth.test.ts b/packages/core/test/auth.test.ts index 89d0206..8a1282c 100644 --- a/packages/core/test/auth.test.ts +++ b/packages/core/test/auth.test.ts @@ -106,4 +106,50 @@ forEachDialect("auth", (getDb) => { await revokeAppToken(db, u.id, id); expect(await verifyAppToken(db, token, "read")).toBeNull(); }); + + test("concurrent first registrations yield exactly one admin", async () => { + const db = getDb(); + const results = await Promise.allSettled([ + registerUser(db, { username: "alice", password: "x" }), + registerUser(db, { username: "bob", password: "x" }), + ]); + const ok = results.filter((r) => r.status === "fulfilled"); + const bad = results.filter((r) => r.status === "rejected"); + expect(ok.length).toBe(1); + expect((ok[0] as PromiseFulfilledResult<{ role: string }>).value.role).toBe("admin"); + expect(((bad[0] as PromiseRejectedResult).reason as AuthError).code).toBe("invite_required"); + const admins = await db.k.selectFrom("users").select("id").where("role", "=", "admin").execute(); + expect(admins.length).toBe(1); + }); + + test("no invite means invite_required even for a taken username", async () => { + const db = getDb(); + await registerUser(db, { username: "alice", password: "x" }); + expect(await code(registerUser(db, { username: "alice", password: "x" }))).toBe("invite_required"); + }); + + test("only hashes are stored for invites and app tokens", async () => { + const db = getDb(); + const u = await registerUser(db, { username: "alice", password: "x" }); + const inv = await createInvite(db, u.id); + const { token } = await createAppToken(db, u.id, "cli", ["read"]); + const invRow = await db.k.selectFrom("invites").selectAll().executeTakeFirstOrThrow(); + const tokRow = await db.k.selectFrom("app_tokens").selectAll().executeTakeFirstOrThrow(); + expect(invRow.code_hash).not.toBe(inv); + expect(tokRow.token_hash).not.toBe(token); + expect(tokRow.token_hash).not.toContain(token); + }); + + test("revoking with another user's id leaves the token valid", async () => { + const db = getDb(); + const a = await registerUser(db, { username: "alice", password: "x" }); + const b = await registerUser(db, { + username: "bob", + password: "x", + inviteCode: await createInvite(db, a.id), + }); + const { id, token } = await createAppToken(db, a.id, "cli", ["read"]); + await revokeAppToken(db, b.id, id); + expect(await verifyAppToken(db, token, "read")).toEqual(a); + }); });