merge: task 4 auth

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 15:59:31 +01:00
co-authored by Claude Opus 5.5
2 changed files with 355 additions and 0 deletions
+200
View File
@@ -0,0 +1,200 @@
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" };
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");
}
let dummy: Promise<string> | undefined;
/** Fixed argon2id hash, computed once, so unknown-user logins cost the same as real ones. */
function dummyHash(): Promise<string> {
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 });
}
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<User> {
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) => {
// 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 id = newId();
const now = nowIso();
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();
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();
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<User | null> {
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<void> {
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<string> {
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<User | null> {
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<string[]>(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<void> {
await db.k
.updateTable("app_tokens")
.set({ revoked_at: nowIso() })
.where("id", "=", id)
.where("user_id", "=", userId)
.where("revoked_at", "is", null)
.execute();
}
+155
View File
@@ -0,0 +1,155 @@
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<unknown>): Promise<string | undefined> {
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();
});
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);
});
});