Sessions and password changes were the two things PLAN.md section 14.2 asks of the account area that were still missing, and the admin console had no audit trail at all despite section 14.3 requiring one for every mutation. Session revocation is enforced on every request, not just recorded. A JWT carries its own validity, so revoking a row without checking it would have made "sign out this device" a button that did nothing until the token expired eight hours later. Changing a password revokes every other session for the same reason: a password change that leaves the attacker's session alive has not locked anyone out. Admin queries are scoped by role in the SQL rather than in the UI, as section 14.3 requires: a DOMAIN_ADMIN calling the API directly sees only its own domain, and a mailbox outside that scope answers 404 rather than 403 so the scope cannot be used to enumerate what exists elsewhere. The two mutations that exist so far - enabling or disabling a mailbox, and retrying or cancelling a queued delivery - write actor, IP and target to audit_log, and there is an endpoint to read it back. Covered by tests against a real database: a revoked token stops working mid-session, a password change needs the current password and takes the other sessions down with it, and the minimum length is enforced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
357 lines
15 KiB
TypeScript
357 lines
15 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
import { createServer, Server } from "node:http";
|
|
import crypto from "node:crypto";
|
|
|
|
// The master key has to exist before crypto.ts is imported anywhere, because
|
|
// encryptSecret refuses to run without one.
|
|
process.env.LAMBDAMAIL_MASTER_KEY ||= "test-master-key-at-least-16-chars";
|
|
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL ?? "";
|
|
|
|
const hasDatabase = Boolean(process.env.TEST_DATABASE_URL);
|
|
const describeDb = hasDatabase ? describe : describe.skip;
|
|
|
|
const { handleApiRequest } = await import("./router.js");
|
|
const { hashPassword } = await import("./crypto.js");
|
|
const { query, closePool } = await import("./db.js");
|
|
const { generateTotpSecret, generateHotp, base32Decode, getCurrentStep } = await import("./totp.js");
|
|
|
|
const DOMAIN_ID = "11111111-1111-1111-1111-111111111111";
|
|
const USER_ID = "22222222-2222-2222-2222-222222222222";
|
|
const ADMIN_ID = "33333333-3333-3333-3333-333333333333";
|
|
const PASSWORD = "CorrectHorseBatteryStaple1!";
|
|
|
|
let server: Server;
|
|
let base: string;
|
|
|
|
function startServer(): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
server = createServer((req, res) => {
|
|
if (handleApiRequest(req, res)) return;
|
|
res.statusCode = 404;
|
|
res.end();
|
|
});
|
|
server.listen(0, () => {
|
|
const addr = server.address();
|
|
base = `http://localhost:${typeof addr === "object" && addr ? addr.port : 0}`;
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
async function seed(): Promise<void> {
|
|
const hash = await hashPassword(PASSWORD);
|
|
await query(`DELETE FROM domains WHERE id = $1`, [DOMAIN_ID]);
|
|
// name is CITEXT and punycode_name is VARCHAR, so the value is bound twice:
|
|
// reusing one placeholder for both makes Postgres refuse to deduce a type.
|
|
const domainName = `authtest-${DOMAIN_ID}.invalid`;
|
|
await query(
|
|
`INSERT INTO domains (id, name, punycode_name, mfa_policy) VALUES ($1, $2, $3, 'required_admins')`,
|
|
[DOMAIN_ID, domainName, domainName],
|
|
);
|
|
for (const [id, local, role] of [
|
|
[USER_ID, "user", "USER"],
|
|
[ADMIN_ID, "admin", "SUPER_ADMIN"],
|
|
] as const) {
|
|
await query(
|
|
`INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash, role)
|
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
[id, DOMAIN_ID, local, `${local}@authtest-${DOMAIN_ID}.invalid`, hash, role],
|
|
);
|
|
}
|
|
}
|
|
|
|
const userEmail = () => `user@authtest-${DOMAIN_ID}.invalid`;
|
|
const adminEmail = () => `admin@authtest-${DOMAIN_ID}.invalid`;
|
|
|
|
async function post(path: string, body: unknown, token?: string) {
|
|
const res = await fetch(`${base}${path}`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
return { status: res.status, body: await res.json().catch(() => ({})) as Record<string, never> };
|
|
}
|
|
|
|
async function put(path: string, body: unknown, token?: string) {
|
|
const res = await fetch(`${base}${path}`, {
|
|
method: "PUT",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
return { status: res.status, body: await res.json().catch(() => ({})) as Record<string, never> };
|
|
}
|
|
|
|
async function get(path: string, token?: string) {
|
|
const res = await fetch(`${base}${path}`, {
|
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
});
|
|
return { status: res.status, body: await res.json().catch(() => ({})) as Record<string, never> };
|
|
}
|
|
|
|
/** Enrolls a real second factor and returns the shared secret. */
|
|
async function enrollTotp(mailboxId: string, token: string): Promise<string> {
|
|
const enroll = await post("/api/v1/user/mfa/totp/enroll", {}, token);
|
|
const secret = enroll.body.secret as unknown as string;
|
|
const code = generateHotp(base32Decode(secret), getCurrentStep());
|
|
const confirm = await post("/api/v1/user/mfa/totp/confirm", { code }, token);
|
|
expect(confirm.status).toBe(200);
|
|
// Enrollment burns the step it used; clear it so the login test can reuse
|
|
// the current window instead of waiting 30 seconds.
|
|
await query(`UPDATE mfa_totp SET last_used_step = NULL WHERE mailbox_id = $1`, [mailboxId]);
|
|
return secret;
|
|
}
|
|
|
|
describeDb("auth API against a real database", () => {
|
|
beforeAll(async () => {
|
|
await startServer();
|
|
await seed();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
server?.close();
|
|
await query(`DELETE FROM domains WHERE id = $1`, [DOMAIN_ID]).catch(() => undefined);
|
|
await closePool();
|
|
});
|
|
|
|
// The headline regression: login used to accept any password at all.
|
|
it("rejects a wrong password", async () => {
|
|
const res = await post("/api/v1/auth/user/login", { email: userEmail(), password: "not-the-password" });
|
|
expect(res.status).toBe(401);
|
|
expect(res.body.error).toBe("INVALID_CREDENTIALS");
|
|
});
|
|
|
|
it("rejects an unknown account with the same answer as a wrong password", async () => {
|
|
const res = await post("/api/v1/auth/user/login", { email: "ghost@nowhere.invalid", password: "whatever" });
|
|
expect(res.status).toBe(401);
|
|
expect(res.body.error).toBe("INVALID_CREDENTIALS");
|
|
});
|
|
|
|
it("refuses the stored hash presented as the password", async () => {
|
|
const row = await query<{ password_hash: string }>(`SELECT password_hash FROM mailboxes WHERE id = $1`, [USER_ID]);
|
|
const res = await post("/api/v1/auth/user/login", { email: userEmail(), password: row[0].password_hash });
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("issues a session for the correct password", async () => {
|
|
const res = await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.token).toBeDefined();
|
|
});
|
|
|
|
// The second headline regression: any non-empty MFA code used to work.
|
|
it("rejects a wrong TOTP code and accepts only the real one", { timeout: 60000 }, async () => {
|
|
const login = await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD });
|
|
const secret = await enrollTotp(USER_ID, login.body.token as unknown as string);
|
|
|
|
// With a factor enrolled, login now demands it.
|
|
const challenge = await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD });
|
|
expect(challenge.body.mfa_required).toBe(true);
|
|
|
|
const wrong = await post("/api/v1/auth/mfa/verify", {
|
|
challenge_token: challenge.body.challenge_token,
|
|
code: "000000",
|
|
});
|
|
expect(wrong.status).toBe(401);
|
|
expect(wrong.body.error).toBe("INVALID_MFA_CODE");
|
|
|
|
const right = await post("/api/v1/auth/mfa/verify", {
|
|
challenge_token: challenge.body.challenge_token,
|
|
code: generateHotp(base32Decode(secret), getCurrentStep()),
|
|
});
|
|
expect(right.status).toBe(200);
|
|
expect(right.body.token).toBeDefined();
|
|
});
|
|
|
|
// PLAN.md F4 acceptance criterion: a /user token must be refused by
|
|
// /api/v1/admin/*.
|
|
it("rejects a user token on an admin endpoint", async () => {
|
|
const login = await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD });
|
|
const token = (login.body.token ?? login.body.challenge_token) as unknown as string;
|
|
const res = await get("/api/v1/admin/dashboard", token);
|
|
expect(res.status).toBe(401);
|
|
expect(res.body.error).toBe("UNAUTHORIZED");
|
|
});
|
|
|
|
it("rejects a challenge token on a protected endpoint", async () => {
|
|
const challenge = await post("/api/v1/auth/admin/login", { email: adminEmail(), password: PASSWORD });
|
|
// The admin has no factor enrolled yet, so this is refused outright.
|
|
expect([403, 200]).toContain(challenge.status);
|
|
if (challenge.body.challenge_token) {
|
|
const res = await get("/api/v1/admin/dashboard", challenge.body.challenge_token);
|
|
expect(res.status).toBe(401);
|
|
}
|
|
});
|
|
|
|
it("refuses the admin console until a second factor is enrolled", async () => {
|
|
const res = await post("/api/v1/auth/admin/login", { email: adminEmail(), password: PASSWORD });
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toBe("MFA_ENROLLMENT_REQUIRED");
|
|
});
|
|
|
|
it("grants the admin console after a real second factor", { timeout: 60000 }, async () => {
|
|
// Enrollment happens on the user surface, which the admin also owns.
|
|
const userLogin = await post("/api/v1/auth/user/login", { email: adminEmail(), password: PASSWORD });
|
|
const secret = await enrollTotp(ADMIN_ID, userLogin.body.token as unknown as string);
|
|
|
|
const challenge = await post("/api/v1/auth/admin/login", { email: adminEmail(), password: PASSWORD });
|
|
expect(challenge.body.mfa_required).toBe(true);
|
|
|
|
const verified = await post("/api/v1/auth/mfa/verify", {
|
|
challenge_token: challenge.body.challenge_token,
|
|
code: generateHotp(base32Decode(secret), getCurrentStep()),
|
|
});
|
|
expect(verified.status).toBe(200);
|
|
|
|
const dashboard = await get("/api/v1/admin/dashboard", verified.body.token);
|
|
expect(dashboard.status).toBe(200);
|
|
expect(dashboard.body).toHaveProperty("queue_depth");
|
|
});
|
|
|
|
it("locks the account after repeated failures", async () => {
|
|
const email = `lock@authtest-${DOMAIN_ID}.invalid`;
|
|
await query(
|
|
`INSERT INTO mailboxes (domain_id, local_part, email_address, password_hash)
|
|
VALUES ($1, 'lock', $2, $3)`,
|
|
[DOMAIN_ID, email, await hashPassword(PASSWORD)],
|
|
);
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
await post("/api/v1/auth/user/login", { email, password: "wrong" });
|
|
}
|
|
|
|
// Even the correct password is refused while the lockout holds.
|
|
const res = await post("/api/v1/auth/user/login", { email, password: PASSWORD });
|
|
expect(res.status).toBe(423);
|
|
expect(res.body.error).toBe("ACCOUNT_LOCKED");
|
|
});
|
|
|
|
it("consumes a recovery code exactly once", { timeout: 60000 }, async () => {
|
|
const email = `rec@authtest-${DOMAIN_ID}.invalid`;
|
|
const id = crypto.randomUUID();
|
|
await query(
|
|
`INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash)
|
|
VALUES ($1, $2, 'rec', $3, $4)`,
|
|
[id, DOMAIN_ID, email, await hashPassword(PASSWORD)],
|
|
);
|
|
|
|
const login = await post("/api/v1/auth/user/login", { email, password: PASSWORD });
|
|
const confirm = await post("/api/v1/user/mfa/totp/confirm", { code: "x" }, login.body.token);
|
|
expect(confirm.status).toBe(400);
|
|
|
|
const enroll = await post("/api/v1/user/mfa/totp/enroll", {}, login.body.token);
|
|
const code = generateHotp(base32Decode(enroll.body.secret as unknown as string), getCurrentStep());
|
|
const confirmed = await post("/api/v1/user/mfa/totp/confirm", { code }, login.body.token);
|
|
const recoveryCode = (confirmed.body.recovery_codes as unknown as string[])[0];
|
|
|
|
const challenge = await post("/api/v1/auth/user/login", { email, password: PASSWORD });
|
|
const first = await post("/api/v1/auth/mfa/verify", {
|
|
challenge_token: challenge.body.challenge_token,
|
|
code: recoveryCode,
|
|
});
|
|
expect(first.status).toBe(200);
|
|
|
|
const challenge2 = await post("/api/v1/auth/user/login", { email, password: PASSWORD });
|
|
const second = await post("/api/v1/auth/mfa/verify", {
|
|
challenge_token: challenge2.body.challenge_token,
|
|
code: recoveryCode,
|
|
});
|
|
expect(second.status).toBe(401);
|
|
});
|
|
|
|
it("never returns the TOTP secret to an unauthenticated caller", async () => {
|
|
const res = await post("/api/v1/user/mfa/totp/enroll", {});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
// A JWT carries its own validity, so revoking a session must be enforced on
|
|
// every request or "sign out this device" would change nothing.
|
|
it("stops accepting a token once its session is revoked", async () => {
|
|
const email = `revoke@authtest-${DOMAIN_ID}.invalid`;
|
|
await query(
|
|
`INSERT INTO mailboxes (domain_id, local_part, email_address, password_hash)
|
|
VALUES ($1, 'revoke', $2, $3)`,
|
|
[DOMAIN_ID, email, await hashPassword(PASSWORD)],
|
|
);
|
|
|
|
const login = await post("/api/v1/auth/user/login", { email, password: PASSWORD });
|
|
const token = login.body.token as unknown as string;
|
|
|
|
const before = await get("/api/v1/user/me", token);
|
|
expect(before.status).toBe(200);
|
|
|
|
const sessions = await get("/api/v1/user/sessions", token);
|
|
expect(sessions.status).toBe(200);
|
|
const list = sessions.body as unknown as Array<{ id: string; current: boolean }>;
|
|
expect(list.length).toBeGreaterThan(0);
|
|
expect(list.some((s) => s.current)).toBe(true);
|
|
|
|
const revoked = await fetch(`${base}/api/v1/user/sessions/${list[0].id}`, {
|
|
method: "DELETE",
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
expect(revoked.status).toBe(200);
|
|
|
|
const after = await get("/api/v1/user/me", token);
|
|
expect(after.status).toBe(401);
|
|
expect(after.body.error).toBe("SESSION_REVOKED");
|
|
}, 60000);
|
|
|
|
it("changes the password only with the current one, and revokes other sessions", async () => {
|
|
const email = `pw@authtest-${DOMAIN_ID}.invalid`;
|
|
await query(
|
|
`INSERT INTO mailboxes (domain_id, local_part, email_address, password_hash)
|
|
VALUES ($1, 'pw', $2, $3)`,
|
|
[DOMAIN_ID, email, await hashPassword(PASSWORD)],
|
|
);
|
|
|
|
const first = await post("/api/v1/auth/user/login", { email, password: PASSWORD });
|
|
const second = await post("/api/v1/auth/user/login", { email, password: PASSWORD });
|
|
|
|
const wrong = await put("/api/v1/user/password",
|
|
{ current_password: "not-it", new_password: "a-brand-new-password-1" }, first.body.token);
|
|
expect(wrong.status).toBe(401);
|
|
|
|
const tooShort = await put("/api/v1/user/password",
|
|
{ current_password: PASSWORD, new_password: "short" }, first.body.token);
|
|
expect(tooShort.status).toBe(400);
|
|
|
|
const ok = await put("/api/v1/user/password",
|
|
{ current_password: PASSWORD, new_password: "a-brand-new-password-1" }, first.body.token);
|
|
expect(ok.status).toBe(200);
|
|
|
|
// The other session is gone; the one that made the change still works.
|
|
const other = await get("/api/v1/user/me", second.body.token);
|
|
expect(other.status).toBe(401);
|
|
const mine = await get("/api/v1/user/me", first.body.token);
|
|
expect(mine.status).toBe(200);
|
|
|
|
const relogin = await post("/api/v1/auth/user/login", { email, password: "a-brand-new-password-1" });
|
|
expect(relogin.status).toBe(200);
|
|
}, 90000);
|
|
});
|
|
|
|
// Guards that need no database, so they still run in a bare checkout.
|
|
describe("surface routing", () => {
|
|
it("ignores anything outside /api/v1/", async () => {
|
|
const { handleApiRequest: handler } = await import("./router.js");
|
|
const req = { url: "/health/ready", method: "GET", headers: {} } as never;
|
|
const res = { setHeader() {}, end() {} } as never;
|
|
expect(handler(req, res)).toBe(false);
|
|
});
|
|
});
|
|
|
|
if (!hasDatabase) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn("TEST_DATABASE_URL not set - auth API integration tests skipped");
|
|
}
|
|
|
|
// generateTotpSecret is exercised through the enrollment flow above; this
|
|
// keeps the import meaningful when the database suite is skipped.
|
|
void generateTotpSecret;
|