diff --git a/.changeset/adr-0069-hibp-breached-password.md b/.changeset/adr-0069-hibp-breached-password.md new file mode 100644 index 0000000000..79d5389e1b --- /dev/null +++ b/.changeset/adr-0069-hibp-breached-password.md @@ -0,0 +1,17 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-settings': minor +'@objectstack/plugin-auth': minor +'@objectstack/cli': minor +--- + +Auth: reject breached passwords via Have I Been Pwned (ADR-0069 D1, P1) + +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). + +- **spec**: new `passwordRejectBreached` flag on `AuthPluginConfigSchema`. +- **service-settings**: new "Reject breached passwords" toggle in the `auth` manifest's password-policy group (`global` scope, `manage_platform_settings`). +- **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`). +- **cli**: surface the knob in the `serve` boot config alongside `twoFactor`. + +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). diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index db7889a463..4ec132454a 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1243,6 +1243,12 @@ export default class Serve extends Command { plugins: { admin: String(process.env.OS_AUTH_ADMIN ?? 'true').toLowerCase() !== 'false', twoFactor: String(process.env.OS_AUTH_TWO_FACTOR ?? 'false').toLowerCase() === 'true', + // ADR-0069 D1: reject breached passwords (Have I Been Pwned). + // Opt-in; the auth Settings toggle (password_reject_breached) is + // the primary control, OS_AUTH_PASSWORD_REJECT_BREACHED the + // operator override (env wins in buildPluginList()). + passwordRejectBreached: + String(process.env.OS_AUTH_PASSWORD_REJECT_BREACHED ?? 'false').toLowerCase() === 'true', }, advanced: process.env.OS_COOKIE_DOMAIN ? ({ diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 51dec9d1f3..cfd14c985a 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -29,6 +29,10 @@ vi.mock('better-auth/plugins/custom-session', () => ({ customSession: vi.fn((fn: any) => ({ id: 'custom-session', _fn: fn })), })); +vi.mock('better-auth/plugins/haveibeenpwned', () => ({ + haveIBeenPwned: vi.fn((opts: any) => ({ id: 'have-i-been-pwned', _opts: opts })), +})); + import { betterAuth } from 'better-auth'; describe('AuthManager', () => { @@ -1524,4 +1528,70 @@ describe('AuthManager', () => { expect(result.user.roles).toBeUndefined(); }); }); + + // ADR-0069 D1: breached-password rejection enables better-auth's native + // `haveibeenpwned` plugin. Default OFF (no false surface, ADR-0049); the + // settings toggle (`password_reject_breached`) and the + // `OS_AUTH_PASSWORD_REJECT_BREACHED` env override both gate it, with env + // winning over config — mirroring the twoFactor / scim gating pattern. + describe('haveibeenpwned plugin (ADR-0069 breached-password rejection)', () => { + const captureConfig = () => { + let capturedConfig: any; + (betterAuth as any).mockImplementation((config: any) => { + capturedConfig = config; + return { handler: vi.fn(), api: {} }; + }); + return () => capturedConfig; + }; + + it('does NOT register the plugin by default (off by default)', async () => { + const get = captureConfig(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + }); + await manager.getAuthInstance(); + warnSpy.mockRestore(); + + expect(get().plugins.map((p: any) => p.id)).not.toContain('have-i-been-pwned'); + }); + + it('registers the plugin when plugins.passwordRejectBreached is true', async () => { + const get = captureConfig(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + plugins: { passwordRejectBreached: true } as any, + }); + await manager.getAuthInstance(); + warnSpy.mockRestore(); + + const hibp = get().plugins.find((p: any) => p.id === 'have-i-been-pwned'); + expect(hibp).toBeDefined(); + // A custom user-facing message is passed (the default error is generic). + expect(typeof hibp._opts.customPasswordCompromisedMessage).toBe('string'); + }); + + it('lets OS_AUTH_PASSWORD_REJECT_BREACHED env override the config (env wins)', async () => { + const get = captureConfig(); + const prev = process.env.OS_AUTH_PASSWORD_REJECT_BREACHED; + process.env.OS_AUTH_PASSWORD_REJECT_BREACHED = 'true'; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + plugins: { passwordRejectBreached: false } as any, + }); + await manager.getAuthInstance(); + expect(get().plugins.map((p: any) => p.id)).toContain('have-i-been-pwned'); + } finally { + if (prev === undefined) delete process.env.OS_AUTH_PASSWORD_REJECT_BREACHED; + else process.env.OS_AUTH_PASSWORD_REJECT_BREACHED = prev; + warnSpy.mockRestore(); + } + }); + }); }); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 2c8b35855e..e8c653dbb8 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -829,9 +829,11 @@ export class AuthManager { // forces `admin` on (organization already defaults on). See ADR-0071. const scimEffective = scimFromEnv ?? (pluginConfig as any).scim ?? false; const twoFactorFromEnv = readBooleanEnv('OS_AUTH_TWO_FACTOR'); + const hibpFromEnv = readBooleanEnv('OS_AUTH_PASSWORD_REJECT_BREACHED'); const enabled = { organization: pluginConfig.organization ?? true, twoFactor: twoFactorFromEnv ?? pluginConfig.twoFactor ?? false, + passwordRejectBreached: hibpFromEnv ?? pluginConfig.passwordRejectBreached ?? false, passkeys: pluginConfig.passkeys ?? false, magicLink: pluginConfig.magicLink ?? false, oidcProvider: oidcFromEnv ?? pluginConfig.oidcProvider ?? false, @@ -1077,6 +1079,19 @@ export class AuthManager { })); } + // Breached-password rejection (ADR-0069 D1). Native, stateless: a + // k-anonymity range check against Have I Been Pwned on better-auth's + // password-mutating endpoints (sign-up / change / reset — the plugin's + // defaults). The plaintext password is never sent; only the first 5 SHA-1 + // hex chars leave the process. Rejects with PASSWORD_COMPROMISED. + if (enabled.passwordRejectBreached) { + const { haveIBeenPwned } = await import('better-auth/plugins/haveibeenpwned'); + plugins.push(haveIBeenPwned({ + customPasswordCompromisedMessage: + 'This password has appeared in a known data breach. Please choose a different one.', + })); + } + if (enabled.admin) { const { admin } = await import('better-auth/plugins/admin'); // Platform admin: ban/unban, set-password, impersonate, set-role. diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index 7d35b6366e..8cdb3fa9a0 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -634,6 +634,20 @@ describe('AuthPlugin', () => { expect(cfg.session?.updateAge).toBeUndefined(); }); + it('binds password_reject_breached into plugins.passwordRejectBreached (ADR-0069 D1)', async () => { + const { manager } = await bootWithAuthSettings({ + password_reject_breached: { value: true, source: 'global' }, + }); + expect((manager as any).config.plugins?.passwordRejectBreached).toBe(true); + }); + + it('does not set passwordRejectBreached when the setting is default-source (off by default)', async () => { + const { manager } = await bootWithAuthSettings({ + password_reject_breached: { value: false, source: 'default' }, // not explicit → no patch + }); + expect((manager as any).config.plugins?.passwordRejectBreached).toBeUndefined(); + }); + it('enables Google from env credentials when google_enabled is explicit true', async () => { process.env.GOOGLE_CLIENT_ID = 'google-env-client-id'; process.env.GOOGLE_CLIENT_SECRET = 'google-env-client-secret'; diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index a8638279af..8790a3bd55 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -486,6 +486,17 @@ export class AuthPlugin implements Plugin { patch.emailAndPassword = emailAndPassword as AuthManagerOptions['emailAndPassword']; } + // Breached-password rejection (ADR-0069 D1) — enables better-auth's + // native `haveibeenpwned` plugin via the plugin-config gate. Default + // off; only an explicit toggle applies (manifest defaults must not + // mask the deployment env var). See buildPluginList() for the seam. + if (isExplicit('password_reject_breached')) { + patch.plugins = { + ...(patch.plugins ?? {}), + passwordRejectBreached: asBoolean(values.password_reject_breached, false), + } as AuthManagerOptions['plugins']; + } + // Session lifetime — days → seconds for better-auth's `session` // (`expiresIn` = absolute lifetime; `updateAge` = refresh threshold). const session: { expiresIn?: number; updateAge?: number } = {}; diff --git a/packages/services/service-settings/src/manifests/auth.manifest.test.ts b/packages/services/service-settings/src/manifests/auth.manifest.test.ts index e1564dc47c..2f76f41c67 100644 --- a/packages/services/service-settings/src/manifests/auth.manifest.test.ts +++ b/packages/services/service-settings/src/manifests/auth.manifest.test.ts @@ -25,6 +25,7 @@ describe('authSettingsManifest', () => { expect(keys).toEqual([ 'email_password_enabled', 'google_enabled', + 'password_reject_breached', 'require_email_verification', 'signup_enabled', ]); diff --git a/packages/services/service-settings/src/manifests/auth.manifest.ts b/packages/services/service-settings/src/manifests/auth.manifest.ts index c7d1733677..f181422706 100644 --- a/packages/services/service-settings/src/manifests/auth.manifest.ts +++ b/packages/services/service-settings/src/manifests/auth.manifest.ts @@ -75,6 +75,16 @@ const manifest = { description: 'Upper bound guards against denial-of-service via very long password hashing.', visible: "${data.email_password_enabled !== false}", }, + { + type: 'toggle', + key: 'password_reject_breached', + label: 'Reject breached passwords', + required: false, + default: false, + description: + 'Block passwords found in public breach corpora via Have I Been Pwned (k-anonymity range check; the password is never sent in full).', + visible: "${data.email_password_enabled !== false}", + }, { type: 'group', diff --git a/packages/spec/src/system/auth-config.zod.ts b/packages/spec/src/system/auth-config.zod.ts index dce08b9870..ae11eaf323 100644 --- a/packages/spec/src/system/auth-config.zod.ts +++ b/packages/spec/src/system/auth-config.zod.ts @@ -21,6 +21,9 @@ export const AuthPluginConfigSchema = lazySchema(() => z.object({ organization: z.boolean().default(true).describe('Enable Organization/Teams support (frontend AuthProvider expects this enabled)'), twoFactor: z.boolean().default(false).describe('Enable 2FA'), passkeys: z.boolean().default(false).describe('Enable Passkey support'), + passwordRejectBreached: z.boolean().default(false).describe( + "Reject passwords found in the Have I Been Pwned breach corpus (enables better-auth's haveibeenpwned plugin)", + ), magicLink: z.boolean().default(false).describe('Enable Magic Link login'), /** * Enable better-auth's `oidc-provider` plugin so that ObjectStack itself