Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/adr-0069-hibp-breached-password.md
Original file line number Diff line number Diff line change
@@ -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).
6 changes: 6 additions & 0 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
? ({
Expand Down
70 changes: 70 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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();
}
});
});
});
15 changes: 15 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
11 changes: 11 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } = {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ describe('authSettingsManifest', () => {
expect(keys).toEqual([
'email_password_enabled',
'google_enabled',
'password_reject_breached',
'require_email_verification',
'signup_enabled',
]);
Expand Down
10 changes: 10 additions & 0 deletions packages/services/service-settings/src/manifests/auth.manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/src/system/auth-config.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down