The counts were computed inline in JSX where nothing could test them. The
sidebar matched a folder by a special-use role folders do not always carry, its
badges were fetched once and then froze - new mail did not raise them and
reading a message did not lower it - and the list header printed the size of
the loaded page, capped at 50, as the size of the folder. The rules now live in
mailCounts.ts behind tests, useFolders keeps them current from the server's
push and from this tab's own changes, and each filter chip carries its number.
Inline images never left the server: RenderMessage dropped every part a body
references as cid:, so mail written by any normal client rendered with broken
icons. They now come back as bounded data URIs, refusing image/svg+xml, and the
reader resolves them, sizes its frame from the message instead of a fixed
500px, and renders on the light surface mail assumes.
Reading mail no longer asks for a second factor - the mailbox is behind the
account's own password - while the console still requires one unconditionally,
now collected by a step-up that does not ask again for the password the session
in hand just proved. An admin sign-in issues the webmail session too, so
leaving the console is a link rather than another login.
Two defects found while testing the above:
- The admin sign-in checked the password and the second factor and never the
role, so any account that had enrolled a factor from its own settings could
obtain an admin-audience session.
- Session tokens carried nothing that varied below one second, so signing in
twice within the same second minted the identical token and collided with
the unique index on web_sessions.refresh_token_hash. A correct password
answered 500, and a double-clicked sign-in was enough to do it. Tokens now
carry a jti.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
869 lines
40 KiB
TypeScript
869 lines
40 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.JWT_SECRET ||= "a-test-signing-secret-long-enough-for-the-guard";
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Returns an authenticated admin token.
|
|
*
|
|
* The second factor is reset first: once one is confirmed, the user login
|
|
* returns a challenge rather than a session, so re-enrolling through the API
|
|
* would need a code from a secret this helper does not have yet. Clearing the
|
|
* rows makes the path deterministic regardless of what earlier tests did.
|
|
*/
|
|
async function adminSession(): Promise<string> {
|
|
await query(`DELETE FROM mfa_totp WHERE mailbox_id = $1`, [ADMIN_ID]);
|
|
|
|
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 });
|
|
const verified = await post("/api/v1/auth/mfa/verify", {
|
|
challenge_token: challenge.body.challenge_token,
|
|
code: generateHotp(base32Decode(secret), getCurrentStep()),
|
|
});
|
|
return verified.body.token as unknown as string;
|
|
}
|
|
|
|
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.
|
|
// The challenge comes from the admin sign-in, which is the surface that
|
|
// collects a second factor. The webmail no longer does - reading your own
|
|
// mail is behind your own password - so this used to obtain its challenge
|
|
// from a path that has none.
|
|
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: adminEmail(), password: PASSWORD });
|
|
await query(`DELETE FROM mfa_totp WHERE mailbox_id = $1`, [ADMIN_ID]);
|
|
const secret = await enrollTotp(ADMIN_ID, login.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 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/*.
|
|
// The browser holds the enrolment secret only in component state, so a
|
|
// reload brings the "enable" button back. Enrolling used to replace the
|
|
// stored secret on every call, which silently orphaned the entry the
|
|
// authenticator had already saved: the app kept showing valid codes for a
|
|
// secret the server no longer had, and confirmation failed forever with no
|
|
// way to tell why.
|
|
it("resumes a pending enrolment instead of replacing the secret", { timeout: 60000 }, async () => {
|
|
await query(`DELETE FROM mfa_totp WHERE mailbox_id = $1`, [USER_ID]);
|
|
const login = await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD });
|
|
const token = login.body.token as unknown as string;
|
|
|
|
const first = await post("/api/v1/user/mfa/totp/enroll", {}, token);
|
|
const second = await post("/api/v1/user/mfa/totp/enroll", {}, token);
|
|
expect(second.body.secret).toBe(first.body.secret);
|
|
|
|
// The code from the secret shown first must still confirm after the
|
|
// second call - that is the reload case.
|
|
const code = generateHotp(base32Decode(first.body.secret as unknown as string), getCurrentStep());
|
|
const confirm = await post("/api/v1/user/mfa/totp/confirm", { code }, token);
|
|
expect(confirm.status).toBe(200);
|
|
});
|
|
|
|
it("issues a different secret when enrolment is explicitly reset", { timeout: 60000 }, async () => {
|
|
await query(`DELETE FROM mfa_totp WHERE mailbox_id = $1`, [USER_ID]);
|
|
const login = await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD });
|
|
const token = login.body.token as unknown as string;
|
|
|
|
const first = await post("/api/v1/user/mfa/totp/enroll", {}, token);
|
|
const reset = await post("/api/v1/user/mfa/totp/enroll?reset=1", {}, token);
|
|
expect(reset.body.secret).not.toBe(first.body.secret);
|
|
// The URI has to carry the secret actually in force, or the QR would
|
|
// enrol something the server will not accept.
|
|
expect(reset.body.uri).toContain(reset.body.secret);
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|
|
|
|
// Refusing is right, but refusing without a way forward was a dead end: the
|
|
// console needs a second factor, and the operator had to already know that
|
|
// enrolment lives on the other surface.
|
|
it("hands back an enrolment grant instead of a dead end", async () => {
|
|
const email = `deadend@authtest-${DOMAIN_ID}.invalid`;
|
|
await query(
|
|
`INSERT INTO mailboxes (domain_id, local_part, email_address, password_hash, role)
|
|
VALUES ($1, 'deadend', $2, $3, 'SUPER_ADMIN') ON CONFLICT (domain_id, local_part) DO NOTHING`,
|
|
[DOMAIN_ID, email, await hashPassword(PASSWORD)],
|
|
);
|
|
|
|
const res = await post("/api/v1/auth/admin/login", { email, password: PASSWORD });
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toBe("MFA_ENROLLMENT_REQUIRED");
|
|
|
|
const grant = res.body.enrollment_token as unknown as string;
|
|
expect(grant).toBeDefined();
|
|
|
|
// The grant enrols, and the account can then reach the console.
|
|
const enroll = await post("/api/v1/user/mfa/totp/enroll", {}, grant);
|
|
expect(enroll.status).toBe(200);
|
|
|
|
const secret = enroll.body.secret as unknown as string;
|
|
const confirm = await post("/api/v1/user/mfa/totp/confirm",
|
|
{ code: generateHotp(base32Decode(secret), getCurrentStep()) }, grant);
|
|
expect(confirm.status).toBe(200);
|
|
|
|
const mailboxId = (await query<{ id: string }>(
|
|
`SELECT id FROM mailboxes WHERE email_address = $1`, [email]))[0].id;
|
|
await query(`UPDATE mfa_totp SET last_used_step = NULL WHERE mailbox_id = $1`, [mailboxId]);
|
|
|
|
const challenge = await post("/api/v1/auth/admin/login", { email, 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);
|
|
expect((await get("/api/v1/admin/dashboard", verified.body.token)).status).toBe(200);
|
|
}, 120000);
|
|
|
|
// The grant is for enrolling and nothing else.
|
|
it("refuses to use an enrolment grant as a session", async () => {
|
|
const email = `grantscope@authtest-${DOMAIN_ID}.invalid`;
|
|
await query(
|
|
`INSERT INTO mailboxes (domain_id, local_part, email_address, password_hash, role)
|
|
VALUES ($1, 'grantscope', $2, $3, 'SUPER_ADMIN') ON CONFLICT (domain_id, local_part) DO NOTHING`,
|
|
[DOMAIN_ID, email, await hashPassword(PASSWORD)],
|
|
);
|
|
|
|
const res = await post("/api/v1/auth/admin/login", { email, password: PASSWORD });
|
|
const grant = res.body.enrollment_token as unknown as string;
|
|
|
|
expect((await get("/api/v1/admin/dashboard", grant)).status).toBe(401);
|
|
expect((await get("/api/v1/user/me", grant)).status).toBe(401);
|
|
expect((await get("/api/v1/user/sessions", grant)).status).toBe(401);
|
|
expect((await get("/api/v1/user/app-passwords", grant)).status).toBe(401);
|
|
}, 90000);
|
|
|
|
it("grants the admin console after a real second factor", { timeout: 60000 }, async () => {
|
|
// Enrollment happens on the user surface, which the admin also owns. The
|
|
// existing factor is cleared first, as adminSession does: enrolling over a
|
|
// confirmed one leaves two secrets on the account and the code generated
|
|
// here is then checked against the wrong one.
|
|
await query(`DELETE FROM mfa_totp WHERE mailbox_id = $1`, [ADMIN_ID]);
|
|
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");
|
|
|
|
// The console's sidebar reads its identity from here. It cannot use
|
|
// /api/v1/user/me, because extractToken picks the cookie from the URL
|
|
// prefix and an operator may reach /admin/login without ever having opened
|
|
// webmail. Until this route existed the sidebar printed a fixed
|
|
// "admin@lambdamail.local" with a hardcoded "SUPER_ADMIN (2FA)".
|
|
const identity = await get("/api/v1/admin/me", verified.body.token);
|
|
expect(identity.status).toBe(200);
|
|
expect(identity.body.email).toBe(adminEmail());
|
|
expect(identity.body.role).toBe("SUPER_ADMIN");
|
|
expect(identity.body.mfa_enrolled).toBe(true);
|
|
});
|
|
|
|
it("refuses /api/v1/admin/me to a user-surface token", 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/me", token);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
// Redeemed at the admin sign-in, which is where a second factor is collected
|
|
// now. The account is an admin for the same reason.
|
|
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, role)
|
|
VALUES ($1, $2, 'rec', $3, $4, 'SUPER_ADMIN')`,
|
|
[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/admin/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/admin/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("creates a mailbox with its standard folders and audits it", async () => {
|
|
const admin = await adminSession();
|
|
const local = `created${Date.now()}`;
|
|
const created = await post("/api/v1/admin/mailboxes", {
|
|
domain_id: DOMAIN_ID, local_part: local, password: "a-long-enough-password", role: "USER",
|
|
}, admin);
|
|
expect(created.status).toBe(201);
|
|
|
|
// Delivery depends on these existing: a mailbox with no Junk folder used
|
|
// to fail the spam path outright.
|
|
const folders = await query<{ special_use: string }>(
|
|
`SELECT special_use FROM folders WHERE mailbox_id = $1`, [created.body.id]);
|
|
expect(folders.map((f) => f.special_use).sort()).toEqual(
|
|
["archive", "drafts", "inbox", "junk", "sent", "trash"]);
|
|
|
|
const audit = await get("/api/v1/admin/audit", admin);
|
|
const entries = audit.body as unknown as Array<{ action: string; target_id: string }>;
|
|
expect(entries.some((e) => e.action === "mailbox.create" && e.target_id === created.body.id)).toBe(true);
|
|
}, 90000);
|
|
|
|
it("refuses a weak password and a duplicate address", async () => {
|
|
const admin = await adminSession();
|
|
const weak = await post("/api/v1/admin/mailboxes",
|
|
{ domain_id: DOMAIN_ID, local_part: "weakpass", password: "short" }, admin);
|
|
expect(weak.status).toBe(400);
|
|
|
|
const dup = await post("/api/v1/admin/mailboxes",
|
|
{ domain_id: DOMAIN_ID, local_part: "user", password: "a-long-enough-password" }, admin);
|
|
expect(dup.status).toBe(409);
|
|
}, 90000);
|
|
|
|
// A DOMAIN_ADMIN naming another domain in the body must not escape its scope.
|
|
it("keeps a domain admin inside its own domain", async () => {
|
|
const otherDomain = "44444444-4444-4444-4444-444444444444";
|
|
const otherName = `other-${otherDomain}.invalid`;
|
|
await query(`DELETE FROM domains WHERE id = $1`, [otherDomain]);
|
|
await query(`INSERT INTO domains (id, name, punycode_name) VALUES ($1, $2, $3)`,
|
|
[otherDomain, otherName, otherName]);
|
|
|
|
const domainAdminEmail = `dadmin@authtest-${DOMAIN_ID}.invalid`;
|
|
await query(
|
|
`INSERT INTO mailboxes (domain_id, local_part, email_address, password_hash, role)
|
|
VALUES ($1, 'dadmin', $2, $3, 'DOMAIN_ADMIN')
|
|
ON CONFLICT (domain_id, local_part) DO NOTHING`,
|
|
[DOMAIN_ID, domainAdminEmail, await hashPassword(PASSWORD)],
|
|
);
|
|
|
|
const userLogin = await post("/api/v1/auth/user/login", { email: domainAdminEmail, password: PASSWORD });
|
|
const secret = await enrollTotp(
|
|
(await query<{ id: string }>(`SELECT id FROM mailboxes WHERE email_address = $1`, [domainAdminEmail]))[0].id,
|
|
userLogin.body.token as unknown as string);
|
|
const challenge = await post("/api/v1/auth/admin/login", { email: domainAdminEmail, password: PASSWORD });
|
|
const verified = await post("/api/v1/auth/mfa/verify", {
|
|
challenge_token: challenge.body.challenge_token,
|
|
code: generateHotp(base32Decode(secret), getCurrentStep()),
|
|
});
|
|
const token = verified.body.token as unknown as string;
|
|
|
|
const escape = await post("/api/v1/admin/mailboxes",
|
|
{ domain_id: otherDomain, local_part: "intruder", password: "a-long-enough-password" }, token);
|
|
expect(escape.status).toBe(403);
|
|
|
|
// Its own domain still works.
|
|
const own = await post("/api/v1/admin/mailboxes",
|
|
{ domain_id: DOMAIN_ID, local_part: `own${Date.now()}`, password: "a-long-enough-password" }, token);
|
|
expect(own.status).toBe(201);
|
|
|
|
await query(`DELETE FROM domains WHERE id = $1`, [otherDomain]);
|
|
}, 120000);
|
|
|
|
it("onboards a domain with the four aliases its DNS records name", async () => {
|
|
const admin = await adminSession();
|
|
const name = `onboard-${Date.now()}.test`;
|
|
|
|
const res = await post("/api/v1/admin/domains/onboard", { domain: name }, admin);
|
|
expect(res.status).toBe(200);
|
|
|
|
const aliases = await query<{ source_address: string; destination_addresses: string[] }>(
|
|
`SELECT source_address, destination_addresses FROM aliases a
|
|
JOIN domains d ON d.id = a.domain_id WHERE d.name = $1 ORDER BY a.source_address`,
|
|
[name],
|
|
);
|
|
// PLAN.md section 7.4b: publishing rua=mailto:dmarc@ without the alias
|
|
// makes the reports bounce and receivers stop sending them.
|
|
expect(aliases.map((a) => a.source_address.split("@")[0]).sort())
|
|
.toEqual(["abuse", "dmarc", "postmaster", "tlsrpt"]);
|
|
expect(aliases[0].destination_addresses[0]).toBe(adminEmail());
|
|
|
|
await query(`DELETE FROM domains WHERE name = $1`, [name]);
|
|
}, 90000);
|
|
|
|
// The destination array used to be built by string interpolation, so a
|
|
// domain name carrying a quote was injected into the statement.
|
|
it("does not let a hostile domain name reach the SQL text", async () => {
|
|
const admin = await adminSession();
|
|
for (const hostile of ["x.test'||(SELECT version())||'", "a.test; DROP TABLE aliases;--", "no-dot"]) {
|
|
const res = await post("/api/v1/admin/domains/onboard", { domain: hostile }, admin);
|
|
expect(res.status).toBe(400);
|
|
}
|
|
// The table is still there, which a successful injection would not leave.
|
|
const check = await query<{ count: string }>(`SELECT count(*)::text AS count FROM aliases`);
|
|
expect(Number(check[0].count)).toBeGreaterThanOrEqual(0);
|
|
}, 90000);
|
|
|
|
it("refuses to let a domain admin create domains", async () => {
|
|
const domainAdminEmail = `donboard@authtest-${DOMAIN_ID}.invalid`;
|
|
await query(
|
|
`INSERT INTO mailboxes (domain_id, local_part, email_address, password_hash, role)
|
|
VALUES ($1, 'donboard', $2, $3, 'DOMAIN_ADMIN') ON CONFLICT (domain_id, local_part) DO NOTHING`,
|
|
[DOMAIN_ID, domainAdminEmail, await hashPassword(PASSWORD)],
|
|
);
|
|
const mailboxId = (await query<{ id: string }>(
|
|
`SELECT id FROM mailboxes WHERE email_address = $1`, [domainAdminEmail]))[0].id;
|
|
await query(`DELETE FROM mfa_totp WHERE mailbox_id = $1`, [mailboxId]);
|
|
|
|
const userLogin = await post("/api/v1/auth/user/login", { email: domainAdminEmail, password: PASSWORD });
|
|
const secret = await enrollTotp(mailboxId, userLogin.body.token as unknown as string);
|
|
const challenge = await post("/api/v1/auth/admin/login", { email: domainAdminEmail, password: PASSWORD });
|
|
const verified = await post("/api/v1/auth/mfa/verify", {
|
|
challenge_token: challenge.body.challenge_token,
|
|
code: generateHotp(base32Decode(secret), getCurrentStep()),
|
|
});
|
|
|
|
const res = await post("/api/v1/admin/domains/onboard",
|
|
{ domain: `escape-${Date.now()}.test` }, verified.body.token);
|
|
expect(res.status).toBe(400);
|
|
}, 120000);
|
|
|
|
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);
|
|
|
|
it("manages user preferences, Sieve scripts, and vacation autoresponder", async () => {
|
|
// A mailbox of its own: the shared fixture has a second factor enrolled by
|
|
// an earlier test, so its login returns a challenge rather than a session
|
|
// and this depended on test order to pass.
|
|
const email = `prefs@authtest-${DOMAIN_ID}.invalid`;
|
|
await query(
|
|
`INSERT INTO mailboxes (domain_id, local_part, email_address, password_hash)
|
|
VALUES ($1, 'prefs', $2, $3) ON CONFLICT (domain_id, local_part) DO NOTHING`,
|
|
[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;
|
|
expect(token).toBeDefined();
|
|
|
|
const prefs = await get("/api/v1/user/preferences", token);
|
|
expect(prefs.status).toBe(200);
|
|
|
|
const updatePrefs = await post("/api/v1/user/preferences", { signature: "-- Best Regards", auto_save_drafts: true }, token);
|
|
expect(updatePrefs.status).toBe(200);
|
|
|
|
const sieve = await get("/api/v1/user/sieve", token);
|
|
expect(sieve.status).toBe(200);
|
|
|
|
const saveVacation = await post("/api/v1/user/vacation", { enabled: true, subject: "On Holiday", message: "Back next week" }, token);
|
|
expect(saveVacation.status).toBe(200);
|
|
}, 60000);
|
|
|
|
it("handles admin bulk import, domain onboarding, DKIM rotation, TLS and Rspamd thresholds", async () => {
|
|
const admin = await adminSession();
|
|
|
|
const domainName = `newdomain-${Date.now()}.test`;
|
|
const onboard = await post("/api/v1/admin/domains/onboard", { domain: domainName }, admin);
|
|
expect(onboard.status).toBe(200);
|
|
// The response used to claim "records: 13"; nothing here touches DNS, and
|
|
// the reconciler in the protocols service is what publishes those.
|
|
expect(onboard.body.aliases).toHaveLength(4);
|
|
|
|
const reconcile = await post("/api/v1/admin/domains/reconcile", { domain_id: onboard.body.id }, admin);
|
|
expect(reconcile.status).toBe(200);
|
|
|
|
const bulk = await post("/api/v1/admin/mailboxes/bulk-import", {
|
|
rows: [{ email: `bulk1@${domainName}`, role: "USER", quota_mb: 1024 }],
|
|
}, admin);
|
|
expect(bulk.status).toBe(200);
|
|
expect(bulk.body.imported).toBe(1);
|
|
|
|
// DKIM rotation and the TLS panel are served by the protocols service,
|
|
// which owns the vault the private key is sealed with and the certificate
|
|
// watcher. They are covered by that service's own tests.
|
|
|
|
const getRspamd = await get("/api/v1/admin/rspamd/thresholds", admin);
|
|
expect(getRspamd.status).toBe(200);
|
|
|
|
const updateRspamd = await post("/api/v1/admin/rspamd/thresholds", { greylist: 4.5, add_header: 6.5, reject: 14.0 }, admin);
|
|
expect(updateRspamd.status).toBe(200);
|
|
|
|
await query(`DELETE FROM domains WHERE name = $1`, [domainName]);
|
|
}, 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);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* What each surface costs to open, and what a session grants once opened.
|
|
*
|
|
* Two rules made the app tiresome in a way no unit test could see. Reading your
|
|
* own mail demanded a second factor, which the mailbox does not require - only
|
|
* the console does. And a session issued at the admin sign-in carried the admin
|
|
* audience alone, so leaving the console landed on the webmail's login screen,
|
|
* asking for a password from somebody who had just proven a password and a
|
|
* second factor a moment earlier.
|
|
*/
|
|
describeDb("moving between the surfaces", () => {
|
|
const stepUpEmail = () => `stepup@authtest-${DOMAIN_ID}.invalid`;
|
|
const plainEmail = () => `plainuser@authtest-${DOMAIN_ID}.invalid`;
|
|
let stepUpId = "";
|
|
|
|
/** Reads the Set-Cookie headers, which is where the two sessions land. */
|
|
async function postForCookies(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,
|
|
cookies: res.headers.getSetCookie(),
|
|
body: (await res.json().catch(() => ({}))) as Record<string, never>,
|
|
};
|
|
}
|
|
|
|
const cookieNames = (cookies: string[]) => cookies.map((c) => c.split("=")[0]).sort();
|
|
|
|
/**
|
|
* One session per account, taken once.
|
|
*
|
|
* Every login costs an Argon2 verify, and this service deliberately bounds
|
|
* how many of those run at a time, so a test file that signs in per assertion
|
|
* spends its time queueing behind itself. A session does not expire during
|
|
* the run, so reusing it tests the same thing and leaves the budget for the
|
|
* assertions that need a fresh sign-in.
|
|
*/
|
|
let stepUpToken = "";
|
|
let plainToken = "";
|
|
let plainId = "";
|
|
|
|
beforeAll(async () => {
|
|
await startServer();
|
|
await seed();
|
|
const hash = await hashPassword(PASSWORD);
|
|
for (const [local, role] of [["stepup", "SUPER_ADMIN"], ["plainuser", "USER"]] as const) {
|
|
await query(
|
|
`INSERT INTO mailboxes (domain_id, local_part, email_address, password_hash, role)
|
|
VALUES ($1, $2, $3, $4, $5) ON CONFLICT (domain_id, local_part) DO NOTHING`,
|
|
[DOMAIN_ID, local, `${local}@authtest-${DOMAIN_ID}.invalid`, hash, role],
|
|
);
|
|
}
|
|
stepUpId = (await query<{ id: string }>(
|
|
`SELECT id FROM mailboxes WHERE email_address = $1`, [stepUpEmail()]))[0].id;
|
|
plainId = (await query<{ id: string }>(
|
|
`SELECT id FROM mailboxes WHERE email_address = $1`, [plainEmail()]))[0].id;
|
|
|
|
stepUpToken = (await post("/api/v1/auth/user/login",
|
|
{ email: stepUpEmail(), password: PASSWORD })).body.token as unknown as string;
|
|
plainToken = (await post("/api/v1/auth/user/login",
|
|
{ email: plainEmail(), password: PASSWORD })).body.token as unknown as string;
|
|
}, 120000);
|
|
|
|
afterAll(async () => {
|
|
server?.close();
|
|
await query(`DELETE FROM domains WHERE id = $1`, [DOMAIN_ID]).catch(() => undefined);
|
|
await closePool();
|
|
});
|
|
|
|
// The mailbox is one account's own mail behind that account's own password.
|
|
// Demanding a code as well meant an enrolled account could not read its mail
|
|
// without its phone.
|
|
it("opens the webmail on the password alone, even with a factor enrolled", async () => {
|
|
await enrollTotp(stepUpId, stepUpToken);
|
|
|
|
const login = await post("/api/v1/auth/user/login", { email: stepUpEmail(), password: PASSWORD });
|
|
expect(login.status).toBe(200);
|
|
expect(login.body.mfa_required).toBeUndefined();
|
|
expect(login.body.token).toBeDefined();
|
|
|
|
// And the session it hands out is one the API actually accepts.
|
|
expect((await get("/api/v1/user/me", login.body.token)).status).toBe(200);
|
|
}, 120000);
|
|
|
|
// A webmail session is still not an admin session; that is the isolation the
|
|
// whole audience split exists for.
|
|
it("does not let the webmail session open the console by itself", async () => {
|
|
expect((await get("/api/v1/admin/dashboard", stepUpToken)).status).toBe(401);
|
|
});
|
|
|
|
// The sign-in checked the password and the second factor and never the role,
|
|
// so an ordinary account that had enrolled a factor - which any account may
|
|
// do from its own settings - could sign in at /admin/login and be handed an
|
|
// admin-audience session. The step-up checks the role; this path did not.
|
|
it("refuses an ordinary account at the admin sign-in, factor or no factor", async () => {
|
|
await query(`DELETE FROM mfa_totp WHERE mailbox_id = $1`, [plainId]);
|
|
const secret = await enrollTotp(plainId, plainToken);
|
|
|
|
const attempt = await post("/api/v1/auth/admin/login", { email: plainEmail(), password: PASSWORD });
|
|
expect(attempt.status).toBe(403);
|
|
expect(attempt.body.error).toBe("FORBIDDEN");
|
|
|
|
// And no challenge came back that could be verified into one anyway.
|
|
expect(attempt.body.challenge_token).toBeUndefined();
|
|
void secret;
|
|
}, 120000);
|
|
|
|
it("refuses a step-up without a webmail session behind it", async () => {
|
|
expect((await post("/api/v1/auth/admin/step-up", { code: "123456" })).status).toBe(401);
|
|
});
|
|
|
|
it("refuses a step-up with the wrong code, and never on the password alone", async () => {
|
|
const res = await post("/api/v1/auth/admin/step-up", { code: "000000" }, stepUpToken);
|
|
expect(res.status).toBe(401);
|
|
expect(res.body.error).toBe("INVALID_MFA_CODE");
|
|
});
|
|
|
|
// The role is read from the database rather than from the token, so an
|
|
// ordinary account cannot cross over however valid its session is.
|
|
it("refuses a step-up from an account that may not open the console", async () => {
|
|
const res = await post("/api/v1/auth/admin/step-up", { code: "123456" }, plainToken);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
// The point of the step-up: the second factor, and not the password, which
|
|
// the session in hand proved already.
|
|
it("crosses into the console on the second factor alone", async () => {
|
|
await query(`DELETE FROM mfa_totp WHERE mailbox_id = $1`, [stepUpId]);
|
|
const secret = await enrollTotp(stepUpId, stepUpToken);
|
|
|
|
const stepUp = await postForCookies(
|
|
"/api/v1/auth/admin/step-up",
|
|
{ code: generateHotp(base32Decode(secret), getCurrentStep()) },
|
|
stepUpToken,
|
|
);
|
|
expect(stepUp.status).toBe(200);
|
|
expect((await get("/api/v1/admin/dashboard", stepUp.body.token)).status).toBe(200);
|
|
|
|
// Both sessions come back, which is what makes leaving the console free.
|
|
expect(cookieNames(stepUp.cookies)).toEqual(["lm_admin_session", "lm_user_session"]);
|
|
}, 120000);
|
|
|
|
// Proving a password and a second factor is strictly more than the webmail
|
|
// asks for, so it must not leave the operator without a webmail session.
|
|
it("issues a webmail session at the admin sign-in too", async () => {
|
|
await query(`DELETE FROM mfa_totp WHERE mailbox_id = $1`, [stepUpId]);
|
|
const secret = await enrollTotp(stepUpId, stepUpToken);
|
|
|
|
const challenge = await post("/api/v1/auth/admin/login", { email: stepUpEmail(), password: PASSWORD });
|
|
expect(challenge.body.mfa_required).toBe(true);
|
|
|
|
const verified = await postForCookies("/api/v1/auth/mfa/verify", {
|
|
challenge_token: challenge.body.challenge_token,
|
|
code: generateHotp(base32Decode(secret), getCurrentStep()),
|
|
});
|
|
expect(verified.status).toBe(200);
|
|
expect(cookieNames(verified.cookies)).toEqual(["lm_admin_session", "lm_user_session"]);
|
|
|
|
// The webmail cookie has to be a webmail token, not the admin one copied
|
|
// across: an admin-audience token is refused by every /user route.
|
|
const userCookie = verified.cookies.find((c) => c.startsWith("lm_user_session="))!;
|
|
const userToken = userCookie.slice("lm_user_session=".length).split(";")[0];
|
|
expect((await get("/api/v1/user/me", userToken)).status).toBe(200);
|
|
}, 120000);
|
|
|
|
// Signing in twice in the same second produced a byte-identical token, which
|
|
// collided with the unique index on web_sessions.refresh_token_hash and
|
|
// answered 500 to a correct password. A double-clicked sign-in was enough.
|
|
it("survives two sign-ins in the same second", async () => {
|
|
const [first, second] = await Promise.all([
|
|
post("/api/v1/auth/user/login", { email: plainEmail(), password: PASSWORD }),
|
|
post("/api/v1/auth/user/login", { email: plainEmail(), password: PASSWORD }),
|
|
]);
|
|
expect(first.status).toBe(200);
|
|
expect(second.status).toBe(200);
|
|
expect(first.body.token).not.toBe(second.body.token);
|
|
}, 60000);
|
|
});
|
|
|
|
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;
|