// Tokens, passwords and the checks that turn a token into a user. import { count, createUser, db, get, PASSWORD, post, test } from "./helpers.ts"; import { assert, assertEquals, assertFalse, assertNotEquals, } from "@std/assert"; import { SignJWT } from "@panva/jose"; import { createJWT, createMediaToken, createPasswordResetToken, hashPassword, verifyJWT, verifyMediaToken, verifyPassword, verifyPasswordResetToken, } from "../lib/jwt.ts"; import { authenticate, authenticateMedia } from "../lib/auth.ts"; const KEY = new TextEncoder().encode("test-secret-test-secret-test-secret"); Deno.test("hashPassword/verifyPassword round-trip with a random salt", async () => { const a = await hashPassword("hunter22"); const b = await hashPassword("hunter22"); assertNotEquals(a, b); assert(await verifyPassword("hunter22", a)); assertFalse(await verifyPassword("hunter23", a)); // A malformed stored hash never verifies. assertFalse(await verifyPassword("hunter22", "not-a-hash")); assertFalse(await verifyPassword("hunter22", "")); }); Deno.test("session, media and reset tokens don't stand in for each other", async () => { const userId = crypto.randomUUID(); const session = await createJWT({ userId, username: "u", role: "user" }); const media = await createMediaToken(userId); const reset = await createPasswordResetToken(userId); assertEquals((await verifyJWT(session))?.userId, userId); assertEquals(await verifyJWT(media), null); assertEquals(await verifyJWT(reset), null); assertEquals((await verifyMediaToken(media))?.userId, userId); assertEquals(await verifyMediaToken(session), null); assertEquals(await verifyMediaToken(reset), null); assertEquals((await verifyPasswordResetToken(reset))?.userId, userId); assertEquals(await verifyPasswordResetToken(session), null); assertEquals(await verifyPasswordResetToken(media), null); }); Deno.test("verifyJWT: refuses a bad signature, an expired token, garbage", async () => { const forged = await new SignJWT({ userId: "x", username: "x", role: "admin", }) .setProtectedHeader({ alg: "HS256" }) .setExpirationTime("1h") .sign(new TextEncoder().encode("some-other-secret")); assertEquals(await verifyJWT(forged), null); const expired = await new SignJWT({ userId: "x", username: "x", role: "user", }) .setProtectedHeader({ alg: "HS256" }) .setExpirationTime(Math.floor(Date.now() / 1000) - 10) .sign(KEY); assertEquals(await verifyJWT(expired), null); const badRole = await new SignJWT({ userId: "x", username: "x", role: "god" }) .setProtectedHeader({ alg: "HS256" }) .setExpirationTime("1h") .sign(KEY); assertEquals(await verifyJWT(badRole), null); assertEquals(await verifyJWT("not.a.jwt"), null); }); test("authenticate: the role comes from the database, not the token", async () => { const user = await createUser({ role: "user" }); db.prepare(`UPDATE users SET role = 'admin' WHERE id = ?;`).run(user.id); assertEquals((await authenticate(user.token))?.role, "admin"); }); test("authenticate: a deleted user's token is refused", async () => { const user = await createUser(); db.prepare(`DELETE FROM users WHERE id = ?;`).run(user.id); assertEquals(await authenticate(user.token), null); assertEquals((await get("/api/users/me", { auth: user.auth })).status, 401); }); test("authenticate: tokens issued before tokens_valid_after are revoked", async () => { const user = await createUser(); const future = new Date(Date.now() + 60_000).toISOString(); db.prepare(`UPDATE users SET tokens_valid_after = ? WHERE id = ?;`).run( future, user.id, ); assertEquals(await authenticate(user.token), null); assertEquals(await authenticateMedia(user.mediaToken), null); // A token issued at the cutoff second or later passes. const past = new Date(Date.now() - 60_000).toISOString(); db.prepare(`UPDATE users SET tokens_valid_after = ? WHERE id = ?;`).run( past, user.id, ); assertEquals((await authenticate(user.token))?.userId, user.id); }); test("authenticateMedia: takes a media token or a legacy session token", async () => { const user = await createUser(); assertEquals(await authenticateMedia(user.mediaToken), user.id); assertEquals(await authenticateMedia(user.token), user.id); assertEquals(await authenticateMedia("garbage"), null); }); test("optional auth: a missing or placeholder token is anonymous, a bad one is 401", async () => { const owner = await createUser(); // A public listing that reads optional auth. const path = `/api/users/${owner.username}/dumps`; assertEquals((await get(path)).status, 200); for (const placeholder of ["Bearer undefined", "Bearer null", "Bearer "]) { assertEquals( (await get(path, { headers: { Authorization: placeholder } })).status, 200, placeholder, ); } assertEquals( (await get(path, { headers: { Authorization: "Basic abc" } })).status, 200, ); const bad = await get(path, { headers: { Authorization: "Bearer nope" } }); assertEquals(bad.status, 401); assertEquals(bad.body.error.code, "UNAUTHORIZED"); }); test("required auth: missing, malformed and forged tokens are 401", async () => { assertEquals((await get("/api/users/me")).status, 401); assertEquals( (await get("/api/users/me", { headers: { Authorization: "Token x" } })) .status, 401, ); const media = (await createUser()).mediaToken; assertEquals( (await get("/api/users/me", { headers: { Authorization: `Bearer ${media}` }, })).status, 401, ); }); // ── Login and register ─────────────────────────────────────────────────────── test("POST /api/users/login: right password logs in, wrong one doesn't say which", async () => { const user = await createUser(); const ok = await post("/api/users/login", { json: { username: user.username, password: PASSWORD }, }); assertEquals(ok.status, 200); assertEquals(ok.body.data.user.id, user.id); assertEquals(ok.body.data.user.passwordHash, undefined); assertEquals((await verifyJWT(ok.body.data.token))?.userId, user.id); assertEquals( (await verifyMediaToken(ok.body.data.mediaToken))?.userId, user.id, ); const wrong = await post("/api/users/login", { json: { username: user.username, password: "nope" }, }); const unknown = await post("/api/users/login", { json: { username: "no_such_user_here", password: "nope" }, }); assertEquals(wrong.status, 401); assertEquals(unknown.status, 401); assertEquals(wrong.body.error.message, unknown.body.error.message); assertEquals( (await post("/api/users/login", { json: { username: user.username } })) .status, 400, ); }); test("POST /api/users/login: rate limited per account, case-insensitively", async () => { const user = await createUser(); const statuses: number[] = []; for (let i = 0; i < 11; i++) { const username = i % 2 ? user.username.toUpperCase() : user.username; statuses.push( (await post("/api/users/login", { json: { username, password: "wrong" }, })).status, ); } assertEquals(statuses.slice(0, 10), Array(10).fill(401)); assertEquals(statuses[10], 429); // Even the right password is held back now. assertEquals( (await post("/api/users/login", { json: { username: user.username, password: PASSWORD }, })).status, 429, ); }); function invite(inviterId: string, createdAt = new Date()): string { const token = crypto.randomUUID(); db.prepare( `INSERT INTO invites (token, inviter_id, created_at) VALUES (?, ?, ?);`, ).run(token, inviterId, createdAt.toISOString()); return token; } test("POST /api/users/register: an invite opens one account", async () => { const inviter = await createUser(); const inviteToken = invite(inviter.id); const res = await post("/api/users/register", { json: { username: "newbie_reg", password: "longenough", email: "newbie@example.test", inviteToken, }, }); assertEquals(res.status, 201); assertEquals(res.body.data.user.username, "newbie_reg"); assertEquals(res.body.data.user.role, "user"); assertEquals(res.body.data.user.passwordHash, undefined); assert(res.body.data.token); const row = db.prepare( `SELECT invited_by FROM users WHERE username = 'newbie_reg';`, ).get() as { invited_by: string }; assertEquals(row.invited_by, inviter.id); const again = await post("/api/users/register", { json: { username: "second_reg", password: "longenough", email: "second@example.test", inviteToken, }, }); assertEquals(again.status, 409); }); test("POST /api/users/register: validation and unknown invites", async () => { const inviter = await createUser(); const inviteToken = invite(inviter.id); const base = { username: "valid_name", password: "longenough", email: "v@example.test", inviteToken, }; const cases: [Record, RegExp][] = [ [{ ...base, email: "nope" }, /email/], [{ ...base, username: "has space" }, /Username/], [{ ...base, username: "x".repeat(33) }, /Username/], [{ ...base, password: "short" }, /at least 8/], [{ ...base, password: "x".repeat(129) }, /at most 128/], [{ ...base, inviteToken: 42 }, /Invalid request/], ]; for (const [json, message] of cases) { const res = await post("/api/users/register", { json }); assertEquals(res.status, 400, JSON.stringify(json)); assert(message.test(res.body.error.message), res.body.error.message); } const unknown = await post("/api/users/register", { json: { ...base, inviteToken: "made-up" }, }); assertEquals(unknown.status, 404); // None of the failures used the invite up. assertEquals( count(`SELECT COUNT(*) as n FROM invites WHERE used_at IS NULL;`), 1, ); }); test("POST /api/users/register: a taken name or email hands the invite back", async () => { const inviter = await createUser(); const taken = await createUser(); const inviteToken = invite(inviter.id); const byName = await post("/api/users/register", { json: { username: taken.username, password: "longenough", email: "fresh@example.test", inviteToken, }, }); assertEquals(byName.status, 400); const byEmail = await post("/api/users/register", { json: { username: "fresh_name", password: "longenough", email: taken.email.toUpperCase(), inviteToken, }, }); assertEquals(byEmail.status, 409); assertEquals( count( `SELECT COUNT(*) as n FROM invites WHERE token = ? AND used_at IS NULL;`, inviteToken, ), 1, ); }); test("POST /api/users/register: an invite older than 7 days is refused", async () => { const inviter = await createUser(); const inviteToken = invite( inviter.id, new Date(Date.now() - 8 * 24 * 60 * 60 * 1000), ); const res = await post("/api/users/register", { json: { username: "late_comer", password: "longenough", email: "late@example.test", inviteToken, }, }); assertEquals(res.status, 404); }); test("POST /api/users/register: parallel registrations can't share an invite", async () => { const inviter = await createUser(); const inviteToken = invite(inviter.id); const results = await Promise.all( [1, 2, 3].map((i) => post("/api/users/register", { json: { username: `racer_${i}`, password: "longenough", email: `racer${i}@example.test`, inviteToken, }, }) ), ); assertEquals(results.filter((r) => r.status === 201).length, 1); assertEquals( count(`SELECT COUNT(*) as n FROM users WHERE invited_by = ?;`, inviter.id), 1, ); }); // ── Password changes ───────────────────────────────────────────────────────── test("change-password: needs the current one, revokes old tokens, returns new ones", async () => { const user = await createUser(); const wrong = await post("/api/users/me/change-password", { auth: user.auth, json: { currentPassword: "nope", newPassword: "brand-new-pass" }, }); assertEquals(wrong.status, 401); const short = await post("/api/users/me/change-password", { auth: user.auth, json: { currentPassword: PASSWORD, newPassword: "short" }, }); assertEquals(short.status, 400); // iat has one-second resolution: make sure the old token is from an earlier // second than the change. await new Promise((r) => setTimeout(r, 1100)); const ok = await post("/api/users/me/change-password", { auth: user.auth, json: { currentPassword: PASSWORD, newPassword: "brand-new-pass" }, }); assertEquals(ok.status, 200); assertEquals((await get("/api/users/me", { auth: user.auth })).status, 401); assertEquals(await authenticateMedia(user.mediaToken), null); const fresh = { Authorization: `Bearer ${ok.body.data.token}` }; assertEquals((await get("/api/users/me", { auth: fresh })).status, 200); assertEquals(await authenticateMedia(ok.body.data.mediaToken), user.id); const login = await post("/api/users/login", { json: { username: user.username, password: "brand-new-pass" }, }); assertEquals(login.status, 200); }); test("request-password-reset: always 200, stores nothing without SMTP", async () => { const user = await createUser(); for (const email of [user.email, "nobody@example.test", "", undefined]) { const res = await post("/api/users/request-password-reset", { json: { email }, }); assertEquals(res.status, 200); } assertEquals(count(`SELECT COUNT(*) as n FROM password_reset_tokens;`), 0); }); test("reset-password: a stored token works once", async () => { const user = await createUser(); const token = await createPasswordResetToken(user.id); db.prepare( `INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?);`, ).run(token, user.id, new Date(Date.now() + 3600_000).toISOString()); const short = await post("/api/users/reset-password", { json: { token, newPassword: "short" }, }); assertEquals(short.status, 400); const ok = await post("/api/users/reset-password", { json: { token, newPassword: "reset-password-1" }, }); assertEquals(ok.status, 200); const login = await post("/api/users/login", { json: { username: user.username, password: "reset-password-1" }, }); assertEquals(login.status, 200); const reuse = await post("/api/users/reset-password", { json: { token, newPassword: "reset-password-2" }, }); assertEquals(reuse.status, 409); }); test("reset-password: an unstored, forged or wrong-purpose token fails", async () => { const user = await createUser(); const unstored = await createPasswordResetToken(user.id); assertEquals( (await post("/api/users/reset-password", { json: { token: unstored, newPassword: "whatever-123" }, })).status, 404, ); for (const token of ["garbage", user.token, user.mediaToken]) { assertEquals( (await post("/api/users/reset-password", { json: { token, newPassword: "whatever-123" }, })).status, 400, ); } assertEquals( (await post("/api/users/reset-password", { json: { token: 1 } })).status, 400, ); }); test("GET /api/users/me/media-token issues a media token for the caller", async () => { const user = await createUser(); const res = await get("/api/users/me/media-token", { auth: user.auth }); assertEquals(res.status, 200); assertEquals(await authenticateMedia(res.body.data.mediaToken), user.id); });