From 35a37eea5cbf7a9ac77a34beb3d3e8391b31d541 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:30:22 +0800 Subject: [PATCH] feat(plugin-auth): password complexity policy (ADR-0069 D1, P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `password_require_complexity` (toggle, default off) + `password_min_classes` (1-4, default 3) auth settings. A custom validator runs in the better-auth `before` hook on /sign-up/email, /reset-password, /change-password and rejects a password using fewer than min_classes of the four character classes (upper/lower/digit/symbol) with PASSWORD_POLICY_VIOLATION — better-auth natively enforces only min/max length. Default-off / additive (no upgrade behavior change); ADR-0049 (enforcement ships with the setting); no new identity fields. Verified live (dogfood): complexity OFF accepts a weak password; ON rejects a lowercase-only password (400 PASSWORD_POLICY_VIOLATION) and accepts a 3-class password, on BOTH /sign-up/email and /change-password (newPassword). Unit: 126 plugin-auth + 6 manifest tests green; builds incl. strict DTS green. Co-Authored-By: Claude Opus 4.8 --- .changeset/adr-0069-d1-password-complexity.md | 10 ++++ .../plugin-auth/src/auth-manager.test.ts | 47 ++++++++++++++++ .../plugins/plugin-auth/src/auth-manager.ts | 55 +++++++++++++++++++ .../plugin-auth/src/auth-plugin.test.ts | 25 +++++++++ .../plugins/plugin-auth/src/auth-plugin.ts | 10 ++++ .../src/manifests/auth.manifest.test.ts | 1 + .../src/manifests/auth.manifest.ts | 21 +++++++ 7 files changed, 169 insertions(+) create mode 100644 .changeset/adr-0069-d1-password-complexity.md diff --git a/.changeset/adr-0069-d1-password-complexity.md b/.changeset/adr-0069-d1-password-complexity.md new file mode 100644 index 0000000000..a35c01bd22 --- /dev/null +++ b/.changeset/adr-0069-d1-password-complexity.md @@ -0,0 +1,10 @@ +--- +'@objectstack/service-settings': minor +'@objectstack/plugin-auth': minor +--- + +Auth: password complexity policy (ADR-0069 D1, P1) + +Adds `password_require_complexity` (toggle, default off) + `password_min_classes` (1–4, default 3) to the `auth` password-policy settings. A custom validator runs in the better-auth `before` hook on `/sign-up/email`, `/reset-password`, and `/change-password`, rejecting passwords that use fewer than `password_min_classes` of the four character classes (upper / lower / digit / symbol) with `PASSWORD_POLICY_VIOLATION` — better-auth natively enforces only min/max length. + +Default-off and additive (no upgrade behavior change); per ADR-0049 the setting ships with its enforcement. No new identity fields. Continues the ADR-0069 P1 password-policy work alongside the HIBP breached-password reject (#2361). diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index dd7d2c6429..eac905a214 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -1731,4 +1731,51 @@ describe('AuthManager', () => { expect(captured).not.toHaveProperty('rateLimit'); }); }); + + // ADR-0069 D1: password complexity validator (custom; better-auth only does + // length). Exercised directly via the AuthManager helper. + describe('password complexity (ADR-0069 D1)', () => { + const SECRET = 'test-secret-at-least-32-chars-long'; + const mgr = (extra: any = {}) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const m = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', ...extra }); + warn.mockRestore(); + return m; + }; + + it('is a no-op when complexity is not required (any password passes)', async () => { + const m = mgr({ passwordRequireComplexity: false }); + await expect((m as any).assertPasswordComplexity('password')).resolves.toBeUndefined(); + }); + + it('rejects a password with too few character classes', async () => { + const m = mgr({ passwordRequireComplexity: true, passwordMinClasses: 3 }); + // only lowercase → 1 class < 3 + await expect((m as any).assertPasswordComplexity('alllowercase')).rejects.toMatchObject({ + body: { code: 'PASSWORD_POLICY_VIOLATION' }, + }); + }); + + it('accepts a password meeting the required class count', async () => { + const m = mgr({ passwordRequireComplexity: true, passwordMinClasses: 3 }); + // upper + lower + digit = 3 classes + await expect((m as any).assertPasswordComplexity('Abcdef12')).resolves.toBeUndefined(); + }); + + it('counts symbols as a class and honours a min of 4', async () => { + const m = mgr({ passwordRequireComplexity: true, passwordMinClasses: 4 }); + await expect((m as any).assertPasswordComplexity('Abcd1234')).rejects.toMatchObject({ + body: { code: 'PASSWORD_POLICY_VIOLATION' }, + }); // 3 classes < 4 + await expect((m as any).assertPasswordComplexity('Abcd123!')).resolves.toBeUndefined(); // 4 classes + }); + + it('clamps an out-of-range min_classes into [1,4] (defaults to 3 when unset)', async () => { + const m = mgr({ passwordRequireComplexity: true, passwordMinClasses: 99 }); + // clamped to 4 → needs all four classes + await expect((m as any).assertPasswordComplexity('Abcd1234')).rejects.toMatchObject({ + body: { code: 'PASSWORD_POLICY_VIOLATION' }, + }); + }); + }); }); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 66b7700c2c..1645826265 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -282,6 +282,18 @@ export interface AuthManagerOptions extends Partial { /** Minutes an account stays locked once the threshold is crossed. Default 15. */ lockoutDurationMinutes?: number; + /** + * ADR-0069 D1 — password complexity. When `passwordRequireComplexity` is on, + * a new password must contain at least `passwordMinClasses` (1-4) of the + * character classes upper / lower / digit / symbol. Enforced by a validator + * in the `/sign-up/email`, `/reset-password`, `/change-password` before hook + * (better-auth only enforces min/max length natively). + */ + passwordRequireComplexity?: boolean; + + /** Minimum distinct character classes required (1-4). Default 3. */ + passwordMinClasses?: number; + /** * ADR-0069 D2 — better-auth-native per-IP rate limiting, passed through to * better-auth's core `rateLimit`. The settings bind tightens `customRules` @@ -576,6 +588,24 @@ export class AuthManager { // sees `userCount > 0` and the toggle is enforced again. hooks: { before: createAuthMiddleware(async (ctx: any) => { + // ── ADR-0069 D1: password complexity (validator) ──────────── + // better-auth enforces only min/max length; class-mix is custom. + // Runs on the password-mutating endpoints; reads the candidate from + // the path-appropriate body field (sign-up: `password`; reset / + // change: `newPassword`). + if ( + ctx?.path === '/sign-up/email' || + ctx?.path === '/reset-password' || + ctx?.path === '/change-password' + ) { + const candidate = + (typeof ctx?.body?.password === 'string' && ctx.body.password) || + (typeof ctx?.body?.newPassword === 'string' && ctx.body.newPassword) || + ''; + if (candidate) await this.assertPasswordComplexity(candidate); + // fall through to the path's own handling below + } + // ── ADR-0024: admin-gate self-service SSO provider registration ── // `@better-auth/sso`'s POST /sso/register only checks org-admin when // `body.organizationId` is present (index.mjs: `if (ctx.body @@ -2061,6 +2091,31 @@ export class AuthManager { } } + /** + * ADR-0069 D1 — reject a password that doesn't meet the configured character- + * class complexity. No-op when `passwordRequireComplexity` is off. Counts the + * four classes (upper / lower / digit / symbol) present and throws + * `PASSWORD_POLICY_VIOLATION` when fewer than `passwordMinClasses` are used. + */ + private async assertPasswordComplexity(password: string): Promise { + if (!this.config.passwordRequireComplexity) return; + const min = Math.min(4, Math.max(1, Math.floor(Number(this.config.passwordMinClasses) || 3))); + const classes = + (/[a-z]/.test(password) ? 1 : 0) + + (/[A-Z]/.test(password) ? 1 : 0) + + (/[0-9]/.test(password) ? 1 : 0) + + (/[^A-Za-z0-9]/.test(password) ? 1 : 0); + if (classes < min) { + const { APIError } = await import('better-auth/api'); + throw new APIError('BAD_REQUEST', { + message: + `Password must include at least ${min} of: uppercase, lowercase, ` + + 'digit, symbol.', + code: 'PASSWORD_POLICY_VIOLATION', + }); + } + } + /** * ADR-0069 D2 — throw `ACCOUNT_LOCKED` when the identity is currently locked * out (brute-force protection). No-op when lockout is disabled diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index d9c2cd43ff..08a4eb3dd4 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -648,6 +648,31 @@ describe('AuthPlugin', () => { expect((manager as any).config.plugins?.passwordRejectBreached).toBeUndefined(); }); + it('binds password complexity settings (ADR-0069 D1)', async () => { + const { manager } = await bootWithAuthSettings({ + password_require_complexity: { value: true, source: 'global' }, + password_min_classes: { value: 4, source: 'global' }, + }); + const cfg = (manager as any).config; + expect(cfg.passwordRequireComplexity).toBe(true); + expect(cfg.passwordMinClasses).toBe(4); + }); + + it('clamps password_min_classes into [1,4]', async () => { + const { manager } = await bootWithAuthSettings({ + password_require_complexity: { value: true, source: 'global' }, + password_min_classes: { value: 9, source: 'global' }, + }); + expect((manager as any).config.passwordMinClasses).toBe(4); + }); + + it('does not set complexity flags when default-source', async () => { + const { manager } = await bootWithAuthSettings({ + password_require_complexity: { value: false, source: 'default' }, + }); + expect((manager as any).config.passwordRequireComplexity).toBeUndefined(); + }); + it('binds account-lockout settings (ADR-0069 D2)', async () => { const { manager } = await bootWithAuthSettings({ lockout_threshold: { value: 5, source: 'global' }, diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index d812c04295..f449b74a4e 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -497,6 +497,16 @@ export class AuthPlugin implements Plugin { } as AuthManagerOptions['plugins']; } + // Password complexity (ADR-0069 D1) — custom validator in the before + // hook (better-auth only enforces length). Only explicit values apply. + if (isExplicit('password_require_complexity')) { + patch.passwordRequireComplexity = asBoolean(values.password_require_complexity, false); + } + if (isExplicit('password_min_classes')) { + const n = asPositiveInt(values.password_min_classes); + if (n !== undefined) patch.passwordMinClasses = Math.min(4, Math.max(1, n)); + } + // 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 ad2c099910..2440d4cdf5 100644 --- a/packages/services/service-settings/src/manifests/auth.manifest.test.ts +++ b/packages/services/service-settings/src/manifests/auth.manifest.test.ts @@ -26,6 +26,7 @@ describe('authSettingsManifest', () => { 'email_password_enabled', 'google_enabled', 'password_reject_breached', + 'password_require_complexity', '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 0a88000035..4268d52141 100644 --- a/packages/services/service-settings/src/manifests/auth.manifest.ts +++ b/packages/services/service-settings/src/manifests/auth.manifest.ts @@ -85,6 +85,27 @@ const manifest = { '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: 'toggle', + key: 'password_require_complexity', + label: 'Require complex passwords', + required: false, + default: false, + description: + 'Require passwords to mix character classes (uppercase, lowercase, digits, symbols) on sign-up and password change/reset.', + visible: "${data.email_password_enabled !== false}", + }, + { + type: 'number', + key: 'password_min_classes', + label: 'Minimum character classes', + required: false, + default: 3, + min: 1, + max: 4, + description: 'How many of the four classes (upper / lower / digit / symbol) a password must include.', + visible: "${data.email_password_enabled !== false && data.password_require_complexity === true}", + }, { type: 'group',