From ed6e8cbd0f2d8058c67d02f64192b911fc2956e8 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Mon, 8 Jun 2026 18:10:04 +0100 Subject: [PATCH 1/3] feat(auth): add per-org SAML SSO (CL-3) 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. --- package.json | 1 + pnpm-lock.yaml | 141 +++++- .../migration.sql | 38 ++ prisma/schema.prisma | 14 + src/__tests__/helpers/mock-org-settings.ts | 12 + src/app/(auth)/login/page.tsx | 79 ++- .../settings/_components/auth-settings.tsx | 184 ++++++- src/app/api/auth/saml/callback/route.ts | 106 ++++ src/app/api/auth/saml/login/route.ts | 47 ++ src/app/api/auth/saml/metadata/route.ts | 21 + src/auth.ts | 24 +- src/server/routers/settings.ts | 57 +++ .../services/auth/__tests__/saml.test.ts | 334 +++++++++++++ src/server/services/auth/jwt-key.ts | 22 + src/server/services/auth/saml-config.ts | 97 ++++ src/server/services/auth/saml.ts | 458 ++++++++++++++++++ 16 files changed, 1612 insertions(+), 23 deletions(-) create mode 100644 prisma/migrations/20260608050000_org_saml_settings/migration.sql create mode 100644 src/app/api/auth/saml/callback/route.ts create mode 100644 src/app/api/auth/saml/login/route.ts create mode 100644 src/app/api/auth/saml/metadata/route.ts create mode 100644 src/server/services/auth/__tests__/saml.test.ts create mode 100644 src/server/services/auth/saml-config.ts create mode 100644 src/server/services/auth/saml.ts 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 && ( +
+
+ or +
+
+ )} + + + )}
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"} + +
+
+ + +
+
+
+ +

+ Show a "Sign in with SAML" button on the login page. +

+
+ { markDirty(); setSamlEnabled(v); if (!v) setSamlEnforced(false); }} + /> +
+ +
+
+ +

+ Disable local password login for this organization (SAML becomes mandatory). +

+
+ { markDirty(); setSamlEnforced(v); }} + /> +
+ +
+ + { markDirty(); setSamlIdpEntityId(e.target.value); }} + /> +

+ The IdP's EntityID (issuer) — must match the Issuer in its assertions. +

+
+ +
+ + { markDirty(); setSamlSsoUrl(e.target.value); }} + /> +

+ The IdP single-sign-on endpoint the login redirect is sent to. +

+
+ +
+ +