docs/adr/0062-enterprise-sso.md
Rama master · réplica de lectura del repositorio original.
# ADR-0062: Enterprise SSO — OIDC Authorization Code + PKCE with backend session cookies
## Metadata
| Field | Value |
|-------|-------|
| **Status** | Accepted |
| **Date** | 2026-08-19 |
| **Authors** | Alessio Brillo |
| **Deciders** | Alessio Brillo |
| **Supersedes** | ADR-0032 (cloud authentication direction) |
| **Relates to** | ADR-0027, ADR-0057, ADR-0034 |
| **Project** | DOMINUS |
## Context
DOMINUS Cloud authenticates tenants through `AUTH_PROVIDER` (`env` for the
community edition, `db`/`auth0` for managed identity). The `Auth0Provider`
already validates JWT bearer tokens against the Auth0 JWKS (`issuer` +
`audience`, mapping `sub`/`org_id`/`role`), and the roadmap promises an
Enterprise tier that includes SSO. The missing piece is the *interactive
login loop*: a browser flow that lets an enterprise user sign in through
their identity provider and land on the dashboard with a usable session.
Three forces shape the decision:
1. **Enterprise expectation** — buyers of the Enterprise tier expect SSO
against their IdP (Auth0, Okta, Azure AD) with no API-key ceremony.
2. **Tenant isolation must survive** — the suspended-tenant gate
(ADR-0057), usage enforcement (ADR-0038) and `requireTenant` semantics
(ADR-0034) all key on `req.tenantId` being populated and trustworthy.
Any SSO flow must feed the same identity fields, not a parallel universe.
3. **Security posture** — the community edition already stores API keys in
the SPA's localStorage. An enterprise SSO flow that followed that pattern
(tokens in localStorage) would inherit its XSS exposure and add refresh
management to the SPA. The enterprise tier is where the bar must be
raised, not lowered.
ADR-0032 (`Cloud authentication — external identity provider (Auth0)`) was
never ratified past `Proposed` despite the code being wired. This ADR
ratifies the direction, pins the flow, and supersedes it.
## Decision Drivers
1. **IdP-agnostic core** — the login flow must not hardcode Auth0 calls into
business logic; the repo's provider-abstraction principle (ADR-0004) is
non-negotiable. Swapping the IdP must be a new implementation file.
2. **Revocable sessions** — a session that the tenant operator or platform
admin can kill must survive IdP-side token revocation latency. Stateless
bearer tokens in localStorage cannot be revoked server-side.
3. **Fail-closed by default** — the SSO endpoints must not exist unless
fully configured; a misconfigured Cloud deploy must degrade to no-SSO,
never to a permissive fallback.
4. **Minimal new configuration** — operators already manage the Auth0
domain, audience, and JWKS. The OIDC flow adds only the application
client credentials and a callback URL.
## Considered Options
### Option A: SPA-direct OIDC with PKCE (no backend session)
The SPA redirects to Auth0, Auth0 redirects back to the SPA, and the SPA
exchanges the code for tokens directly at the IdP, storing them in
localStorage and sending them as `Authorization: Bearer`. The backend only
keeps its existing JWKS validation.
**Advantages:**
- Smallest implementation — no session infrastructure, no cookies.
- Standard Auth0 SPA pattern, well-trodden docs.
**Disadvantages:**
- Access tokens in localStorage — the XSS exposure the enterprise tier
should be eliminating.
- Refresh tokens cannot be stored safely in a SPA; silent renewal needs a
hidden iframe + `prompt=none`, which breaks for many enterprise IdP
tenants (third-party cookies, iframe-blocking policies).
- Session revocation requires waiting for the IdP token expiry or a
blocklist — the suspended-tenant gate (ADR-0057) would not take effect
until token expiry.
- No logout control from the backend.
**Cost Implications:** ~0.5 day. **Risk Assessment:** Medium — XSS token
theft, weak revocation, IdP-dependent renewal.
---
### Option B: Backend-mediated OIDC with httpOnly session cookie
The SPA redirects to `GET /api/v1/auth/oidc/start`; the backend generates a
PKCE verifier + state, stores them in a signed httpOnly transient cookie,
and 302-redirects to Auth0. Auth0 redirects to `GET
/api/v1/auth/oidc/callback`; the backend verifies the state, exchanges the
code with the verifier, validates the ID token against the JWKS (reusing
`Auth0Provider`), and mints an internal session JWT set as a second httpOnly
cookie (`dominus_session`). All subsequent browser requests authenticate via
the cookie; API/CLI clients keep the Bearer path.
**Advantages:**
- No tokens reach the SPA — XSS surface unchanged from today (cookies are
httpOnly).
- Sessions are server-mintable and revocable: logout clears the cookie, and
rotating `AUTH0_CLIENT_SECRET` invalidates every session immediately.
- The suspended-tenant gate and `requireTenant` apply unchanged — the
middleware accepts the session cookie and populates `req.tenantId`/`auth`
identically to the Bearer path.
- Refresh tokens stay server-side (the IdP refresh flow is opaque to the
SPA; a long-lived session TTL covers the dashboard without one today).
- Auth0 redirect flow stays standard (Authorization Code + PKCE S256).
**Disadvantages:**
- More moving parts: a transient signed cookie, a session JWT minter.
- The session JWT must be signed with a secret under operator control
(derived via HKDF from `AUTH0_CLIENT_SECRET`, so rotating the Auth0
application secret revokes sessions — a documented trade-off).
- CSRF surface shifts to the callback route — mitigated by the signed
state/verifier cookie and strict state comparison.
**Cost Implications:** ~2-3 days. **Risk Assessment:** Low — standard
pattern, no new dependencies (jose is already a dependency).
---
### Option C: Native SAML 2.0
Implement a SAML 2.0 Service Provider (SP) flow with IdP-initiated and
SP-initiated SSO.
**Advantages:**
- Direct compatibility with Okta/Azure AD SAML apps and IdP-first SSO.
**Disadvantages:**
- SAML is XML-heavy; the protocol/redirect flow is substantially more code
than OIDC, and a correct parser/validator is a security-critical surface.
- Auth0 (the reference IdP) is OIDC-first; SAML support is a conversion
layer at the IdP.
- Adds a dependency (e.g. `samlify`) or a large hand-rolled XML layer,
against the repo's dependency discipline.
**Cost Implications:** 5-10 days + dependency. **Risk Assessment:** High —
XML security (XXE, signature validation) is a standing vulnerability class.
## Decision
**Chosen option: Option B — Backend-mediated OIDC with httpOnly session cookie**
Option B satisfies every driver: the flow is IdP-agnostic through an
`OidcProvider` interface (`buildAuthorizeUrl`, `exchangeCode`,
`validateIdToken`, `logoutUrl`), with `Auth0OidcProvider` as the reference
implementation; sessions are httpOnly cookies minted by the backend and
revocable by logout or secret rotation; the endpoints are mounted only when
`AUTH_PROVIDER=auth0` AND `AUTH0_CLIENT_ID`/`AUTH0_CLIENT_SECRET`/
`AUTH0_CALLBACK_URL` are all present, otherwise they 404 (fail-closed); and
the configuration delta is exactly the three client credentials plus a TTL
knob (`AUTH0_SESSION_TTL_HOURS`, default 8h).
Option A was rejected because it inherits the localStorage-token exposure
and cannot revoke sessions server-side. Option C was rejected because its
cost and XML security surface are disproportionate for the reference IdP,
which is OIDC-native; a SAML adapter remains possible at the IdP layer
without touching this flow.
Session signing keys are HKDF-derived from `AUTH0_CLIENT_SECRET`
(salt `dominus-session-v1`, separate `info` labels for the session JWT and
the transient PKCE cookie). This avoids a new operator-managed secret while
making rotation of the Auth0 client secret an explicit global session
kill-switch — documented in the migration plan.
## Consequences
### Positive
- Enterprise users get an SSO button that lands them on the dashboard with
a revocable, httpOnly session.
- The suspended-tenant gate (ADR-0057), usage enforcement (ADR-0038) and
`requireTenant` (ADR-0034) work unchanged for browser sessions.
- No new dependencies and no tokens in the SPA.
### Negative
- Rotating `AUTH0_CLIENT_SECRET` signs every user out (accepted — it is a
deliberate kill-switch, documented in `.env.example`).
- IdP-side global logout (`/v2/logout` redirect) is deferred; today logout
only clears the DOMINUS session cookie.
- Refresh-token rotation and silent renewal are deferred until a session
store exists.
### Compliance and Security Implications
- The callback route must be added to the Auth0 application's allowed
callback URLs (documented in `.env.example`).
- The transient cookie is HMAC-signed, httpOnly, `SameSite=Lax`, `Secure`;
the callback verifies the state strictly before exchanging the code
(CSRF protection). PKCE S256 protects against code interception.
- The `AUTH0_CALLBACK_URL` is validated as an absolute URL by the config
schema.
### Migration and Monitoring Plan
1. Deploy with `AUTH_PROVIDER=auth0` and the new client credentials in the
cloud `.env`; the `/oidc` routes appear. No DB migration required.
2. Frontend ships the SSO button only when `GET /api/v1/auth/oidc/me`
indicates the flow is mounted (the 404 case hides the button).
3. Success metrics: SSO logins per day, session-cookie auth rate on
protected routes, and the absence of `sso_error=authentication_failed`
spikes (alerted via request logs).
4. Rollback: remove the client credentials from `.env` — the routes 404 and
Bearer auth is unaffected; existing sessions die at TTL.
### Validation
- Unit: session-JWT round-trip and tamper rejection; transient-cookie
round-trip and tamper rejection; provider authorize/exchange/logout URLs;
router happy paths and every fail-closed branch; middleware
cookie-fallback with and without `requireTenant`.
- CI: the `release-gate` job and the backend matrix run the new suites.
---
*This ADR was created following the MADR 4.0.0 standard. All DOMINUS ADRs
should be consistent with the ADR series starting at
`docs/adr/0001-project-architecture.md`.*