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
10 changes: 10 additions & 0 deletions .changeset/adr-0069-d1-password-complexity.md
Original file line number Diff line number Diff line change
@@ -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).
47 changes: 47 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
});
});
});
});
55 changes: 55 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,18 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
/** 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`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<void> {
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
Expand Down
25 changes: 25 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
10 changes: 10 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } = {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ describe('authSettingsManifest', () => {
'email_password_enabled',
'google_enabled',
'password_reject_breached',
'password_require_complexity',
'require_email_verification',
'signup_enabled',
]);
Expand Down
21 changes: 21 additions & 0 deletions packages/services/service-settings/src/manifests/auth.manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down