Skip to content

feat(auth): per-org SAML SSO (CL-3)#503

Merged
TerrifiedBug merged 3 commits into
mainfrom
feat/cl-3-saml
Jun 8, 2026
Merged

feat(auth): per-org SAML SSO (CL-3)#503
TerrifiedBug merged 3 commits into
mainfrom
feat/cl-3-saml

Conversation

@TerrifiedBug

@TerrifiedBug TerrifiedBug commented Jun 8, 2026

Copy link
Copy Markdown
Owner

Summary

Adds per-organisation SAML 2.0 SSO, mirroring the existing per-org OIDC surface and coexisting with OIDC and local auth.

  • Schema + migration 20260608050000_org_saml_settings — additive OrganizationSettings columns: samlEnabled, samlIdpEntityId, samlIdpSsoUrl, samlIdpCert, samlEnforced, samlGroupAttribute. RLS table → no RLS block.
  • Routes src/app/api/auth/saml/{metadata,login,callback} — SP metadata, SP-initiated AuthnRequest, and the signature-verified ACS.
  • Session is minted through the same per-org JWT path OIDC uses (getSessionSigningKey + next-auth/jwt encode, same id/provider/org_id/authedAt claims), so the proxy gate and auth() accept it identically.
  • Group→team reconciliation reuses the shared OIDC mechanism (reconcileUserTeamMemberships over oidcTeamMappings), keyed by samlGroupAttribute.
  • samlEnforced disables local credential login per-org in src/auth.ts (analogue of the global VF_DISABLE_LOCAL_AUTH).
  • UI — SAML config card in auth-settings.tsx (admin-gated) + a "Sign in with SAML" button on the login page when enabled.

Dependency: @node-saml/node-saml@^5.1.0 (all XML/crypto/signature handling is delegated to it — nothing hand-rolled).

Type of change

  • New feature

Security model

Verified on every ACS response (validateSamlResponse@node-saml validatePostResponseAsync):

  • SignaturewantAuthnResponseSigned: true and wantAssertionsSigned: true; the response/assertion is verified against the org's configured IdP certificate (samlIdpCert). Unsigned or bad-signature responses are rejected. Attributes are never read without a verified signature.
  • Audience — pinned to the SP EntityID (audience = <origin>/api/auth/saml/metadata); an assertion whose AudienceRestriction doesn't list us is rejected.
  • Replay / CSRFvalidateInResponseTo: always. The AuthnRequest id is bound to the browser via a single-use, encrypted state cookie (vf-saml-state, AES-GCM JWE via next-auth/jwt, SameSite=None; Secure so it survives the IdP's cross-site POST); the cache provider only accepts that one id, and the IdP-echoed RelayState nonce must match the cookie. IdP-initiated (unsolicited) responses are rejected. The state cookie is cleared on use.
  • ExpiryNotBefore/NotOnOrAfter + max assertion age, enforced with a 60s clock-skew tolerance.
  • Org binding — the state cookie carries the org it was minted for and is re-checked against the host-resolved org on callback; cross-org replay fails closed.
  • Account-takeover guard — SAML never auto-links onto an existing non-SSO (LOCAL/MAGIC_LINK) account; an admin must link explicitly. New users are provisioned with authMethod: "OIDC", the codebase-wide external-SSO marker.

Any validation failure → generic /login?error=saml redirect; no session is set.

What a full IdP round-trip still needs: the auth UI and the SP↔IdP redirect/POST round-trip are not locally verifiable without a real or sandbox IdP (signed SAMLResponse, cross-site cookies over HTTPS). This PR's bar is unit coverage of the validation + session/provisioning logic plus type-checking; end-to-end SSO should be smoke-tested against a real IdP (e.g. Okta/Azure AD/Keycloak) before GA.

Testing

  • src/server/services/auth/__tests__/saml.test.ts19 tests: validation accept (mocked verified profile) + reject (unsigned / bad-signature / wrong-audience / expired / replayed InResponseTo / null profile), the hardened-options posture, the InResponseTo cache binding, group→team sync, non-SSO link refusal, auto-provision, and samlEnforced gating (incl. "enforced but not configured ⇒ gate off").
  • Affected existing suites green: settings.test.ts, login/__tests__/page.test.tsx, and the load-bearing cross-org-access.test.ts walker (confirms settings.updateSaml is gate-compliant).
  • tsc --noEmit clean (filtered for the pre-existing absent @clickhouse/client); eslint clean on all changed files.
  • New or changed behaviour has test coverage

Per task scope: ran targeted vitest/tsc/eslint rather than project-wide pnpm test/pnpm lint/pnpm build.

Prisma migration checklist

  • Backfill or data migration plan is documented, or confirmed not needed. Not needed — all columns are additive; samlEnabled/samlEnforced default false and the rest are nullable, so existing rows are unaffected and local/OIDC auth is unchanged.
  • Index impact is reviewed for new queries, changed filters, and high-churn tables. No new indexes — SAML settings are read by the existing OrganizationSettings.organizationId unique key (one row per org, per request); no new filters on high-churn tables.
  • TimescaleDB compatibility is reviewed for hypertables, compression, continuous aggregates, and plain PostgreSQL fallback. N/A — OrganizationSettings is a plain PostgreSQL table (not a hypertable); the change only adds scalar columns.
  • Rollback plan is documented, including any manual SQL or data restoration steps. Documented inline in migration.sqlALTER TABLE "OrganizationSettings" DROP COLUMN ... for the six columns; no data restoration needed.

UI changes

  • Settings → Auth: a new SAML SSO Configuration card (enable/enforce switches, IdP Entity ID / SSO URL / signing certificate / group attribute, and the SP metadata + ACS URLs to hand to the IdP).
  • Login page: a Sign in with SAML button when samlEnabled, and SAML-only mode when samlEnforced.

Documentation

  • Added inline code comments where logic is non-obvious (security model in saml.ts, the authMethod/migration rationale, the InResponseTo cookie binding).

Migration checklist

  • Backfill or data migration plan is documented, or confirmed not needed.
  • Index impact is reviewed for new queries, changed filters, and high-churn tables.
  • TimescaleDB compatibility is reviewed for hypertables, compression, continuous aggregates, and plain PostgreSQL fallback.
  • Rollback plan is documented, including any manual SQL or data restoration steps.

Mirror the per-org OIDC surface with SAML 2.0 SP-initiated SSO.

- OrganizationSettings SAML columns + migration 20260608050000_org_saml_settings
  (additive: samlEnabled/IdpEntityId/IdpSsoUrl/IdpCert/Enforced/GroupAttribute).
- src/app/api/auth/saml/{metadata,login,callback} routes. The ACS validates
  the signed response via @node-saml/node-saml: wantAuthnResponseSigned +
  wantAssertionsSigned against the org IdP cert, audience pinned to the SP
  EntityID, InResponseTo bound to a single-use encrypted state cookie,
  RelayState CSRF nonce, and assertion expiry.
- Session minted through the same per-org JWT path OIDC uses
  (getSessionSigningKey + next-auth/jwt encode); group->team reconciliation
  reuses the shared oidcTeamMappings mechanism.
- samlEnforced gates local credential login in src/auth.ts, coexisting with
  OIDC and local auth.
- SAML config card in auth-settings.tsx and a 'Sign in with SAML' login button.
- Focused tests: signature/audience/expiry/replay validation accept + reject,
  group sync, and enforced gating.

IdP signing certs are public, so samlIdpCert is stored plaintext (never
encrypted). Full IdP round-trip needs a real/sandbox IdP; unit tests cover the
validation + session logic.
@github-actions github-actions Bot added feature dependencies Pull requests that update a dependency file labels Jun 8, 2026
…iewer CL-3)

[P1] samlEnforced was gated only in the password provider, so a passkey holder
could bypass enforced SSO (skipping IdP MFA/deprovisioning). Apply the same
getSamlSettings().enforced gate in authorizeWebauthn.
[P2] sanitizeReturnTo used a prefix check that the WHATWG URL parser bypasses via
backslash folding (/\evil.com -> https://evil.com/), a post-auth open redirect.
Validate by resolving against a sentinel origin and rejecting any escape.
Tests: WebAuthn enforced-deny; sanitizeReturnTo rejects backslash escapes + keeps
safe same-origin paths.
@github-actions github-actions Bot added feature and removed feature labels Jun 8, 2026
@github-actions github-actions Bot added feature and removed feature labels Jun 8, 2026
@TerrifiedBug TerrifiedBug merged commit 5bcb41a into main Jun 8, 2026
21 checks passed
@TerrifiedBug TerrifiedBug deleted the feat/cl-3-saml branch June 8, 2026 17:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant