From e1faa7433b0c0b8016a8ddb403daf0dcd9195269 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 15:54:47 +0100 Subject: [PATCH 1/2] feat(core): users, sessions, invites, app tokens Co-Authored-By: Claude Sonnet 5.5 --- packages/core/src/auth.ts | 187 ++++++++++++++++++++++++++++++++ packages/core/test/auth.test.ts | 109 +++++++++++++++++++ 2 files changed, 296 insertions(+) create mode 100644 packages/core/src/auth.ts create mode 100644 packages/core/test/auth.test.ts diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts new file mode 100644 index 0000000..4de2eb4 --- /dev/null +++ b/packages/core/src/auth.ts @@ -0,0 +1,187 @@ +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 { AuthError } from "./errors.ts"; + +export type User = { id: string; username: string; displayName: string; role: "admin" | "member" }; + +const USERNAME_RE = /^[a-z0-9_]{2,32}$/; +const DAY_MS = 86_400_000; +const SESSION_DAYS = 30; + +function sha256Hex(s: string): string { + return new Bun.CryptoHasher("sha256").update(s).digest("hex"); +} + +function randomToken(bytes = 32): string { + return crypto.getRandomValues(new Uint8Array(bytes)).toBase64({ alphabet: "base64url", omitPadding: true }); +} + +function toUser(r: { id: string; username: string; display_name: string; role: "admin" | "member" }): User { + return { id: r.id, username: r.username, displayName: r.display_name, role: r.role }; +} + +export async function registerUser( + db: Db, + input: { username: string; password: string; displayName?: string; inviteCode?: string }, +): Promise { + const username = input.username.toLowerCase(); + if (!USERNAME_RE.test(username)) throw new AuthError("invalid_username"); + const passwordHash = await Bun.password.hash(input.password, { algorithm: "argon2id" }); + + return db.k.transaction().execute(async (tx) => { + 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 + .insertInto("users") + .values({ + id, + username, + display_name, + password_hash: passwordHash, + role, + preferences: toJson({}), + created_at: now, + updated_at: now, + }) + .execute(); + + // used_by references users.id, so claim after the insert; the tx rolls back on failure. + if (anyUser) { + const claimed = await tx + .updateTable("invites") + .set({ used_by: id }) + .where("code_hash", "=", sha256Hex(input.inviteCode!)) + .where("used_by", "is", null) + .where("expires_at", ">", now) + .executeTakeFirst(); + if (Number(claimed.numUpdatedRows) !== 1) throw new AuthError("invite_required"); + } + return { id, username, displayName: display_name, role }; + }); +} + +export async function login( + db: Db, + username: string, + password: string, +): Promise<{ user: User; sessionToken: string }> { + const row = await db.k + .selectFrom("users") + .selectAll() + .where("username", "=", username.toLowerCase()) + .executeTakeFirst(); + if (!row || !(await Bun.password.verify(password, row.password_hash))) { + throw new AuthError("invalid_credentials"); + } + const sessionToken = randomToken(); + await db.k + .insertInto("sessions") + .values({ + id: newId(), + user_id: row.id, + token_hash: sha256Hex(sessionToken), + expires_at: new Date(Date.now() + SESSION_DAYS * DAY_MS).toISOString(), + created_at: nowIso(), + }) + .execute(); + return { user: toUser(row), sessionToken }; +} + +export async function getSessionUser(db: Db, token: string): Promise { + const row = await db.k + .selectFrom("sessions") + .innerJoin("users", "users.id", "sessions.user_id") + .select(["users.id", "users.username", "users.display_name", "users.role"]) + .where("sessions.token_hash", "=", sha256Hex(token)) + .where("sessions.expires_at", ">", nowIso()) + .executeTakeFirst(); + return row ? toUser(row) : null; +} + +export async function logout(db: Db, token: string): Promise { + await db.k.deleteFrom("sessions").where("token_hash", "=", sha256Hex(token)).execute(); +} + +/** Admin-only. Returns the invite code (shown once; only its hash is stored). */ +export async function createInvite(db: Db, adminId: string, ttlDays = 7): Promise { + const admin = await db.k.selectFrom("users").select("role").where("id", "=", adminId).executeTakeFirst(); + if (admin?.role !== "admin") throw new AuthError("forbidden"); + const code = randomToken(16); + await db.k + .insertInto("invites") + .values({ + id: newId(), + code_hash: sha256Hex(code), + created_by: adminId, + used_by: null, + expires_at: new Date(Date.now() + ttlDays * DAY_MS).toISOString(), + created_at: nowIso(), + }) + .execute(); + return code; +} + +export async function createAppToken( + db: Db, + userId: string, + name: string, + scopes: TokenScope[], +): Promise<{ id: string; token: string }> { + const id = newId(); + const token = `stt_${randomToken()}`; + await db.k + .insertInto("app_tokens") + .values({ + id, + user_id: userId, + name, + token_hash: sha256Hex(token), + scopes: toJson(scopes), + last_used_at: null, + revoked_at: null, + created_at: nowIso(), + }) + .execute(); + return { id, token }; +} + +export async function verifyAppToken(db: Db, token: string, scope: TokenScope): Promise { + const row = await db.k + .selectFrom("app_tokens") + .innerJoin("users", "users.id", "app_tokens.user_id") + .select([ + "app_tokens.id as token_id", + "app_tokens.scopes", + "users.id", + "users.username", + "users.display_name", + "users.role", + ]) + .where("app_tokens.token_hash", "=", sha256Hex(token)) + .where("app_tokens.revoked_at", "is", null) + .executeTakeFirst(); + if (!row || !fromJson(row.scopes, []).includes(scope)) return null; + await db.k.updateTable("app_tokens").set({ last_used_at: nowIso() }).where("id", "=", row.token_id).execute(); + return toUser(row); +} + +export async function revokeAppToken(db: Db, userId: string, id: string): Promise { + await db.k + .updateTable("app_tokens") + .set({ revoked_at: nowIso() }) + .where("id", "=", id) + .where("user_id", "=", userId) + .where("revoked_at", "is", null) + .execute(); +} diff --git a/packages/core/test/auth.test.ts b/packages/core/test/auth.test.ts new file mode 100644 index 0000000..89d0206 --- /dev/null +++ b/packages/core/test/auth.test.ts @@ -0,0 +1,109 @@ +import { expect, test } from "bun:test"; +import { forEachDialect } from "@sttracker/db/testing"; +import { + createAppToken, + createInvite, + getSessionUser, + login, + logout, + registerUser, + revokeAppToken, + verifyAppToken, +} from "../src/auth.ts"; +import { AuthError } from "../src/errors.ts"; + +async function code(p: Promise): Promise { + try { + await p; + } catch (e) { + return e instanceof AuthError ? e.code : "other"; + } + return undefined; +} + +forEachDialect("auth", (getDb) => { + test("first user is admin and username is lowercased", async () => { + const u = await registerUser(getDb(), { username: "Alice", password: "pw-one" }); + expect(u.role).toBe("admin"); + expect(u.username).toBe("alice"); + expect(u.displayName).toBe("alice"); + }); + + test("invalid and duplicate usernames are rejected", async () => { + const db = getDb(); + expect(await code(registerUser(db, { username: "a b", password: "x" }))).toBe("invalid_username"); + await registerUser(db, { username: "alice", password: "x" }); + const inv = await createInvite(db, (await login(db, "alice", "x")).user.id); + expect(await code(registerUser(db, { username: "ALICE", password: "x", inviteCode: inv }))).toBe( + "username_taken", + ); + }); + + test("second user needs a single-use invite", async () => { + const db = getDb(); + const admin = await registerUser(db, { username: "alice", password: "x" }); + expect(await code(registerUser(db, { username: "bob", password: "x" }))).toBe("invite_required"); + expect(await code(registerUser(db, { username: "bob", password: "x", inviteCode: "nope" }))).toBe( + "invite_required", + ); + const inv = await createInvite(db, admin.id); + const bob = await registerUser(db, { username: "bob", password: "x", inviteCode: inv }); + expect(bob.role).toBe("member"); + expect(await code(registerUser(db, { username: "carol", password: "x", inviteCode: inv }))).toBe( + "invite_required", + ); + }); + + test("expired invite is rejected; members cannot create invites", async () => { + const db = getDb(); + const admin = await registerUser(db, { username: "alice", password: "x" }); + const inv = await createInvite(db, admin.id, -1); + expect(await code(registerUser(db, { username: "bob", password: "x", inviteCode: inv }))).toBe( + "invite_required", + ); + const ok = await createInvite(db, admin.id); + const bob = await registerUser(db, { username: "bob", password: "x", inviteCode: ok }); + expect(await code(createInvite(db, bob.id))).toBe("forbidden"); + }); + + test("wrong password or unknown user -> invalid_credentials", async () => { + const db = getDb(); + await registerUser(db, { username: "alice", password: "right" }); + expect(await code(login(db, "alice", "wrong"))).toBe("invalid_credentials"); + expect(await code(login(db, "nobody", "x"))).toBe("invalid_credentials"); + }); + + test("session round-trip and logout", async () => { + const db = getDb(); + await registerUser(db, { username: "alice", password: "pw" }); + const { user, sessionToken } = await login(db, "Alice", "pw"); + expect(await getSessionUser(db, sessionToken)).toEqual(user); + const stored = await db.k.selectFrom("sessions").selectAll().execute(); + expect(stored[0]!.token_hash).not.toBe(sessionToken); + await logout(db, sessionToken); + expect(await getSessionUser(db, sessionToken)).toBeNull(); + expect(await getSessionUser(db, "garbage")).toBeNull(); + }); + + test("expired session returns null", async () => { + const db = getDb(); + await registerUser(db, { username: "alice", password: "pw" }); + const { sessionToken } = await login(db, "alice", "pw"); + await db.k.updateTable("sessions").set({ expires_at: new Date(Date.now() - 1000).toISOString() }).execute(); + expect(await getSessionUser(db, sessionToken)).toBeNull(); + }); + + test("app tokens: scope check, last_used_at, revoke", async () => { + const db = getDb(); + const u = await registerUser(db, { username: "alice", password: "pw" }); + const { id, token } = await createAppToken(db, u.id, "cli", ["read"]); + expect(token.startsWith("stt_")).toBe(true); + expect(await verifyAppToken(db, token, "write")).toBeNull(); + expect(await verifyAppToken(db, token, "read")).toEqual(u); + const row = await db.k.selectFrom("app_tokens").selectAll().executeTakeFirstOrThrow(); + expect(row.last_used_at).not.toBeNull(); + expect(await verifyAppToken(db, "stt_bogus", "read")).toBeNull(); + await revokeAppToken(db, u.id, id); + expect(await verifyAppToken(db, token, "read")).toBeNull(); + }); +}); From fe3c9e4881e91253735a8bfc4e4e2e4a3a0ecd9f Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 15:56:36 +0100 Subject: [PATCH 2/2] 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); + }); });