Skip to content

Commit fee8f7f

Browse files
os-zhuangclaude
andcommitted
docs(auth): document ADR-0069 enterprise auth hardening + SAML SSO
The hand-written auth guides predated ADR-0069 and were stale: - auth-sso.mdx: only covered OIDC; added an "Enterprise SSO (SAML 2.0)" section (Register SAML Provider action, IdP fields, SP ACS/metadata URLs, sign-in flow) and reframed the ADR-0069 note around the admin-managed Setup → SSO Providers UI. - authentication.mdx: "Key Features" listed none of the P1/P2 controls; added them and a canonical "Enterprise Authentication Hardening (ADR-0069)" settings reference (password policy / anti-abuse / enforced MFA / sessions / network + how the session-validation gate works). Reframed the stale "do not expose 2FA" caveat to point at enforced MFA + the Console remediation flow. - HARDENING.md: expanded the TL;DR auth row, added an "Authentication hardening" production-settings section, and noted the new session-control settings. - security.mdx: cross-linked authentication hardening as the first security layer and added it to the security checklist. References/* are generated from object/action metadata and self-update; not touched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 51bec81 commit fee8f7f

4 files changed

Lines changed: 164 additions & 9 deletions

File tree

content/docs/guides/auth-sso.mdx

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -113,11 +113,14 @@ Enterprise packages can pass `oidcProviders` into `@objectstack/plugin-auth`
113113
or contribute them through `auth:configure`. The open-source package does not
114114
ship a generic OIDC settings UI.
115115

116-
> **Per-environment external IdP (ADR-0069).** Recent releases add a
117-
> per-environment external-IdP path built on `@better-auth/sso` (a generic OIDC
118-
> relying party) and surface an `sso` flag in the public `/auth/config`
119-
> (`features.sso`) so a client can show an enterprise-login button when SSO is
120-
> configured. The `oidcProviders` extension below remains the in-process path.
116+
> **Admin-managed external IdP (ADR-0069).** Recent releases add a
117+
> per-environment external-IdP path built on `@better-auth/sso` and surface an
118+
> `sso` flag in the public `/auth/config` (`features.sso`) so a client can show
119+
> an enterprise-login button when SSO is configured. Admins register providers
120+
> **without code** from **Setup → SSO Providers** — both **OIDC** (Okta, Entra,
121+
> Auth0, Keycloak, …) and **SAML 2.0** (see [SAML 2.0](#enterprise-sso-saml-20)
122+
> below). The `oidcProviders` extension shown here remains the in-process path
123+
> for framework/enterprise packages that prefer wiring providers in code.
121124
122125
### Quick start — Okta
123126

@@ -204,6 +207,58 @@ const oidcProviders = [
204207

205208
---
206209

210+
## Enterprise SSO (SAML 2.0)
211+
212+
SAML 2.0 is provided natively by `@better-auth/sso` (the same package behind the
213+
OIDC path) — **no custom plugin or extension is needed**. Admins register a SAML
214+
IdP from **Setup → SSO Providers → Register SAML Provider**, which posts to the
215+
env-side bridge at `POST /api/v1/auth/admin/sso/register-saml`.
216+
217+
### Register a SAML provider
218+
219+
Fill in the IdP details collected by the **Register SAML Provider** action:
220+
221+
| Field | Required | Description |
222+
|---|---|---|
223+
| `providerId` || Stable identifier, e.g. `acme-saml`. |
224+
| `issuer` || The IdP's SAML **EntityID** (issuer), e.g. `https://saml.acme.com/entityid`. |
225+
| `domain` || Users with this email domain are routed to this IdP, e.g. `acme.com`. |
226+
| `entryPoint` || The IdP's SAML single sign-on (redirect) URL that receives the `SAMLRequest`. |
227+
| `cert` || The IdP's X.509 signing certificate (PEM body) — used to verify assertion signatures. |
228+
| `identifierFormat` || Requested SAML NameID format (defaults to the IdP's configured format), e.g. `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress`. |
229+
230+
On success the response returns the two **Service Provider (SP)** URLs you
231+
configure on the IdP side:
232+
233+
```json
234+
{
235+
"success": true,
236+
"data": { "providerId": "acme-saml" },
237+
"acsUrl": "https://<your-domain>/api/v1/auth/sso/saml2/sp/acs/acme-saml",
238+
"spMetadataUrl": "https://<your-domain>/api/v1/auth/sso/saml2/sp/metadata?providerId=acme-saml"
239+
}
240+
```
241+
242+
- **ACS (Assertion Consumer Service) URL** — register this as the SP ACS / reply
243+
URL on the IdP.
244+
- **SP metadata URL** — serves the SP `EntityDescriptor`/`SPSSODescriptor` XML
245+
(many IdPs can import it directly to auto-configure the SP).
246+
247+
### SAML sign-in flow
248+
249+
1. User enters their work email (or clicks the enterprise-login button).
250+
2. Client calls `POST /api/v1/auth/sign-in/sso` with `{ email, callbackURL }`.
251+
3. The email **domain** is matched to the registered provider; the response is a
252+
redirect to the IdP's `entryPoint` carrying a signed `SAMLRequest`.
253+
4. The IdP authenticates the user and POSTs a SAML assertion back to the SP ACS
254+
URL; `@better-auth/sso` (samlify) validates the signature/timestamp, then
255+
creates a session and redirects to `callbackURL`.
256+
257+
> Signature, timestamp, and replay validation are handled by `@better-auth/sso`
258+
> (samlify). You only supply the IdP's signing certificate at registration time.
259+
260+
---
261+
207262
## Verify configuration
208263

209264
Restart the server (`pnpm dev`), then:

content/docs/guides/authentication.mdx

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,22 @@ The `@objectstack/plugin-auth` package provides enterprise-grade authentication
3333
-**Session Management** - Automatic session handling with configurable expiry
3434
-**Password Reset** - Email-based password reset flow
3535
-**Email Verification** - Email verification workflow
36-
- ⚙️ **2FA backend** - better-auth two-factor plugin wiring is available for custom UIs
36+
- **Multi-Factor Authentication** - TOTP two-factor, plus org-wide **enforced MFA** with a grace period and a Console enrollment/remediation flow (ADR-0069)
3737
-**Magic Links** - Passwordless authentication
3838
-**Organizations** - Multi-tenant support
3939
-**ObjectQL Integration** - Native ObjectStack data persistence (no ORM required)
4040

41+
**Enterprise hardening (ADR-0069)** — configured from **Setup → Authentication**, each setting backed by real runtime enforcement:
42+
43+
-**Password policy** - Complexity (min character classes), history (no-reuse), expiry, and breached-password rejection via Have I Been Pwned (k-anonymity)
44+
-**Account lockout & rate-limiting** - Per-account lockout after N failed sign-ins (admin **Unlock** action) and per-IP throttling
45+
-**Enforced MFA** - Org-wide `require_mfa` with a configurable grace period; the session is gated from data access until the user enrolls
46+
-**Session controls** - Idle timeout, absolute lifetime cap, and concurrent-session limit per user
47+
-**IP allowlist** - Restrict authenticated access to CIDR ranges
48+
-**Enterprise SSO** - Admin-managed OIDC and SAML 2.0 trust list (see [Social & Enterprise SSO](/docs/guides/auth-sso))
49+
50+
See [Enterprise Authentication Hardening](#enterprise-authentication-hardening-adr-0069) for the full settings reference.
51+
4152
### Architecture
4253

4354
The plugin uses a **direct forwarding** architecture where all authentication requests are forwarded to Better-Auth's universal handler. This ensures:
@@ -324,13 +335,71 @@ Better-Auth automatically handles the OAuth callback at `/api/v1/auth/callback/g
324335

325336
---
326337

338+
## Enterprise Authentication Hardening (ADR-0069)
339+
340+
Beyond the core flows above, `@objectstack/plugin-auth` ships an enterprise
341+
hardening layer. Every toggle is configured from **Setup → Authentication** and
342+
is wired to real runtime enforcement (ADR-0049 — no setting ships without the
343+
code that reads it). Changes take effect immediately; no restart is required.
344+
345+
### Password policy
346+
347+
| Setting | Effect |
348+
|---|---|
349+
| `password_reject_breached` | Reject passwords found in the Have I Been Pwned corpus (k-anonymity range check — the password is never sent in full). |
350+
| `password_require_complexity` / `password_min_classes` | Require a minimum number of character classes (upper / lower / digit / symbol). |
351+
| `password_history_count` | Block reuse of the last *N* passwords. |
352+
| `password_expiry_days` | Force a password change after *N* days; expired sessions are gated until the password is rotated. |
353+
354+
### Anti-abuse (lockout & rate-limiting)
355+
356+
| Setting | Effect |
357+
|---|---|
358+
| `lockout_threshold` | Lock an account after this many consecutive failed sign-ins. |
359+
| `lockout_duration_minutes` | How long a lockout lasts. Admins can clear it early with the **Unlock** action on the user record. |
360+
| `rate_limit_max` / `rate_limit_window_seconds` | Per-IP request throttle on auth endpoints. |
361+
362+
### Multi-factor (enforced MFA)
363+
364+
| Setting | Effect |
365+
|---|---|
366+
| `mfa_required` | Require MFA org-wide (also settable per organization via `require_mfa`). |
367+
| `mfa_grace_period_days` | Grace window for existing users to enroll. After it elapses, the session is allowed to sign in but **gated from data access** until TOTP is enrolled — the Console surfaces a remediation overlay that walks the user through enrollment. |
368+
369+
### Sessions
370+
371+
| Setting | Effect |
372+
|---|---|
373+
| `session_idle_timeout_minutes` | Expire a session after inactivity. |
374+
| `session_absolute_max_hours` | Hard cap on total session lifetime regardless of activity. |
375+
| `max_concurrent_sessions_per_user` | Cap simultaneous sessions; the oldest is revoked (expired in place) when the cap is exceeded. |
376+
377+
### Network
378+
379+
| Setting | Effect |
380+
|---|---|
381+
| `allowed_ip_ranges` | Restrict authenticated access to one or more IPv4 CIDR ranges. IP is extracted with trust-proxy awareness (`x-forwarded-for` / `cf-connecting-ip` / `x-real-ip`); requests whose IP can't be determined fail open. |
382+
383+
> **How enforcement works.** Password expiry and enforced MFA share a single
384+
> *session-validation gate*: `customSession` stamps the session with an
385+
> `authGate` posture, and the REST server + runtime dispatcher block gated
386+
> sessions from data access (auth, health, and a small allowlist still pass so
387+
> the user can remediate). This is why a non-compliant user can sign in but
388+
> can't read data until they fix the issue.
389+
390+
---
391+
327392
## Advanced Features
328393

329394
### Two-Factor Authentication (2FA)
330395

331-
ObjectStack wires the better-auth two-factor plugin at the backend layer, but
332-
the open-source Console login UI does not yet ship the complete TOTP challenge
333-
flow. Do not expose 2FA as an end-user feature until your UI handles:
396+
ObjectStack wires the better-auth two-factor (TOTP) plugin at the backend layer.
397+
The endpoints below let you build an **opt-in** 2FA experience in a custom account
398+
UI; for **organization-wide enforced MFA**, see
399+
[Enterprise Authentication Hardening](#enterprise-authentication-hardening-adr-0069)
400+
— the Console ships an enrollment/remediation flow for that path.
401+
402+
A complete opt-in 2FA UX still needs to handle:
334403

335404
- enrollment (`/two-factor/enable`),
336405
- TOTP confirmation (`/two-factor/verify-totp`),

content/docs/guides/security.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ Complete guide to implementing enterprise-grade security in ObjectStack with fin
2323

2424
## Security Architecture
2525

26+
> **Authentication is the first layer.** This guide covers *authorization*
27+
> who can see and do what once signed in (RBAC, RLS, FLS, sharing). Harden
28+
> *authentication* first — password policy, enforced MFA, account lockout,
29+
> session controls, and IP allowlist — in
30+
> [Enterprise Authentication Hardening](./authentication#enterprise-authentication-hardening-adr-0069).
31+
2632
ObjectStack implements a multi-layered security model:
2733

2834
```
@@ -689,6 +695,7 @@ writes.
689695

690696
### Initial Setup
691697

698+
- [ ] Harden authentication (password policy, enforced MFA, lockout, session controls, IP allowlist) — see [Authentication Hardening](./authentication#enterprise-authentication-hardening-adr-0069)
692699
- [ ] Define user roles and hierarchies
693700
- [ ] Create profiles for each role
694701
- [ ] Set organization-wide defaults

docs/HARDENING.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ adapter layer for a production deployment.
1616
| Token-bucket rate limit | Off (opt-in) | `RateLimiter` primitive |
1717
| CSRF | Adapter-layer concern | `hono/secure-headers` / `hono/csrf` |
1818
| Auth (better-auth) | On | `@objectstack/plugin-auth` |
19+
| Auth hardening (ADR-0069) | Off (opt-in) | Setup → Authentication |
1920
| Project membership (RBAC) | On when scoped | dispatcher plugin |
2021
| Field- and row-level perms | On | SecurityPlugin |
2122
| Request id / metrics / 5xx reporting | Noop default | see [Observability](./OBSERVABILITY.md) |
@@ -137,6 +138,28 @@ CSRF protection becomes required when you switch to **cookie-based
137138
session auth**. In that case wire a CSRF middleware (e.g. `hono/csrf`)
138139
and exempt only the auth callback routes.
139140

141+
## Authentication hardening (ADR-0069)
142+
143+
Beyond transport-layer defences, `@objectstack/plugin-auth` ships an enterprise
144+
authentication-hardening layer. Each control is **opt-in** from **Setup →
145+
Authentication** and backed by real runtime enforcement (ADR-0049). For a new
146+
production deployment, enable at least:
147+
148+
| Group | Recommended settings |
149+
| ----------- | ------------------------------------------------------------------------------------ |
150+
| Password | `password_reject_breached: true`, `password_require_complexity: true`, `password_history_count: 3–5`, `password_expiry_days: 90` |
151+
| Anti-abuse | `lockout_threshold: 5`, `lockout_duration_minutes: 15`, `rate_limit_max` / `rate_limit_window_seconds` |
152+
| MFA | `mfa_required: true` (or per-org `require_mfa`), `mfa_grace_period_days: 7` |
153+
| Sessions | `session_idle_timeout_minutes: 30–60`, `session_absolute_max_hours: 8–12`, `max_concurrent_sessions_per_user` |
154+
| Network | `allowed_ip_ranges` (IPv4 CIDR) for tenant- or office-scoped access |
155+
156+
Password expiry and enforced MFA share a *session-validation gate*: a
157+
non-compliant session can authenticate but is blocked from data access (auth +
158+
health + a remediation allowlist still pass) until the user rotates the password
159+
or enrols MFA. See the
160+
[Authentication guide](../content/docs/guides/authentication.mdx#enterprise-authentication-hardening-adr-0069)
161+
for the full per-setting reference.
162+
140163
## JWT / session lifecycle
141164

142165
ObjectStack uses [better-auth](https://www.better-auth.com/) via
@@ -148,6 +171,7 @@ ObjectStack uses [better-auth](https://www.better-auth.com/) via
148171
| Access token TTL | inherited from session | configure in better-auth |
149172
| Refresh | better-auth `/auth/get-session` rolls TTL | `session.updateAge` (seconds) |
150173
| Revocation | DELETE on `session` row | `revokeSessionsOnPasswordReset: true` |
174+
| Idle / absolute / concurrent caps | Off (opt-in) | `session_idle_timeout_minutes` / `session_absolute_max_hours` / `max_concurrent_sessions_per_user` (Setup → Authentication) |
151175
| Email verify TTL | 1 hour | `emailVerification.expiresIn` |
152176

153177
### Verification checklist (run before going live)

0 commit comments

Comments
 (0)