215 lines
9.1 KiB
TypeScript
215 lines
9.1 KiB
TypeScript
// @vitest-environment node
|
|
import { createHash } from "node:crypto";
|
|
import { describe, expect, test, vi } from "vitest";
|
|
import {
|
|
assertPasswordPolicy,
|
|
isPasswordPwned,
|
|
loadPasswordPolicy,
|
|
validatePassword,
|
|
validatePasswordPolicy,
|
|
} from "./password-policy.server.js";
|
|
|
|
function sha1Upper(value: string): string {
|
|
return createHash("sha1").update(value).digest("hex").toUpperCase();
|
|
}
|
|
|
|
function hibpFetcherFor(pwnedPasswords: Map<string, number>): typeof fetch {
|
|
return (async (input: RequestInfo | URL) => {
|
|
const url = typeof input === "string" ? input : (input as URL).toString();
|
|
const prefix = url.slice(url.lastIndexOf("/") + 1).toUpperCase();
|
|
const matches: string[] = [];
|
|
for (const [password, count] of pwnedPasswords) {
|
|
const hash = sha1Upper(password);
|
|
if (hash.startsWith(prefix)) {
|
|
matches.push(`${hash.slice(5)}:${count}`);
|
|
}
|
|
}
|
|
matches.push("0".repeat(35) + ":0"); // padding row
|
|
return new Response(matches.join("\n"), { status: 200 });
|
|
}) as unknown as typeof fetch;
|
|
}
|
|
|
|
const ALL_FLAGS_OFF = {
|
|
minLength: 1,
|
|
requireUpper: false,
|
|
requireLower: false,
|
|
requireDigit: false,
|
|
requireSpecial: false,
|
|
hibpCheck: false,
|
|
hibpMaxOccurrences: 0,
|
|
};
|
|
|
|
describe("validatePassword", () => {
|
|
test("rejects empty input with code 'empty'", async () => {
|
|
const result = await validatePassword("", {}, { policy: ALL_FLAGS_OFF });
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors).toEqual([{ code: "auth.password.errors.empty" }]);
|
|
});
|
|
|
|
test("enforces minLength at the boundary", async () => {
|
|
const policy = { ...ALL_FLAGS_OFF, minLength: 12 };
|
|
expect((await validatePassword("12345678901", {}, { policy })).valid).toBe(false);
|
|
expect((await validatePassword("123456789012", {}, { policy })).valid).toBe(true);
|
|
});
|
|
|
|
test("flags missingUpper / missingLower / missingDigit / missingSpecial independently", async () => {
|
|
const policy = { ...ALL_FLAGS_OFF, requireUpper: true, requireLower: true, requireDigit: true, requireSpecial: true };
|
|
const result = await validatePassword("abcdef", {}, { policy });
|
|
const codes = result.errors.map((e) => e.code);
|
|
expect(codes).toContain("auth.password.errors.missingUpper");
|
|
expect(codes).toContain("auth.password.errors.missingDigit");
|
|
expect(codes).toContain("auth.password.errors.missingSpecial");
|
|
expect(codes).not.toContain("auth.password.errors.missingLower");
|
|
});
|
|
|
|
test("accepts a password that meets every active rule", async () => {
|
|
const policy = { ...ALL_FLAGS_OFF, minLength: 12, requireUpper: true, requireLower: true, requireDigit: true, requireSpecial: true };
|
|
const result = await validatePassword("Strong-Pass1word!", {}, { policy });
|
|
expect(result.valid).toBe(true);
|
|
expect(result.errors).toEqual([]);
|
|
});
|
|
|
|
test("rejects passwords that contain the email local-part", async () => {
|
|
const policy = { ...ALL_FLAGS_OFF, minLength: 1 };
|
|
const result = await validatePassword("grace1234567890", { email: "grace@example.com" }, { policy });
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.map((e) => e.code)).toContain("auth.password.errors.containsEmail");
|
|
});
|
|
|
|
test("ignores email local-parts shorter than 3 chars to avoid false positives", async () => {
|
|
const policy = { ...ALL_FLAGS_OFF, minLength: 1 };
|
|
const result = await validatePassword("any-password-here", { email: "x@example.com" }, { policy });
|
|
expect(result.errors.map((e) => e.code)).not.toContain("auth.password.errors.containsEmail");
|
|
});
|
|
|
|
test("HIBP-pwned password is rejected", async () => {
|
|
const pwnedPasswords = new Map<string, number>([["Password123!", 12345]]);
|
|
const policy = { ...ALL_FLAGS_OFF, hibpCheck: true };
|
|
const result = await validatePassword("Password123!", {}, { policy, fetcher: hibpFetcherFor(pwnedPasswords) });
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.map((e) => e.code)).toContain("auth.password.errors.pwned");
|
|
});
|
|
|
|
test("HIBP-clean password passes", async () => {
|
|
const pwnedPasswords = new Map<string, number>();
|
|
const policy = { ...ALL_FLAGS_OFF, hibpCheck: true };
|
|
const result = await validatePassword("Never-Pwned-XyZ-12345", {}, { policy, fetcher: hibpFetcherFor(pwnedPasswords) });
|
|
expect(result.valid).toBe(true);
|
|
expect(result.hibpBypassed).toBeUndefined();
|
|
});
|
|
|
|
test("HIBP-pwned but under hibpMaxOccurrences is accepted", async () => {
|
|
const pwnedPasswords = new Map<string, number>([["RarelyLeaked-XyZ", 1]]);
|
|
const policy = { ...ALL_FLAGS_OFF, hibpCheck: true, hibpMaxOccurrences: 5 };
|
|
const result = await validatePassword("RarelyLeaked-XyZ", {}, { policy, fetcher: hibpFetcherFor(pwnedPasswords) });
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
test("HIBP-5xx fails open with hibpBypassed=true", async () => {
|
|
const fetcher = (async () => new Response("", { status: 503 })) as unknown as typeof fetch;
|
|
const policy = { ...ALL_FLAGS_OFF, hibpCheck: true };
|
|
const result = await validatePassword("Long-Enough-Password", {}, { policy, fetcher });
|
|
expect(result.valid).toBe(true);
|
|
expect(result.hibpBypassed).toBe(true);
|
|
});
|
|
|
|
test("HIBP-network-error fails open with hibpBypassed=true", async () => {
|
|
const fetcher = vi.fn(async () => {
|
|
throw new Error("ENOTFOUND api.pwnedpasswords.com");
|
|
}) as unknown as typeof fetch;
|
|
const policy = { ...ALL_FLAGS_OFF, hibpCheck: true };
|
|
const result = await validatePassword("Long-Enough-Password", {}, { policy, fetcher });
|
|
expect(result.valid).toBe(true);
|
|
expect(result.hibpBypassed).toBe(true);
|
|
});
|
|
|
|
test("Unicode passwords are length-counted by JS string length (sufficient for the policy)", async () => {
|
|
const policy = { ...ALL_FLAGS_OFF, minLength: 8 };
|
|
const result = await validatePassword("Pässwört✓123", {}, { policy });
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
test("absurdly long passwords are rejected by the ABSOLUTE_MAX_LENGTH guard", async () => {
|
|
const policy = { ...ALL_FLAGS_OFF, minLength: 1 };
|
|
const result = await validatePassword("a".repeat(2048), {}, { policy });
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.map((e) => e.code)).toContain("auth.password.errors.tooLong");
|
|
});
|
|
});
|
|
|
|
describe("loadPasswordPolicy", () => {
|
|
test("uses OWASP-aligned defaults when env is empty", () => {
|
|
const policy = loadPasswordPolicy({});
|
|
expect(policy.minLength).toBe(12);
|
|
expect(policy.requireUpper).toBe(true);
|
|
expect(policy.requireLower).toBe(true);
|
|
expect(policy.requireDigit).toBe(true);
|
|
expect(policy.requireSpecial).toBe(false);
|
|
expect(policy.hibpCheck).toBe(true);
|
|
expect(policy.hibpMaxOccurrences).toBe(0);
|
|
});
|
|
|
|
test("reads overrides from env vars", () => {
|
|
const policy = loadPasswordPolicy({
|
|
AUTH_PASSWORD_MIN_LENGTH: "16",
|
|
AUTH_PASSWORD_REQUIRE_UPPER: "false",
|
|
AUTH_PASSWORD_REQUIRE_DIGIT: "no",
|
|
AUTH_PASSWORD_REQUIRE_SPECIAL: "true",
|
|
AUTH_PASSWORD_HIBP_CHECK: "0",
|
|
AUTH_PASSWORD_HIBP_MAX_OCCURRENCES: "10",
|
|
});
|
|
expect(policy.minLength).toBe(16);
|
|
expect(policy.requireUpper).toBe(false);
|
|
expect(policy.requireLower).toBe(true);
|
|
expect(policy.requireDigit).toBe(false);
|
|
expect(policy.requireSpecial).toBe(true);
|
|
expect(policy.hibpCheck).toBe(false);
|
|
expect(policy.hibpMaxOccurrences).toBe(10);
|
|
});
|
|
|
|
test("invalid env values fall back to defaults", () => {
|
|
const policy = loadPasswordPolicy({
|
|
AUTH_PASSWORD_MIN_LENGTH: "garbage",
|
|
AUTH_PASSWORD_REQUIRE_UPPER: "maybe",
|
|
});
|
|
expect(policy.minLength).toBe(12);
|
|
expect(policy.requireUpper).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("prototype compatibility shims", () => {
|
|
test("isPasswordPwned returns true only when HIBP reports the password above the threshold", async () => {
|
|
const fetcher = hibpFetcherFor(new Map<string, number>([["Password123!", 12345]]));
|
|
await expect(isPasswordPwned("Password123!", fetcher)).resolves.toBe(true);
|
|
await expect(isPasswordPwned("Never-Pwned-XyZ-12345", fetcher)).resolves.toBe(false);
|
|
});
|
|
|
|
test("validatePasswordPolicy maps canonical errors to legacy issue names", async () => {
|
|
const fetcher = hibpFetcherFor(new Map<string, number>([["short", 99]]));
|
|
await expect(validatePasswordPolicy("short", { fetchImpl: fetcher })).resolves.toEqual({
|
|
ok: false,
|
|
issues: ["too-short", "pwned"],
|
|
});
|
|
});
|
|
|
|
test("validatePasswordPolicy reports unavailable HIBP checks as a legacy issue", async () => {
|
|
const fetcher = vi.fn(async () => {
|
|
throw new Error("network down");
|
|
}) as unknown as typeof fetch;
|
|
|
|
await expect(validatePasswordPolicy("Long-Enough-Password", { fetchImpl: fetcher })).resolves.toEqual({
|
|
ok: false,
|
|
issues: ["pwned-check-unavailable"],
|
|
});
|
|
});
|
|
|
|
test("assertPasswordPolicy resolves successful checks and throws with legacy issues on failure", async () => {
|
|
const cleanFetcher = hibpFetcherFor(new Map());
|
|
await expect(assertPasswordPolicy("Strong-Enough-Password1", { fetchImpl: cleanFetcher })).resolves.toEqual({
|
|
ok: true,
|
|
issues: [],
|
|
});
|
|
await expect(assertPasswordPolicy("short", { fetchImpl: cleanFetcher })).rejects.toThrow(/too-short/u);
|
|
});
|
|
});
|