diff --git a/.changeset/fresh-cats-check-passwords.md b/.changeset/fresh-cats-check-passwords.md new file mode 100644 index 0000000000..80c8052151 --- /dev/null +++ b/.changeset/fresh-cats-check-passwords.md @@ -0,0 +1,5 @@ +--- +"better-auth": patch +--- + +Add `isPasswordCompromised` for checking passwords against Have I Been Pwned in custom server-side flows, while ignoring padded response entries with zero occurrences. diff --git a/docs/content/docs/plugins/have-i-been-pwned.mdx b/docs/content/docs/plugins/have-i-been-pwned.mdx index 30c0b3efe9..75b52adb31 100644 --- a/docs/content/docs/plugins/have-i-been-pwned.mdx +++ b/docs/content/docs/plugins/have-i-been-pwned.mdx @@ -32,6 +32,21 @@ When a user attempts to create an account or update their password with a compro } ``` +### Custom password flows + +Use `isPasswordCompromised` to check passwords in server-side flows that do not use Better Auth endpoints. + +```ts +import { isPasswordCompromised } from "better-auth/plugins/haveibeenpwned" + +const compromised = await isPasswordCompromised(password) +if (compromised) { + throw new Error("Please choose a password that has not been compromised") +} +``` + +The function only sends the first five characters of the password's SHA-1 hash to Have I Been Pwned. It throws an `APIError` if the service cannot complete the check. + ## Options ### `enabled` diff --git a/packages/better-auth/src/plugins/haveibeenpwned/haveibeenpwned.test.ts b/packages/better-auth/src/plugins/haveibeenpwned/haveibeenpwned.test.ts index 890f1584b4..b2e8188a40 100644 --- a/packages/better-auth/src/plugins/haveibeenpwned/haveibeenpwned.test.ts +++ b/packages/better-auth/src/plugins/haveibeenpwned/haveibeenpwned.test.ts @@ -7,7 +7,7 @@ import { emailOTP } from "../email-otp"; import { emailOTPClient } from "../email-otp/client"; import { phoneNumber } from "../phone-number"; import { phoneNumberClient } from "../phone-number/client"; -import { haveIBeenPwned } from "./index"; +import { haveIBeenPwned, isPasswordCompromised } from "./index"; /** * Stubs the Have I Been Pwned range API so the given password is reported as @@ -298,3 +298,106 @@ describe("have-i-been-pwned", async () => { }); }); }); + +/** + * @see https://haveibeenpwned.com/API/v3#PwnedPasswords + */ +describe("isPasswordCompromised", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should report a compromised password", async () => { + const password = "breached-password"; + await mockBreached(password); + + await expect(isPasswordCompromised(password)).resolves.toBe(true); + }); + + it("should report a safe password", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("00000000000000000000000000000000000:1\n", { + status: 200, + }), + ); + + await expect(isPasswordCompromised("unique-password")).resolves.toBe(false); + }); + + it("should ignore padded entries with a zero count", async () => { + const password = "padded-password"; + const hash = ( + await createHash("SHA-1", "hex").digest(password) + ).toUpperCase(); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(`${hash.slice(5)}:0\n`, { status: 200 }), + ); + + await expect(isPasswordCompromised(password)).resolves.toBe(false); + }); + + it("should match lowercase hash suffixes in CRLF responses", async () => { + const password = "compromised-password"; + const hash = ( + await createHash("SHA-1", "hex").digest(password) + ).toUpperCase(); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(`${hash.slice(5).toLowerCase()}:1\r\n`, { status: 200 }), + ); + + await expect(isPasswordCompromised(password)).resolves.toBe(true); + }); + + it("should check an empty password", async () => { + await mockBreached(""); + + await expect(isPasswordCompromised("")).resolves.toBe(true); + }); + + it("should throw an API error when the service cannot complete the check", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(null, { status: 503 }), + ); + + await expect(isPasswordCompromised("password")).rejects.toMatchObject({ + status: "INTERNAL_SERVER_ERROR", + body: { + message: "Failed to check password. Status: 503", + }, + }); + }); + + it("should hide unexpected service errors", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue( + new Error("network unavailable"), + ); + + await expect(isPasswordCompromised("password")).rejects.toMatchObject({ + status: "INTERNAL_SERVER_ERROR", + body: { + message: "Failed to check password. Please try again later.", + }, + }); + }); + + it.each([ + "", + " ", + "invalid", + ])("should reject an invalid compromise count %#", async (compromiseCount) => { + const password = "password"; + const hash = ( + await createHash("SHA-1", "hex").digest(password) + ).toUpperCase(); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(`${hash.slice(5)}:${compromiseCount}\n`, { status: 200 }), + ); + + await expect(isPasswordCompromised(password)).rejects.toMatchObject({ + status: "INTERNAL_SERVER_ERROR", + body: { + message: "Failed to check password. Please try again later.", + }, + }); + }); +}); diff --git a/packages/better-auth/src/plugins/haveibeenpwned/index.ts b/packages/better-auth/src/plugins/haveibeenpwned/index.ts index 929c87d2d6..ff863467d4 100644 --- a/packages/better-auth/src/plugins/haveibeenpwned/index.ts +++ b/packages/better-auth/src/plugins/haveibeenpwned/index.ts @@ -20,18 +20,44 @@ const ERROR_CODES = defineErrorCodes({ "The password you entered has been compromised. Please choose a different password.", }); -async function checkPasswordCompromise( - password: string, - customMessage?: string | undefined, -) { - if (!password) return; +function getPasswordCompromiseCount(response: string, hashSuffix: string) { + const matchingEntryPrefix = `${hashSuffix.toUpperCase()}:`; - const sha1Hash = ( - await createHash("SHA-1", "hex").digest(password) - ).toUpperCase(); - const prefix = sha1Hash.substring(0, 5); - const suffix = sha1Hash.substring(5); + for (const line of response.split(/\r?\n/)) { + const entryPrefix = line.slice(0, matchingEntryPrefix.length).toUpperCase(); + if (entryPrefix !== matchingEntryPrefix) continue; + + const compromiseCountText = line.slice(matchingEntryPrefix.length); + const compromiseCount = Number(compromiseCountText); + const validCompromiseCount = + Number.isSafeInteger(compromiseCount) && + compromiseCount >= 0 && + String(compromiseCount) === compromiseCountText; + if (!validCompromiseCount) { + throw new Error("Invalid password compromise count"); + } + return compromiseCount; + } + + return 0; +} + +/** + * Checks whether a password appears in the Have I Been Pwned password corpus. + * Only the first five characters of its SHA-1 hash are sent to the service. + * + * @returns Whether the password has been compromised. + * @throws {APIError} When the password could not be checked. + */ +export async function isPasswordCompromised( + password: string, +): Promise { try { + const sha1Hash = ( + await createHash("SHA-1", "hex").digest(password) + ).toUpperCase(); + const prefix = sha1Hash.substring(0, 5); + const suffix = sha1Hash.substring(5); const { data, error } = await betterFetch( `https://api.pwnedpasswords.com/range/${prefix}`, { @@ -47,17 +73,8 @@ async function checkPasswordCompromise( message: `Failed to check password. Status: ${error.status}`, }); } - const lines = data.split("\n"); - const found = lines.some( - (line) => line.split(":")[0]!.toUpperCase() === suffix.toUpperCase(), - ); - - if (found) { - throw APIError.from("BAD_REQUEST", { - message: customMessage || ERROR_CODES.PASSWORD_COMPROMISED.message, - code: ERROR_CODES.PASSWORD_COMPROMISED.code, - }); - } + const compromiseCount = getPasswordCompromiseCount(data, suffix); + return compromiseCount > 0; } catch (error) { if (isAPIError(error)) throw error; throw new APIError("INTERNAL_SERVER_ERROR", { @@ -66,6 +83,19 @@ async function checkPasswordCompromise( } } +async function rejectCompromisedPassword( + password: string, + customMessage?: string | undefined, +) { + const compromised = await isPasswordCompromised(password); + if (compromised) { + throw APIError.from("BAD_REQUEST", { + message: customMessage || ERROR_CODES.PASSWORD_COMPROMISED.message, + code: ERROR_CODES.PASSWORD_COMPROMISED.code, + }); + } +} + export interface HaveIBeenPwnedOptions { /** * Custom error message shown when a compromised password is detected. @@ -112,7 +142,7 @@ export const haveIBeenPwned = (options?: HaveIBeenPwnedOptions | undefined) => { if (!c.path || !paths.includes(c.path)) { return originalHash(password); } - await checkPasswordCompromise( + await rejectCompromisedPassword( password, options?.customPasswordCompromisedMessage, );