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
15 changes: 15 additions & 0 deletions .changeset/auth-security-settings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@objectstack/service-settings": minor
"@objectstack/plugin-auth": minor
---

feat(auth): password-policy & session settings — live, enforced (P0 security)

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):

- **Password policy** — `password_min_length` (default 8), `password_max_length` (default 128). Enforced by better-auth on sign-up and password reset.
- **Sessions** — `session_expiry_days` (default 7, absolute lifetime), `session_refresh_days` (default 1, refresh threshold).

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.

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.
26 changes: 26 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,32 @@ describe('AuthPlugin', () => {
expect(manager.getPublicConfig().emailPassword.requireEmailVerification).toBe(true);
});

it('applies password-policy bounds and session lifetime (days → seconds)', async () => {
const { manager } = await bootWithAuthSettings({
password_min_length: { value: 12, source: 'global' },
password_max_length: { value: 200, source: 'global' },
session_expiry_days: { value: 30, source: 'global' },
session_refresh_days: { value: 3, source: 'global' },
});
const cfg = (manager as any).config;
expect(cfg.emailAndPassword.minPasswordLength).toBe(12);
expect(cfg.emailAndPassword.maxPasswordLength).toBe(200);
expect(cfg.session.expiresIn).toBe(30 * 86_400);
expect(cfg.session.updateAge).toBe(3 * 86_400);
});

it('ignores unset (default-source) and malformed password/session values', async () => {
const { manager } = await bootWithAuthSettings({
password_min_length: { value: 8, source: 'default' }, // not explicit → ignored
session_expiry_days: { value: 'abc', source: 'global' }, // malformed → ignored
session_refresh_days: { value: 0, source: 'global' }, // non-positive → ignored
});
const cfg = (manager as any).config;
expect(cfg.emailAndPassword?.minPasswordLength).toBeUndefined();
expect(cfg.session?.expiresIn).toBeUndefined();
expect(cfg.session?.updateAge).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
29 changes: 29 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,10 @@ export class AuthPlugin implements Plugin {
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
};
const asPositiveInt = (value: unknown): number | undefined => {
const n = Math.floor(Number(value));
return Number.isFinite(n) && n > 0 ? n : undefined;
};

const patch: Partial<AuthManagerOptions> = {};
const emailAndPassword: Partial<NonNullable<AuthConfig['emailAndPassword']>> = {};
Expand All @@ -408,10 +412,35 @@ export class AuthPlugin implements Plugin {
false,
);
}
// Password policy — better-auth enforces these bounds on sign-up and
// password reset. Ignore malformed/non-positive values (keep the default).
if (isExplicit('password_min_length')) {
const n = asPositiveInt(values.password_min_length);
if (n !== undefined) emailAndPassword.minPasswordLength = n;
}
if (isExplicit('password_max_length')) {
const n = asPositiveInt(values.password_max_length);
if (n !== undefined) emailAndPassword.maxPasswordLength = n;
}
if (Object.keys(emailAndPassword).length > 0) {
patch.emailAndPassword = emailAndPassword as AuthManagerOptions['emailAndPassword'];
}

// Session lifetime — days → seconds for better-auth's `session`
// (`expiresIn` = absolute lifetime; `updateAge` = refresh threshold).
const session: { expiresIn?: number; updateAge?: number } = {};
if (isExplicit('session_expiry_days')) {
const d = asPositiveInt(values.session_expiry_days);
if (d !== undefined) session.expiresIn = d * 86_400;
}
if (isExplicit('session_refresh_days')) {
const d = asPositiveInt(values.session_refresh_days);
if (d !== undefined) session.updateAge = d * 86_400;
}
if (Object.keys(session).length > 0) {
patch.session = session as AuthManagerOptions['session'];
}

if (
isExplicit('google_enabled') ||
isExplicit('google_client_id') ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,25 @@ describe('authSettingsManifest', () => {
]);
});

it('exposes password-policy + session number fields with bounds and defaults', () => {
const specs = authSettingsManifest.specifiers as any[];
const byKey = (k: string) => specs.find((s) => s.key === k);

const min = byKey('password_min_length');
expect(min.type).toBe('number');
expect(min.default).toBe(8);
expect(min.min).toBe(6);
expect(min.max).toBe(64);

expect(byKey('password_max_length').default).toBe(128);
expect(byKey('session_expiry_days').default).toBe(7);
expect(byKey('session_refresh_days').default).toBe(1);

const groups = specs.filter((s) => s.type === 'group').map((s) => s.id);
expect(groups).toContain('password_policy');
expect(groups).toContain('sessions');
});

it('exposes encrypted Google OAuth credential fields', () => {
const keys = (authSettingsManifest.specifiers as any[])
.map((s) => s.key)
Expand Down
57 changes: 57 additions & 0 deletions packages/services/service-settings/src/manifests/auth.manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,63 @@ const manifest = {
visible: "${data.email_password_enabled !== false}",
},

{
type: 'group',
id: 'password_policy',
label: 'Password policy',
required: false,
description: 'Length bounds enforced by the auth provider on sign-up and password reset.',
},
{
type: 'number',
key: 'password_min_length',
label: 'Minimum password length',
required: false,
default: 8,
min: 6,
max: 64,
visible: "${data.email_password_enabled !== false}",
},
{
type: 'number',
key: 'password_max_length',
label: 'Maximum password length',
required: false,
default: 128,
min: 16,
max: 256,
description: 'Upper bound guards against denial-of-service via very long password hashing.',
visible: "${data.email_password_enabled !== false}",
},

{
type: 'group',
id: 'sessions',
label: 'Sessions',
required: false,
description: 'How long a signed-in session stays valid.',
},
{
type: 'number',
key: 'session_expiry_days',
label: 'Session lifetime (days)',
required: false,
default: 7,
min: 1,
max: 365,
description: 'A session expires this many days after sign-in.',
},
{
type: 'number',
key: 'session_refresh_days',
label: 'Refresh threshold (days)',
required: false,
default: 1,
min: 1,
max: 90,
description: 'An active session is extended when it is older than this.',
},

{
type: 'group',
id: 'social',
Expand Down
12 changes: 12 additions & 0 deletions packages/services/service-settings/src/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,14 @@ export const en: TranslationData = {
title: 'Email and password',
description: 'Control local email/password sign-in and self-service registration.',
},
password_policy: {
title: 'Password policy',
description: 'Length bounds enforced by the auth provider on sign-up and password reset.',
},
sessions: {
title: 'Sessions',
description: 'How long a signed-in session stays valid.',
},
social: {
title: 'Social sign-in',
description:
Expand All @@ -136,6 +144,10 @@ export const en: TranslationData = {
email_password_enabled: { label: 'Enable email/password login' },
signup_enabled: { label: 'Allow self-service registration' },
require_email_verification: { label: 'Require email verification' },
password_min_length: { label: 'Minimum password length' },
password_max_length: { label: 'Maximum password length', help: 'Guards against denial-of-service via very long password hashing.' },
session_expiry_days: { label: 'Session lifetime (days)', help: 'A session expires this many days after sign-in.' },
session_refresh_days: { label: 'Refresh threshold (days)', help: 'An active session is extended when it is older than this.' },
google_enabled: {
label: 'Enable Google login',
help: 'Requires a Google OAuth client ID and secret from Google Cloud Console.',
Expand Down
12 changes: 12 additions & 0 deletions packages/services/service-settings/src/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,14 @@ export const zhCN: TranslationData = {
title: '邮箱与密码',
description: '控制本地邮箱/密码登录与自助注册。',
},
password_policy: {
title: '密码策略',
description: '由认证提供商在注册和重置密码时强制的长度限制。',
},
sessions: {
title: '会话',
description: '登录会话的有效时长。',
},
social: {
title: '社交登录',
description: '配置内置的 Google 登录提供商。部署环境变量仍优先生效。',
Expand All @@ -131,6 +139,10 @@ export const zhCN: TranslationData = {
email_password_enabled: { label: '启用邮箱/密码登录' },
signup_enabled: { label: '允许自助注册' },
require_email_verification: { label: '要求邮箱验证' },
password_min_length: { label: '密码最小长度' },
password_max_length: { label: '密码最大长度', help: '防止超长密码哈希导致的拒绝服务。' },
session_expiry_days: { label: '会话有效期(天)', help: '登录后会话在此天数后过期。' },
session_refresh_days: { label: '刷新阈值(天)', help: '活跃会话在超过此时长后自动续期。' },
google_enabled: {
label: '启用 Google 登录',
help: '需要在 Google Cloud Console 中创建的 Google OAuth 客户端 ID 与密钥。',
Expand Down
Loading