diff --git a/package.json b/package.json
index b3d1066f6..9386a6312 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,7 @@
"@dagrejs/dagre": "^3.0.0",
"@hookform/resolvers": "^5.4.0",
"@monaco-editor/react": "^4.7.0",
+ "@node-saml/node-saml": "^5.1.0",
"@octokit/rest": "^22.0.1",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 30a7f85ee..aea332156 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -34,6 +34,9 @@ importers:
'@monaco-editor/react':
specifier: ^4.7.0
version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@node-saml/node-saml':
+ specifier: ^5.1.0
+ version: 5.1.0
'@octokit/rest':
specifier: ^22.0.1
version: 22.0.1
@@ -1397,6 +1400,10 @@ packages:
'@nodable/entities@2.1.1':
resolution: {integrity: sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==}
+ '@node-saml/node-saml@5.1.0':
+ resolution: {integrity: sha512-t3cJnZ4aC7HhPZ6MGylGZULvUtBOZ6FzuUndaHGXjmIZHXnLfC/7L8a57O9Q9V7AxJGKAiRM5zu2wNm9EsvQpw==}
+ engines: {node: '>= 18'}
+
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -2983,6 +2990,9 @@ packages:
'@types/dagre@0.7.54':
resolution: {integrity: sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==}
+ '@types/debug@4.1.13':
+ resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
+
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
@@ -3007,6 +3017,9 @@ packages:
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
+ '@types/ms@2.1.0':
+ resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
+
'@types/node-cron@3.0.11':
resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==}
@@ -3022,6 +3035,9 @@ packages:
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
+ '@types/qs@6.15.1':
+ resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
+
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@@ -3045,6 +3061,12 @@ packages:
'@types/validate-npm-package-name@4.0.2':
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
+ '@types/xml-encryption@1.2.4':
+ resolution: {integrity: sha512-I69K/WW1Dv7j6O3jh13z0X8sLWJRXbu5xnHDl9yHzUNDUBtUoBY058eb5s+x/WG6yZC1h8aKdI2EoyEPjyEh+Q==}
+
+ '@types/xml2js@0.4.14':
+ resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==}
+
'@typescript-eslint/eslint-plugin@8.60.0':
resolution: {integrity: sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -3288,6 +3310,14 @@ packages:
'@webassemblyjs/wast-printer@1.14.1':
resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==}
+ '@xmldom/is-dom-node@1.0.1':
+ resolution: {integrity: sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==}
+ engines: {node: '>= 16'}
+
+ '@xmldom/xmldom@0.8.13':
+ resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
+ engines: {node: '>=10.0.0'}
+
'@xtuc/ieee754@1.2.0':
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
@@ -5939,6 +5969,10 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+ sax@1.6.0:
+ resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
+ engines: {node: '>=11.0.0'}
+
saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
@@ -6721,6 +6755,13 @@ packages:
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
engines: {node: '>=20'}
+ xml-crypto@6.1.2:
+ resolution: {integrity: sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==}
+ engines: {node: '>=16'}
+
+ xml-encryption@3.1.0:
+ resolution: {integrity: sha512-PV7qnYpoAMXbf1kvQkqMScLeQpjCMixddAKq9PtqVrho8HnYbBOWNfG0kA4R7zxQDo7w9kiYAyzS/ullAyO55Q==}
+
xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
@@ -6729,9 +6770,33 @@ packages:
resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==}
engines: {node: '>=16.0.0'}
+ xml2js@0.6.2:
+ resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
+ engines: {node: '>=4.0.0'}
+
+ xmlbuilder@11.0.1:
+ resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
+ engines: {node: '>=4.0'}
+
+ xmlbuilder@15.1.1:
+ resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
+ engines: {node: '>=8.0'}
+
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+ xpath@0.0.32:
+ resolution: {integrity: sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==}
+ engines: {node: '>=0.6.0'}
+
+ xpath@0.0.33:
+ resolution: {integrity: sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==}
+ engines: {node: '>=0.6.0'}
+
+ xpath@0.0.34:
+ resolution: {integrity: sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==}
+ engines: {node: '>=0.6.0'}
+
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
@@ -7944,6 +8009,23 @@ snapshots:
'@nodable/entities@2.1.1': {}
+ '@node-saml/node-saml@5.1.0':
+ dependencies:
+ '@types/debug': 4.1.13
+ '@types/qs': 6.15.1
+ '@types/xml-encryption': 1.2.4
+ '@types/xml2js': 0.4.14
+ '@xmldom/is-dom-node': 1.0.1
+ '@xmldom/xmldom': 0.8.13
+ debug: 4.4.3
+ xml-crypto: 6.1.2
+ xml-encryption: 3.1.0
+ xml2js: 0.6.2
+ xmlbuilder: 15.1.1
+ xpath: 0.0.34
+ transitivePeerDependencies:
+ - supports-color
+
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -9622,6 +9704,10 @@ snapshots:
'@types/dagre@0.7.54': {}
+ '@types/debug@4.1.13':
+ dependencies:
+ '@types/ms': 2.1.0
+
'@types/deep-eql@4.0.2': {}
'@types/eslint-scope@3.7.7':
@@ -9644,6 +9730,8 @@ snapshots:
'@types/json5@0.0.29': {}
+ '@types/ms@2.1.0': {}
+
'@types/node-cron@3.0.11': {}
'@types/node@25.9.1':
@@ -9664,6 +9752,8 @@ snapshots:
dependencies:
'@types/node': 25.9.1
+ '@types/qs@6.15.1': {}
+
'@types/react-dom@19.2.3(@types/react@19.2.15)':
dependencies:
'@types/react': 19.2.15
@@ -9685,6 +9775,14 @@ snapshots:
'@types/validate-npm-package-name@4.0.2': {}
+ '@types/xml-encryption@1.2.4':
+ dependencies:
+ '@types/node': 25.9.1
+
+ '@types/xml2js@0.4.14':
+ dependencies:
+ '@types/node': 25.9.1
+
'@typescript-eslint/eslint-plugin@8.60.0(@typescript-eslint/parser@8.60.0(eslint@9.39.3(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0))(typescript@6.0.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -9964,6 +10062,10 @@ snapshots:
'@webassemblyjs/ast': 1.14.1
'@xtuc/long': 4.2.2
+ '@xmldom/is-dom-node@1.0.1': {}
+
+ '@xmldom/xmldom@0.8.13': {}
+
'@xtuc/ieee754@1.2.0': {}
'@xtuc/long@4.2.2': {}
@@ -10830,7 +10932,7 @@ snapshots:
'@next/eslint-plugin-next': 16.2.6
eslint: 9.39.3(jiti@2.7.0)
eslint-import-resolver-node: 0.3.10
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.7.0))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.0(eslint@9.39.3(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)))(eslint@9.39.3(jiti@2.7.0))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.0(eslint@9.39.3(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.7.0))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.3(jiti@2.7.0))
eslint-plugin-react: 7.37.5(eslint@9.39.3(jiti@2.7.0))
@@ -10853,7 +10955,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.7.0)):
+ eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.0(eslint@9.39.3(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)))(eslint@9.39.3(jiti@2.7.0)):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3
@@ -10868,14 +10970,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.0(eslint@9.39.3(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.7.0)):
+ eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.0(eslint@9.39.3(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.0(eslint@9.39.3(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)))(eslint@9.39.3(jiti@2.7.0)))(eslint@9.39.3(jiti@2.7.0)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.60.0(eslint@9.39.3(jiti@2.7.0))(typescript@6.0.3)
eslint: 9.39.3(jiti@2.7.0)
eslint-import-resolver-node: 0.3.10
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.7.0))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.0(eslint@9.39.3(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)))(eslint@9.39.3(jiti@2.7.0))
transitivePeerDependencies:
- supports-color
@@ -10890,7 +10992,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.3(jiti@2.7.0)
eslint-import-resolver-node: 0.3.10
- eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.0(eslint@9.39.3(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.7.0))
+ eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.0(eslint@9.39.3(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.0(eslint@9.39.3(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)))(eslint@9.39.3(jiti@2.7.0)))(eslint@9.39.3(jiti@2.7.0))
hasown: 2.0.4
is-core-module: 2.16.2
is-glob: 4.0.3
@@ -12818,6 +12920,8 @@ snapshots:
safer-buffer@2.1.2: {}
+ sax@1.6.0: {}
+
saxes@6.0.0:
dependencies:
xmlchars: 2.2.0
@@ -13713,12 +13817,39 @@ snapshots:
is-wsl: 3.1.1
powershell-utils: 0.1.0
+ xml-crypto@6.1.2:
+ dependencies:
+ '@xmldom/is-dom-node': 1.0.1
+ '@xmldom/xmldom': 0.8.13
+ xpath: 0.0.33
+
+ xml-encryption@3.1.0:
+ dependencies:
+ '@xmldom/xmldom': 0.8.13
+ escape-html: 1.0.3
+ xpath: 0.0.32
+
xml-name-validator@5.0.0: {}
xml-naming@0.1.0: {}
+ xml2js@0.6.2:
+ dependencies:
+ sax: 1.6.0
+ xmlbuilder: 11.0.1
+
+ xmlbuilder@11.0.1: {}
+
+ xmlbuilder@15.1.1: {}
+
xmlchars@2.2.0: {}
+ xpath@0.0.32: {}
+
+ xpath@0.0.33: {}
+
+ xpath@0.0.34: {}
+
xtend@4.0.2: {}
y18n@4.0.3: {}
diff --git a/prisma/migrations/20260608050000_org_saml_settings/migration.sql b/prisma/migrations/20260608050000_org_saml_settings/migration.sql
new file mode 100644
index 000000000..9a60417b4
--- /dev/null
+++ b/prisma/migrations/20260608050000_org_saml_settings/migration.sql
@@ -0,0 +1,38 @@
+-- Per-org SAML SSO settings (CL-3). Mirrors the existing per-org OIDC
+-- columns on OrganizationSettings and coexists with OIDC + local auth.
+--
+-- All columns are additive:
+-- * samlEnabled / samlEnforced — NOT NULL, DEFAULT false, so every
+-- existing row keeps SAML off and local/OIDC auth unchanged.
+-- * samlIdpEntityId / samlIdpSsoUrl / samlIdpCert / samlGroupAttribute
+-- — nullable TEXT, NULL until an org admin configures its IdP.
+--
+-- `samlIdpCert` holds the IdP's PUBLIC signing certificate (PEM). It is a
+-- public credential — unlike `oidcClientSecret` it is NOT encrypted at rest.
+-- Group→team reconciliation reuses the shared `oidcTeamMappings` mechanism,
+-- keyed by the assertion attribute named in `samlGroupAttribute`.
+--
+-- OrganizationSettings already carries RLS (organizationId-scoped); adding
+-- columns does not change the row-level policy, so no RLS block is needed.
+--
+-- Rollback:
+-- ALTER TABLE "OrganizationSettings"
+-- DROP COLUMN "samlEnabled",
+-- DROP COLUMN "samlIdpEntityId",
+-- DROP COLUMN "samlIdpSsoUrl",
+-- DROP COLUMN "samlIdpCert",
+-- DROP COLUMN "samlEnforced",
+-- DROP COLUMN "samlGroupAttribute";
+
+ALTER TABLE "OrganizationSettings"
+ ADD COLUMN "samlEnabled" BOOLEAN NOT NULL DEFAULT false,
+ ADD COLUMN "samlIdpEntityId" TEXT,
+ ADD COLUMN "samlIdpSsoUrl" TEXT,
+ ADD COLUMN "samlIdpCert" TEXT,
+ ADD COLUMN "samlEnforced" BOOLEAN NOT NULL DEFAULT false,
+ ADD COLUMN "samlGroupAttribute" TEXT;
+
+COMMENT ON COLUMN "OrganizationSettings"."samlIdpCert" IS
+ 'IdP public signing certificate (PEM). Public credential — stored in plaintext, never encrypted.';
+COMMENT ON COLUMN "OrganizationSettings"."samlEnforced" IS
+ 'When TRUE and SAML is fully configured, local credential login is disabled for the org (SAML SSO is mandatory).';
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index c7cd7d3ec..33b0493c2 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -127,6 +127,20 @@ model OrganizationSettings {
oidcTeamMappings String?
oidcDefaultTeamId String?
+ // SAML SSO (per-org; mirrors the OIDC block above). Coexists with OIDC
+ // and local auth. `samlIdpCert` is the IdP's PUBLIC signing certificate,
+ // so — unlike `oidcClientSecret` — it is stored in plaintext and never
+ // encrypted. Group→team reconciliation reuses the shared
+ // `oidcTeamMappings` mechanism, keyed by the assertion attribute named in
+ // `samlGroupAttribute`. `samlEnforced` disables local credential login
+ // for the org once SAML is fully configured (see src/auth.ts).
+ samlEnabled Boolean @default(false)
+ samlIdpEntityId String?
+ samlIdpSsoUrl String?
+ samlIdpCert String?
+ samlEnforced Boolean @default(false)
+ samlGroupAttribute String?
+
// Fleet tuning (moved from SystemSettings)
fleetPollIntervalMs Int @default(15000)
fleetUnhealthyThreshold Int @default(3)
diff --git a/src/__tests__/helpers/mock-org-settings.ts b/src/__tests__/helpers/mock-org-settings.ts
index 83741f99f..df5b6ddc5 100644
--- a/src/__tests__/helpers/mock-org-settings.ts
+++ b/src/__tests__/helpers/mock-org-settings.ts
@@ -32,6 +32,12 @@ export function mockOrgSettings(
oidcTokenEndpointAuthMethod: string | null;
oidcTeamMappings: string | null;
oidcDefaultTeamId: string | null;
+ samlEnabled: boolean;
+ samlIdpEntityId: string | null;
+ samlIdpSsoUrl: string | null;
+ samlIdpCert: string | null;
+ samlEnforced: boolean;
+ samlGroupAttribute: string | null;
fleetPollIntervalMs: number;
fleetUnhealthyThreshold: number;
metricsRetentionDays: number;
@@ -82,6 +88,12 @@ export function mockOrgSettings(
oidcTokenEndpointAuthMethod: "client_secret_post",
oidcTeamMappings: null,
oidcDefaultTeamId: null,
+ samlEnabled: false,
+ samlIdpEntityId: null,
+ samlIdpSsoUrl: null,
+ samlIdpCert: null,
+ samlEnforced: false,
+ samlGroupAttribute: null,
fleetPollIntervalMs: 15000,
fleetUnhealthyThreshold: 3,
metricsRetentionDays: 7,
diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx
index bba5b81e3..0d2272360 100644
--- a/src/app/(auth)/login/page.tsx
+++ b/src/app/(auth)/login/page.tsx
@@ -89,6 +89,8 @@ function LoginPageContent() {
enabled: boolean;
displayName: string;
localAuthDisabled: boolean;
+ samlEnabled: boolean;
+ samlEnforced: boolean;
} | null>(null);
const [checkingSetup, setCheckingSetup] = useState(true);
@@ -99,13 +101,13 @@ function LoginPageContent() {
.catch(() => ({ setupRequired: false })),
fetch("/api/auth/oidc-status")
.then((res) => res.json())
- .catch(() => ({ enabled: false, displayName: "SSO" })),
+ .catch(() => ({ enabled: false, displayName: "SSO", samlEnabled: false, samlEnforced: false })),
]).then(([setup, oidc]) => {
if ((setup as { setupRequired: boolean }).setupRequired) {
router.replace("/setup");
return;
}
- setOidcStatus(oidc as { enabled: boolean; displayName: string; localAuthDisabled: boolean });
+ setOidcStatus(oidc as { enabled: boolean; displayName: string; localAuthDisabled: boolean; samlEnabled: boolean; samlEnforced: boolean });
setCheckingSetup(false);
});
}, [router]);
@@ -145,6 +147,13 @@ function LoginPageContent() {
signIn("oidc", { callbackUrl: "/" });
}
+ function handleSamlLogin() {
+ // SAML is not a NextAuth provider — kick off the SP-initiated flow via our
+ // own route, which redirects to the org IdP and sets the state cookie.
+ const target = searchParams.get("callbackUrl") ?? "/";
+ window.location.href = `/api/auth/saml/login?callbackUrl=${encodeURIComponent(target)}`;
+ }
+
function handleBackToLogin() {
setTotpRequired(false);
setTotpCode("");
@@ -160,7 +169,13 @@ function LoginPageContent() {
);
}
- if (oidcStatus?.localAuthDisabled && !oidcStatus?.enabled) {
+ const oidcEnabled = !!oidcStatus?.enabled;
+ const samlEnabled = !!oidcStatus?.samlEnabled;
+ // Local password login is off when the env flag is set OR the org enforces SAML.
+ const localAuthDisabled = !!(oidcStatus?.localAuthDisabled || oidcStatus?.samlEnforced);
+ const anySsoEnabled = oidcEnabled || samlEnabled;
+
+ if (localAuthDisabled && !anySsoEnabled) {
return (
@@ -176,7 +191,7 @@ function LoginPageContent() {
);
}
- const ssoOnlyMode = oidcStatus?.localAuthDisabled && oidcStatus?.enabled;
+ const ssoOnlyMode = localAuthDisabled && anySsoEnabled;
const content = ssoOnlyMode ? (
@@ -197,16 +212,30 @@ function LoginPageContent() {
)}
-
+ {oidcEnabled && (
+
+ )}
+ {samlEnabled && (
+
+ )}
) : (
@@ -359,6 +388,28 @@ function LoginPageContent() {
>
)}
+
+ {!totpRequired && samlEnabled && (
+ <>
+ {!oidcEnabled && (
+
+ )}
+
+ >
+ )}
diff --git a/src/app/(dashboard)/settings/_components/auth-settings.tsx b/src/app/(dashboard)/settings/_components/auth-settings.tsx
index 83e233222..226129c31 100644
--- a/src/app/(dashboard)/settings/_components/auth-settings.tsx
+++ b/src/app/(dashboard)/settings/_components/auth-settings.tsx
@@ -42,6 +42,7 @@ import {
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
+import { Textarea } from "@/components/ui/textarea";
import { QueryError } from "@/components/query-error";
import { StatusBadge } from "@/components/ui/status-badge";
import {
@@ -139,6 +140,52 @@ export function AuthSettings() {
testOidcMutation.mutate({ issuer });
};
+ // ─── SAML SSO ───────────────────────────────────────────────────────────
+ const [samlEnabled, setSamlEnabled] = useState(false);
+ const [samlEnforced, setSamlEnforced] = useState(false);
+ const [samlIdpEntityId, setSamlIdpEntityId] = useState("");
+ const [samlSsoUrl, setSamlSsoUrl] = useState("");
+ const [samlIdpCert, setSamlIdpCert] = useState("");
+ const [samlGroupAttribute, setSamlGroupAttribute] = useState("");
+
+ useEffect(() => {
+ if (!settings) return;
+ if (hasLoadedRef.current && isDirty) return; // Don't clobber dirty edits on refetch.
+ setSamlEnabled(settings.samlEnabled ?? false);
+ setSamlEnforced(settings.samlEnforced ?? false);
+ setSamlIdpEntityId(settings.samlIdpEntityId ?? "");
+ setSamlSsoUrl(settings.samlIdpSsoUrl ?? "");
+ setSamlIdpCert(settings.samlIdpCert ?? "");
+ setSamlGroupAttribute(settings.samlGroupAttribute ?? "");
+ }, [settings, isDirty]);
+
+ const updateSamlMutation = useMutation(
+ // eslint-disable-next-line react-hooks/refs
+ trpc.settings.updateSaml.mutationOptions({
+ onSuccess: () => {
+ setIsDirty(false);
+ hasLoadedRef.current = false; // Allow next sync from server.
+ queryClient.invalidateQueries({ queryKey: trpc.settings.get.queryKey() });
+ toast.success("SAML settings saved");
+ },
+ onError: (error) => {
+ toast.error(error.message || "Failed to save SAML settings", { duration: 6000 });
+ },
+ })
+ );
+
+ const handleSaveSaml = (e: React.FormEvent) => {
+ e.preventDefault();
+ updateSamlMutation.mutate({
+ enabled: samlEnabled,
+ enforced: samlEnforced,
+ idpEntityId: samlIdpEntityId,
+ ssoUrl: samlSsoUrl,
+ idpCert: samlIdpCert,
+ groupAttribute: samlGroupAttribute,
+ });
+ };
+
const [teamMappings, setTeamMappings] = useState
>([]);
function mergeMappings(
@@ -416,6 +463,141 @@ export function AuthSettings() {
+
+
+
+ SAML SSO Configuration
+
+
+
+ Configure a SAML 2.0 identity provider for single sign-on. Coexists
+ with OIDC and local auth. Group→team mapping reuses the IdP Group
+ Mappings below (set the assertion attribute that carries group names).
+
+
+
+ {settings?.samlEnabled ? "Enabled" : "Disabled"}
+
+
+
+
+
+
+
+
+
+
@@ -423,7 +605,7 @@ export function AuthSettings() {
- Map identity provider groups to teams and roles. Used by both OIDC login (via groups claim) and SCIM sync (via group membership).
+ Map identity provider groups to teams and roles. Used by OIDC login (via groups claim), SAML login (via the configured group attribute), and SCIM sync (via group membership).
diff --git a/src/app/api/auth/saml/callback/route.ts b/src/app/api/auth/saml/callback/route.ts
new file mode 100644
index 000000000..df8225950
--- /dev/null
+++ b/src/app/api/auth/saml/callback/route.ts
@@ -0,0 +1,106 @@
+import { NextResponse } from "next/server";
+import { cookies } from "next/headers";
+
+import { resolveOrgIdFromHost } from "@/lib/host-to-org";
+import { getRequestHostFromHeaders } from "@/lib/request-host";
+import { warnLog } from "@/lib/logger";
+import { getSamlSettings } from "@/server/services/auth/saml-config";
+import {
+ resolveSpEndpoints,
+ consumeSamlState,
+ validateSamlResponse,
+ provisionSamlUser,
+ buildSamlSessionCookie,
+ extractSamlEmail,
+ sanitizeReturnTo,
+ samlStateCookieOptions,
+ SAML_STATE_COOKIE,
+ SAML_LOGIN_ERROR_REDIRECT,
+} from "@/server/services/auth/saml";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+/**
+ * SAML Assertion Consumer Service (ACS). Validates the IdP's signed POST
+ * (signature against the org IdP cert, audience == our SP EntityID,
+ * InResponseTo bound to the issued request via the single-use state cookie,
+ * RelayState CSRF nonce, and assertion expiry — all enforced by
+ * `validateSamlResponse`), provisions/links the user, and mints the same
+ * per-org session an OIDC sign-in would. Any failure redirects to /login with
+ * a generic error; no session is set. Uses 303 so the browser GETs the
+ * post-login target after this POST.
+ */
+export async function POST(request: Request): Promise {
+ const endpoints = resolveSpEndpoints(request);
+ const failUrl = new URL(SAML_LOGIN_ERROR_REDIRECT, endpoints.origin);
+
+ const orgId = await resolveOrgIdFromHost(getRequestHostFromHeaders(request.headers));
+ const settings = await getSamlSettings(orgId);
+ if (!settings) return NextResponse.redirect(failUrl, 303);
+
+ let samlResponse: string | null = null;
+ let relayState: string | null = null;
+ try {
+ const form = await request.formData();
+ const response = form.get("SAMLResponse");
+ const relay = form.get("RelayState");
+ samlResponse = typeof response === "string" ? response : null;
+ relayState = typeof relay === "string" ? relay : null;
+ } catch {
+ return NextResponse.redirect(failUrl, 303);
+ }
+ if (!samlResponse) return NextResponse.redirect(failUrl, 303);
+
+ // CSRF + replay binding: the state cookie must decode, belong to this org,
+ // and carry the same RelayState nonce the IdP echoed back.
+ const cookieStore = await cookies();
+ const state = await consumeSamlState(
+ cookieStore.get(SAML_STATE_COOKIE)?.value,
+ relayState,
+ orgId,
+ );
+ if (!state) return NextResponse.redirect(failUrl, 303);
+
+ let profile;
+ try {
+ // Throws on unsigned / bad-signature / wrong-audience / expired /
+ // InResponseTo-mismatch — never extracts attributes without a verified,
+ // solicited, in-window signature.
+ profile = await validateSamlResponse(settings, endpoints, samlResponse, state.rid);
+ } catch (err) {
+ warnLog("saml", "SAML response validation failed", err);
+ return NextResponse.redirect(failUrl, 303);
+ }
+
+ const email = extractSamlEmail(profile);
+ if (!email) return NextResponse.redirect(failUrl, 303);
+
+ const ipAddress =
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
+ request.headers.get("x-real-ip") ||
+ null;
+ const result = await provisionSamlUser(orgId, settings, profile, ipAddress);
+
+ // Always clear the single-use state cookie, success or fail.
+ const clearState = { ...samlStateCookieOptions(endpoints.secure), maxAge: 0 };
+
+ if ("errorRedirect" in result) {
+ const res = NextResponse.redirect(new URL(result.errorRedirect, endpoints.origin), 303);
+ res.cookies.set(SAML_STATE_COOKIE, "", clearState);
+ return res;
+ }
+
+ const name = typeof profile.displayName === "string" ? profile.displayName : null;
+ const cookie = await buildSamlSessionCookie({
+ orgId,
+ userId: result.userId,
+ name,
+ email,
+ secure: endpoints.secure,
+ });
+ const res = NextResponse.redirect(new URL(sanitizeReturnTo(state.returnTo), endpoints.origin), 303);
+ res.cookies.set(cookie.name, cookie.value, cookie.options);
+ res.cookies.set(SAML_STATE_COOKIE, "", clearState);
+ return res;
+}
diff --git a/src/app/api/auth/saml/login/route.ts b/src/app/api/auth/saml/login/route.ts
new file mode 100644
index 000000000..53be30316
--- /dev/null
+++ b/src/app/api/auth/saml/login/route.ts
@@ -0,0 +1,47 @@
+import { NextResponse } from "next/server";
+
+import { resolveOrgIdFromHost } from "@/lib/host-to-org";
+import { getRequestHostFromHeaders } from "@/lib/request-host";
+import { warnLog } from "@/lib/logger";
+import { getSamlSettings } from "@/server/services/auth/saml-config";
+import {
+ resolveSpEndpoints,
+ beginSamlLogin,
+ sanitizeReturnTo,
+ samlStateCookieOptions,
+ SAML_STATE_COOKIE,
+ SAML_LOGIN_ERROR_REDIRECT,
+} from "@/server/services/auth/saml";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+/**
+ * SP-initiated SAML login. Resolves the org from the request host, builds the
+ * AuthnRequest redirect to the org's IdP SSO URL with a RelayState nonce, and
+ * sets the single-use state cookie that pins the request id + nonce to this
+ * browser for the ACS callback. Redirects to the login page on any failure.
+ */
+export async function GET(request: Request): Promise {
+ const endpoints = resolveSpEndpoints(request);
+ const orgId = await resolveOrgIdFromHost(getRequestHostFromHeaders(request.headers));
+ const settings = await getSamlSettings(orgId);
+ if (!settings) {
+ return NextResponse.redirect(new URL(SAML_LOGIN_ERROR_REDIRECT, endpoints.origin));
+ }
+
+ const url = new URL(request.url);
+ const returnTo = sanitizeReturnTo(
+ url.searchParams.get("callbackUrl") ?? url.searchParams.get("returnTo"),
+ );
+
+ try {
+ const { redirectUrl, stateCookieValue } = await beginSamlLogin(settings, endpoints, returnTo);
+ const res = NextResponse.redirect(redirectUrl);
+ res.cookies.set(SAML_STATE_COOKIE, stateCookieValue, samlStateCookieOptions(endpoints.secure));
+ return res;
+ } catch (err) {
+ warnLog("saml", "Failed to build SAML AuthnRequest", err);
+ return NextResponse.redirect(new URL(SAML_LOGIN_ERROR_REDIRECT, endpoints.origin));
+ }
+}
diff --git a/src/app/api/auth/saml/metadata/route.ts b/src/app/api/auth/saml/metadata/route.ts
new file mode 100644
index 000000000..9bffdbb4b
--- /dev/null
+++ b/src/app/api/auth/saml/metadata/route.ts
@@ -0,0 +1,21 @@
+import { resolveSpEndpoints, generateSamlSpMetadata } from "@/server/services/auth/saml";
+
+// node-saml uses Node crypto + xmldom; never bundle this on the Edge runtime.
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+/**
+ * SAML SP metadata for this org's subdomain. Describes our EntityID and
+ * Assertion Consumer Service so an IdP admin can register the SP. Public and
+ * IdP-independent — safe to serve before the org has pasted its IdP cert.
+ */
+export function GET(request: Request): Response {
+ const xml = generateSamlSpMetadata(resolveSpEndpoints(request));
+ return new Response(xml, {
+ status: 200,
+ headers: {
+ "content-type": "application/samlmetadata+xml; charset=utf-8",
+ "cache-control": "no-store",
+ },
+ });
+}
diff --git a/src/auth.ts b/src/auth.ts
index ae8355780..0b8c8f695 100644
--- a/src/auth.ts
+++ b/src/auth.ts
@@ -36,6 +36,7 @@ import { getRequestHostFromHeaders } from "@/lib/request-host";
import { webauthnProvider } from "@/server/services/auth/webauthn-provider";
import { getJwtSecretForOrg } from "@/server/services/auth/jwt-key";
import { isOidcEmailVerified } from "@/server/services/auth/oidc-email-verified";
+import { getSamlSettings } from "@/server/services/auth/saml-config";
async function getClientIp(): Promise {
try {
@@ -157,6 +158,17 @@ const credentialsProvider = Credentials({
throw new Error("Local authentication is disabled");
}
+ // Per-org SAML enforcement: when an org has SAML fully configured AND
+ // `samlEnforced`, local credential login is disabled for that org (the
+ // analogue of the global `VF_DISABLE_LOCAL_AUTH`, coexisting with OIDC).
+ // `getSamlSettings()` resolves the org from the request host and returns
+ // null unless the IdP config is complete, so a half-configured org can
+ // never lock its users out.
+ const samlCfg = await getSamlSettings();
+ if (samlCfg?.enforced) {
+ throw new Error("Local authentication is disabled for this organization (SAML SSO is enforced)");
+ }
+
if (!credentials?.email || !credentials?.password) return null;
const ipAddress = await getClientIp();
@@ -754,18 +766,24 @@ export async function signOut(...args: any[]) {
}
/**
- * Check whether OIDC SSO is configured (for the login page).
- * This is a server-only function.
+ * SSO status for the login page: whether OIDC and/or SAML are configured for
+ * the org owning the request, plus whether local auth is disabled. SAML is
+ * resolved independently of OIDC so an org can run either, both, or neither.
+ * Server-only.
*/
export async function getOidcStatus(): Promise<{
enabled: boolean;
displayName: string;
localAuthDisabled: boolean;
+ samlEnabled: boolean;
+ samlEnforced: boolean;
}> {
- const oidc = await getOidcSettings();
+ const [oidc, saml] = await Promise.all([getOidcSettings(), getSamlSettings()]);
return {
enabled: !!oidc,
displayName: oidc?.displayName ?? "SSO",
localAuthDisabled: env.VF_DISABLE_LOCAL_AUTH === "true",
+ samlEnabled: !!saml,
+ samlEnforced: saml?.enforced ?? false,
};
}
diff --git a/src/server/routers/settings.ts b/src/server/routers/settings.ts
index 7de516222..170dea64a 100644
--- a/src/server/routers/settings.ts
+++ b/src/server/routers/settings.ts
@@ -102,6 +102,15 @@ export const settingsRouter = router({
}
})(),
oidcDefaultTeamId: settings.oidcDefaultTeamId,
+ // SAML SSO (per-org; mirrors OIDC). `samlIdpCert` is the IdP's PUBLIC
+ // signing cert — returned verbatim (not masked) so the admin can see
+ // the configured value; it is never a secret.
+ samlEnabled: settings.samlEnabled,
+ samlIdpEntityId: settings.samlIdpEntityId,
+ samlIdpSsoUrl: settings.samlIdpSsoUrl,
+ samlIdpCert: settings.samlIdpCert,
+ samlEnforced: settings.samlEnforced,
+ samlGroupAttribute: settings.samlGroupAttribute,
fleetPollIntervalMs: settings.fleetPollIntervalMs,
fleetUnhealthyThreshold: settings.fleetUnhealthyThreshold,
metricsRetentionDays: settings.metricsRetentionDays,
@@ -205,6 +214,54 @@ export const settingsRouter = router({
return result;
}),
+ /**
+ * Per-org SAML SSO config (mirrors `updateOidc`). The IdP signing
+ * certificate is a PUBLIC credential, so — unlike the OIDC client secret —
+ * it is stored verbatim, never encrypted. Enabling requires a complete IdP
+ * config; enforcing requires SAML to be enabled (so an org can't lock its
+ * users out of local auth without a working IdP). Group→team mapping is the
+ * shared `oidcTeamMappings` mechanism, keyed by `samlGroupAttribute`.
+ */
+ updateSaml: protectedProcedure
+ .use(denyInDemo())
+ .use(requireOrgAdmin())
+ .input(
+ z
+ .object({
+ enabled: z.boolean(),
+ enforced: z.boolean(),
+ idpEntityId: z.string().trim().max(2048).default(""),
+ ssoUrl: z.string().trim().max(2048).default(""),
+ idpCert: z.string().trim().max(20000).default(""),
+ groupAttribute: z.string().trim().max(256).default(""),
+ })
+ .refine((d) => !d.ssoUrl || /^https?:\/\//.test(d.ssoUrl), {
+ message: "SSO URL must be a valid http(s) URL.",
+ path: ["ssoUrl"],
+ })
+ .refine((d) => !d.enabled || (!!d.idpEntityId && !!d.ssoUrl && !!d.idpCert), {
+ message: "IdP Entity ID, SSO URL, and certificate are required to enable SAML.",
+ })
+ .refine((d) => !d.enforced || d.enabled, {
+ message: "Enable SAML before enforcing it.",
+ }),
+ )
+ .use(withAudit("settings.saml_updated", "OrganizationSettings"))
+ .mutation(async ({ input, ctx }) => {
+ const result = await updateOrgSettings(ctx.organizationId, {
+ samlEnabled: input.enabled,
+ samlEnforced: input.enforced,
+ samlIdpEntityId: input.idpEntityId || null,
+ samlIdpSsoUrl: input.ssoUrl || null,
+ samlIdpCert: input.idpCert || null,
+ samlGroupAttribute: input.groupAttribute || null,
+ });
+ // Local-auth enforcement is read live from settings, but invalidate the
+ // per-org auth instance for parity with updateOidc.
+ invalidateAuthCache();
+ return result;
+ }),
+
updateOidcRoleMapping: protectedProcedure
.use(denyInDemo())
.use(requireOrgAdmin())
diff --git a/src/server/services/auth/__tests__/saml.test.ts b/src/server/services/auth/__tests__/saml.test.ts
new file mode 100644
index 000000000..59cf1d51e
--- /dev/null
+++ b/src/server/services/auth/__tests__/saml.test.ts
@@ -0,0 +1,360 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended";
+import type { PrismaClient } from "@/generated/prisma";
+
+// ─── @node-saml/node-saml mock ──────────────────────────────────────────────
+// The library does the real XML/signature/audience/expiry/InResponseTo work;
+// we mock it to (a) return a verified profile for the accept path, (b) throw
+// for each reject path, and (c) capture the constructor options + cache
+// provider so we can assert our hardened security posture and replay binding.
+const { samlCtor, validateMock, getAuthorizeUrlMock } = vi.hoisted(() => ({
+ samlCtor: vi.fn(),
+ validateMock: vi.fn(),
+ getAuthorizeUrlMock: vi.fn(),
+}));
+
+vi.mock("@node-saml/node-saml", () => ({
+ SAML: class {
+ constructor(opts: unknown) {
+ samlCtor(opts);
+ }
+ validatePostResponseAsync = validateMock;
+ getAuthorizeUrlAsync = getAuthorizeUrlMock;
+ },
+ generateServiceProviderMetadata: vi.fn(() => ""),
+ ValidateInResponseTo: { never: "never", ifPresent: "ifPresent", always: "always" },
+}));
+
+// ─── dependency mocks ────────────────────────────────────────────────────────
+vi.mock("@/lib/prisma", () => ({ prisma: mockDeep() }));
+vi.mock("@/lib/org-settings", () => ({ getOrgSettings: vi.fn() }));
+vi.mock("@/lib/org-context", () => ({ runWithOrgContext: vi.fn() }));
+vi.mock("@/lib/with-org-tx", () => ({ withOrgTx: vi.fn() }));
+vi.mock("@/server/services/audit", () => ({ writeAuditLog: vi.fn().mockResolvedValue(undefined) }));
+vi.mock("@/server/services/group-mappings", () => ({ reconcileUserTeamMemberships: vi.fn() }));
+vi.mock("@/server/services/auth/jwt-key", () => ({ getSessionSigningKey: vi.fn() }));
+vi.mock("@/lib/logger", () => ({
+ debugLog: vi.fn(),
+ infoLog: vi.fn(),
+ warnLog: vi.fn(),
+ errorLog: vi.fn(),
+}));
+
+import { prisma } from "@/lib/prisma";
+import { getOrgSettings } from "@/lib/org-settings";
+import { runWithOrgContext } from "@/lib/org-context";
+import { withOrgTx } from "@/lib/with-org-tx";
+import { reconcileUserTeamMemberships } from "@/server/services/group-mappings";
+import { mockOrgSettings } from "@/__tests__/helpers/mock-org-settings";
+import {
+ validateSamlResponse,
+ sanitizeReturnTo,
+ provisionSamlUser,
+ extractSamlEmail,
+ extractSamlGroups,
+ type SamlEndpoints,
+} from "@/server/services/auth/saml";
+import { getSamlSettings, type SamlSettings } from "@/server/services/auth/saml-config";
+import type { CacheProvider, Profile } from "@node-saml/node-saml";
+
+const prismaMock = prisma as unknown as DeepMockProxy;
+
+const ENDPOINTS: SamlEndpoints = {
+ origin: "https://acme.vectorflow.sh",
+ secure: true,
+ spEntityId: "https://acme.vectorflow.sh/api/auth/saml/metadata",
+ acsUrl: "https://acme.vectorflow.sh/api/auth/saml/callback",
+};
+
+const SETTINGS: SamlSettings = {
+ organizationId: "org-1",
+ idpEntityId: "https://idp.example.com/entity",
+ ssoUrl: "https://idp.example.com/sso",
+ idpCert: "-----BEGIN CERTIFICATE-----\nMIID...\n-----END CERTIFICATE-----",
+ enforced: false,
+ groupAttribute: "groups",
+};
+
+function verifiedProfile(overrides: Partial = {}): Profile {
+ return {
+ issuer: SETTINGS.idpEntityId,
+ nameID: "user@acme.com",
+ nameIDFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
+ email: "user@acme.com",
+ ...overrides,
+ } as Profile;
+}
+
+beforeEach(() => {
+ mockReset(prismaMock);
+ vi.clearAllMocks();
+ // Default passthroughs for the org-context wrappers.
+ vi.mocked(runWithOrgContext).mockImplementation(
+ async (_orgId: string, fn: () => Promise) => fn(),
+ );
+ vi.mocked(withOrgTx).mockImplementation(
+ ((_orgId: string, fn: (tx: unknown) => Promise) => fn({})) as unknown as typeof withOrgTx,
+ );
+});
+
+describe("validateSamlResponse", () => {
+ it("returns the profile when @node-saml verifies the response", async () => {
+ validateMock.mockResolvedValue({ profile: verifiedProfile(), loggedOut: false });
+
+ const profile = await validateSamlResponse(SETTINGS, ENDPOINTS, "base64-response", "rid-1");
+
+ expect(profile.email).toBe("user@acme.com");
+ expect(validateMock).toHaveBeenCalledWith({ SAMLResponse: "base64-response" });
+ });
+
+ it("constructs node-saml with the hardened security options", async () => {
+ validateMock.mockResolvedValue({ profile: verifiedProfile(), loggedOut: false });
+
+ await validateSamlResponse(SETTINGS, ENDPOINTS, "base64-response", "rid-1");
+
+ expect(samlCtor).toHaveBeenCalledWith(
+ expect.objectContaining({
+ // Trust anchor + identities.
+ idpCert: SETTINGS.idpCert,
+ entryPoint: SETTINGS.ssoUrl,
+ idpIssuer: SETTINGS.idpEntityId,
+ issuer: ENDPOINTS.spEntityId,
+ callbackUrl: ENDPOINTS.acsUrl,
+ // The non-negotiable security gates.
+ wantAuthnResponseSigned: true,
+ wantAssertionsSigned: true,
+ audience: ENDPOINTS.spEntityId,
+ validateInResponseTo: "always",
+ }),
+ );
+ });
+
+ it("binds InResponseTo to the issued request id via the cache provider", async () => {
+ validateMock.mockResolvedValue({ profile: verifiedProfile(), loggedOut: false });
+
+ await validateSamlResponse(SETTINGS, ENDPOINTS, "base64-response", "rid-1");
+
+ const opts = samlCtor.mock.calls[0]![0] as { cacheProvider: CacheProvider };
+ // Only the request id we issued is accepted; any other (replayed/unsolicited
+ // InResponseTo) resolves null, which makes node-saml reject the response.
+ await expect(opts.cacheProvider.getAsync("rid-1")).resolves.not.toBeNull();
+ await expect(opts.cacheProvider.getAsync("rid-other")).resolves.toBeNull();
+ });
+
+ it("rejects when no request id is expected (unsolicited / no state cookie)", async () => {
+ validateMock.mockResolvedValue({ profile: verifiedProfile(), loggedOut: false });
+
+ await validateSamlResponse(SETTINGS, ENDPOINTS, "base64-response", null);
+
+ const opts = samlCtor.mock.calls[0]![0] as { cacheProvider: CacheProvider };
+ await expect(opts.cacheProvider.getAsync("rid-1")).resolves.toBeNull();
+ });
+
+ it.each([
+ ["unsigned response", "SAML assertion is not signed"],
+ ["bad signature", "Invalid signature on documentElement"],
+ ["wrong audience", "SAML assertion audience mismatch. Expected: ..."],
+ ["expired assertion", "SAML assertion expired: clocks skewed too much"],
+ ["replayed InResponseTo", "InResponseTo is not valid"],
+ ])("rejects and yields no profile on %s", async (_label, message) => {
+ validateMock.mockRejectedValue(new Error(message));
+
+ await expect(
+ validateSamlResponse(SETTINGS, ENDPOINTS, "base64-response", "rid-1"),
+ ).rejects.toThrow(message);
+ });
+
+ it("rejects when the library returns a null profile", async () => {
+ validateMock.mockResolvedValue({ profile: null, loggedOut: false });
+
+ await expect(
+ validateSamlResponse(SETTINGS, ENDPOINTS, "base64-response", "rid-1"),
+ ).rejects.toThrow(/did not yield a profile/);
+ });
+});
+
+describe("extractSamlEmail / extractSamlGroups", () => {
+ it("prefers email, then mail, then an email-shaped nameID", () => {
+ expect(extractSamlEmail(verifiedProfile({ email: "a@x.com" }))).toBe("a@x.com");
+ expect(
+ extractSamlEmail(verifiedProfile({ email: undefined, mail: "b@x.com" } as Partial)),
+ ).toBe("b@x.com");
+ expect(
+ extractSamlEmail({ nameID: "c@x.com", nameIDFormat: "", issuer: "" } as Profile),
+ ).toBe("c@x.com");
+ expect(
+ extractSamlEmail({ nameID: "not-an-email", nameIDFormat: "", issuer: "" } as Profile),
+ ).toBeNull();
+ });
+
+ it("normalises single, multi-value, and missing group attributes", () => {
+ expect(extractSamlGroups({ groups: "eng" } as unknown as Profile, "groups")).toEqual(["eng"]);
+ expect(
+ extractSamlGroups({ groups: ["eng", "ops", "eng"] } as unknown as Profile, "groups"),
+ ).toEqual(["eng", "ops"]);
+ expect(
+ extractSamlGroups({ attributes: { Roles: ["admin"] } } as unknown as Profile, "Roles"),
+ ).toEqual(["admin"]);
+ expect(extractSamlGroups(verifiedProfile(), "groups")).toEqual([]);
+ });
+});
+
+describe("provisionSamlUser — group→team reconciliation", () => {
+ it("reconciles team memberships from the configured group attribute", async () => {
+ prismaMock.user.findUnique.mockResolvedValue({
+ id: "u1",
+ email: "user@acme.com",
+ name: "User",
+ authMethod: "OIDC",
+ } as never);
+ vi.mocked(getOrgSettings).mockResolvedValue(
+ mockOrgSettings({ organizationId: "org-1", scimEnabled: false, oidcDefaultTeamId: null }) as never,
+ );
+
+ const profile = verifiedProfile({ groups: ["team-eng", "team-ops"] } as Partial);
+ const result = await provisionSamlUser("org-1", SETTINGS, profile, "1.2.3.4");
+
+ expect(result).toEqual({ userId: "u1" });
+ expect(reconcileUserTeamMemberships).toHaveBeenCalledWith(
+ expect.anything(),
+ "u1",
+ ["team-eng", "team-ops"],
+ "org-1",
+ );
+ // Reconciliation runs inside the org transaction context.
+ expect(withOrgTx).toHaveBeenCalledWith("org-1", expect.any(Function));
+ });
+
+ it("does not reconcile when no group attribute is configured", async () => {
+ prismaMock.user.findUnique.mockResolvedValue({
+ id: "u1",
+ email: "user@acme.com",
+ name: "User",
+ authMethod: "OIDC",
+ } as never);
+ vi.mocked(getOrgSettings).mockResolvedValue(mockOrgSettings({ organizationId: "org-1" }) as never);
+
+ const result = await provisionSamlUser(
+ "org-1",
+ { ...SETTINGS, groupAttribute: null },
+ verifiedProfile({ groups: ["team-eng"] } as Partial),
+ null,
+ );
+
+ expect(result).toEqual({ userId: "u1" });
+ expect(reconcileUserTeamMemberships).not.toHaveBeenCalled();
+ });
+
+ it("refuses to link onto an existing non-SSO (LOCAL) account", async () => {
+ prismaMock.user.findUnique.mockResolvedValue({
+ id: "u2",
+ email: "user@acme.com",
+ name: "Local User",
+ authMethod: "LOCAL",
+ } as never);
+ vi.mocked(getOrgSettings).mockResolvedValue(mockOrgSettings({ organizationId: "org-1" }) as never);
+
+ const result = await provisionSamlUser("org-1", SETTINGS, verifiedProfile(), null);
+
+ expect(result).toEqual({ errorRedirect: "/login?error=local_account" });
+ expect(prismaMock.user.create).not.toHaveBeenCalled();
+ expect(reconcileUserTeamMemberships).not.toHaveBeenCalled();
+ });
+
+ it("auto-provisions a new user with the external-SSO (OIDC) auth marker", async () => {
+ prismaMock.user.findUnique.mockResolvedValue(null);
+ prismaMock.user.create.mockResolvedValue({ id: "u3", email: "user@acme.com", name: "user" } as never);
+ vi.mocked(getOrgSettings).mockResolvedValue(
+ mockOrgSettings({ organizationId: "org-1", oidcDefaultTeamId: null }) as never,
+ );
+
+ const result = await provisionSamlUser(
+ "org-1",
+ { ...SETTINGS, groupAttribute: null },
+ verifiedProfile(),
+ null,
+ );
+
+ expect(result).toEqual({ userId: "u3" });
+ expect(prismaMock.user.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ email: "user@acme.com", authMethod: "OIDC" }),
+ }),
+ );
+ });
+});
+
+describe("getSamlSettings — enforced gating", () => {
+ it("surfaces enforced=true when SAML is enabled and fully configured", async () => {
+ vi.mocked(getOrgSettings).mockResolvedValue(
+ mockOrgSettings({
+ organizationId: "org-1",
+ samlEnabled: true,
+ samlEnforced: true,
+ samlIdpEntityId: "https://idp.example.com/entity",
+ samlIdpSsoUrl: "https://idp.example.com/sso",
+ samlIdpCert: "CERT",
+ }) as never,
+ );
+
+ const settings = await getSamlSettings("org-1");
+ expect(settings).not.toBeNull();
+ expect(settings!.enforced).toBe(true);
+ expect(settings!.idpCert).toBe("CERT");
+ });
+
+ it("returns null (gate OFF) when samlEnforced is set but SAML is not enabled", async () => {
+ // Guards against locking everyone out: enforcement requires a usable IdP.
+ vi.mocked(getOrgSettings).mockResolvedValue(
+ mockOrgSettings({
+ organizationId: "org-1",
+ samlEnabled: false,
+ samlEnforced: true,
+ samlIdpEntityId: "https://idp.example.com/entity",
+ samlIdpSsoUrl: "https://idp.example.com/sso",
+ samlIdpCert: "CERT",
+ }) as never,
+ );
+
+ await expect(getSamlSettings("org-1")).resolves.toBeNull();
+ });
+
+ it("returns null when enabled but the IdP certificate is missing", async () => {
+ vi.mocked(getOrgSettings).mockResolvedValue(
+ mockOrgSettings({
+ organizationId: "org-1",
+ samlEnabled: true,
+ samlIdpEntityId: "https://idp.example.com/entity",
+ samlIdpSsoUrl: "https://idp.example.com/sso",
+ samlIdpCert: null,
+ }) as never,
+ );
+
+ await expect(getSamlSettings("org-1")).resolves.toBeNull();
+ });
+});
+
+describe("sanitizeReturnTo — open-redirect prevention", () => {
+ it("keeps a safe same-origin path (with query + hash)", () => {
+ expect(sanitizeReturnTo("/pipelines?x=1#h")).toBe("/pipelines?x=1#h");
+ });
+
+ it("defaults to / for null / non-relative / protocol-relative inputs", () => {
+ expect(sanitizeReturnTo(null)).toBe("/");
+ expect(sanitizeReturnTo("https://evil.com")).toBe("/");
+ expect(sanitizeReturnTo("//evil.com")).toBe("/");
+ });
+
+ it("rejects backslash paths that fold to a foreign origin (open redirect)", () => {
+ // Backslashes are folded to "/" by the WHATWG URL parser for http(s), so
+ // these escape to https://evil.com/ and MUST be rejected.
+ for (const evil of ["/\\evil.com", "/\\/evil.com", "/\\@evil.com"]) {
+ expect(sanitizeReturnTo(evil)).toBe("/");
+ }
+ });
+
+ it("keeps a tab-containing path same-origin (folds to a local path, not a redirect)", () => {
+ // A tab is stripped, leaving "/evil.com//" — a path on THIS origin, safe.
+ expect(sanitizeReturnTo("/\tevil.com//")).toBe("/evil.com//");
+ });
+});
diff --git a/src/server/services/auth/__tests__/webauthn-provider.test.ts b/src/server/services/auth/__tests__/webauthn-provider.test.ts
index 701b70dcf..2a85901eb 100644
--- a/src/server/services/auth/__tests__/webauthn-provider.test.ts
+++ b/src/server/services/auth/__tests__/webauthn-provider.test.ts
@@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({
finishAuthentication: vi.fn(),
userFindUnique: vi.fn(),
writeAuditLog: vi.fn().mockResolvedValue(undefined),
+ getSamlSettings: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/server/services/webauthn", () => ({
@@ -18,6 +19,9 @@ vi.mock("@/lib/logger", () => ({
warnLog: vi.fn(),
errorLog: vi.fn(),
}));
+vi.mock("@/server/services/auth/saml-config", () => ({
+ getSamlSettings: mocks.getSamlSettings,
+}));
import { authorizeWebauthn } from "../webauthn-provider";
@@ -37,6 +41,14 @@ describe("webauthnProvider", () => {
mocks.finishAuthentication.mockReset();
mocks.userFindUnique.mockReset();
mocks.writeAuditLog.mockClear();
+ mocks.getSamlSettings.mockResolvedValue(null);
+ });
+
+ it("denies WebAuthn login when the org enforces SAML SSO (no passkey bypass)", async () => {
+ mocks.getSamlSettings.mockResolvedValue({ enforced: true });
+ const result = await authorize({ assertionJSON: goodAssertion });
+ expect(result).toBeNull();
+ expect(mocks.finishAuthentication).not.toHaveBeenCalled();
});
it("returns the NextAuth user on a successful assertion", async () => {
diff --git a/src/server/services/auth/jwt-key.ts b/src/server/services/auth/jwt-key.ts
index ade017515..345398050 100644
--- a/src/server/services/auth/jwt-key.ts
+++ b/src/server/services/auth/jwt-key.ts
@@ -148,6 +148,28 @@ export async function getJwtSecretForOrg(orgId: string): Promise {
return { value: Buffer.from(envSecret, "utf8"), fromEnv: true, kmsFailure: false, rotationCounter: 0 };
}
+/**
+ * The single JWT signing key (as a string) that `auth.ts` passes as the
+ * FIRST entry of NextAuth's `secret` array. The custom SAML ACS route mints
+ * its session cookie with this exact key so a SAML-issued JWT verifies
+ * identically to an OIDC- or credentials-issued one — same per-org signing
+ * key, same `org_id` claim binding.
+ *
+ * - env-fallback orgs (no DEK): the raw `NEXTAUTH_SECRET` string, matching
+ * `auth.ts`'s `secretArg = [process.env.NEXTAUTH_SECRET!]`.
+ * - DEK-provisioned orgs: the base64url encoding of the derived key,
+ * matching `auth.ts`'s `jwtKeyResult.value.toString("base64url")`.
+ *
+ * Throws (via `getJwtSecretForOrg`) when neither a per-org DEK nor
+ * `NEXTAUTH_SECRET` is configured.
+ */
+export async function getSessionSigningKey(orgId: string): Promise {
+ const result = await getJwtSecretForOrg(orgId);
+ return result.fromEnv
+ ? process.env.NEXTAUTH_SECRET!
+ : result.value.toString("base64url");
+}
+
export interface RevokeOrgSessionsRequestor {
/** "customer" = owner/admin self-serve; "operator" = a platform-operator account. */
kind: "customer" | "operator";
diff --git a/src/server/services/auth/saml-config.ts b/src/server/services/auth/saml-config.ts
new file mode 100644
index 000000000..5b4141480
--- /dev/null
+++ b/src/server/services/auth/saml-config.ts
@@ -0,0 +1,97 @@
+/**
+ * Per-organisation SAML SSO settings loader.
+ *
+ * Mirrors `getOidcSettings` in `src/auth.ts`:
+ * - The request host's first DNS label is matched against
+ * `Organization.slug`; each tenant sees only its own IdP. A session
+ * minted for org A can never be obtained through org B's IdP.
+ * - Hosts without an org-slug subdomain fall back to `DEFAULT_ORG_ID`
+ * so self-hosted (single-org) deployments behave unchanged.
+ * - Reads are RLS-fenced (`OrganizationSettings`), so they run inside
+ * `runWithOrgContext(orgId, …)` — these run PRE-AUTH (login page probing
+ * status, the SP login route) where there is no ambient org scope.
+ *
+ * This module deliberately does NOT import `@node-saml/node-saml`: it is
+ * imported by `src/auth.ts` (the `samlEnforced` local-auth gate), and we keep
+ * the XML/crypto library off that hot import path. The SAML library lives in
+ * the sibling `saml.ts` service.
+ *
+ * `getSamlSettings` returns null unless SAML is BOTH enabled AND fully
+ * configured (entityId + SSO URL + IdP certificate). That invariant means a
+ * misconfigured org can never trip the `samlEnforced` gate and lock every
+ * user out — enforcement requires a usable IdP first.
+ */
+
+import { isBuildPhase } from "@/lib/env";
+import { getOrgSettings } from "@/lib/org-settings";
+import { resolveOrgIdFromHost } from "@/lib/host-to-org";
+import { runWithOrgContext } from "@/lib/org-context";
+import { getRequestHostFromHeaders } from "@/lib/request-host";
+import { headers } from "next/headers";
+
+/** Default SAML attribute name carrying the user's group memberships. */
+export const SAML_DEFAULT_GROUP_ATTRIBUTE = "groups";
+
+export interface SamlSettings {
+ organizationId: string;
+ /** IdP EntityID — the expected `Issuer` of the SAML response/assertion. */
+ idpEntityId: string;
+ /** IdP Single-Sign-On URL — destination for the SP-initiated AuthnRequest. */
+ ssoUrl: string;
+ /** IdP public signing certificate (PEM or bare base64). Used to verify the
+ * response/assertion signature. A PUBLIC credential — never encrypted. */
+ idpCert: string;
+ /** When true (and the config is complete) local credential login is disabled. */
+ enforced: boolean;
+ /** Assertion attribute name holding group names for team reconciliation,
+ * or null when group→team sync is not configured for this org. */
+ groupAttribute: string | null;
+}
+
+/**
+ * Load and validate SAML settings for the org owning the incoming request
+ * (or `orgIdOverride` when the caller already resolved the org from the
+ * request). Returns null during the build phase, when SAML is disabled, or
+ * when any required IdP field is missing.
+ */
+export async function getSamlSettings(
+ orgIdOverride?: string,
+): Promise {
+ if (isBuildPhase) return null;
+
+ try {
+ let orgId = orgIdOverride;
+ if (!orgId) {
+ let host: string | null = null;
+ try {
+ host = getRequestHostFromHeaders(await headers());
+ } catch {
+ // headers() is unavailable outside a request scope — fall back below.
+ }
+ orgId = await resolveOrgIdFromHost(host);
+ }
+
+ return await runWithOrgContext(orgId, async () => {
+ const settings = await getOrgSettings(orgId);
+ if (
+ !settings?.samlEnabled ||
+ !settings.samlIdpEntityId ||
+ !settings.samlIdpSsoUrl ||
+ !settings.samlIdpCert
+ ) {
+ return null;
+ }
+ return {
+ organizationId: orgId,
+ idpEntityId: settings.samlIdpEntityId,
+ ssoUrl: settings.samlIdpSsoUrl,
+ idpCert: settings.samlIdpCert,
+ enforced: settings.samlEnforced,
+ groupAttribute: settings.samlGroupAttribute,
+ };
+ });
+ } catch {
+ // DB may be unavailable (e.g. during build) — treat as "no SAML".
+ return null;
+ }
+}
diff --git a/src/server/services/auth/saml.ts b/src/server/services/auth/saml.ts
new file mode 100644
index 000000000..a26b77797
--- /dev/null
+++ b/src/server/services/auth/saml.ts
@@ -0,0 +1,467 @@
+/**
+ * Per-organisation SAML 2.0 SSO service (CL-3).
+ *
+ * Security model — all XML parsing and signature/crypto handling is delegated
+ * to the maintained `@node-saml/node-saml` library. We NEVER hand-roll XML or
+ * signature checks. The SP-initiated flow enforces, on the ACS response:
+ *
+ * - signature: `wantAuthnResponseSigned` + `wantAssertionsSigned` are forced
+ * true and the response/assertion is verified against the org's configured
+ * IdP signing certificate (`samlIdpCert`). Unsigned or bad-signature
+ * responses are rejected.
+ * - audience: `audience` is pinned to our SP EntityID; an assertion whose
+ * `AudienceRestriction` does not list us is rejected.
+ * - replay / CSRF: `validateInResponseTo: always` requires every response to
+ * answer an AuthnRequest WE issued. The request id is bound to the browser
+ * via a single-use, encrypted state cookie, and the IdP-echoed `RelayState`
+ * nonce must match that cookie. IdP-initiated (unsolicited) responses are
+ * rejected.
+ * - expiry: NotBefore / NotOnOrAfter (and max assertion age) are enforced by
+ * the library with a small clock-skew tolerance.
+ *
+ * A SAML-authenticated session is minted through the SAME per-org JWT path
+ * OIDC/credentials use — `encode()` from `next-auth/jwt` with the per-org
+ * signing key (`getSessionSigningKey`) and the same claim shape
+ * (`id`, `provider`, `org_id`, `authedAt`) — so the proxy gate and `auth()`
+ * accept it identically to any other session. Group→team reconciliation reuses
+ * the shared OIDC mapping mechanism (`reconcileUserTeamMemberships` over
+ * `oidcTeamMappings`).
+ */
+
+import { SAML, generateServiceProviderMetadata, ValidateInResponseTo } from "@node-saml/node-saml";
+import type { CacheProvider, Profile } from "@node-saml/node-saml";
+import { encode, decode } from "next-auth/jwt";
+import { randomUUID } from "node:crypto";
+
+import { prisma } from "@/lib/prisma";
+import { getOrgSettings } from "@/lib/org-settings";
+import { withOrgTx } from "@/lib/with-org-tx";
+import { runWithOrgContext } from "@/lib/org-context";
+import { writeAuditLog } from "@/server/services/audit";
+import { debugLog, warnLog } from "@/lib/logger";
+import { getSessionSigningKey } from "@/server/services/auth/jwt-key";
+import { reconcileUserTeamMemberships } from "@/server/services/group-mappings";
+import { authConfig } from "@/auth.config";
+import type { SamlSettings } from "@/server/services/auth/saml-config";
+
+/** Login-page redirect target for any SAML failure (kept generic — details
+ * go to the audit log / server logs, never to the user-facing URL). */
+export const SAML_LOGIN_ERROR_REDIRECT = "/login?error=saml";
+
+/** Short-lived, single-use cookie binding the AuthnRequest id + RelayState
+ * nonce to the browser between `/login` and the ACS `/callback`. */
+export const SAML_STATE_COOKIE = "vf-saml-state";
+const SAML_STATE_MAX_AGE_S = 600; // 10 minutes to complete the round-trip.
+
+/** Session lifetime — matches Auth.js's default JWT session maxAge so a
+ * SAML-minted cookie expires on the same schedule as an OIDC one. */
+const SESSION_MAX_AGE_S = 30 * 24 * 60 * 60;
+
+/** Tolerate modest IdP/SP clock drift on the NotBefore/NotOnOrAfter checks. */
+const SAML_CLOCK_SKEW_MS = 60_000;
+
+export interface SamlEndpoints {
+ origin: string;
+ secure: boolean;
+ /** SP EntityID == our metadata URL; also the expected assertion Audience. */
+ spEntityId: string;
+ /** Assertion Consumer Service URL (the ACS / callback). */
+ acsUrl: string;
+}
+
+interface SamlState {
+ /** AuthnRequest id we issued — must equal the response's InResponseTo. */
+ rid: string;
+ /** Random RelayState nonce — must equal the IdP-echoed RelayState. */
+ relay: string;
+ /** Org the login was started for — re-checked against the host on callback. */
+ org: string;
+ /** Local post-login path. */
+ returnTo: string;
+}
+
+/**
+ * Derive this request's externally-visible SP endpoints. Behind a TLS-
+ * terminating proxy the forwarded headers carry the real scheme/host, so the
+ * SP EntityID/ACS match what the browser (and therefore the IdP) sees. Each
+ * org subdomain yields its own EntityID/ACS, which is what the IdP registers.
+ */
+export function resolveSpEndpoints(request: Request): SamlEndpoints {
+ const url = new URL(request.url);
+ const fwdProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
+ const fwdHost = request.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
+ const host = fwdHost || request.headers.get("host") || url.host;
+ const proto = fwdProto || url.protocol.replace(/:$/, "");
+ const origin = `${proto}://${host}`;
+ return {
+ origin,
+ secure: proto === "https",
+ spEntityId: `${origin}/api/auth/saml/metadata`,
+ acsUrl: `${origin}/api/auth/saml/callback`,
+ };
+}
+
+/** Attributes for the single-use SAML state cookie. On HTTPS it MUST be
+ * `SameSite=None; Secure` so the browser sends it on the IdP's cross-site
+ * POST to our ACS; on plain HTTP (dev only) we degrade to Lax. */
+export function samlStateCookieOptions(secure: boolean) {
+ return {
+ httpOnly: true,
+ secure,
+ sameSite: secure ? ("none" as const) : ("lax" as const),
+ path: "/",
+ maxAge: SAML_STATE_MAX_AGE_S,
+ };
+}
+
+function buildSamlInstance(
+ settings: SamlSettings,
+ endpoints: SamlEndpoints,
+ cacheProvider: CacheProvider,
+): SAML {
+ return new SAML({
+ // SP identity (AuthnRequest issuer + expected assertion audience).
+ issuer: endpoints.spEntityId,
+ callbackUrl: endpoints.acsUrl,
+ audience: endpoints.spEntityId,
+ // IdP identity + trust anchor.
+ entryPoint: settings.ssoUrl,
+ idpCert: settings.idpCert,
+ idpIssuer: settings.idpEntityId,
+ // Hard security requirements — reject anything not fully signed by the
+ // configured IdP cert, replayed, or unsolicited.
+ wantAuthnResponseSigned: true,
+ wantAssertionsSigned: true,
+ validateInResponseTo: ValidateInResponseTo.always,
+ acceptedClockSkewMs: SAML_CLOCK_SKEW_MS,
+ cacheProvider,
+ });
+}
+
+/** SP metadata XML describing this org's EntityID + ACS. Independent of the
+ * IdP config so it can be served before an admin pastes the IdP cert. */
+export function generateSamlSpMetadata(endpoints: SamlEndpoints): string {
+ return generateServiceProviderMetadata({
+ issuer: endpoints.spEntityId,
+ callbackUrl: endpoints.acsUrl,
+ wantAssertionsSigned: true,
+ });
+}
+
+function samlStateSecret(): string {
+ const secret = process.env.AUTH_SECRET ?? process.env.NEXTAUTH_SECRET;
+ if (!secret) {
+ throw new Error("SAML state cookie requires AUTH_SECRET / NEXTAUTH_SECRET");
+ }
+ return secret;
+}
+
+async function decodeSamlState(value: string | undefined): Promise {
+ if (!value) return null;
+ try {
+ const decoded = await decode({
+ salt: SAML_STATE_COOKIE,
+ secret: samlStateSecret(),
+ token: value,
+ });
+ if (
+ !decoded ||
+ typeof decoded.rid !== "string" ||
+ typeof decoded.relay !== "string" ||
+ typeof decoded.org !== "string" ||
+ typeof decoded.returnTo !== "string"
+ ) {
+ return null;
+ }
+ return {
+ rid: decoded.rid,
+ relay: decoded.relay,
+ org: decoded.org,
+ returnTo: decoded.returnTo,
+ };
+ } catch {
+ // Tampered/expired cookie — fail closed (no expected request id ⇒ the
+ // InResponseTo check will reject the response anyway).
+ return null;
+ }
+}
+
+/**
+ * Build the SP-initiated AuthnRequest redirect for an org and the encrypted
+ * state cookie that binds the issued request id + RelayState nonce to the
+ * browser. `returnTo` must already be a sanitised local path.
+ */
+export async function beginSamlLogin(
+ settings: SamlSettings,
+ endpoints: SamlEndpoints,
+ returnTo: string,
+): Promise<{ redirectUrl: string; stateCookieValue: string }> {
+ // Capture the request id node-saml generates (saved via the cache provider
+ // because validateInResponseTo !== never) so we can pin it in the cookie.
+ let issuedRequestId: string | null = null;
+ const captureCache: CacheProvider = {
+ saveAsync: async (key, value) => {
+ issuedRequestId = key;
+ return { value, createdAt: Date.now() };
+ },
+ getAsync: async () => null,
+ removeAsync: async () => null,
+ };
+
+ const relay = randomUUID();
+ const saml = buildSamlInstance(settings, endpoints, captureCache);
+ const redirectUrl = await saml.getAuthorizeUrlAsync(relay, undefined, {});
+ if (!issuedRequestId) {
+ throw new Error("SAML AuthnRequest id was not generated");
+ }
+
+ const stateCookieValue = await encode({
+ salt: SAML_STATE_COOKIE,
+ secret: samlStateSecret(),
+ token: { rid: issuedRequestId, relay, org: settings.organizationId, returnTo },
+ maxAge: SAML_STATE_MAX_AGE_S,
+ });
+ return { redirectUrl, stateCookieValue };
+}
+
+/**
+ * Signature/audience/expiry/InResponseTo validation of an ACS POST. Throws if
+ * the response is unsigned, badly signed, has the wrong audience, is expired,
+ * or its InResponseTo does not match `expectedRequestId`. The expected id is
+ * accepted by the cache provider for exactly this single request id, so a
+ * replayed response (or one we never solicited) is rejected.
+ */
+export async function validateSamlResponse(
+ settings: SamlSettings,
+ endpoints: SamlEndpoints,
+ samlResponse: string,
+ expectedRequestId: string | null,
+): Promise {
+ const expectCache: CacheProvider = {
+ saveAsync: async (key, value) => ({ value, createdAt: Date.now() }),
+ getAsync: async (key) =>
+ expectedRequestId && key === expectedRequestId ? String(Date.now()) : null,
+ removeAsync: async () => null,
+ };
+
+ const saml = buildSamlInstance(settings, endpoints, expectCache);
+ const { profile } = await saml.validatePostResponseAsync({ SAMLResponse: samlResponse });
+ if (!profile) {
+ throw new Error("SAML response did not yield a profile");
+ }
+ return profile;
+}
+
+/**
+ * Resolve + CSRF-check the state cookie against the IdP-echoed RelayState and
+ * the host-resolved org. Returns the validated state or null (caller rejects).
+ */
+export async function consumeSamlState(
+ cookieValue: string | undefined,
+ relayStateFromForm: string | null,
+ orgId: string,
+): Promise {
+ const state = await decodeSamlState(cookieValue);
+ if (!state) return null;
+ if (state.org !== orgId) return null;
+ if (!relayStateFromForm || relayStateFromForm !== state.relay) return null;
+ return state;
+}
+
+/** First email-shaped value among the standard profile fields. */
+export function extractSamlEmail(profile: Profile): string | null {
+ for (const candidate of [profile.email, profile.mail, profile.nameID]) {
+ if (typeof candidate === "string" && candidate.includes("@")) {
+ return candidate.trim().toLowerCase();
+ }
+ }
+ return null;
+}
+
+/** Group names from the configured assertion attribute, normalised to a
+ * deduped string list (single value, multi-value array, or attributes map). */
+export function extractSamlGroups(profile: Profile, attribute: string): string[] {
+ const attrs = profile.attributes as Record | undefined;
+ const raw = (profile as Record)[attribute] ?? attrs?.[attribute];
+ if (raw == null) return [];
+ const values = Array.isArray(raw) ? raw : [raw];
+ return [...new Set(values.map((v) => String(v).trim()).filter(Boolean))];
+}
+
+export type SamlProvisionResult =
+ | { userId: string }
+ | { errorRedirect: string };
+
+/**
+ * Provision/link the SAML user and reconcile team memberships. Mirrors the
+ * OIDC `signIn` callback in `src/auth.ts`:
+ * - auto-creates the user with `authMethod: "OIDC"` (the codebase-wide
+ * external-SSO marker keyed off by the UI, 2FA, and password-reset gates;
+ * a dedicated SAML enum value would need an `AuthMethod` migration, which
+ * the additive OrganizationSettings migration deliberately avoids);
+ * - refuses to link onto an existing NON-SSO (LOCAL / MAGIC_LINK) account —
+ * an admin must link it explicitly, never via implicit email collision;
+ * - reconciles team memberships from the group attribute via the shared
+ * `oidcTeamMappings` mechanism, unioning SCIM groups when SCIM is enabled,
+ * and falls back to the shared default team.
+ * Runs inside the org RLS context so fenced reads/writes see this org's rows.
+ */
+export async function provisionSamlUser(
+ orgId: string,
+ settings: SamlSettings,
+ profile: Profile,
+ ipAddress: string | null,
+): Promise {
+ return runWithOrgContext(orgId, async () => {
+ const email = extractSamlEmail(profile);
+ if (!email) {
+ warnLog("saml", "SAML assertion carried no email/nameID — cannot provision.");
+ return { errorRedirect: SAML_LOGIN_ERROR_REDIRECT };
+ }
+ const name =
+ (typeof profile.displayName === "string" && profile.displayName) ||
+ (typeof profile.cn === "string" && profile.cn) ||
+ email.split("@")[0];
+
+ let dbUser = await prisma.user.findUnique({ where: { email } });
+ if (!dbUser) {
+ dbUser = await prisma.user.create({
+ data: { email, name, authMethod: "OIDC" },
+ });
+ writeAuditLog({
+ organizationId: orgId, userId: dbUser.id, action: "auth.user_provisioned",
+ entityType: "Auth", entityId: "saml", ipAddress, userEmail: email, userName: name,
+ }).catch(() => {});
+ } else if (dbUser.authMethod && dbUser.authMethod !== "OIDC") {
+ writeAuditLog({
+ organizationId: orgId, userId: dbUser.id, action: "auth.saml_link_blocked",
+ entityType: "Auth", entityId: "saml", ipAddress, userEmail: dbUser.email, userName: dbUser.name,
+ metadata: { reason: "non_sso_account_exists", existingAuthMethod: dbUser.authMethod },
+ }).catch(() => {});
+ warnLog("saml", `SAML login blocked: existing account uses ${dbUser.authMethod} for ${dbUser.email}. Admin must link accounts explicitly.`);
+ return { errorRedirect: "/login?error=local_account" };
+ }
+
+ if (settings.groupAttribute) {
+ const tokenGroups = extractSamlGroups(profile, settings.groupAttribute);
+ debugLog("saml", `User ${email} groups (attr "${settings.groupAttribute}"):`, tokenGroups);
+ const orgSettings = await getOrgSettings(orgId);
+
+ let userGroupNames = tokenGroups;
+ if (orgSettings.scimEnabled) {
+ // SCIM+SAML: union of provisioned ScimGroupMember groups + assertion
+ // groups (SAML does not write ScimGroupMember). Mirrors OIDC.
+ const scimGroups = await prisma.scimGroupMember.findMany({
+ where: { userId: dbUser.id },
+ include: { scimGroup: { select: { displayName: true } } },
+ });
+ userGroupNames = [
+ ...new Set([...scimGroups.map((g) => g.scimGroup.displayName), ...tokenGroups]),
+ ];
+ }
+
+ const userId = dbUser.id;
+ await withOrgTx(orgId, async (tx) => {
+ await reconcileUserTeamMemberships(tx, userId, userGroupNames, orgId);
+ });
+
+ // Shared default-team fallback (reuses the OIDC default-team config).
+ if (orgSettings.oidcDefaultTeamId) {
+ const hasMembership = await prisma.teamMember.findFirst({ where: { userId } });
+ if (!hasMembership) {
+ await prisma.teamMember.upsert({
+ where: { userId_teamId: { userId, teamId: orgSettings.oidcDefaultTeamId } },
+ create: {
+ userId,
+ teamId: orgSettings.oidcDefaultTeamId,
+ role: orgSettings.oidcDefaultRole ?? "VIEWER",
+ source: "group_mapping",
+ },
+ update: {},
+ });
+ }
+ }
+ }
+
+ writeAuditLog({
+ organizationId: orgId, userId: dbUser.id, action: "auth.login_success",
+ entityType: "Auth", entityId: "saml", ipAddress, userEmail: dbUser.email, userName: dbUser.name,
+ }).catch(() => {});
+ return { userId: dbUser.id };
+ });
+}
+
+interface SessionCookieSpec {
+ name: string;
+ value: string;
+ options: {
+ httpOnly: true;
+ sameSite: "lax";
+ path: "/";
+ secure: boolean;
+ maxAge: number;
+ };
+}
+
+/**
+ * Mint the NextAuth session cookie for a SAML-authenticated user. Uses the
+ * same per-org signing key, cookie name (strict `__Host-` override or the
+ * Auth.js default for the connection), and claim shape as the OIDC/credentials
+ * path, so the proxy gate and `auth()` validate it identically.
+ */
+export async function buildSamlSessionCookie(params: {
+ orgId: string;
+ userId: string;
+ name: string | null;
+ email: string;
+ secure: boolean;
+}): Promise {
+ const overrideName = authConfig.cookies?.sessionToken?.name;
+ const name =
+ overrideName ??
+ (params.secure ? "__Secure-authjs.session-token" : "authjs.session-token");
+ const secret = await getSessionSigningKey(params.orgId);
+ const value = await encode({
+ salt: name,
+ secret,
+ maxAge: SESSION_MAX_AGE_S,
+ token: {
+ id: params.userId,
+ sub: params.userId,
+ name: params.name,
+ email: params.email,
+ picture: null,
+ provider: "saml",
+ org_id: params.orgId,
+ authedAt: Date.now(),
+ },
+ });
+ return {
+ name,
+ value,
+ options: {
+ httpOnly: true,
+ sameSite: "lax",
+ path: "/",
+ secure: params.secure || name.startsWith("__Secure-") || name.startsWith("__Host-"),
+ maxAge: SESSION_MAX_AGE_S,
+ },
+ };
+}
+
+/** Ensure a post-login redirect target stays on this origin (no open redirect). */
+export function sanitizeReturnTo(raw: string | null | undefined): string {
+ // Must be a relative path. Resolve against a sentinel origin and confirm it
+ // did NOT escape to a foreign origin: the WHATWG URL parser folds backslashes
+ // and strips tab/newline for http(s), so prefix checks like `!startsWith("//")`
+ // are bypassable (e.g. "/\\evil.com" -> https://evil.com/). Compare origins.
+ if (typeof raw !== "string" || !raw.startsWith("/")) return "/";
+ try {
+ const base = "https://sp.invalid";
+ const u = new URL(raw, base);
+ if (u.origin !== base) return "/";
+ return u.pathname + u.search + u.hash;
+ } catch {
+ return "/";
+ }
+}
diff --git a/src/server/services/auth/webauthn-provider.ts b/src/server/services/auth/webauthn-provider.ts
index b5dcae84e..2ab559ae6 100644
--- a/src/server/services/auth/webauthn-provider.ts
+++ b/src/server/services/auth/webauthn-provider.ts
@@ -36,6 +36,7 @@ import { finishAuthentication } from "@/server/services/webauthn";
import { writeAuditLog } from "@/server/services/audit";
import { warnLog, infoLog } from "@/lib/logger";
import { getRemainingLockSeconds } from "@/server/services/login-protection";
+import { getSamlSettings } from "@/server/services/auth/saml-config";
const RP_ID = process.env.VF_WEBAUTHN_RP_ID ?? "localhost";
const RP_NAME = process.env.VF_WEBAUTHN_RP_NAME ?? "VectorFlow";
@@ -148,6 +149,18 @@ export async function authorizeWebauthn(
warnLog("webauthn-provider", "VF_DISABLE_LOCAL_AUTH is set; denying WebAuthn login");
return null;
}
+ // Per-org SAML enforcement must block passkeys too — otherwise a previously
+ // registered WebAuthn credential bypasses enforced SSO (skipping IdP MFA /
+ // deprovisioning). getSamlSettings() returns null unless SAML is fully
+ // configured, so a half-configured org can't lock its users out.
+ const samlCfg = await getSamlSettings();
+ if (samlCfg?.enforced) {
+ warnLog(
+ "webauthn-provider",
+ "SAML SSO is enforced for this org; denying WebAuthn login",
+ );
+ return null;
+ }
let assertion: AuthenticationResponseJSON;
try {