Skip to content

Commit 99d41b5

Browse files
os-zhuangclaude
andauthored
docs(adr): ADR-0069 enterprise authentication hardening (#2275)
Proposes the target end-state for the auth settings surface (password policy, enforced MFA, SSO/OIDC, session controls, IP allowlist, anti-brute-force), each capability specified as a (setting -> enforcement seam -> mechanism) triple per ADR-0049 (no unenforced security properties). Distinguishes better-auth-native knobs (rateLimit, haveibeenpwned, twoFactor, genericOAuth, session windows) from the custom hooks/middleware + identity fields needed for complexity/expiry/history, account lockout, org-wide MFA requirement, idle/ absolute/concurrent sessions, and IP gating. SAML scoped P3/external (not in better-auth core). Phased P1/P2/P3; SCIM deferred to a separate ADR. Grounded in a read of plugin-auth (better-auth 1.6.x wiring) + auth.manifest + identity objects. Relates to #2246. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b4487ff commit 99d41b5

1 file changed

Lines changed: 151 additions & 0 deletions

File tree

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# ADR-0069: Enterprise authentication hardening — password policy, enforced MFA, SSO, session controls, network gating, and anti-brute-force, all enforcement-wired
2+
3+
**Status**: Proposed (2026-06-24)
4+
**Deciders**: ObjectStack Protocol Architects
5+
**Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (**the governing constraint** — a security property that isn't enforced at runtime is forbidden; no toggle may be a "false surface"), [ADR-0007](./0007-settings-manifest-and-kv-store.md) (settings manifest + cascade KV store), [ADR-0057](./0057-erp-authorization-core-business-units-and-scope-depth.md) (`sys_role` is platform-native, decoupled from better-auth; org scoping), [ADR-0066](./0066-unified-authorization-model.md) (capability/assignment split), [ADR-0068](./0068-unified-user-context-and-built-in-identity-roles.md) (`current_user` contract, built-in roles)
6+
**Consumers**: `@objectstack/plugin-auth` (better-auth wiring, `bindAuthSettings`/`applyConfigPatch`, auth route middleware), `@objectstack/service-settings` (`auth.manifest.ts`), `@objectstack/platform-objects` (identity objects `sys_user`/`sys_session`/`sys_account`), `@objectstack/rest` (auth request middleware seam), `../objectui` (settings UI rendering)
7+
8+
**Premise**: pre-launch — specify the target end-state for the authentication settings surface. Dogfooding the **Setup app** as a brand-new system administrator standing up the platform for a company (see issue #2246) surfaced that today's auth surface is **10 settings** — email/password on, self-signup on, email-verification, password min/max length, session expiry/refresh days, and Google OAuth (`packages/services/service-settings/src/manifests/auth.manifest.ts`). That is enough for a demo, **not** enough for a company security review. A real admin cannot enable the platform without: a real password policy, the ability to *require* MFA, SSO beyond Google, session lifetime/idle/concurrency controls, network (IP) gating, and brute-force protection.
9+
10+
> **Hard constraint (ADR-0049).** Every setting introduced here MUST ship with the runtime code path that enforces it. A "Require MFA" toggle that doesn't actually block un-enrolled users, or an "IP allowlist" field nothing reads, is worse than nothing — it gives a false sense of security and fails the spec-liveness gate. This ADR is therefore organized as **(setting -> enforcement seam -> mechanism)** triples, not a wishlist of fields.
11+
12+
## TL;DR
13+
14+
1. **[new]** Extend `auth.manifest.ts` with enterprise groups — *Password policy*, *Multi-factor*, *Sessions*, *Network*, *Anti-abuse*, *SSO* — each field bound to a concrete enforcement seam below.
15+
2. **[ruled]** Prefer **better-auth-native** mechanisms where they exist (1.6.x ships: core `rateLimit`, `haveibeenpwned` breached-password plugin, `twoFactor`, `genericOAuth`/`oidcProvider`, `multiSession`, session `expiresIn`/`updateAge`/`freshAge`). Add **custom hooks/middleware** only where better-auth has no native knob (password complexity/expiry/history, **account lockout**, **org-wide MFA requirement**, idle/absolute/concurrent session limits, **IP allowlist**).
16+
3. **[new]** Add the identity fields that back enforcement: `sys_user.{password_changed_at, failed_login_count, locked_until, last_login_at, last_login_ip, mfa_required_at}`, `sys_session.{last_activity_at, revoked_at, revoke_reason}`, `sys_account.previous_password_hashes`. Without these there is nothing to enforce against.
17+
4. **[ruled]** **SAML is out of better-auth core** (1.6.x exposes `genericOAuth` + `oidcProvider`, no `saml` plugin) -> scope SAML as **P3 / external** (assess `@better-auth/sso` or a custom plugin); ship **generic OIDC** (already wired via `genericOAuth`) as the v1 enterprise-SSO answer.
18+
5. **[ruled]** Phased delivery: **P1** password policy + account lockout + enforced MFA; **P2** session controls + IP allowlist + rate-limit tuning; **P3** SSO expansion (OIDC trust-list UI, SAML assessment). SCIM/bulk-provisioning is a **separate** ADR (directory sync != auth hardening).
19+
6. **[ruled]** Settings scope follows ADR-0007: security knobs are **`global`** scope, `manage_platform_settings` read/write; per-org overrides (`sys_organization.*`) are explicit and additive (an org can only *tighten*, never loosen, the global floor).
20+
21+
---
22+
23+
## Context: what exists today (better-auth 1.6.x)
24+
25+
`@objectstack/plugin-auth` wires better-auth and binds the Settings namespace into it (`bindAuthSettings` -> `applyConfigPatch`, which resets the auth instance on next request). Enabled better-auth features today: `bearer` (always), `organization`, `customSession`, and **opt-in** `twoFactor` / `admin` / `magicLink` / `oidcProvider` / `deviceAuthorization`; `genericOAuth` when `oidcProviders` is configured.
26+
27+
| Setting (today) | Enforcement seam | Real? |
28+
|---|---|---|
29+
| `email_password_enabled` | better-auth `emailAndPassword.enabled` | yes |
30+
| `signup_enabled` | `emailAndPassword.disableSignUp` (+ first-user bypass) | yes |
31+
| `require_email_verification` | `emailAndPassword.requireEmailVerification` | yes |
32+
| `password_min_length` / `password_max_length` | `emailAndPassword.minPasswordLength` / `maxPasswordLength` | yes |
33+
| `session_expiry_days` / `session_refresh_days` | `session.expiresIn` / `session.updateAge` (seconds) | yes |
34+
| `google_*` | adds/removes Google from `socialProviders` | yes |
35+
36+
Everything present is genuinely enforced. The gap is **coverage**, not honesty — which is why the rule for what we add is the same one (ADR-0049).
37+
38+
---
39+
40+
## Decision — capabilities, each as (settings -> seam -> mechanism)
41+
42+
Legend: **[native]** = a better-auth config/plugin does the enforcing; **[custom]** = ObjectStack code (hook or Hono middleware) does it; **[field]** = new identity column required.
43+
44+
### D1 — Password policy (P1)
45+
46+
| Setting (`auth` ns) | Default | Enforcement seam | Mechanism |
47+
|---|---|---|---|
48+
| `password_require_complexity` (toggle) + `password_min_classes` (1-4: upper/lower/digit/symbol) | off | sign-up + reset/change-password path | **[custom]** validator invoked from a better-auth `before` hook on `/sign-up/email`, `/reset-password`, `/change-password`; rejects with `PASSWORD_POLICY_VIOLATION` |
49+
| `password_reject_breached` (toggle) | off | same path | **[native]** enable better-auth `haveibeenpwned` plugin (k-anonymity HIBP range check) |
50+
| `password_expiry_days` (number, 0 = off) | 0 | session validation | **[custom]+[field]** `sys_user.password_changed_at`; on authenticated requests, if `now - password_changed_at > expiry`, force `/change-password` (return `PASSWORD_EXPIRED`) |
51+
| `password_history_count` (0-24) | 0 | reset/change path | **[custom]+[field]** `sys_account.previous_password_hashes` (bounded ring); reject reuse |
52+
53+
> better-auth gives min/max length only; complexity/expiry/history are not native. HIBP **is** native (`haveibeenpwned`) and is the highest-value, lowest-effort win — adopt it first.
54+
55+
### D2 — Account lockout / anti-brute-force (P1)
56+
57+
| Setting | Default | Seam | Mechanism |
58+
|---|---|---|---|
59+
| `lockout_threshold` (failed attempts, 0 = off) | 0 | `before`/`after` hook on `/sign-in/email` | **[custom]+[field]** `sys_user.failed_login_count`, `sys_user.locked_until`; increment on failure, set `locked_until = now + lockout_duration` past threshold, **reject even on correct password** while locked, reset on success |
60+
| `lockout_duration_minutes` | 15 | as above | **[custom]** |
61+
| `rate_limit_window_seconds` / `rate_limit_max` (per IP, auth endpoints) | 60 / 10 | better-auth core `rateLimit` | **[native]** enable + tune better-auth `rateLimit` (stricter `customRules` for `/sign-in/*`, `/sign-up/*`, `/reset-password`); use a **shared store** (not in-memory) for multi-node |
62+
63+
> Distinction that matters: better-auth `rateLimit` throttles **requests per IP/path** (native); **account lockout** (per-identity, survives IP rotation) is **custom** and needs the two `sys_user` fields above + an admin "unlock" action.
64+
65+
### D3 — Enforced multi-factor (P1)
66+
67+
| Setting | Default | Seam | Mechanism |
68+
|---|---|---|---|
69+
| `mfa_required` (global toggle) | off | post-`/sign-in` session gate | **[custom]** `twoFactor` plugin already provides TOTP + backup codes (opt-in via `sys_user.two_factor_enabled` + `sys_two_factor`). When `mfa_required`, a session for a user with `two_factor_enabled = false` is marked **un-stepped-up** and gated out of all protected resources until enrollment + verification |
70+
| `mfa_grace_period_days` | 7 | same | **[custom]+[field]** `sys_user.mfa_required_at`; allow a grace window to enroll before hard-blocking |
71+
| `sys_organization.require_mfa` (per-org) || `customSession` org resolution | **[custom]** an org may *require* MFA above the global floor |
72+
73+
> better-auth makes MFA *available*; making it *required* is the missing enforcement. Gate at the session-validation seam so a half-authenticated (password-only) session cannot read data.
74+
75+
### D4 — Session controls (P2)
76+
77+
| Setting | Default | Seam | Mechanism |
78+
|---|---|---|---|
79+
| `session_idle_timeout_minutes` (0 = off) | 0 | authenticated-request middleware | **[custom]+[field]** `sys_session.last_activity_at`; touch on each request; reject if idle beyond timeout |
80+
| `session_absolute_max_hours` (0 = off) | 0 | refresh path | **[custom]** cap total lifetime regardless of refresh (better-auth `updateAge` only slides the window; absolute cap is custom) |
81+
| `max_concurrent_sessions_per_user` (0 = off) | 0 | post-`/sign-in` | **[custom]** count live `sys_session` for the user; reject or evict-oldest past the cap |
82+
| "Sign out all other sessions" ||| **[native]** already wired (`/revoke-other-sessions`, action on `sys_session`) — keep |
83+
| `session_expiry_days` / `session_refresh_days` | 7 / 1 | `session.expiresIn`/`updateAge` | **[native]** existing — keep |
84+
85+
### D5 — Network gating / IP allowlist (P2)
86+
87+
| Setting | Default | Seam | Mechanism |
88+
|---|---|---|---|
89+
| `sys_organization.allowed_ip_ranges` (CIDR[]) || auth request middleware (Hono, **before** the better-auth handler in `plugin-auth` route registration) | **[custom]** extract client IP (`x-forwarded-for` / `cf-connecting-ip`, trust-proxy aware); reject login/session outside the org's ranges with `IP_NOT_ALLOWED` |
90+
| `sys_user.allowed_ip_ranges` (optional override) || same | **[custom]** per-user override, evaluated before org |
91+
| `require_ip_match` (toggle) | off | session refresh | **[custom]** detect `request IP != session.ip_address` (already captured) -> re-verify or revoke |
92+
93+
> IP is already captured on `sys_session.ip_address`; the only missing piece is the **gate** — a middleware seam exists in the auth route registration, before requests reach better-auth.
94+
95+
### D6 — Enterprise SSO (P2/P3)
96+
97+
| Capability | Status | Plan |
98+
|---|---|---|
99+
| Generic **OIDC** RP | **[native]** `genericOAuth` already supported when `oidcProviders` configured | **P2**: surface it in settings — an admin-managed **trusted OIDC provider list** (`issuer`, `client_id`, `client_secret`, `scopes`, allowed email domains for account-linking) instead of code/env-only config |
100+
| Be an **OIDC Provider** (IdP) | **[native]** `oidcProvider` plugin | keep opt-in; out of scope to expand here |
101+
| Social providers beyond Google | partial (Google is settings-driven; others hardcoded) | **P2**: make the provider set settings-driven via the same manifest pattern |
102+
| **SAML 2.0** | **not in better-auth core** (no `saml` plugin in 1.6.x) | **P3**: assess `@better-auth/sso` (adds SAML in newer lines) vs a custom plugin wrapping a SAML lib; do **not** add a SAML settings surface until an enforcing implementation exists (ADR-0049) |
103+
104+
### D7 — Identity-object fields (prerequisite for D1-D5)
105+
106+
**[new fields]** — these back the enforcement and must land with (or before) the settings that read them:
107+
108+
- `sys_user`: `password_changed_at`, `failed_login_count`, `locked_until`, `last_login_at`, `last_login_ip`, `mfa_required_at`
109+
- `sys_session`: `last_activity_at`, `revoked_at`, `revoke_reason`
110+
- `sys_account`: `previous_password_hashes` (bounded)
111+
- `sys_organization`: `require_mfa`, `allowed_ip_ranges`
112+
113+
Plus admin-facing surface: a `sys_user` list-view filter for locked accounts + an **Unlock** action; a "force password reset" action.
114+
115+
---
116+
117+
## Enforcement seam summary (where the code hooks)
118+
119+
1. **better-auth config** (`auth-manager.ts` instance creation) — native knobs: `rateLimit`, `haveibeenpwned`, `session.{expiresIn,updateAge,freshAge}`, `emailAndPassword.{min,max}PasswordLength`, `twoFactor`, `genericOAuth`.
120+
2. **better-auth `hooks.before` / `hooks.after`** on `/sign-in/*`, `/sign-up/*`, `/reset-password`, `/change-password` — complexity/history validation, lockout counter, last-login stamping.
121+
3. **Hono auth-route middleware** (in `plugin-auth` route registration, *before* delegating to better-auth) — IP allowlist, rate-limit pre-check.
122+
4. **Session-validation path** (resolve-execution-context / `customSession`) — MFA step-up gate, password-expiry gate, idle-timeout, locked-account rejection.
123+
5. **`bindAuthSettings` -> `applyConfigPatch`** — the existing channel that turns every new setting into live config without restart.
124+
125+
Each row in D1-D6 names exactly one of these seams. No setting is introduced without one.
126+
127+
---
128+
129+
## Phasing
130+
131+
- **P1 (security floor for first real customers)**: D1 (complexity + HIBP + expiry/history), D2 (lockout + rate-limit tuning), D3 (enforced MFA + grace). + D7 fields for these.
132+
- **P2 (defense in depth)**: D4 (idle/absolute/concurrent sessions), D5 (IP allowlist), D6 OIDC trust-list UI.
133+
- **P3 (federation breadth)**: SAML assessment, broader social providers, per-org overrides UI polish.
134+
135+
## Out of scope / deferred
136+
137+
- **SCIM / directory provisioning & bulk import** — separate ADR (provisioning, not authentication); tracked in #2246.
138+
- **Passkeys / WebAuthn** — future (better-auth has a `passkey` direction); not P1.
139+
- **Audit of every auth event** — partially covered by `sys_audit_log`; a dedicated security-events stream is a follow-up.
140+
141+
## Consequences
142+
143+
- **Positive**: the auth settings surface becomes defensible in an enterprise security review; every knob is real (ADR-0049 upheld); most of P1 leans on better-auth-native mechanisms (low risk).
144+
- **Negative / risk**: custom session-gate logic (MFA step-up, idle, lockout) sits on the hot path — must be cheap and well-tested; multi-node rate-limit/lockout needs a shared store (couples to the cloud deployment story); per-org overrides add evaluation cost to `customSession`.
145+
- **Migration**: new identity fields are additive (nullable / default-off); all new settings default to **off**, so existing environments are unchanged until an admin opts in. No behavior change on upgrade.
146+
147+
## Alternatives considered
148+
149+
- **Adopt the settings fields now, wire enforcement later** — rejected: violates ADR-0049 (false surface). Fields and their enforcement land together.
150+
- **Outsource all of auth to an external IdP (Auth0/WorkOS)** — rejected as the default: the platform must be self-hostable and own its identity; external IdP is supported *through* D6 (OIDC), not instead of the local floor.
151+
- **Build SAML now** — deferred: no native better-auth support in 1.6.x; building it before an enforcing implementation exists would be an ADR-0049 violation.

0 commit comments

Comments
 (0)