feat(auth): per-org SAML SSO (CL-3)#503
Merged
Merged
Conversation
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.
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds per-organisation SAML 2.0 SSO, mirroring the existing per-org OIDC surface and coexisting with OIDC and local auth.
20260608050000_org_saml_settings— additiveOrganizationSettingscolumns:samlEnabled,samlIdpEntityId,samlIdpSsoUrl,samlIdpCert,samlEnforced,samlGroupAttribute. RLS table → no RLS block.src/app/api/auth/saml/{metadata,login,callback}— SP metadata, SP-initiated AuthnRequest, and the signature-verified ACS.getSessionSigningKey+next-auth/jwtencode, sameid/provider/org_id/authedAtclaims), so the proxy gate andauth()accept it identically.reconcileUserTeamMembershipsoveroidcTeamMappings), keyed bysamlGroupAttribute.samlEnforceddisables local credential login per-org insrc/auth.ts(analogue of the globalVF_DISABLE_LOCAL_AUTH).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
Security model
Verified on every ACS response (
validateSamlResponse→@node-samlvalidatePostResponseAsync):wantAuthnResponseSigned: trueandwantAssertionsSigned: 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 = <origin>/api/auth/saml/metadata); an assertion whoseAudienceRestrictiondoesn't list us is rejected.validateInResponseTo: always. The AuthnRequest id is bound to the browser via a single-use, encrypted state cookie (vf-saml-state, AES-GCM JWE vianext-auth/jwt,SameSite=None; Secureso it survives the IdP's cross-site POST); the cache provider only accepts that one id, and the IdP-echoedRelayStatenonce must match the cookie. IdP-initiated (unsolicited) responses are rejected. The state cookie is cleared on use.NotBefore/NotOnOrAfter+ max assertion age, enforced with a 60s clock-skew tolerance.authMethod: "OIDC", the codebase-wide external-SSO marker.Any validation failure → generic
/login?error=samlredirect; 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.ts— 19 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, andsamlEnforcedgating (incl. "enforced but not configured ⇒ gate off").settings.test.ts,login/__tests__/page.test.tsx, and the load-bearingcross-org-access.test.tswalker (confirmssettings.updateSamlis gate-compliant).tsc --noEmitclean (filtered for the pre-existing absent@clickhouse/client);eslintclean on all changed files.Prisma migration checklist
samlEnabled/samlEnforceddefaultfalseand the rest are nullable, so existing rows are unaffected and local/OIDC auth is unchanged.OrganizationSettings.organizationIdunique key (one row per org, per request); no new filters on high-churn tables.OrganizationSettingsis a plain PostgreSQL table (not a hypertable); the change only adds scalar columns.migration.sql—ALTER TABLE "OrganizationSettings" DROP COLUMN ...for the six columns; no data restoration needed.UI changes
samlEnabled, and SAML-only mode whensamlEnforced.Documentation
saml.ts, theauthMethod/migration rationale, the InResponseTo cookie binding).Migration checklist