Enterprise SSO (SAML 2.0) integration and fallback runbook
If SAML has a serious problem (bad assertions, unstable IdP, or a compliance pause), you can fall back quickly to an "OIDC only" state with an environment variable:
Enterprise SSO (SAML 2.0) integration and fallback runbook
Scope: dual-stack SAML SP configuration for
enterprise/apps/web-portalandenterprise/apps/admin-console; coexists with the OIDC path.
>
This is the sibling of
sso-oidc-setup.md. For OIDC, keep using the original OIDC runbook.
0. One-click fallback (most important)
If SAML has a serious problem (bad assertions, unstable IdP, or a compliance pause), you can fall back quickly to an "OIDC only" state with an environment variable:
1# .env.local or the deployment environment2SSO_SAML_DISABLED=true
After fallback (restart portal / admin-console), it takes effect immediately:
- portal
/api/auth/sso/saml/startand admin/api/auth/sso/saml/start: redirect straight back to the login page with error codesaml.provider_not_configured. - portal/admin
/api/auth/sso/saml/callback: return HTTP 400, body{"error":"saml.provider_not_configured"}, and write an audit eventauth.sso.login_failed (protocol=saml). - Admin "Add SSO Provider" rejects SAML protocol selection (creating a SAML provider is forbidden when
SSO_SAML_DISABLED=true). - The OIDC path, error codes, routes, and audit fields are completely unaffected.
This switch turns off SAML only. Do not introduce a global switch such as
SSO_DISABLEDorSSO_OIDC_DISABLED— an OIDC outage should not be caused by a SAML failure.
1. Route overview
| Role | Start (GET) | Callback (POST) |
|---|---|---|
| Portal | /api/auth/sso/saml/start?provider=<id>&returnTo=<safe> | /api/auth/sso/saml/callback |
| Admin | /api/auth/sso/saml/start?provider=<id> | /api/auth/sso/saml/callback |
Implementation locations:
- Protocol handling:
enterprise/packages/auth/src/services/saml-protocol-handler.ts - RelayState:
enterprise/packages/auth/src/services/saml-state.ts - Attribute mapping:
enterprise/packages/auth/src/services/saml-attribute-mapper.ts - Portal routes:
enterprise/apps/web-portal/src/app/api/auth/sso/saml/{start,callback}/route.ts - Admin routes:
enterprise/apps/admin-console/src/app/api/auth/sso/saml/{start,callback}/route.ts
Underlying dependency: @node-saml/node-saml v5 (pinned; do not introduce the old passport-saml branch).
2. Required environment variables
| Variable | Default | Description |
|---|---|---|
SSO_STATE_SIGNING_SECRET | Generated by bootstrap.sh | Symmetric key that encrypts RelayState/cookie; shared with OIDC |
SSO_PROVIDER_SECRET_KEY | Generated by bootstrap.sh | Encryption key for sensitive provider fields such as clientSecret/PEM |
SSO_SAML_DISABLED | false | One-click fallback switch; see the previous section |
NEXT_PUBLIC_SSO_PROVIDERS | default:Enterprise unified identity | Login-page button list; to add a SAML entry, append <providerId>:<display name> |
After adding any SAML-specific environment variable, you must also update:
enterprise/.env.local.exampleenterprise/turbo.jsonglobalEnv- Default values in
enterprise/scripts/bootstrap.sh - This document
3. Configure a SAML provider (admin console)
3.1 IdP preparation
- On the IdP side (Feishu / Okta / the customer project's IDaaS), create a SAML application:
- Service provider / SP Entity ID: usually https://<your-portal-domain>/ (use the admin-console domain if it is shared with admin-console)
- ACS (Assertion Consumer Service) URL: https://<host>/api/auth/sso/saml/callback
- NameID Format: emailAddress recommended
- Required attributes: email is required; optional displayName / firstName / lastName / dept / roles
3.2 Enter it in admin-console
On /settings/sso → choose protocol SAML 2.0 → fill in:
- Provider ID: matches the portal login page
?provider= - IdP Entity ID / IdP SSO URL / optional SLO URL
- One or more IdP signing-certificate PEMs (keep the full
BEGIN/END CERTIFICATEblock for each certificate) - SP Entity ID / ACS URL (must match the IdP application configuration)
- NameID Format / clock skew (default 60 seconds)
- Whether to require Assertion signing (recommended) / Response signing
- attribute mapping (
emailis required)
After save, the admin console writes PG sso_providers.saml_config and an audit event auth.sso.provider.create (including protocol).
3.3 Health check
The admin-console "Health check" button (POST /api/admin/sso/providers/<id>/health) will:
- Parse each IdP certificate and report
validFrom / validTo / expired or not. - Send one HTTP HEAD to
idpSsoUrl(5s timeout) to confirm reachability; a non-200 is not fatal, only informational.
Health-check requests go through sso-url-guard for SSRF protection and do not send any user information or SAMLRequest to the IdP.
4. End-to-end acceptance (prefer the local mock IdP fixture)
| Scenario | Entry | Expected result |
|---|---|---|
| Valid happy path | portal /api/auth/sso/saml/start?provider=<id> | Login succeeds, access/refresh cookies are written, redirect to returnTo |
| Missing email attribute | Same as above | Error code saml.attribute_email_missing, redirect back to the login page |
| Bad signature | Tamper with the SAMLResponse signature | Error code saml.invalid_signature |
| Expired assertion | Set NotOnOrAfter to the past | Error code saml.expired_assertion |
| Issuer mismatch | Change the IdP entityID | Error code saml.invalid_issuer |
| Audience mismatch | Change the SP entityID | Error code saml.invalid_audience |
| RelayState tampered | Modify the cookie | Error code saml.relay_state_invalid |
| Provider disabled | Turn enabled off in the admin console | Error code saml.provider_disabled |
Admin missing admin:enter | Log in with an unauthorized account | Error code admin_scope_missing |
Every failure writes an auth.sso.login_failed audit event. Detail fields:
1{2 "protocol": "saml",3 "reason_code": "saml.invalid_signature",4 "provider_id": "<id>",5 "issuer": "<idpEntityId>",6 "external_subject": "<NameID or null>",7 "email_hint": "<optional>"8}
Successful login writes auth.sso.login (portal) or auth.sso.admin_login (admin), also with protocol = "saml".
5. Common IdP configuration entry points (for operations index)
- Feishu SAML SSO: Feishu Admin → Security → SSO → SAML 2.0 → custom application.
- Okta SAML: Okta Admin → Applications → Create Custom SAML 2.0 App.
- The customer project's IDaaS: placeholder; actual integration parameters are provided by the customer (see the "customer-cloud IDaaS intake checklist" in
sso-oidc-setup.md).
6. Troubleshooting cheat sheet
| Symptom | First check |
|---|---|
| Login page returns to an error page immediately after redirect | Read the ?sso_error= code in the URL; compare with the "End-to-end acceptance" table in this document |
Callback reports saml.relay_state_invalid | Usually the cookie was stripped by a proxy, or SameSite does not match the environment; production should use SameSite=None + Secure to support cross-site callbacks; non-production keeps SameSite=Lax to reduce local-debug risk |
Callback reports saml.callback_failed | Check backend logs; usually the IdP certificate does not match the PEM, or audience is inconsistent |
Audit only has protocol=saml failure events | The SP-init flow already reached the callback stage; continue with reason_code |
| Want to pause SAML | Set SSO_SAML_DISABLED=true and restart portal/admin-console |
7. Local mock IdP fixture (local only)
To verify the SAML flow without a real IdP, the repo includes a read-only, local-only mock IdP at
enterprise/scripts/sso/mock-saml-idp/. Steps:
1# 1) Generate a local signing keypair once (never commit)2pnpm sso:saml-mock-setup3# 2) Start the mock IdP (default 127.0.0.1:4444)4pnpm sso:saml-mock
After start you can visit:
GET /metadata— IdP metadata XML (includes the X.509 public key)GET /sso?acs=<acsUrl>&audience=<spEntityId>&email=<email>&displayName=<name>&roles=<csv>&RelayState=<state>&inResponseTo=<requestId>
— returns an HTML form that auto-POSTs to the SP acsUrl with SAMLResponse + RelayState
Note: to support the local fixture, admin-console allows idpEntityId/idpSsoUrl to be http://localhost or http://127.0.0.1 only in non-production. Production still requires HTTPS and forbids local addresses.
This keypair is for local integration tests only. Do not deploy it to any external environment.
scripts/sso/mock-saml-idp/.fixture/is in
.gitignore, so a normal workflow will not put the private key in git history.
For an example of wiring the mock IdP to an admin-console SAML provider, see
enterprise/scripts/sso/mock-saml-idp/README.md.
8. Relationship to OIDC
- No OIDC change may bypass via the SAML path, and vice versa.
- Error-code namespaces:
oidc.*andsaml.*are maintained separately, centralized inenterprise/packages/auth/src/services/oidc-error-codes.ts. - Shared by both paths:
SSO_STATE_SIGNING_SECRET,sso-url-guardSSRF protection, andsanitizeSsoAuditDetailaudit redaction. - Independent between paths: two cookie families
agenticx_oidc_state_*/agenticx_saml_state_*; start/callback routes live in separate directories.
Made-with: Damon Li