feat(haveibeenpwned): expose password compromise check (#11147)

This commit is contained in:
Taesu
2026-09-04 00:26:54 +00:00
committed by GitHub
parent 74a7369179
commit a9d8c12d14
4 changed files with 176 additions and 23 deletions
+5
View File
@@ -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.
@@ -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`
@@ -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.",
},
});
});
});
@@ -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<boolean> {
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<string>(
`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,
);