import { INestApplication } from "@nestjs/common"; import { generate } from "otplib"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, qaEmail, type AuthedTokens, } from "./setup.js"; /** * Protects the MFA/TOTP invariants added in apps/api/src/auth/mfa.service.ts * and the login-flow branch in auth.service.ts. Boots the real app against * the real Postgres (same pattern as auth.spec.ts) — codes are generated * for real via `otplib.generate` against the plaintext secret returned by * the real enroll endpoint, never faked. */ describe("auth — MFA/TOTP (login step-up)", () => { let app: INestApplication; let admin: AuthedTokens; let fixtureEmail: string; let fixtureUserId: string; let fixturePassword: string; let fixtureAccessToken: string; let secret: string; beforeAll(async () => { app = await createTestApp(); admin = await loginSuperAdmin(app); fixtureEmail = qaEmail("mfa"); const invite = await agent(app) .post("/api/v1/users/invite") .set(...bearer(admin.accessToken)) .send({ email: fixtureEmail, roleKey: "user", applications: ["core"] }) .expect(201); fixtureUserId = invite.body.user.id; fixturePassword = invite.body.tempPassword; const login = await agent(app) .post("/api/v1/auth/login") .send({ email: fixtureEmail, password: fixturePassword, applicationKey: "core" }) .expect(200); fixtureAccessToken = login.body.accessToken as string; }); afterAll(async () => { await agent(app) .patch(`/api/v1/users/${fixtureUserId}`) .set(...bearer(admin.accessToken)) .send({ isActive: false }) .expect(200); await closeTestApp(app); }); it("login before enrollment returns tokens directly (no mfaRequired)", async () => { const res = await agent(app) .post("/api/v1/auth/login") .send({ email: fixtureEmail, password: fixturePassword, applicationKey: "core" }) .expect(200); expect(res.body.accessToken).toBeTypeOf("string"); expect(res.body.mfaRequired).toBeUndefined(); }); it("confirming enrollment with a wrong code is rejected and does not enable MFA", async () => { const enroll = await agent(app) .post("/api/v1/auth/mfa/enroll") .set(...bearer(fixtureAccessToken)) .expect(201); secret = enroll.body.secret as string; expect(enroll.body.otpauthUrl).toContain("otpauth://totp/"); await agent(app) .post("/api/v1/auth/mfa/confirm") .set(...bearer(fixtureAccessToken)) .send({ code: "000000" }) .expect(400); // MFA must still be off — login without a code still returns tokens directly. const res = await agent(app) .post("/api/v1/auth/login") .send({ email: fixtureEmail, password: fixturePassword, applicationKey: "core" }) .expect(200); expect(res.body.mfaRequired).toBeUndefined(); }); it("confirming enrollment with the real current code enables MFA and step-up login works end to end", async () => { await agent(app) .post("/api/v1/auth/mfa/confirm") .set(...bearer(fixtureAccessToken)) .send({ code: await generate({ secret }) }) .expect(204); // Password alone is no longer enough — login returns a challenge, not tokens. const challenge = await agent(app) .post("/api/v1/auth/login") .send({ email: fixtureEmail, password: fixturePassword, applicationKey: "core" }) .expect(200); expect(challenge.body.mfaRequired).toBe(true); expect(challenge.body.accessToken).toBeUndefined(); const mfaChallenge = challenge.body.mfaChallenge as string; // Regression: the challenge token must NEVER work as a bearer access // token — it's signed with the same secret as a real access token, so // without an explicit `purpose` check it would grant full API access // without ever verifying the second factor (caught live against the // real running stack, not a hypothetical). await agent(app).get("/api/v1/auth/me").set(...bearer(mfaChallenge)).expect(401); // Wrong TOTP code is rejected. await agent(app) .post("/api/v1/auth/mfa/challenge") .send({ mfaChallenge, code: "000000" }) .expect(401); // Correct TOTP code completes login with real tokens. const done = await agent(app) .post("/api/v1/auth/mfa/challenge") .send({ mfaChallenge, code: await generate({ secret }) }) .expect(200); expect(done.body.accessToken).toBeTypeOf("string"); expect(done.body.refreshToken).toBeTypeOf("string"); }); it("disabling MFA requires a valid current code, then login goes back to returning tokens directly", async () => { await agent(app) .post("/api/v1/auth/mfa/disable") .set(...bearer(fixtureAccessToken)) .send({ code: "000000" }) .expect(401); await agent(app) .post("/api/v1/auth/mfa/disable") .set(...bearer(fixtureAccessToken)) .send({ code: await generate({ secret }) }) .expect(204); const res = await agent(app) .post("/api/v1/auth/login") .send({ email: fixtureEmail, password: fixturePassword, applicationKey: "core" }) .expect(200); expect(res.body.mfaRequired).toBeUndefined(); expect(res.body.accessToken).toBeTypeOf("string"); }); });