diff --git a/.changeset/adr-0069-d2-account-lockout.md b/.changeset/adr-0069-d2-account-lockout.md new file mode 100644 index 0000000000..223e415309 --- /dev/null +++ b/.changeset/adr-0069-d2-account-lockout.md @@ -0,0 +1,16 @@ +--- +'@objectstack/platform-objects': minor +'@objectstack/service-settings': minor +'@objectstack/plugin-auth': minor +'@objectstack/cli': minor +--- + +Auth: account lockout + rate-limit tuning (ADR-0069 D2, P1) + +Second slice of ADR-0069 — per-identity brute-force protection, reusing the setting→enforcement pattern from the HIBP PR. + +- **Account lockout** `[custom][field]`: new `sys_user.failed_login_count` / `sys_user.locked_until` columns; `auth` settings `lockout_threshold` (0 = off) + `lockout_duration_minutes`. Enforced in the `/sign-in/email` before/after hooks — failures increment the counter, crossing the threshold stamps `locked_until`, and a locked account is rejected **even with the correct password** (survives IP rotation, unlike rate limiting). A successful sign-in resets both. +- **Admin Unlock**: new admin-guarded `POST /api/v1/auth/admin/unlock-user` route + an `unlock_user` action on `sys_user`. +- **Rate-limit tuning** `[native]`: `auth` settings `rate_limit_max` / `rate_limit_window_seconds` wire better-auth's core `rateLimit` with stricter `customRules` for `/sign-in/email`, `/sign-up/email`, `/request-password-reset`, `/reset-password`. + +All settings default off / to safe values; additive (no upgrade behavior change). Per ADR-0049 each setting ships with its enforcement. Timestamps are written as `Date` (never epoch-ms) per ADR-0074. diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 7254095169..bbf6b1ff55 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -97,6 +97,21 @@ export const SysUser = ObjectSchema.create({ successMessage: 'User unbanned', refreshAfter: true, }, + { + // ADR-0069 D2 — clear a brute-force lockout early (locked_until auto- + // expires, but an admin can release a user immediately). Hits the + // plugin-auth custom route, which is admin-guarded server-side. + name: 'unlock_user', + label: 'Unlock Account', + icon: 'lock-open', + variant: 'secondary', + locations: ['list_item'], + type: 'api', + target: '/api/v1/auth/admin/unlock-user', + recordIdParam: 'userId', + successMessage: 'Account unlocked', + refreshAfter: true, + }, { name: 'set_user_password', label: 'Set Password', @@ -410,6 +425,28 @@ export const SysUser = ObjectSchema.create({ description: 'When set, the ban auto-clears at this time.', }), + // ── Anti-brute-force (ADR-0069 D2) — owned by objectql, better-auth is + // oblivious. The auth manager's sign-in hooks maintain these: failures + // increment the counter; crossing `lockout_threshold` stamps + // `locked_until`; a successful sign-in resets both. Admins can clear + // them early via the Unlock action. + failed_login_count: Field.number({ + label: 'Failed Login Count', + required: false, + defaultValue: 0, + readonly: true, + group: 'Admin', + description: 'Consecutive failed sign-in attempts; reset to 0 on success. Maintained by the auth manager.', + }), + + locked_until: Field.datetime({ + label: 'Locked Until', + required: false, + readonly: true, + group: 'Admin', + description: 'When set and in the future, sign-in is rejected (brute-force lockout). Auto-clears past this time; an admin can clear it early via Unlock.', + }), + ai_access: Field.boolean({ label: 'AI Access', defaultValue: false, diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index cfd14c985a..dd7d2c6429 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -1594,4 +1594,141 @@ describe('AuthManager', () => { } }); }); + + // ADR-0069 D2: per-identity account lockout + native rate-limit passthrough. + // The lockout state machine is exercised directly via the AuthManager helpers + // with a mocked data engine (deterministic; the live multi-failure path is + // covered by the dogfood smoke). + describe('account lockout + rate limiting (ADR-0069 D2)', () => { + const SECRET = 'test-secret-at-least-32-chars-long'; + // findOne honours the `where` clause (the ObjectQL engine key) — so a + // regression to the wrong key (`filter`, silently ignored → returns the + // first/arbitrary row) makes these tests fail, not pass. Caught a real bug + // in dogfood that a query-agnostic mock had masked. + const makeEngine = (user: any) => ({ + findOne: vi.fn(async (_obj: string, q: any) => { + const w = q?.where ?? {}; + if (!user) return null; + const matches = Object.entries(w).every(([k, v]) => (user as any)[k] === v); + return matches ? user : null; + }), + update: vi.fn(async () => ({ ...(user ?? {}) })), + count: vi.fn(), + }); + const mgr = (engine: any, extra: any = {}) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const m = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', dataEngine: engine, ...extra }); + warn.mockRestore(); + return m; + }; + + it('assertAccountNotLocked is a no-op when lockout is disabled (threshold 0)', async () => { + const engine = makeEngine({ id: 'u1', email: 'a@b.com', locked_until: new Date(Date.now() + 60_000).toISOString() }); + const m = mgr(engine, { lockoutThreshold: 0 }); + await expect((m as any).assertAccountNotLocked('a@b.com')).resolves.toBeUndefined(); + expect(engine.findOne).not.toHaveBeenCalled(); + }); + + it('assertAccountNotLocked throws ACCOUNT_LOCKED while locked_until is in the future', async () => { + const engine = makeEngine({ id: 'u1', email: 'a@b.com', locked_until: new Date(Date.now() + 60_000).toISOString() }); + const m = mgr(engine, { lockoutThreshold: 3 }); + await expect((m as any).assertAccountNotLocked('a@b.com')).rejects.toMatchObject({ + body: { code: 'ACCOUNT_LOCKED' }, + }); + }); + + it('assertAccountNotLocked allows sign-in once the lock has expired', async () => { + const engine = makeEngine({ id: 'u1', email: 'a@b.com', locked_until: new Date(Date.now() - 60_000).toISOString() }); + const m = mgr(engine, { lockoutThreshold: 3 }); + await expect((m as any).assertAccountNotLocked('a@b.com')).resolves.toBeUndefined(); + }); + + it('recordSignInOutcome increments below threshold without locking', async () => { + const engine = makeEngine({ id: 'u1', email: 'a@b.com', failed_login_count: 0, locked_until: null }); + const m = mgr(engine, { lockoutThreshold: 3 }); + await (m as any).recordSignInOutcome('a@b.com', false); + const patch = engine.update.mock.calls[0][1]; + expect(patch.failed_login_count).toBe(1); + expect(patch.locked_until).toBeUndefined(); + }); + + it('recordSignInOutcome stamps locked_until (a Date) once the threshold is reached', async () => { + const engine = makeEngine({ id: 'u1', email: 'a@b.com', failed_login_count: 2, locked_until: null }); + const m = mgr(engine, { lockoutThreshold: 3, lockoutDurationMinutes: 15 }); + await (m as any).recordSignInOutcome('a@b.com', false); + const patch = engine.update.mock.calls[0][1]; + expect(patch.failed_login_count).toBe(3); + expect(patch.locked_until instanceof Date).toBe(true); + // ~15 minutes out (datetime stored as a Date, never epoch-ms — see ADR-0074). + expect((patch.locked_until as Date).getTime()).toBeGreaterThan(Date.now() + 14 * 60_000); + }); + + it('recordSignInOutcome resets counter + lock on success when there is state to clear', async () => { + const engine = makeEngine({ id: 'u1', email: 'a@b.com', failed_login_count: 2, locked_until: null }); + const m = mgr(engine, { lockoutThreshold: 3 }); + await (m as any).recordSignInOutcome('a@b.com', true); + expect(engine.update).toHaveBeenCalledWith( + 'sys_user', + { id: 'u1', failed_login_count: 0, locked_until: null }, + expect.anything(), + ); + }); + + it('recordSignInOutcome skips the write on success when nothing needs clearing', async () => { + const engine = makeEngine({ id: 'u1', email: 'a@b.com', failed_login_count: 0, locked_until: null }); + const m = mgr(engine, { lockoutThreshold: 3 }); + await (m as any).recordSignInOutcome('a@b.com', true); + expect(engine.update).not.toHaveBeenCalled(); + }); + + it('recordSignInOutcome is a no-op when lockout is disabled', async () => { + const engine = makeEngine({ id: 'u1', email: 'a@b.com', failed_login_count: 5 }); + const m = mgr(engine, { lockoutThreshold: 0 }); + await (m as any).recordSignInOutcome('a@b.com', false); + expect(engine.update).not.toHaveBeenCalled(); + }); + + it('unlockUser clears failed_login_count and locked_until', async () => { + const engine = makeEngine({ id: 'u1' }); + const m = mgr(engine); + await expect(m.unlockUser('u1')).resolves.toBe(true); + expect(engine.update).toHaveBeenCalledWith( + 'sys_user', + { id: 'u1', failed_login_count: 0, locked_until: null }, + expect.anything(), + ); + }); + + it('unlockUser returns false for an unknown user', async () => { + const engine = makeEngine(null); + const m = mgr(engine); + await expect(m.unlockUser('nope')).resolves.toBe(false); + expect(engine.update).not.toHaveBeenCalled(); + }); + + it('passes a configured rateLimit through to betterAuth', async () => { + let captured: any; + (betterAuth as any).mockImplementation((cfg: any) => { captured = cfg; return { handler: vi.fn(), api: {} }; }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const m = new AuthManager({ + secret: SECRET, + baseUrl: 'http://localhost:3000', + rateLimit: { enabled: true, window: 60, max: 10, customRules: { '/sign-in/email': { window: 60, max: 10 } } } as any, + }); + await m.getAuthInstance(); + warn.mockRestore(); + expect(captured.rateLimit).toMatchObject({ enabled: true, max: 10, window: 60 }); + expect(captured.rateLimit.customRules['/sign-in/email']).toEqual({ window: 60, max: 10 }); + }); + + it('omits rateLimit from the betterAuth config when unset (keeps library defaults)', async () => { + let captured: any; + (betterAuth as any).mockImplementation((cfg: any) => { captured = cfg; return { handler: vi.fn(), api: {} }; }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const m = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000' }); + await m.getAuthInstance(); + warn.mockRestore(); + expect(captured).not.toHaveProperty('rateLimit'); + }); + }); }); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index e8c653dbb8..1160163316 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -269,6 +269,26 @@ export interface AuthManagerOptions extends Partial { * "create organization" screen. */ databaseHooks?: BetterAuthOptions['databaseHooks']; + + /** + * ADR-0069 D2 — account lockout (anti-brute-force). After this many + * consecutive failed sign-ins the account is locked for + * {@link lockoutDurationMinutes}. `0` (default) disables lockout. + * Enforced per-identity in the `/sign-in/email` before/after hooks + * (survives IP rotation, unlike the per-IP {@link rateLimit}). + */ + lockoutThreshold?: number; + + /** Minutes an account stays locked once the threshold is crossed. Default 15. */ + lockoutDurationMinutes?: number; + + /** + * ADR-0069 D2 — better-auth-native per-IP rate limiting, passed through to + * better-auth's core `rateLimit`. The settings bind tightens `customRules` + * for the auth endpoints (`/sign-in/email`, `/sign-up/email`, + * `/reset-password`). Multi-node deployments need a shared `storage`. + */ + rateLimit?: BetterAuthOptions['rateLimit']; } /** @@ -531,6 +551,11 @@ export class AuthManager { updateAge: this.config.session?.updateAge || 60 * 60 * 24, // 1 day default }, + // ADR-0069 D2 — per-IP rate limiting (native). Only set when configured + // so better-auth keeps its own defaults otherwise. The settings bind + // supplies stricter `customRules` for the auth endpoints. + ...(this.config.rateLimit ? { rateLimit: this.config.rateLimit } : {}), + // better-auth plugins — registered based on AuthPluginConfig flags plugins, @@ -675,6 +700,15 @@ export class AuthManager { // fall through to better-auth's own handler } + // ── ADR-0069 D2: account lockout (gate) ───────────────────── + // Reject a sign-in for a locked identity BEFORE better-auth checks + // the password — a lock must hold even against the correct password. + if (ctx?.path === '/sign-in/email') { + const email = typeof ctx?.body?.email === 'string' ? ctx.body.email : ''; + if (email) await this.assertAccountNotLocked(email); + return; + } + if (ctx?.path !== '/sign-up/email') return; const ep = ctx?.context?.options?.emailAndPassword; if (!ep?.disableSignUp) return; @@ -690,6 +724,25 @@ export class AuthManager { } }), after: createAuthMiddleware(async (ctx: any) => { + // ── ADR-0069 D2: account lockout (counter) ────────────────── + // better-auth catches an INVALID_EMAIL_OR_PASSWORD APIError and runs + // the after-hook with it on `ctx.context.returned`; a success leaves + // the session payload there. Count failures, reset on success. + if (ctx?.path === '/sign-in/email') { + const email = typeof ctx?.body?.email === 'string' ? ctx.body.email : ''; + if (email) { + let succeeded = true; + try { + const { isAPIError } = await import('better-auth/api'); + succeeded = !isAPIError(ctx?.context?.returned); + } catch { + succeeded = !(ctx?.context?.returned instanceof Error); + } + await this.recordSignInOutcome(email, succeeded); + } + return; + } + if (ctx?.path !== '/sign-up/email') return; const ep = ctx?.context?.options?.emailAndPassword; if (ep && ctx.context.__osDisableSignUpOrig !== undefined) { @@ -1863,6 +1916,103 @@ export class AuthManager { } } + /** + * ADR-0069 D2 — throw `ACCOUNT_LOCKED` when the identity is currently locked + * out (brute-force protection). No-op when lockout is disabled + * (`lockoutThreshold <= 0`) or no data engine is wired. Fails OPEN on a + * lookup error: an infra hiccup must never block every login. + */ + private async assertAccountNotLocked(email: string): Promise { + const threshold = Number(this.config.lockoutThreshold) || 0; + if (threshold <= 0) return; + const engine = this.getDataEngine(); + if (!engine) return; + let locked = false; + try { + const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] }; + const u = await engine.findOne('sys_user', { + where: { email }, fields: ['id', 'locked_until'], context: SYSTEM_CTX, + } as any); + const lu = u?.locked_until; + locked = !!(lu && new Date(lu).getTime() > Date.now()); + } catch { + return; // fail-open + } + if (locked) { + const { APIError } = await import('better-auth/api'); + throw new APIError('FORBIDDEN', { + message: + 'This account is temporarily locked after too many failed sign-in ' + + 'attempts. Try again later or ask an administrator to unlock it.', + code: 'ACCOUNT_LOCKED', + }); + } + } + + /** + * ADR-0069 D2 — record a sign-in outcome for lockout accounting. On failure + * increments `failed_login_count` and, once it reaches `lockoutThreshold`, + * stamps `locked_until = now + lockoutDurationMinutes`. On success resets + * both (only writing when there is something to clear, to avoid a no-op + * history row on every login). No-op when lockout is disabled. Never throws — + * a counter write must not turn a valid login into an error. + */ + private async recordSignInOutcome(email: string, success: boolean): Promise { + const threshold = Number(this.config.lockoutThreshold) || 0; + if (threshold <= 0) return; + const engine = this.getDataEngine(); + if (!engine) return; + try { + const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] }; + const u = await engine.findOne('sys_user', { + where: { email }, + fields: ['id', 'failed_login_count', 'locked_until'], + context: SYSTEM_CTX, + } as any); + if (!u?.id) return; + if (success) { + if ((Number(u.failed_login_count) || 0) !== 0 || u.locked_until) { + await engine.update( + 'sys_user', + { id: u.id, failed_login_count: 0, locked_until: null }, + { context: SYSTEM_CTX } as any, + ); + } + return; + } + const next = (Number(u.failed_login_count) || 0) + 1; + const patch: Record = { id: u.id, failed_login_count: next }; + if (next >= threshold) { + const mins = Number(this.config.lockoutDurationMinutes) || 15; + patch.locked_until = new Date(Date.now() + mins * 60_000); + } + await engine.update('sys_user', patch, { context: SYSTEM_CTX } as any); + } catch { + // Lockout accounting is best-effort — never break the auth response. + } + } + + /** + * ADR-0069 D2 — clear a user's lockout state (admin "Unlock" action). + * Resets `failed_login_count` and `locked_until`. Returns false when no data + * engine is wired or the user does not exist. + */ + public async unlockUser(userId: string): Promise { + const engine = this.getDataEngine(); + if (!engine || !userId) return false; + const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] }; + const u = await engine.findOne('sys_user', { + where: { id: userId }, fields: ['id'], context: SYSTEM_CTX, + } as any); + if (!u?.id) return false; + await engine.update( + 'sys_user', + { id: userId, failed_login_count: 0, locked_until: null }, + { context: SYSTEM_CTX } as any, + ); + return true; + } + /** * Returns the data engine wired into this auth manager. Used by route * handlers (e.g. bootstrap-status) that need to query identity tables diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index 8cdb3fa9a0..d9c2cd43ff 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -648,6 +648,46 @@ describe('AuthPlugin', () => { expect((manager as any).config.plugins?.passwordRejectBreached).toBeUndefined(); }); + it('binds account-lockout settings (ADR-0069 D2)', async () => { + const { manager } = await bootWithAuthSettings({ + lockout_threshold: { value: 5, source: 'global' }, + lockout_duration_minutes: { value: 30, source: 'global' }, + }); + const cfg = (manager as any).config; + expect(cfg.lockoutThreshold).toBe(5); + expect(cfg.lockoutDurationMinutes).toBe(30); + }); + + it('allows an explicit lockout_threshold of 0 to disable lockout', async () => { + const { manager } = await bootWithAuthSettings({ + lockout_threshold: { value: 0, source: 'global' }, + }); + // 0 is a meaningful (feature-off) value, so it must still be applied — + // unlike the positive-int session fields where 0 is rejected as malformed. + expect((manager as any).config.lockoutThreshold).toBe(0); + }); + + it('binds auth rate-limit tuning into a native rateLimit with customRules (ADR-0069 D2)', async () => { + const { manager } = await bootWithAuthSettings({ + rate_limit_max: { value: 5, source: 'global' }, + rate_limit_window_seconds: { value: 30, source: 'global' }, + }); + const rl = (manager as any).config.rateLimit; + expect(rl).toMatchObject({ enabled: true, max: 5, window: 30 }); + expect(rl.customRules['/sign-in/email']).toEqual({ window: 30, max: 5 }); + expect(rl.customRules['/reset-password']).toEqual({ window: 30, max: 5 }); + }); + + it('does not set lockout/rateLimit when those settings are default-source', async () => { + const { manager } = await bootWithAuthSettings({ + lockout_threshold: { value: 0, source: 'default' }, + rate_limit_max: { value: 10, source: 'default' }, + }); + const cfg = (manager as any).config; + expect(cfg.lockoutThreshold).toBeUndefined(); + expect(cfg.rateLimit).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'; diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 8790a3bd55..d812c04295 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -512,6 +512,40 @@ export class AuthPlugin implements Plugin { patch.session = session as AuthManagerOptions['session']; } + // Anti-abuse (ADR-0069 D2) — account lockout (custom, per-identity) + // and rate-limit tuning (better-auth-native, per-IP). `asPositiveInt` + // rejects 0/malformed; lockout_threshold uses a non-negative reader so + // an explicit 0 can turn the feature off. + const asNonNegativeInt = (value: unknown): number | undefined => { + const n = Math.floor(Number(value)); + return Number.isFinite(n) && n >= 0 ? n : undefined; + }; + if (isExplicit('lockout_threshold')) { + const n = asNonNegativeInt(values.lockout_threshold); + if (n !== undefined) patch.lockoutThreshold = n; + } + if (isExplicit('lockout_duration_minutes')) { + const n = asPositiveInt(values.lockout_duration_minutes); + if (n !== undefined) patch.lockoutDurationMinutes = n; + } + if (isExplicit('rate_limit_max') || isExplicit('rate_limit_window_seconds')) { + const max = asPositiveInt(values.rate_limit_max) ?? 10; + const window = asPositiveInt(values.rate_limit_window_seconds) ?? 60; + // Tighten the auth-mutating endpoints; better-auth keeps its own + // defaults for everything else. customRules support `*` wildcards. + patch.rateLimit = { + enabled: true, + window, + max, + customRules: { + '/sign-in/email': { window, max }, + '/sign-up/email': { window, max }, + '/request-password-reset': { window, max }, + '/reset-password': { window, max }, + }, + } as AuthManagerOptions['rateLimit']; + } + if ( isExplicit('google_enabled') || isExplicit('google_client_id') || @@ -835,6 +869,51 @@ export class AuthPlugin implements Plugin { } }); + // ──────────────────────────────────────────────────────────────────── + // ADR-0069 D2 — admin: clear a brute-force lockout on an account. + // Lockout (`sys_user.locked_until` / `failed_login_count`) is a custom, + // per-identity mechanism with no better-auth endpoint, so this route owns + // the "Unlock" affordance (sys_user `unlock_user` action). Admin-guarded + // server-side; mirrors the toggle-disabled route's session+role check. + rawApp.post(`${basePath}/admin/unlock-user`, async (c: any) => { + try { + let body: any = {}; + try { body = await c.req.json(); } catch { body = {}; } + const userId: unknown = body?.userId ?? body?.user_id; + if (typeof userId !== 'string' || userId.length === 0) { + return c.json({ success: false, error: { code: 'invalid_request', message: 'userId is required' } }, 400); + } + + const authApi = await this.authManager!.getApi(); + const session = await authApi.getSession({ headers: c.req.raw.headers }); + if (!session?.user?.id) { + return c.json({ success: false, error: { code: 'unauthorized', message: 'Sign in first' } }, 401); + } + // Platform-admin gate. Accept any of the equivalent signals the + // customSession plugin may carry (ADR-0068): the derived + // `isPlatformAdmin` alias, the canonical `platform_admin` in roles[], + // or the legacy admin-plugin `role` scalar. + const u: any = session.user; + const isAdmin = + u?.isPlatformAdmin === true || + (Array.isArray(u?.roles) && u.roles.includes('platform_admin')) || + u?.role === 'admin'; + if (!isAdmin) { + return c.json({ success: false, error: { code: 'forbidden', message: 'Admin role required' } }, 403); + } + + const ok = await this.authManager!.unlockUser(userId); + if (!ok) { + return c.json({ success: false, error: { code: 'not_found', message: 'User not found or data engine unavailable' } }, 404); + } + return c.json({ success: true, data: { userId } }); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + ctx.logger.error('[AuthPlugin] unlock-user failed', err); + return c.json({ success: false, error: { code: 'internal', message: err.message } }, 500); + } + }); + // ──────────────────────────────────────────────────────────────────── // OAuth self-service: register an OAuth application for the signed-in // user. Thin wrapper over better-auth's `/oauth2/create-client` 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 2f76f41c67..ad2c099910 100644 --- a/packages/services/service-settings/src/manifests/auth.manifest.test.ts +++ b/packages/services/service-settings/src/manifests/auth.manifest.test.ts @@ -48,6 +48,21 @@ describe('authSettingsManifest', () => { const groups = specs.filter((s) => s.type === 'group').map((s) => s.id); expect(groups).toContain('password_policy'); expect(groups).toContain('sessions'); + expect(groups).toContain('anti_abuse'); + }); + + it('exposes anti-abuse lockout + rate-limit number fields (ADR-0069 D2)', () => { + const specs = authSettingsManifest.specifiers as any[]; + const byKey = (k: string) => specs.find((s) => s.key === k); + + const threshold = byKey('lockout_threshold'); + expect(threshold.type).toBe('number'); + expect(threshold.default).toBe(0); // off by default + expect(threshold.min).toBe(0); + + expect(byKey('lockout_duration_minutes').default).toBe(15); + expect(byKey('rate_limit_max').default).toBe(10); + expect(byKey('rate_limit_window_seconds').default).toBe(60); }); it('exposes encrypted Google OAuth credential fields', () => { diff --git a/packages/services/service-settings/src/manifests/auth.manifest.ts b/packages/services/service-settings/src/manifests/auth.manifest.ts index f181422706..0a88000035 100644 --- a/packages/services/service-settings/src/manifests/auth.manifest.ts +++ b/packages/services/service-settings/src/manifests/auth.manifest.ts @@ -86,6 +86,57 @@ const manifest = { visible: "${data.email_password_enabled !== false}", }, + { + type: 'group', + id: 'anti_abuse', + label: 'Anti-abuse', + required: false, + description: + 'Brute-force protection: per-identity account lockout and per-IP rate limiting on auth endpoints.', + }, + { + type: 'number', + key: 'lockout_threshold', + label: 'Account lockout threshold', + required: false, + default: 0, + min: 0, + max: 20, + description: + 'Lock an account after this many consecutive failed sign-ins. 0 disables lockout. While locked, sign-in is rejected even with the correct password.', + visible: "${data.email_password_enabled !== false}", + }, + { + type: 'number', + key: 'lockout_duration_minutes', + label: 'Lockout duration (minutes)', + required: false, + default: 15, + min: 1, + max: 1440, + description: 'How long an account stays locked once the threshold is crossed.', + visible: "${data.email_password_enabled !== false && data.lockout_threshold > 0}", + }, + { + type: 'number', + key: 'rate_limit_max', + label: 'Auth rate-limit: max requests', + required: false, + default: 10, + min: 1, + max: 1000, + description: 'Maximum requests per IP, per window, to the sign-in / sign-up / password-reset endpoints.', + }, + { + type: 'number', + key: 'rate_limit_window_seconds', + label: 'Auth rate-limit: window (seconds)', + required: false, + default: 60, + min: 1, + max: 3600, + description: 'Sliding window over which the request cap above is counted.', + }, { type: 'group', id: 'sessions',