mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
192 lines
6.5 KiB
TypeScript
192 lines
6.5 KiB
TypeScript
import * as OTPAuth from "otpauth";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
createTotp,
|
|
hashRecoveryCodes,
|
|
isMfaRequiredForUser,
|
|
verifyRecoveryCode,
|
|
verifyTotpCode,
|
|
} from "../../../apps/api/src/plugins/mfa.js";
|
|
|
|
describe("MFA", () => {
|
|
describe("createTotp", () => {
|
|
it("generates a valid TOTP URI", () => {
|
|
const totp = createTotp("testuser");
|
|
const uri = totp.toString();
|
|
expect(uri).toContain("otpauth://totp/");
|
|
expect(uri).toContain("SnapOtter");
|
|
expect(uri).toContain("testuser");
|
|
expect(uri).toContain("algorithm=SHA1");
|
|
expect(uri).toContain("digits=6");
|
|
expect(uri).toContain("period=30");
|
|
});
|
|
|
|
it("generates a TOTP with a valid secret", () => {
|
|
const totp = createTotp("testuser");
|
|
expect(totp.secret.base32).toMatch(/^[A-Z2-7]+=*$/);
|
|
// 20-byte secret = 32 base32 chars
|
|
expect(totp.secret.base32.length).toBe(32);
|
|
});
|
|
|
|
it("uses a provided secret when given", () => {
|
|
const knownSecret = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP";
|
|
const totp = createTotp("testuser", knownSecret);
|
|
expect(totp.secret.base32).toBe(knownSecret);
|
|
});
|
|
|
|
it("generates unique secrets across calls", () => {
|
|
const a = createTotp("user1");
|
|
const b = createTotp("user2");
|
|
expect(a.secret.base32).not.toBe(b.secret.base32);
|
|
});
|
|
});
|
|
|
|
describe("verifyTotpCode", () => {
|
|
it("verifies a correct TOTP code", () => {
|
|
const totp = createTotp("testuser");
|
|
const secret = totp.secret.base32;
|
|
const code = totp.generate();
|
|
expect(verifyTotpCode(secret, code)).toBe(true);
|
|
});
|
|
|
|
it("rejects an incorrect TOTP code", () => {
|
|
const totp = createTotp("testuser");
|
|
const secret = totp.secret.base32;
|
|
expect(verifyTotpCode(secret, "000000")).toBe(false);
|
|
});
|
|
|
|
it("rejects an empty code", () => {
|
|
const totp = createTotp("testuser");
|
|
const secret = totp.secret.base32;
|
|
expect(verifyTotpCode(secret, "")).toBe(false);
|
|
});
|
|
|
|
it("rejects a code from a different secret", () => {
|
|
const totp1 = createTotp("user1");
|
|
const totp2 = createTotp("user2");
|
|
const code = totp1.generate();
|
|
expect(verifyTotpCode(totp2.secret.base32, code)).toBe(false);
|
|
});
|
|
|
|
it("accepts codes within the 1-step window", () => {
|
|
const secret = new OTPAuth.Secret({ size: 20 });
|
|
const totp = new OTPAuth.TOTP({
|
|
issuer: "SnapOtter",
|
|
label: "test",
|
|
algorithm: "SHA1",
|
|
digits: 6,
|
|
period: 30,
|
|
secret,
|
|
});
|
|
|
|
// Generate code for the current period
|
|
const code = totp.generate();
|
|
expect(verifyTotpCode(secret.base32, code)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("hashRecoveryCodes", () => {
|
|
it("hashes recovery codes into comma-separated SHA-256 hashes", () => {
|
|
const codes = ["abcd1234", "efgh5678"];
|
|
const result = hashRecoveryCodes(codes);
|
|
const parts = result.split(",");
|
|
expect(parts).toHaveLength(2);
|
|
// Each hash should be 64 hex chars (256 bits)
|
|
for (const hash of parts) {
|
|
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
|
}
|
|
});
|
|
|
|
it("produces deterministic hashes", () => {
|
|
const codes = ["code1", "code2", "code3"];
|
|
const a = hashRecoveryCodes(codes);
|
|
const b = hashRecoveryCodes(codes);
|
|
expect(a).toBe(b);
|
|
});
|
|
|
|
it("handles a single code", () => {
|
|
const result = hashRecoveryCodes(["onlycode"]);
|
|
expect(result).not.toContain(",");
|
|
expect(result).toMatch(/^[0-9a-f]{64}$/);
|
|
});
|
|
});
|
|
|
|
describe("verifyRecoveryCode", () => {
|
|
it("verifies a valid recovery code", () => {
|
|
const codes = ["aaaa1111", "bbbb2222", "cccc3333"];
|
|
const hashList = hashRecoveryCodes(codes);
|
|
const result = verifyRecoveryCode("bbbb2222", hashList);
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it("rejects an invalid recovery code", () => {
|
|
const codes = ["aaaa1111", "bbbb2222"];
|
|
const hashList = hashRecoveryCodes(codes);
|
|
const result = verifyRecoveryCode("invalid0", hashList);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.remaining).toBe(hashList);
|
|
});
|
|
|
|
it("consumes a recovery code on use", () => {
|
|
const codes = ["aaaa1111", "bbbb2222", "cccc3333"];
|
|
const hashList = hashRecoveryCodes(codes);
|
|
const result = verifyRecoveryCode("bbbb2222", hashList);
|
|
expect(result.valid).toBe(true);
|
|
// Remaining should have 2 hashes
|
|
const remaining = result.remaining.split(",");
|
|
expect(remaining).toHaveLength(2);
|
|
// The used code should no longer verify
|
|
const secondTry = verifyRecoveryCode("bbbb2222", result.remaining);
|
|
expect(secondTry.valid).toBe(false);
|
|
});
|
|
|
|
it("returns empty remaining when last code is used", () => {
|
|
const codes = ["onlycode"];
|
|
const hashList = hashRecoveryCodes(codes);
|
|
const result = verifyRecoveryCode("onlycode", hashList);
|
|
expect(result.valid).toBe(true);
|
|
expect(result.remaining).toBe("");
|
|
});
|
|
|
|
it("preserves other codes when one is consumed", () => {
|
|
const codes = ["first000", "second00", "third000"];
|
|
const hashList = hashRecoveryCodes(codes);
|
|
|
|
// Use the first code
|
|
const r1 = verifyRecoveryCode("first000", hashList);
|
|
expect(r1.valid).toBe(true);
|
|
|
|
// Second and third should still work
|
|
const r2 = verifyRecoveryCode("second00", r1.remaining);
|
|
expect(r2.valid).toBe(true);
|
|
|
|
const r3 = verifyRecoveryCode("third000", r2.remaining);
|
|
expect(r3.valid).toBe(true);
|
|
expect(r3.remaining).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("isMfaRequiredForUser", () => {
|
|
it("returns false for optional policy", () => {
|
|
expect(isMfaRequiredForUser("optional", "admin")).toBe(false);
|
|
expect(isMfaRequiredForUser("optional", "editor")).toBe(false);
|
|
expect(isMfaRequiredForUser("optional", "user")).toBe(false);
|
|
});
|
|
|
|
it("returns true for required policy regardless of role", () => {
|
|
expect(isMfaRequiredForUser("required", "admin")).toBe(true);
|
|
expect(isMfaRequiredForUser("required", "editor")).toBe(true);
|
|
expect(isMfaRequiredForUser("required", "user")).toBe(true);
|
|
});
|
|
|
|
it("returns true for admins_only policy when role is admin", () => {
|
|
expect(isMfaRequiredForUser("admins_only", "admin")).toBe(true);
|
|
});
|
|
|
|
it("returns false for admins_only policy when role is not admin", () => {
|
|
expect(isMfaRequiredForUser("admins_only", "editor")).toBe(false);
|
|
expect(isMfaRequiredForUser("admins_only", "user")).toBe(false);
|
|
});
|
|
});
|
|
});
|