Skip to content

Commit 21b3208

Browse files
os-zhuangclaude
andauthored
feat(plugin-auth): password complexity policy (ADR-0069 D1, P1) (#2368)
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: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent cb5b393 commit 21b3208

7 files changed

Lines changed: 169 additions & 0 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/service-settings': minor
3+
'@objectstack/plugin-auth': minor
4+
---
5+
6+
Auth: password complexity policy (ADR-0069 D1, P1)
7+
8+
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.
9+
10+
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).

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1731,4 +1731,51 @@ describe('AuthManager', () => {
17311731
expect(captured).not.toHaveProperty('rateLimit');
17321732
});
17331733
});
1734+
1735+
// ADR-0069 D1: password complexity validator (custom; better-auth only does
1736+
// length). Exercised directly via the AuthManager helper.
1737+
describe('password complexity (ADR-0069 D1)', () => {
1738+
const SECRET = 'test-secret-at-least-32-chars-long';
1739+
const mgr = (extra: any = {}) => {
1740+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
1741+
const m = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', ...extra });
1742+
warn.mockRestore();
1743+
return m;
1744+
};
1745+
1746+
it('is a no-op when complexity is not required (any password passes)', async () => {
1747+
const m = mgr({ passwordRequireComplexity: false });
1748+
await expect((m as any).assertPasswordComplexity('password')).resolves.toBeUndefined();
1749+
});
1750+
1751+
it('rejects a password with too few character classes', async () => {
1752+
const m = mgr({ passwordRequireComplexity: true, passwordMinClasses: 3 });
1753+
// only lowercase → 1 class < 3
1754+
await expect((m as any).assertPasswordComplexity('alllowercase')).rejects.toMatchObject({
1755+
body: { code: 'PASSWORD_POLICY_VIOLATION' },
1756+
});
1757+
});
1758+
1759+
it('accepts a password meeting the required class count', async () => {
1760+
const m = mgr({ passwordRequireComplexity: true, passwordMinClasses: 3 });
1761+
// upper + lower + digit = 3 classes
1762+
await expect((m as any).assertPasswordComplexity('Abcdef12')).resolves.toBeUndefined();
1763+
});
1764+
1765+
it('counts symbols as a class and honours a min of 4', async () => {
1766+
const m = mgr({ passwordRequireComplexity: true, passwordMinClasses: 4 });
1767+
await expect((m as any).assertPasswordComplexity('Abcd1234')).rejects.toMatchObject({
1768+
body: { code: 'PASSWORD_POLICY_VIOLATION' },
1769+
}); // 3 classes < 4
1770+
await expect((m as any).assertPasswordComplexity('Abcd123!')).resolves.toBeUndefined(); // 4 classes
1771+
});
1772+
1773+
it('clamps an out-of-range min_classes into [1,4] (defaults to 3 when unset)', async () => {
1774+
const m = mgr({ passwordRequireComplexity: true, passwordMinClasses: 99 });
1775+
// clamped to 4 → needs all four classes
1776+
await expect((m as any).assertPasswordComplexity('Abcd1234')).rejects.toMatchObject({
1777+
body: { code: 'PASSWORD_POLICY_VIOLATION' },
1778+
});
1779+
});
1780+
});
17341781
});

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,18 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
282282
/** Minutes an account stays locked once the threshold is crossed. Default 15. */
283283
lockoutDurationMinutes?: number;
284284

285+
/**
286+
* ADR-0069 D1 — password complexity. When `passwordRequireComplexity` is on,
287+
* a new password must contain at least `passwordMinClasses` (1-4) of the
288+
* character classes upper / lower / digit / symbol. Enforced by a validator
289+
* in the `/sign-up/email`, `/reset-password`, `/change-password` before hook
290+
* (better-auth only enforces min/max length natively).
291+
*/
292+
passwordRequireComplexity?: boolean;
293+
294+
/** Minimum distinct character classes required (1-4). Default 3. */
295+
passwordMinClasses?: number;
296+
285297
/**
286298
* ADR-0069 D2 — better-auth-native per-IP rate limiting, passed through to
287299
* better-auth's core `rateLimit`. The settings bind tightens `customRules`
@@ -576,6 +588,24 @@ export class AuthManager {
576588
// sees `userCount > 0` and the toggle is enforced again.
577589
hooks: {
578590
before: createAuthMiddleware(async (ctx: any) => {
591+
// ── ADR-0069 D1: password complexity (validator) ────────────
592+
// better-auth enforces only min/max length; class-mix is custom.
593+
// Runs on the password-mutating endpoints; reads the candidate from
594+
// the path-appropriate body field (sign-up: `password`; reset /
595+
// change: `newPassword`).
596+
if (
597+
ctx?.path === '/sign-up/email' ||
598+
ctx?.path === '/reset-password' ||
599+
ctx?.path === '/change-password'
600+
) {
601+
const candidate =
602+
(typeof ctx?.body?.password === 'string' && ctx.body.password) ||
603+
(typeof ctx?.body?.newPassword === 'string' && ctx.body.newPassword) ||
604+
'';
605+
if (candidate) await this.assertPasswordComplexity(candidate);
606+
// fall through to the path's own handling below
607+
}
608+
579609
// ── ADR-0024: admin-gate self-service SSO provider registration ──
580610
// `@better-auth/sso`'s POST /sso/register only checks org-admin when
581611
// `body.organizationId` is present (index.mjs: `if (ctx.body
@@ -2061,6 +2091,31 @@ export class AuthManager {
20612091
}
20622092
}
20632093

2094+
/**
2095+
* ADR-0069 D1 — reject a password that doesn't meet the configured character-
2096+
* class complexity. No-op when `passwordRequireComplexity` is off. Counts the
2097+
* four classes (upper / lower / digit / symbol) present and throws
2098+
* `PASSWORD_POLICY_VIOLATION` when fewer than `passwordMinClasses` are used.
2099+
*/
2100+
private async assertPasswordComplexity(password: string): Promise<void> {
2101+
if (!this.config.passwordRequireComplexity) return;
2102+
const min = Math.min(4, Math.max(1, Math.floor(Number(this.config.passwordMinClasses) || 3)));
2103+
const classes =
2104+
(/[a-z]/.test(password) ? 1 : 0) +
2105+
(/[A-Z]/.test(password) ? 1 : 0) +
2106+
(/[0-9]/.test(password) ? 1 : 0) +
2107+
(/[^A-Za-z0-9]/.test(password) ? 1 : 0);
2108+
if (classes < min) {
2109+
const { APIError } = await import('better-auth/api');
2110+
throw new APIError('BAD_REQUEST', {
2111+
message:
2112+
`Password must include at least ${min} of: uppercase, lowercase, ` +
2113+
'digit, symbol.',
2114+
code: 'PASSWORD_POLICY_VIOLATION',
2115+
});
2116+
}
2117+
}
2118+
20642119
/**
20652120
* ADR-0069 D2 — throw `ACCOUNT_LOCKED` when the identity is currently locked
20662121
* out (brute-force protection). No-op when lockout is disabled

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,31 @@ describe('AuthPlugin', () => {
648648
expect((manager as any).config.plugins?.passwordRejectBreached).toBeUndefined();
649649
});
650650

651+
it('binds password complexity settings (ADR-0069 D1)', async () => {
652+
const { manager } = await bootWithAuthSettings({
653+
password_require_complexity: { value: true, source: 'global' },
654+
password_min_classes: { value: 4, source: 'global' },
655+
});
656+
const cfg = (manager as any).config;
657+
expect(cfg.passwordRequireComplexity).toBe(true);
658+
expect(cfg.passwordMinClasses).toBe(4);
659+
});
660+
661+
it('clamps password_min_classes into [1,4]', async () => {
662+
const { manager } = await bootWithAuthSettings({
663+
password_require_complexity: { value: true, source: 'global' },
664+
password_min_classes: { value: 9, source: 'global' },
665+
});
666+
expect((manager as any).config.passwordMinClasses).toBe(4);
667+
});
668+
669+
it('does not set complexity flags when default-source', async () => {
670+
const { manager } = await bootWithAuthSettings({
671+
password_require_complexity: { value: false, source: 'default' },
672+
});
673+
expect((manager as any).config.passwordRequireComplexity).toBeUndefined();
674+
});
675+
651676
it('binds account-lockout settings (ADR-0069 D2)', async () => {
652677
const { manager } = await bootWithAuthSettings({
653678
lockout_threshold: { value: 5, source: 'global' },

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,16 @@ export class AuthPlugin implements Plugin {
497497
} as AuthManagerOptions['plugins'];
498498
}
499499

500+
// Password complexity (ADR-0069 D1) — custom validator in the before
501+
// hook (better-auth only enforces length). Only explicit values apply.
502+
if (isExplicit('password_require_complexity')) {
503+
patch.passwordRequireComplexity = asBoolean(values.password_require_complexity, false);
504+
}
505+
if (isExplicit('password_min_classes')) {
506+
const n = asPositiveInt(values.password_min_classes);
507+
if (n !== undefined) patch.passwordMinClasses = Math.min(4, Math.max(1, n));
508+
}
509+
500510
// Session lifetime — days → seconds for better-auth's `session`
501511
// (`expiresIn` = absolute lifetime; `updateAge` = refresh threshold).
502512
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
@@ -26,6 +26,7 @@ describe('authSettingsManifest', () => {
2626
'email_password_enabled',
2727
'google_enabled',
2828
'password_reject_breached',
29+
'password_require_complexity',
2930
'require_email_verification',
3031
'signup_enabled',
3132
]);

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,27 @@ const manifest = {
8585
'Block passwords found in public breach corpora via Have I Been Pwned (k-anonymity range check; the password is never sent in full).',
8686
visible: "${data.email_password_enabled !== false}",
8787
},
88+
{
89+
type: 'toggle',
90+
key: 'password_require_complexity',
91+
label: 'Require complex passwords',
92+
required: false,
93+
default: false,
94+
description:
95+
'Require passwords to mix character classes (uppercase, lowercase, digits, symbols) on sign-up and password change/reset.',
96+
visible: "${data.email_password_enabled !== false}",
97+
},
98+
{
99+
type: 'number',
100+
key: 'password_min_classes',
101+
label: 'Minimum character classes',
102+
required: false,
103+
default: 3,
104+
min: 1,
105+
max: 4,
106+
description: 'How many of the four classes (upper / lower / digit / symbol) a password must include.',
107+
visible: "${data.email_password_enabled !== false && data.password_require_complexity === true}",
108+
},
88109

89110
{
90111
type: 'group',

0 commit comments

Comments
 (0)