268 lines
11 KiB
TypeScript
268 lines
11 KiB
TypeScript
// @vitest-environment node
|
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
import * as OTPAuth from "otpauth";
|
|
|
|
vi.mock("~/utils/api/.server/CryptoApi", () => ({
|
|
default: {
|
|
encrypt: vi.fn((value: string) => `encrypted:${value}`),
|
|
decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
|
|
},
|
|
}));
|
|
|
|
vi.mock("@toolctrl/config", () => ({
|
|
loadRequiredWebConfig: vi.fn(() => ({ appName: "ToolCTRL" })),
|
|
}));
|
|
|
|
vi.mock("~/utils/db/users.db.server", () => ({
|
|
activateUserTotpPendingSecret: vi.fn(),
|
|
getUserTotpPendingEnrollment: vi.fn(),
|
|
setUserMfaRecoveryCodes: vi.fn(),
|
|
setUserTotpPendingSecret: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("~/utils/auth/mfa/recovery-codes.server", () => ({
|
|
generateRecoveryCodes: vi.fn(() => ({
|
|
codes: ["recovery-1", "recovery-2"],
|
|
entries: [
|
|
{ codeHash: "hash-1", createdAt: new Date("2026-05-25T10:00:00.000Z") },
|
|
{ codeHash: "hash-2", createdAt: new Date("2026-05-25T10:00:00.000Z") },
|
|
],
|
|
generatedAt: new Date("2026-05-25T10:00:00.000Z"),
|
|
})),
|
|
}));
|
|
|
|
const vectorSecret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ";
|
|
|
|
function generateExpectedCode(secret: string, timestamp: number): string {
|
|
const totp = new OTPAuth.TOTP({
|
|
issuer: "ToolCTRL",
|
|
label: "test",
|
|
algorithm: "SHA1",
|
|
digits: 6,
|
|
period: 30,
|
|
secret: OTPAuth.Secret.fromBase32(secret),
|
|
});
|
|
return totp.generate({ timestamp });
|
|
}
|
|
|
|
describe("services/auth/totp.server", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.resetModules();
|
|
});
|
|
|
|
test("generateTotpSecret returns a 32-char base32 string", async () => {
|
|
const { generateTotpSecret } = await import("./totp.server");
|
|
const secret = generateTotpSecret();
|
|
expect(secret).toMatch(/^[A-Z2-7]{32}$/);
|
|
});
|
|
|
|
test("encryptTotpSecret / decryptTotpSecret round-trip via CryptoApi", async () => {
|
|
const { encryptTotpSecret, decryptTotpSecret } = await import("./totp.server");
|
|
const encrypted = encryptTotpSecret(vectorSecret);
|
|
expect(encrypted).toBe(`encrypted:${vectorSecret}`);
|
|
expect(decryptTotpSecret(encrypted)).toBe(vectorSecret);
|
|
});
|
|
|
|
test("verifyTotpCode accepts the current code with default window", async () => {
|
|
const { encryptTotpSecret, verifyTotpCode } = await import("./totp.server");
|
|
const encryptedSecret = encryptTotpSecret(vectorSecret);
|
|
const now = 1_234_567_890_000;
|
|
const code = generateExpectedCode(vectorSecret, now);
|
|
expect(verifyTotpCode(encryptedSecret, code, { timestamp: now })).toBe(true);
|
|
});
|
|
|
|
test("verifyTotpCode with window=1 accepts ±30s clock drift", async () => {
|
|
const { encryptTotpSecret, verifyTotpCode } = await import("./totp.server");
|
|
const encryptedSecret = encryptTotpSecret(vectorSecret);
|
|
const now = 1_234_567_890_000;
|
|
|
|
const pastCode = generateExpectedCode(vectorSecret, now - 30_000);
|
|
const futureCode = generateExpectedCode(vectorSecret, now + 30_000);
|
|
|
|
expect(verifyTotpCode(encryptedSecret, pastCode, { timestamp: now })).toBe(true);
|
|
expect(verifyTotpCode(encryptedSecret, futureCode, { timestamp: now })).toBe(true);
|
|
});
|
|
|
|
test("verifyTotpCode rejects ±60s clock drift outside the window", async () => {
|
|
const { encryptTotpSecret, verifyTotpCode } = await import("./totp.server");
|
|
const encryptedSecret = encryptTotpSecret(vectorSecret);
|
|
const now = 1_234_567_890_000;
|
|
|
|
const tooOld = generateExpectedCode(vectorSecret, now - 60_000);
|
|
const tooNew = generateExpectedCode(vectorSecret, now + 60_000);
|
|
|
|
expect(verifyTotpCode(encryptedSecret, tooOld, { timestamp: now })).toBe(false);
|
|
expect(verifyTotpCode(encryptedSecret, tooNew, { timestamp: now })).toBe(false);
|
|
});
|
|
|
|
test("verifyTotpCode rejects malformed codes without consulting otpauth", async () => {
|
|
const { encryptTotpSecret, verifyTotpCode } = await import("./totp.server");
|
|
const encryptedSecret = encryptTotpSecret(vectorSecret);
|
|
const now = 1_234_567_890_000;
|
|
|
|
expect(verifyTotpCode(encryptedSecret, "12345", { timestamp: now })).toBe(false);
|
|
expect(verifyTotpCode(encryptedSecret, "1234567", { timestamp: now })).toBe(false);
|
|
expect(verifyTotpCode(encryptedSecret, "12345a", { timestamp: now })).toBe(false);
|
|
expect(verifyTotpCode(encryptedSecret, "", { timestamp: now })).toBe(false);
|
|
});
|
|
|
|
test("verifyTotpCode returns false when decryption throws", async () => {
|
|
const cryptoApi = (await import("~/utils/api/.server/CryptoApi")).default;
|
|
vi.mocked(cryptoApi.decrypt).mockImplementationOnce(() => {
|
|
throw new Error("Invalid encrypted payload");
|
|
});
|
|
const { verifyTotpCode } = await import("./totp.server");
|
|
const now = 1_234_567_890_000;
|
|
const code = generateExpectedCode(vectorSecret, now);
|
|
expect(verifyTotpCode("corrupt-encrypted-secret", code, { timestamp: now })).toBe(false);
|
|
});
|
|
|
|
test("verifyTotpCode rejects a non-integer or negative window", async () => {
|
|
const { encryptTotpSecret, verifyTotpCode } = await import("./totp.server");
|
|
const encryptedSecret = encryptTotpSecret(vectorSecret);
|
|
const now = 1_234_567_890_000;
|
|
const code = generateExpectedCode(vectorSecret, now);
|
|
expect(() => verifyTotpCode(encryptedSecret, code, { timestamp: now, window: -1 })).toThrow();
|
|
expect(() => verifyTotpCode(encryptedSecret, code, { timestamp: now, window: 1.5 })).toThrow();
|
|
});
|
|
|
|
test("buildTotpOtpauthUri produces a parsable otpauth:// URI with issuer and label", async () => {
|
|
const { buildTotpOtpauthUri } = await import("./totp.server");
|
|
const uri = buildTotpOtpauthUri(vectorSecret, "user+security@example.com", { issuer: "Tool Ctrl Admin" });
|
|
|
|
expect(uri.startsWith("otpauth://totp/")).toBe(true);
|
|
expect(uri).toContain(`secret=${vectorSecret}`);
|
|
expect(uri).toContain("algorithm=SHA1");
|
|
expect(uri).toContain("digits=6");
|
|
expect(uri).toContain("period=30");
|
|
// Issuer + email appear URL-encoded, not as raw plaintext
|
|
expect(uri).not.toContain("Tool Ctrl Admin:user+security@example.com");
|
|
|
|
const parsed = OTPAuth.URI.parse(uri);
|
|
expect(parsed).toBeInstanceOf(OTPAuth.TOTP);
|
|
expect(parsed.issuer).toBe("Tool Ctrl Admin");
|
|
expect(parsed.label).toBe("user+security@example.com");
|
|
});
|
|
|
|
test("startTotpEnrollment yields encrypted secret + parsable otpauth URI", async () => {
|
|
const { startTotpEnrollment } = await import("./totp.server");
|
|
const enrollment = startTotpEnrollment("person@example.com", { issuer: "ToolCTRL" });
|
|
|
|
expect(enrollment.secret).toMatch(/^[A-Z2-7]{32}$/);
|
|
expect(enrollment.encryptedSecret).toBe(`encrypted:${enrollment.secret}`);
|
|
expect(enrollment.otpauthUri).toBe(enrollment.qrValue);
|
|
expect(enrollment.otpauthUri).toContain(`secret=${enrollment.secret}`);
|
|
});
|
|
|
|
test("getTotpSafeStatus exposes only booleans and timestamps", async () => {
|
|
const { getTotpSafeStatus } = await import("./totp.server");
|
|
const enabledAt = new Date("2026-05-08T13:10:00.000Z");
|
|
const pendingCreatedAt = new Date("2026-05-08T13:00:00.000Z");
|
|
|
|
const status = getTotpSafeStatus({
|
|
hasEncryptedSecret: true,
|
|
hasEncryptedPendingSecret: true,
|
|
totpEnabledAt: enabledAt,
|
|
totpPendingCreatedAt: pendingCreatedAt,
|
|
});
|
|
|
|
expect(status).toEqual({ enabled: true, pending: true, enabledAt, pendingCreatedAt });
|
|
expect(JSON.stringify(status)).not.toMatch(/secret|otpauth|encrypted/i);
|
|
});
|
|
|
|
test("getTotpSafeStatus returns disabled when prerequisites are missing", async () => {
|
|
const { getTotpSafeStatus } = await import("./totp.server");
|
|
expect(
|
|
getTotpSafeStatus({
|
|
hasEncryptedSecret: false,
|
|
hasEncryptedPendingSecret: false,
|
|
totpEnabledAt: null,
|
|
totpPendingCreatedAt: null,
|
|
})
|
|
).toEqual({ enabled: false, pending: false, enabledAt: null, pendingCreatedAt: null });
|
|
});
|
|
|
|
test("createTotpEnrollmentForUser stores a pending encrypted secret", async () => {
|
|
const { setUserTotpPendingSecret } = await import("~/utils/db/users.db.server");
|
|
const { createTotpEnrollmentForUser } = await import("./totp.server");
|
|
const now = new Date("2026-05-25T09:00:00.000Z");
|
|
|
|
const result = await createTotpEnrollmentForUser({
|
|
userId: "user_1",
|
|
userEmail: "security@example.com",
|
|
issuer: "ToolCTRL Admin",
|
|
now,
|
|
});
|
|
|
|
expect(result.secret).toMatch(/^[A-Z2-7]{32}$/);
|
|
expect(result.encryptedSecret).toBe(`encrypted:${result.secret}`);
|
|
expect(result.recoveryCodesPending).toBe(true);
|
|
expect(result.otpauthUri).toContain(`secret=${result.secret}`);
|
|
expect(setUserTotpPendingSecret).toHaveBeenCalledWith("user_1", result.encryptedSecret, now);
|
|
});
|
|
|
|
test("activatePendingTotpForUser returns false when no pending secret exists", async () => {
|
|
const usersDb = await import("~/utils/db/users.db.server");
|
|
vi.mocked(usersDb.getUserTotpPendingEnrollment).mockResolvedValueOnce(null);
|
|
const { activatePendingTotpForUser } = await import("./totp.server");
|
|
|
|
await expect(
|
|
activatePendingTotpForUser({
|
|
userId: "user_1",
|
|
token: "123456",
|
|
now: new Date("2026-05-25T09:00:00.000Z"),
|
|
})
|
|
).resolves.toEqual({ activated: false, recoveryCodes: [] });
|
|
expect(usersDb.activateUserTotpPendingSecret).not.toHaveBeenCalled();
|
|
expect(usersDb.setUserMfaRecoveryCodes).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("activatePendingTotpForUser returns false when the token is invalid", async () => {
|
|
const usersDb = await import("~/utils/db/users.db.server");
|
|
vi.mocked(usersDb.getUserTotpPendingEnrollment).mockResolvedValueOnce({
|
|
totpPendingSecretEncrypted: `encrypted:${vectorSecret}`,
|
|
} as never);
|
|
const { activatePendingTotpForUser } = await import("./totp.server");
|
|
|
|
await expect(
|
|
activatePendingTotpForUser({
|
|
userId: "user_1",
|
|
token: "not-a-code",
|
|
now: new Date("2026-05-25T09:00:00.000Z"),
|
|
})
|
|
).resolves.toEqual({ activated: false, recoveryCodes: [] });
|
|
expect(usersDb.activateUserTotpPendingSecret).not.toHaveBeenCalled();
|
|
expect(usersDb.setUserMfaRecoveryCodes).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("activatePendingTotpForUser promotes the pending secret and stores recovery codes", async () => {
|
|
const usersDb = await import("~/utils/db/users.db.server");
|
|
const { generateRecoveryCodes } = await import("~/utils/auth/mfa/recovery-codes.server");
|
|
const now = new Date("2026-05-25T09:00:00.000Z");
|
|
const code = generateExpectedCode(vectorSecret, now.getTime());
|
|
vi.mocked(usersDb.getUserTotpPendingEnrollment).mockResolvedValueOnce({
|
|
totpPendingSecretEncrypted: `encrypted:${vectorSecret}`,
|
|
} as never);
|
|
const { activatePendingTotpForUser, TOTP_RECOVERY_CODE_COUNT } = await import("./totp.server");
|
|
|
|
await expect(
|
|
activatePendingTotpForUser({
|
|
userId: "user_1",
|
|
token: code,
|
|
now,
|
|
})
|
|
).resolves.toEqual({ activated: true, recoveryCodes: ["recovery-1", "recovery-2"] });
|
|
expect(generateRecoveryCodes).toHaveBeenCalledWith({ count: TOTP_RECOVERY_CODE_COUNT, now });
|
|
expect(usersDb.activateUserTotpPendingSecret).toHaveBeenCalledWith("user_1", `encrypted:${vectorSecret}`, now);
|
|
expect(usersDb.setUserMfaRecoveryCodes).toHaveBeenCalledWith(
|
|
"user_1",
|
|
[
|
|
{ codeHash: "hash-1", createdAt: new Date("2026-05-25T10:00:00.000Z") },
|
|
{ codeHash: "hash-2", createdAt: new Date("2026-05-25T10:00:00.000Z") },
|
|
],
|
|
new Date("2026-05-25T10:00:00.000Z")
|
|
);
|
|
});
|
|
});
|