Skip to content

Commit fdb5f3b

Browse files
os-zhuangclaude
andcommitted
feat(auth): password-policy & session settings — live, enforced (P0 security)
Extends the existing `auth` settings manifest (global scope) with the security keys that are genuinely enforced today, instead of a new `security` namespace full of non-functional toggles (false surface). - Password policy: password_min_length (8), password_max_length (128) — enforced by better-auth on sign-up / password reset. - Sessions: session_expiry_days (7), session_refresh_days (1). Rides the existing AuthPlugin.bindAuthSettings → AuthManager.applyConfigPatch path (read on kernel:ready, re-applied live via settings.subscribe('auth'), which invalidates the cached better-auth instance). Days → seconds for session.{expiresIn,updateAge}; unset/malformed values ignored so the provider default holds. en + zh-CN translations. Out of scope (no enforcement → not declared): MFA-required, IP allowlist, SSO/SAML, SCIM, API rate limits, password complexity/rotation/history. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8e5a3b5 commit fdb5f3b

7 files changed

Lines changed: 170 additions & 0 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/service-settings": minor
3+
"@objectstack/plugin-auth": minor
4+
---
5+
6+
feat(auth): password-policy & session settings — live, enforced (P0 security)
7+
8+
Extends the existing `auth` settings manifest (global scope) with the security policy keys that are **genuinely enforced today**, rather than standing up a new `security` namespace full of non-functional toggles (which would be false surface):
9+
10+
- **Password policy**`password_min_length` (default 8), `password_max_length` (default 128). Enforced by better-auth on sign-up and password reset.
11+
- **Sessions**`session_expiry_days` (default 7, absolute lifetime), `session_refresh_days` (default 1, refresh threshold).
12+
13+
These ride the existing `AuthPlugin.bindAuthSettings``AuthManager.applyConfigPatch` path (read on `kernel:ready`, re-applied live via `settings.subscribe('auth')`, which invalidates the cached better-auth instance). Days are converted to seconds for better-auth's `session.{expiresIn,updateAge}`; unset (`source: 'default'`) and malformed/non-positive values are ignored so the provider default holds. Ships en + zh-CN translations.
14+
15+
Deliberately **out of scope** (no enforcement exists, so they're not declared as settings): MFA-required, IP allowlist, SSO/SAML, SCIM, API rate limits, password complexity/rotation/history. These are real features to be built, not settings toggles.

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,32 @@ describe('AuthPlugin', () => {
608608
expect(manager.getPublicConfig().emailPassword.requireEmailVerification).toBe(true);
609609
});
610610

611+
it('applies password-policy bounds and session lifetime (days → seconds)', async () => {
612+
const { manager } = await bootWithAuthSettings({
613+
password_min_length: { value: 12, source: 'global' },
614+
password_max_length: { value: 200, source: 'global' },
615+
session_expiry_days: { value: 30, source: 'global' },
616+
session_refresh_days: { value: 3, source: 'global' },
617+
});
618+
const cfg = (manager as any).config;
619+
expect(cfg.emailAndPassword.minPasswordLength).toBe(12);
620+
expect(cfg.emailAndPassword.maxPasswordLength).toBe(200);
621+
expect(cfg.session.expiresIn).toBe(30 * 86_400);
622+
expect(cfg.session.updateAge).toBe(3 * 86_400);
623+
});
624+
625+
it('ignores unset (default-source) and malformed password/session values', async () => {
626+
const { manager } = await bootWithAuthSettings({
627+
password_min_length: { value: 8, source: 'default' }, // not explicit → ignored
628+
session_expiry_days: { value: 'abc', source: 'global' }, // malformed → ignored
629+
session_refresh_days: { value: 0, source: 'global' }, // non-positive → ignored
630+
});
631+
const cfg = (manager as any).config;
632+
expect(cfg.emailAndPassword?.minPasswordLength).toBeUndefined();
633+
expect(cfg.session?.expiresIn).toBeUndefined();
634+
expect(cfg.session?.updateAge).toBeUndefined();
635+
});
636+
611637
it('enables Google from env credentials when google_enabled is explicit true', async () => {
612638
process.env.GOOGLE_CLIENT_ID = 'google-env-client-id';
613639
process.env.GOOGLE_CLIENT_SECRET = 'google-env-client-secret';

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,10 @@ export class AuthPlugin implements Plugin {
393393
const trimmed = value.trim();
394394
return trimmed ? trimmed : undefined;
395395
};
396+
const asPositiveInt = (value: unknown): number | undefined => {
397+
const n = Math.floor(Number(value));
398+
return Number.isFinite(n) && n > 0 ? n : undefined;
399+
};
396400

397401
const patch: Partial<AuthManagerOptions> = {};
398402
const emailAndPassword: Partial<NonNullable<AuthConfig['emailAndPassword']>> = {};
@@ -408,10 +412,35 @@ export class AuthPlugin implements Plugin {
408412
false,
409413
);
410414
}
415+
// Password policy — better-auth enforces these bounds on sign-up and
416+
// password reset. Ignore malformed/non-positive values (keep the default).
417+
if (isExplicit('password_min_length')) {
418+
const n = asPositiveInt(values.password_min_length);
419+
if (n !== undefined) emailAndPassword.minPasswordLength = n;
420+
}
421+
if (isExplicit('password_max_length')) {
422+
const n = asPositiveInt(values.password_max_length);
423+
if (n !== undefined) emailAndPassword.maxPasswordLength = n;
424+
}
411425
if (Object.keys(emailAndPassword).length > 0) {
412426
patch.emailAndPassword = emailAndPassword as AuthManagerOptions['emailAndPassword'];
413427
}
414428

429+
// Session lifetime — days → seconds for better-auth's `session`
430+
// (`expiresIn` = absolute lifetime; `updateAge` = refresh threshold).
431+
const session: { expiresIn?: number; updateAge?: number } = {};
432+
if (isExplicit('session_expiry_days')) {
433+
const d = asPositiveInt(values.session_expiry_days);
434+
if (d !== undefined) session.expiresIn = d * 86_400;
435+
}
436+
if (isExplicit('session_refresh_days')) {
437+
const d = asPositiveInt(values.session_refresh_days);
438+
if (d !== undefined) session.updateAge = d * 86_400;
439+
}
440+
if (Object.keys(session).length > 0) {
441+
patch.session = session as AuthManagerOptions['session'];
442+
}
443+
415444
if (
416445
isExplicit('google_enabled') ||
417446
isExplicit('google_client_id') ||

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,25 @@ describe('authSettingsManifest', () => {
3030
]);
3131
});
3232

33+
it('exposes password-policy + session number fields with bounds and defaults', () => {
34+
const specs = authSettingsManifest.specifiers as any[];
35+
const byKey = (k: string) => specs.find((s) => s.key === k);
36+
37+
const min = byKey('password_min_length');
38+
expect(min.type).toBe('number');
39+
expect(min.default).toBe(8);
40+
expect(min.min).toBe(6);
41+
expect(min.max).toBe(64);
42+
43+
expect(byKey('password_max_length').default).toBe(128);
44+
expect(byKey('session_expiry_days').default).toBe(7);
45+
expect(byKey('session_refresh_days').default).toBe(1);
46+
47+
const groups = specs.filter((s) => s.type === 'group').map((s) => s.id);
48+
expect(groups).toContain('password_policy');
49+
expect(groups).toContain('sessions');
50+
});
51+
3352
it('exposes encrypted Google OAuth credential fields', () => {
3453
const keys = (authSettingsManifest.specifiers as any[])
3554
.map((s) => s.key)

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

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,63 @@ const manifest = {
4747
visible: "${data.email_password_enabled !== false}",
4848
},
4949

50+
{
51+
type: 'group',
52+
id: 'password_policy',
53+
label: 'Password policy',
54+
required: false,
55+
description: 'Length bounds enforced by the auth provider on sign-up and password reset.',
56+
},
57+
{
58+
type: 'number',
59+
key: 'password_min_length',
60+
label: 'Minimum password length',
61+
required: false,
62+
default: 8,
63+
min: 6,
64+
max: 64,
65+
visible: "${data.email_password_enabled !== false}",
66+
},
67+
{
68+
type: 'number',
69+
key: 'password_max_length',
70+
label: 'Maximum password length',
71+
required: false,
72+
default: 128,
73+
min: 16,
74+
max: 256,
75+
description: 'Upper bound guards against denial-of-service via very long password hashing.',
76+
visible: "${data.email_password_enabled !== false}",
77+
},
78+
79+
{
80+
type: 'group',
81+
id: 'sessions',
82+
label: 'Sessions',
83+
required: false,
84+
description: 'How long a signed-in session stays valid.',
85+
},
86+
{
87+
type: 'number',
88+
key: 'session_expiry_days',
89+
label: 'Session lifetime (days)',
90+
required: false,
91+
default: 7,
92+
min: 1,
93+
max: 365,
94+
description: 'A session expires this many days after sign-in.',
95+
},
96+
{
97+
type: 'number',
98+
key: 'session_refresh_days',
99+
label: 'Refresh threshold (days)',
100+
required: false,
101+
default: 1,
102+
min: 1,
103+
max: 90,
104+
description: 'An active session is extended when it is older than this.',
105+
},
106+
50107
{
51108
type: 'group',
52109
id: 'social',

packages/services/service-settings/src/translations/en.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,14 @@ export const en: TranslationData = {
126126
title: 'Email and password',
127127
description: 'Control local email/password sign-in and self-service registration.',
128128
},
129+
password_policy: {
130+
title: 'Password policy',
131+
description: 'Length bounds enforced by the auth provider on sign-up and password reset.',
132+
},
133+
sessions: {
134+
title: 'Sessions',
135+
description: 'How long a signed-in session stays valid.',
136+
},
129137
social: {
130138
title: 'Social sign-in',
131139
description:
@@ -136,6 +144,10 @@ export const en: TranslationData = {
136144
email_password_enabled: { label: 'Enable email/password login' },
137145
signup_enabled: { label: 'Allow self-service registration' },
138146
require_email_verification: { label: 'Require email verification' },
147+
password_min_length: { label: 'Minimum password length' },
148+
password_max_length: { label: 'Maximum password length', help: 'Guards against denial-of-service via very long password hashing.' },
149+
session_expiry_days: { label: 'Session lifetime (days)', help: 'A session expires this many days after sign-in.' },
150+
session_refresh_days: { label: 'Refresh threshold (days)', help: 'An active session is extended when it is older than this.' },
139151
google_enabled: {
140152
label: 'Enable Google login',
141153
help: 'Requires a Google OAuth client ID and secret from Google Cloud Console.',

packages/services/service-settings/src/translations/zh-CN.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,14 @@ export const zhCN: TranslationData = {
122122
title: '邮箱与密码',
123123
description: '控制本地邮箱/密码登录与自助注册。',
124124
},
125+
password_policy: {
126+
title: '密码策略',
127+
description: '由认证提供商在注册和重置密码时强制的长度限制。',
128+
},
129+
sessions: {
130+
title: '会话',
131+
description: '登录会话的有效时长。',
132+
},
125133
social: {
126134
title: '社交登录',
127135
description: '配置内置的 Google 登录提供商。部署环境变量仍优先生效。',
@@ -131,6 +139,10 @@ export const zhCN: TranslationData = {
131139
email_password_enabled: { label: '启用邮箱/密码登录' },
132140
signup_enabled: { label: '允许自助注册' },
133141
require_email_verification: { label: '要求邮箱验证' },
142+
password_min_length: { label: '密码最小长度' },
143+
password_max_length: { label: '密码最大长度', help: '防止超长密码哈希导致的拒绝服务。' },
144+
session_expiry_days: { label: '会话有效期(天)', help: '登录后会话在此天数后过期。' },
145+
session_refresh_days: { label: '刷新阈值(天)', help: '活跃会话在超过此时长后自动续期。' },
134146
google_enabled: {
135147
label: '启用 Google 登录',
136148
help: '需要在 Google Cloud Console 中创建的 Google OAuth 客户端 ID 与密钥。',

0 commit comments

Comments
 (0)