Skip to content

Commit ab5718a

Browse files
os-zhuangclaude
andauthored
feat(plugin-auth): reject breached passwords via HIBP (ADR-0069 D1, P1) (#2361)
First slice of ADR-0069 (enterprise authentication hardening) and the enforcement-wired pattern template the rest of the ADR copies. Adds a `password_reject_breached` auth setting (default off) wired end-to-end to better-auth's native `haveibeenpwned` plugin — a k-anonymity range check on sign-up / change / reset; the plaintext password never leaves the process. - spec: `passwordRejectBreached` flag on AuthPluginConfigSchema. - service-settings: "Reject breached passwords" toggle in the auth manifest password-policy group (global scope, manage_platform_settings). - plugin-auth: bindAuthSettings maps the setting into plugin config; buildPluginList gates + mounts haveIBeenPwned (env OS_AUTH_PASSWORD_REJECT_BREACHED wins over config, mirroring twoFactor). - cli: surface the knob in the serve boot config. Default-off and additive (no upgrade behavior change). Per ADR-0049 the toggle ships with its enforcement — no false surface; no new identity fields (the [custom] D1 items land in follow-ups). Verified: 102 plugin-auth + 128 service-settings unit tests green; spec / service-settings / plugin-auth (incl. DTS) / cli build green; live dogfood — with the toggle ON the HIBP plugin intercepts /sign-up/email (fail-closed when the corpus is unreachable), with it OFF the same breached password is accepted. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 39f896c commit ab5718a

9 files changed

Lines changed: 147 additions & 0 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-settings': minor
4+
'@objectstack/plugin-auth': minor
5+
'@objectstack/cli': minor
6+
---
7+
8+
Auth: reject breached passwords via Have I Been Pwned (ADR-0069 D1, P1)
9+
10+
First slice of ADR-0069 (enterprise authentication hardening) and the enforcement-wired pattern template the rest of the ADR follows. Adds a `password_reject_breached` auth setting (default **off**) bound end-to-end to better-auth's native `haveibeenpwned` plugin — a k-anonymity range check on sign-up / change-password / reset-password (the plaintext password never leaves the process).
11+
12+
- **spec**: new `passwordRejectBreached` flag on `AuthPluginConfigSchema`.
13+
- **service-settings**: new "Reject breached passwords" toggle in the `auth` manifest's password-policy group (`global` scope, `manage_platform_settings`).
14+
- **plugin-auth**: `bindAuthSettings` maps the setting into the plugin config; `buildPluginList` gates and mounts the `haveIBeenPwned` plugin (env `OS_AUTH_PASSWORD_REJECT_BREACHED` wins over config, mirroring `OS_AUTH_TWO_FACTOR`).
15+
- **cli**: surface the knob in the `serve` boot config alongside `twoFactor`.
16+
17+
Default-off and additive — no behavior change on upgrade. Per ADR-0049 the toggle ships with its enforcement (no false surface). No new identity fields (the `[custom]` D1 items — complexity / expiry / history — land in follow-up PRs).

packages/cli/src/commands/serve.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1243,6 +1243,12 @@ export default class Serve extends Command {
12431243
plugins: {
12441244
admin: String(process.env.OS_AUTH_ADMIN ?? 'true').toLowerCase() !== 'false',
12451245
twoFactor: String(process.env.OS_AUTH_TWO_FACTOR ?? 'false').toLowerCase() === 'true',
1246+
// ADR-0069 D1: reject breached passwords (Have I Been Pwned).
1247+
// Opt-in; the auth Settings toggle (password_reject_breached) is
1248+
// the primary control, OS_AUTH_PASSWORD_REJECT_BREACHED the
1249+
// operator override (env wins in buildPluginList()).
1250+
passwordRejectBreached:
1251+
String(process.env.OS_AUTH_PASSWORD_REJECT_BREACHED ?? 'false').toLowerCase() === 'true',
12461252
},
12471253
advanced: process.env.OS_COOKIE_DOMAIN
12481254
? ({

packages/plugins/plugin-auth/src/auth-manager.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ vi.mock('better-auth/plugins/custom-session', () => ({
2929
customSession: vi.fn((fn: any) => ({ id: 'custom-session', _fn: fn })),
3030
}));
3131

32+
vi.mock('better-auth/plugins/haveibeenpwned', () => ({
33+
haveIBeenPwned: vi.fn((opts: any) => ({ id: 'have-i-been-pwned', _opts: opts })),
34+
}));
35+
3236
import { betterAuth } from 'better-auth';
3337

3438
describe('AuthManager', () => {
@@ -1524,4 +1528,70 @@ describe('AuthManager', () => {
15241528
expect(result.user.roles).toBeUndefined();
15251529
});
15261530
});
1531+
1532+
// ADR-0069 D1: breached-password rejection enables better-auth's native
1533+
// `haveibeenpwned` plugin. Default OFF (no false surface, ADR-0049); the
1534+
// settings toggle (`password_reject_breached`) and the
1535+
// `OS_AUTH_PASSWORD_REJECT_BREACHED` env override both gate it, with env
1536+
// winning over config — mirroring the twoFactor / scim gating pattern.
1537+
describe('haveibeenpwned plugin (ADR-0069 breached-password rejection)', () => {
1538+
const captureConfig = () => {
1539+
let capturedConfig: any;
1540+
(betterAuth as any).mockImplementation((config: any) => {
1541+
capturedConfig = config;
1542+
return { handler: vi.fn(), api: {} };
1543+
});
1544+
return () => capturedConfig;
1545+
};
1546+
1547+
it('does NOT register the plugin by default (off by default)', async () => {
1548+
const get = captureConfig();
1549+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
1550+
const manager = new AuthManager({
1551+
secret: 'test-secret-at-least-32-chars-long',
1552+
baseUrl: 'http://localhost:3000',
1553+
});
1554+
await manager.getAuthInstance();
1555+
warnSpy.mockRestore();
1556+
1557+
expect(get().plugins.map((p: any) => p.id)).not.toContain('have-i-been-pwned');
1558+
});
1559+
1560+
it('registers the plugin when plugins.passwordRejectBreached is true', async () => {
1561+
const get = captureConfig();
1562+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
1563+
const manager = new AuthManager({
1564+
secret: 'test-secret-at-least-32-chars-long',
1565+
baseUrl: 'http://localhost:3000',
1566+
plugins: { passwordRejectBreached: true } as any,
1567+
});
1568+
await manager.getAuthInstance();
1569+
warnSpy.mockRestore();
1570+
1571+
const hibp = get().plugins.find((p: any) => p.id === 'have-i-been-pwned');
1572+
expect(hibp).toBeDefined();
1573+
// A custom user-facing message is passed (the default error is generic).
1574+
expect(typeof hibp._opts.customPasswordCompromisedMessage).toBe('string');
1575+
});
1576+
1577+
it('lets OS_AUTH_PASSWORD_REJECT_BREACHED env override the config (env wins)', async () => {
1578+
const get = captureConfig();
1579+
const prev = process.env.OS_AUTH_PASSWORD_REJECT_BREACHED;
1580+
process.env.OS_AUTH_PASSWORD_REJECT_BREACHED = 'true';
1581+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
1582+
try {
1583+
const manager = new AuthManager({
1584+
secret: 'test-secret-at-least-32-chars-long',
1585+
baseUrl: 'http://localhost:3000',
1586+
plugins: { passwordRejectBreached: false } as any,
1587+
});
1588+
await manager.getAuthInstance();
1589+
expect(get().plugins.map((p: any) => p.id)).toContain('have-i-been-pwned');
1590+
} finally {
1591+
if (prev === undefined) delete process.env.OS_AUTH_PASSWORD_REJECT_BREACHED;
1592+
else process.env.OS_AUTH_PASSWORD_REJECT_BREACHED = prev;
1593+
warnSpy.mockRestore();
1594+
}
1595+
});
1596+
});
15271597
});

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -829,9 +829,11 @@ export class AuthManager {
829829
// forces `admin` on (organization already defaults on). See ADR-0071.
830830
const scimEffective = scimFromEnv ?? (pluginConfig as any).scim ?? false;
831831
const twoFactorFromEnv = readBooleanEnv('OS_AUTH_TWO_FACTOR');
832+
const hibpFromEnv = readBooleanEnv('OS_AUTH_PASSWORD_REJECT_BREACHED');
832833
const enabled = {
833834
organization: pluginConfig.organization ?? true,
834835
twoFactor: twoFactorFromEnv ?? pluginConfig.twoFactor ?? false,
836+
passwordRejectBreached: hibpFromEnv ?? pluginConfig.passwordRejectBreached ?? false,
835837
passkeys: pluginConfig.passkeys ?? false,
836838
magicLink: pluginConfig.magicLink ?? false,
837839
oidcProvider: oidcFromEnv ?? pluginConfig.oidcProvider ?? false,
@@ -1077,6 +1079,19 @@ export class AuthManager {
10771079
}));
10781080
}
10791081

1082+
// Breached-password rejection (ADR-0069 D1). Native, stateless: a
1083+
// k-anonymity range check against Have I Been Pwned on better-auth's
1084+
// password-mutating endpoints (sign-up / change / reset — the plugin's
1085+
// defaults). The plaintext password is never sent; only the first 5 SHA-1
1086+
// hex chars leave the process. Rejects with PASSWORD_COMPROMISED.
1087+
if (enabled.passwordRejectBreached) {
1088+
const { haveIBeenPwned } = await import('better-auth/plugins/haveibeenpwned');
1089+
plugins.push(haveIBeenPwned({
1090+
customPasswordCompromisedMessage:
1091+
'This password has appeared in a known data breach. Please choose a different one.',
1092+
}));
1093+
}
1094+
10801095
if (enabled.admin) {
10811096
const { admin } = await import('better-auth/plugins/admin');
10821097
// Platform admin: ban/unban, set-password, impersonate, set-role.

packages/plugins/plugin-auth/src/auth-plugin.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,20 @@ describe('AuthPlugin', () => {
634634
expect(cfg.session?.updateAge).toBeUndefined();
635635
});
636636

637+
it('binds password_reject_breached into plugins.passwordRejectBreached (ADR-0069 D1)', async () => {
638+
const { manager } = await bootWithAuthSettings({
639+
password_reject_breached: { value: true, source: 'global' },
640+
});
641+
expect((manager as any).config.plugins?.passwordRejectBreached).toBe(true);
642+
});
643+
644+
it('does not set passwordRejectBreached when the setting is default-source (off by default)', async () => {
645+
const { manager } = await bootWithAuthSettings({
646+
password_reject_breached: { value: false, source: 'default' }, // not explicit → no patch
647+
});
648+
expect((manager as any).config.plugins?.passwordRejectBreached).toBeUndefined();
649+
});
650+
637651
it('enables Google from env credentials when google_enabled is explicit true', async () => {
638652
process.env.GOOGLE_CLIENT_ID = 'google-env-client-id';
639653
process.env.GOOGLE_CLIENT_SECRET = 'google-env-client-secret';

packages/plugins/plugin-auth/src/auth-plugin.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,17 @@ export class AuthPlugin implements Plugin {
486486
patch.emailAndPassword = emailAndPassword as AuthManagerOptions['emailAndPassword'];
487487
}
488488

489+
// Breached-password rejection (ADR-0069 D1) — enables better-auth's
490+
// native `haveibeenpwned` plugin via the plugin-config gate. Default
491+
// off; only an explicit toggle applies (manifest defaults must not
492+
// mask the deployment env var). See buildPluginList() for the seam.
493+
if (isExplicit('password_reject_breached')) {
494+
patch.plugins = {
495+
...(patch.plugins ?? {}),
496+
passwordRejectBreached: asBoolean(values.password_reject_breached, false),
497+
} as AuthManagerOptions['plugins'];
498+
}
499+
489500
// Session lifetime — days → seconds for better-auth's `session`
490501
// (`expiresIn` = absolute lifetime; `updateAge` = refresh threshold).
491502
const session: { expiresIn?: number; updateAge?: number } = {};

packages/services/service-settings/src/manifests/auth.manifest.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ describe('authSettingsManifest', () => {
2525
expect(keys).toEqual([
2626
'email_password_enabled',
2727
'google_enabled',
28+
'password_reject_breached',
2829
'require_email_verification',
2930
'signup_enabled',
3031
]);

packages/services/service-settings/src/manifests/auth.manifest.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,16 @@ const manifest = {
7575
description: 'Upper bound guards against denial-of-service via very long password hashing.',
7676
visible: "${data.email_password_enabled !== false}",
7777
},
78+
{
79+
type: 'toggle',
80+
key: 'password_reject_breached',
81+
label: 'Reject breached passwords',
82+
required: false,
83+
default: false,
84+
description:
85+
'Block passwords found in public breach corpora via Have I Been Pwned (k-anonymity range check; the password is never sent in full).',
86+
visible: "${data.email_password_enabled !== false}",
87+
},
7888

7989
{
8090
type: 'group',

packages/spec/src/system/auth-config.zod.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ export const AuthPluginConfigSchema = lazySchema(() => z.object({
2121
organization: z.boolean().default(true).describe('Enable Organization/Teams support (frontend AuthProvider expects this enabled)'),
2222
twoFactor: z.boolean().default(false).describe('Enable 2FA'),
2323
passkeys: z.boolean().default(false).describe('Enable Passkey support'),
24+
passwordRejectBreached: z.boolean().default(false).describe(
25+
"Reject passwords found in the Have I Been Pwned breach corpus (enables better-auth's haveibeenpwned plugin)",
26+
),
2427
magicLink: z.boolean().default(false).describe('Enable Magic Link login'),
2528
/**
2629
* Enable better-auth's `oidc-provider` plugin so that ObjectStack itself

0 commit comments

Comments
 (0)