@hammadj/better-auth-sso 1.5.0-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.turbo/turbo-build.log +116 -0
  2. package/LICENSE.md +20 -0
  3. package/dist/client.d.mts +10 -0
  4. package/dist/client.mjs +15 -0
  5. package/dist/client.mjs.map +1 -0
  6. package/dist/index.d.mts +738 -0
  7. package/dist/index.mjs +2953 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/package.json +87 -0
  10. package/src/client.ts +29 -0
  11. package/src/constants.ts +58 -0
  12. package/src/domain-verification.test.ts +551 -0
  13. package/src/index.ts +265 -0
  14. package/src/linking/index.ts +2 -0
  15. package/src/linking/org-assignment.test.ts +325 -0
  16. package/src/linking/org-assignment.ts +176 -0
  17. package/src/linking/types.ts +10 -0
  18. package/src/oidc/discovery.test.ts +1157 -0
  19. package/src/oidc/discovery.ts +494 -0
  20. package/src/oidc/errors.ts +92 -0
  21. package/src/oidc/index.ts +31 -0
  22. package/src/oidc/types.ts +219 -0
  23. package/src/oidc.test.ts +688 -0
  24. package/src/providers.test.ts +1326 -0
  25. package/src/routes/domain-verification.ts +275 -0
  26. package/src/routes/providers.ts +565 -0
  27. package/src/routes/schemas.ts +96 -0
  28. package/src/routes/sso.ts +2750 -0
  29. package/src/saml/algorithms.test.ts +449 -0
  30. package/src/saml/algorithms.ts +338 -0
  31. package/src/saml/assertions.test.ts +239 -0
  32. package/src/saml/assertions.ts +62 -0
  33. package/src/saml/index.ts +13 -0
  34. package/src/saml/parser.ts +56 -0
  35. package/src/saml-state.ts +78 -0
  36. package/src/saml.test.ts +4319 -0
  37. package/src/types.ts +365 -0
  38. package/src/utils.test.ts +103 -0
  39. package/src/utils.ts +81 -0
  40. package/tsconfig.json +14 -0
  41. package/tsdown.config.ts +9 -0
  42. package/vitest.config.ts +3 -0
@@ -0,0 +1,4319 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { createServer } from "node:http";
3
+ import { betterFetch } from "@better-fetch/fetch";
4
+ import { betterAuth } from "better-auth";
5
+ import { memoryAdapter } from "better-auth/adapters/memory";
6
+ import { APIError } from "better-auth/api";
7
+ import { createAuthClient } from "better-auth/client";
8
+ import { parseSetCookieHeader, setCookieToHeader } from "better-auth/cookies";
9
+ import { bearer } from "better-auth/plugins";
10
+ import { getTestInstance } from "better-auth/test";
11
+ import bodyParser from "body-parser";
12
+ import type {
13
+ Application as ExpressApp,
14
+ Request as ExpressRequest,
15
+ Response as ExpressResponse,
16
+ } from "express";
17
+ import express from "express";
18
+ import * as saml from "samlify";
19
+ import {
20
+ afterAll,
21
+ afterEach,
22
+ beforeAll,
23
+ beforeEach,
24
+ describe,
25
+ expect,
26
+ it,
27
+ vi,
28
+ } from "vitest";
29
+ import { sso, validateSAMLTimestamp } from ".";
30
+ import { ssoClient } from "./client";
31
+ import { DEFAULT_CLOCK_SKEW_MS } from "./constants";
32
+
33
+ const spMetadata = `
34
+ <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="http://localhost:3001/api/sso/saml2/sp/metadata">
35
+ <md:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
36
+ <md:KeyDescriptor use="signing">
37
+ <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
38
+ <ds:X509Data>
39
+ <ds:X509Certificate>MIIE3jCCAsYCCQDE5FzoAkixzzANBgkqhkiG9w0BAQsFADAxMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzAeFw0yMzExMTkxMjUyMTVaFw0zMzExMTYxMjUyMTVaMDExCzAJBgNVBAYTAlVTMRAwDgYDVQQIDAdGbG9yaWRhMRAwDgYDVQQHDAdPcmxhbmRvMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA2ELJsLZs4yBH7a2U5pA7xw+Oiut7b/ROKh2BqSTKRbEG4xy7WwljT02Mh7GTjLvswtZSUObWFO5v14HNORa3+J9JT2DH+9F+FJ770HX8a3cKYBNQt3xP4IeUyjI3QWzrGtkYPwSZ74tDpAUtuqPAxtoCaZXFDtX6lvCJDqiPnfxRZrKkepYWINSwu4DRpg6KoiPWRCYTsEcCzImInzlACdM97jpG1gLGA6a4dmjalQbRtvC56N0Z56gIhYq2F5JdzB2a10pqoIY8ggXZGIJS9I++8mmdTj6So5pPxLwnCYUhwDew1/DMbi9xIwYozs9pEtHCTn1l34jldDwTziVAxGQZO7QUuoMl997zqcPS7pVWRnfz5odKuytLvQDA0lRVfzOxtqbM3qVhoLT2iDmnuEtlZzgfbt4WEuT2538qxZJkFRpZQIrTj3ybqmWAv36Cp49dfeMwaqjhfX7/mVfbsPMSC653DSZBB+n+Uz0FC3QhH+vIdNhXNAQ5tBseHUR6pXiMnLtI/WVbMvpvFwK2faFTcx1oaP/Qk6yCq66tJvPbnatT9qGF8rdBJmAk9aBdQTI+hAh5mDtDweCrgVL+Tm/+Q85hSl4HGzH/LhLVS478tZVX+o+0yorZ35LCW3e4v8iX+1VEGSdg2ooOWtbSSXK2cYZr8ilyUQp0KueenR0CAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAsonAahruWuHlYbDNQVD0ryhL/b+ttKKqVeT87XYDkvVhlSSSVAKcCwK/UU6z8Ty9dODUkd93Qsbof8fGMlXeYCtDHMRanvWLtk4wVkAMyNkDYHzJ1FbO7v44ZBbqNzSLy2kosbRELlcz+P3/42xumlDqAw/k13tWUdlLDxb0pd8R5yBev6HkIdJBIWtKmUuI+e8F/yTNf5kY7HO1p0NeKdVeZw4Ydw33+BwVxVNmhIxzdP5ZFQv0XRFWhCMo/6RLEepCvWUp/T1WRFqgwAdURaQrvvfpjO/Ls+neht1SWDeP8RRgsDrXIc3gZfaD8q4liIDTZ6HsFi7FmLbZatU8jJ4pCstxQLCvmix+1zF6Fwa9V5OApSTbVqBOsDZbJxeAoSzy5Wx28wufAZT4Kc/OaViXPV5o/ordPs4EYKgd/eNFCgIsZYXe75rYXqnieAIfJEGddsLBpqlgLkwvf5KVS4QNqqX+2YubP63y+3sICq2ScdhO3LZs3nlqQ/SgMiJnCBbDUDZ9GGgJNJVVytcSz5IDQHeflrq/zTt1c4q1DO3CS7mimAnTCjetERRQ3mgY/2hRiuCDFj3Cy7QMjFs3vBsbWrjNWlqyveFmHDRkq34Om7eA2jl3LZ5u7vSm0/ylp/vtoysMjwEmw/0NA3hZPTG3OJxcvFcXBsz0SiFcd1U=</ds:X509Certificate>
40
+ </ds:X509Data>
41
+ </ds:KeyInfo>
42
+ </md:KeyDescriptor>
43
+ <md:KeyDescriptor use="encryption">
44
+ <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
45
+ <ds:X509Data>
46
+ <ds:X509Certificate>MIIE3jCCAsYCCQDE5FzoAkixzzANBgkqhkiG9w0BAQsFADAxMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzAeFw0yMzExMTkxMjUyMTVaFw0zMzExMTYxMjUyMTVaMDExCzAJBgNVBAYTAlVTMRAwDgYDVQQIDAdGbG9yaWRhMRAwDgYDVQQHDAdPcmxhbmRvMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA2ELJsLZs4yBH7a2U5pA7xw+Oiut7b/ROKh2BqSTKRbEG4xy7WwljT02Mh7GTjLvswtZSUObWFO5v14HNORa3+J9JT2DH+9F+FJ770HX8a3cKYBNQt3xP4IeUyjI3QWzrGtkYPwSZ74tDpAUtuqPAxtoCaZXFDtX6lvCJDqiPnfxRZrKkepYWINSwu4DRpg6KoiPWRCYTsEcCzImInzlACdM97jpG1gLGA6a4dmjalQbRtvC56N0Z56gIhYq2F5JdzB2a10pqoIY8ggXZGIJS9I++8mmdTj6So5pPxLwnCYUhwDew1/DMbi9xIwYozs9pEtHCTn1l34jldDwTziVAxGQZO7QUuoMl997zqcPS7pVWRnfz5odKuytLvQDA0lRVfzOxtqbM3qVhoLT2iDmnuEtlZzgfbt4WEuT2538qxZJkFRpZQIrTj3ybqmWAv36Cp49dfeMwaqjhfX7/mVfbsPMSC653DSZBB+n+Uz0FC3QhH+vIdNhXNAQ5tBseHUR6pXiMnLtI/WVbMvpvFwK2faFTcx1oaP/Qk6yCq66tJvPbnatT9qGF8rdBJmAk9aBdQTI+hAh5mDtDweCrgVL+Tm/+Q85hSl4HGzH/LhLVS478tZVX+o+0yorZ35LCW3e4v8iX+1VEGSdg2ooOWtbSSXK2cYZr8ilyUQp0KueenR0CAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAsonAahruWuHlYbDNQVD0ryhL/b+ttKKqVeT87XYDkvVhlSSSVAKcCwK/UU6z8Ty9dODUkd93Qsbof8fGMlXeYCtDHMRanvWLtk4wVkAMyNkDYHzJ1FbO7v44ZBbqNzSLy2kosbRELlcz+P3/42xumlDqAw/k13tWUdlLDxb0pd8R5yBev6HkIdJBIWtKmUuI+e8F/yTNf5kY7HO1p0NeKdVeZw4Ydw33+BwVxVNmhIxzdP5ZFQv0XRFWhCMo/6RLEepCvWUp/T1WRFqgwAdURaQrvvfpjO/Ls+neht1SWDeP8RRgsDrXIc3gZfaD8q4liIDTZ6HsFi7FmLbZatU8jJ4pCstxQLCvmix+1zF6Fwa9V5OApSTbVqBOsDZbJxeAoSzy5Wx28wufAZT4Kc/OaViXPV5o/ordPs4EYKgd/eNFCgIsZYXe75rYXqnieAIfJEGddsLBpqlgLkwvf5KVS4QNqqX+2YubP63y+3sICq2ScdhO3LZs3nlqQ/SgMiJnCBbDUDZ9GGgJNJVVytcSz5IDQHeflrq/zTt1c4q1DO3CS7mimAnTCjetERRQ3mgY/2hRiuCDFj3Cy7QMjFs3vBsbWrjNWlqyveFmHDRkq34Om7eA2jl3LZ5u7vSm0/ylp/vtoysMjwEmw/0NA3hZPTG3OJxcvFcXBsz0SiFcd1U=</ds:X509Certificate>
47
+ </ds:X509Data>
48
+ </ds:KeyInfo>
49
+ </md:KeyDescriptor>
50
+ <md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:3001/api/sso/saml2/sp/sls"/>
51
+ <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
52
+ <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost:3001/api/sso/saml2/sp/acs" index="1"/>
53
+ <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:3001/api/sso/saml2/sp/acs" index="1"/>
54
+ </md:SPSSODescriptor>
55
+ <md:Organization>
56
+ <md:OrganizationName xml:lang="en-US">Organization Name</md:OrganizationName>
57
+ <md:OrganizationDisplayName xml:lang="en-US">Organization DisplayName</md:OrganizationDisplayName>
58
+ <md:OrganizationURL xml:lang="en-US">http://localhost:3001/</md:OrganizationURL>
59
+ </md:Organization>
60
+ <md:ContactPerson contactType="technical">
61
+ <md:GivenName>Technical Contact Name</md:GivenName>
62
+ <md:EmailAddress>technical_contact@gmail.com</md:EmailAddress>
63
+ </md:ContactPerson>
64
+ <md:ContactPerson contactType="support">
65
+ <md:GivenName>Support Contact Name</md:GivenName>
66
+ <md:EmailAddress>support_contact@gmail.com</md:EmailAddress>
67
+ </md:ContactPerson>
68
+ </md:EntityDescriptor>
69
+ `;
70
+ const idpMetadata = `
71
+ <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="http://localhost:8081/api/sso/saml2/idp/metadata">
72
+ <md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
73
+ <md:KeyDescriptor use="signing">
74
+ <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
75
+ <ds:X509Data>
76
+ <ds:X509Certificate>MIIFOjCCAyICCQCqP5DN+xQZDjANBgkqhkiG9w0BAQsFADBfMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzENMAsGA1UECgwEVGVzdDEdMBsGCSqGSIb3DQEJARYOdGVzdEBnbWFpbC5jb20wHhcNMjMxMTE5MTIzNzE3WhcNMzMxMTE2MTIzNzE3WjBfMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzENMAsGA1UECgwEVGVzdDEdMBsGCSqGSIb3DQEJARYOdGVzdEBnbWFpbC5jb20wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQD5giLoLyED41IHt0RxB/k6x4K0vzAKiGecPyedRNR1oyiv3OYkuG5jgTE2wcPZc7kD1Eg5d6th0BWHy/ovaNS5mkgnOV6jKkMaWW4sCMSnLnaWy0seftPK3O4mNeZpM5e9amj2gXnZvKrK8cqnJ/bsUUQvXxttXNVVmOHWg/t3c2vJ4XuUfph6wIKbrj297ILzuAFRNvAVxeS0tElwepvZ5Wbf7Hc1MORAqTpw/mp8cRjHRzYCA9y6OM4hgVs1gvTJS8WGoMmsdAZHaOnv9vLJvW3jDLQQecOheYIJncWgcESzJFIkmXadorYCEfWhwwBdVphknmeLr4BMpJBclAYaFjYDLIKpMcXYO5k/2r3BgSPlw4oqbxbR5geD05myKYtZ/wNUtku118NjhIfJFulU/kfDcp1rYYkvzgBfqr80wgNps4oQzVr1mnpgHsSTAhXMuZbaTByJRmPqecyvyQqRQcRIN0oTLJNGyzoUf0RkH6DKJ4+7qDhlq4Zhlfso9OFMv9xeONfIrJo5HtTfFZfidkXZqir2ZqwqNlNOMfK5DsYq37x2Gkgqig4nqLpITXyxfnQpL2HsaoFrlctt/OL+Zqba7NT4heYk9GX8qlAS+Ipsv6T2HSANbah55oSS3uvcrDOug2Zq7+GYMLKS1IKUKhwX+wLMxmMwSJQ9ZgFwfQIDAQABMA0GCSqGSIb3DQEBCwUAA4ICAQCkGPZdflocTSXIe5bbehsBn/IPdyb38eH2HaAvWqO2XNcDcq+6/uLc8BVK4JMa3AFS9xtBza7MOXN/lw/Ccb8uJGVNUE31+rTvsJaDtMCQkp+9aG04I1BonEHfSB0ANcTy/Gp+4hKyFCd6x35uyPO7CWX5Z8I87q9LF6Dte3/v1j7VZgDjAi9yHpBJv9Xje33AK1vF+WmEfDUOi8y2B8htVeoyS3owln3ZUbnmJdCmMp2BMRq63ymINwklEaYaNrp1L201bSqNdKZF2sNwROWyDX+WFYgufrnzPYb6HS8gYb4oEZmaG5cBM7Hs730/3BlbHKhxNTy1Io2TVCYcMQD+ieiVg5e5eGTwaPYGuVvY3NVhO8FaYBG7K2NT2hqutdCMaQpGyHEzbbbTY1afhbeMmWWqivRnVJNDv4kgBc2SE8JO82qHikIW9Om0cghC5xwTT+1JTtxxD1KeC1M1IwLzzuuMmwJSKAsv4duDqN+YRIP78J2SlrssqlsmoF8+48e7Vzr7JRT/Ya274P8RpUPNtxTR7WDmZ4tunqXjiBpz6l0uTtVXnj5UBo4HCyRjWJOGf15OCuQX03qz8tKn1IbZUf723qrmSF+cxBwHqpAywqhTSsaLjIXKnQ0UlMov7QWb0a5N07JZMdMSerbHvbXd/z9S1Ssea2+EGuTYuQur3A==</ds:X509Certificate>
77
+ </ds:X509Data>
78
+ </ds:KeyInfo>
79
+ </md:KeyDescriptor>
80
+ <md:KeyDescriptor use="encryption">
81
+ <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
82
+ <ds:X509Data>
83
+ <ds:X509Certificate>MIIFOjCCAyICCQCqP5DN+xQZDjANBgkqhkiG9w0BAQsFADBfMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzENMAsGA1UECgwEVGVzdDEdMBsGCSqGSIb3DQEJARYOdGVzdEBnbWFpbC5jb20wHhcNMjMxMTE5MTIzNzE3WhcNMzMxMTE2MTIzNzE3WjBfMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzENMAsGA1UECgwEVGVzdDEdMBsGCSqGSIb3DQEJARYOdGVzdEBnbWFpbC5jb20wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQD5giLoLyED41IHt0RxB/k6x4K0vzAKiGecPyedRNR1oyiv3OYkuG5jgTE2wcPZc7kD1Eg5d6th0BWHy/ovaNS5mkgnOV6jKkMaWW4sCMSnLnaWy0seftPK3O4mNeZpM5e9amj2gXnZvKrK8cqnJ/bsUUQvXxttXNVVmOHWg/t3c2vJ4XuUfph6wIKbrj297ILzuAFRNvAVxeS0tElwepvZ5Wbf7Hc1MORAqTpw/mp8cRjHRzYCA9y6OM4hgVs1gvTJS8WGoMmsdAZHaOnv9vLJvW3jDLQQecOheYIJncWgcESzJFIkmXadorYCEfWhwwBdVphknmeLr4BMpJBclAYaFjYDLIKpMcXYO5k/2r3BgSPlw4oqbxbR5geD05myKYtZ/wNUtku118NjhIfJFulU/kfDcp1rYYkvzgBfqr80wgNps4oQzVr1mnpgHsSTAhXMuZbaTByJRmPqecyvyQqRQcRIN0oTLJNGyzoUf0RkH6DKJ4+7qDhlq4Zhlfso9OFMv9xeONfIrJo5HtTfFZfidkXZqir2ZqwqNlNOMfK5DsYq37x2Gkgqig4nqLpITXyxfnQpL2HsaoFrlctt/OL+Zqba7NT4heYk9GX8qlAS+Ipsv6T2HSANbah55oSS3uvcrDOug2Zq7+GYMLKS1IKUKhwX+wLMxmMwSJQ9ZgFwfQIDAQABMA0GCSqGSIb3DQEBCwUAA4ICAQCkGPZdflocTSXIe5bbehsBn/IPdyb38eH2HaAvWqO2XNcDcq+6/uLc8BVK4JMa3AFS9xtBza7MOXN/lw/Ccb8uJGVNUE31+rTvsJaDtMCQkp+9aG04I1BonEHfSB0ANcTy/Gp+4hKyFCd6x35uyPO7CWX5Z8I87q9LF6Dte3/v1j7VZgDjAi9yHpBJv9Xje33AK1vF+WmEfDUOi8y2B8htVeoyS3owln3ZUbnmJdCmMp2BMRq63ymINwklEaYaNrp1L201bSqNdKZF2sNwROWyDX+WFYgufrnzPYb6HS8gYb4oEZmaG5cBM7Hs730/3BlbHKhxNTy1Io2TVCYcMQD+ieiVg5e5eGTwaPYGuVvY3NVhO8FaYBG7K2NT2hqutdCMaQpGyHEzbbbTY1afhbeMmWWqivRnVJNDv4kgBc2SE8JO82qHikIW9Om0cghC5xwTT+1JTtxxD1KeC1M1IwLzzuuMmwJSKAsv4duDqN+YRIP78J2SlrssqlsmoF8+48e7Vzr7JRT/Ya274P8RpUPNtxTR7WDmZ4tunqXjiBpz6l0uTtVXnj5UBo4HCyRjWJOGf15OCuQX03qz8tKn1IbZUf723qrmSF+cxBwHqpAywqhTSsaLjIXKnQ0UlMov7QWb0a5N07JZMdMSerbHvbXd/z9S1Ssea2+EGuTYuQur3A==</ds:X509Certificate>
84
+ </ds:X509Data>
85
+ </ds:KeyInfo>
86
+ </md:KeyDescriptor>
87
+ <md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:8081/api/sso/saml2/idp/slo"/>
88
+ <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
89
+ <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:8081/api/sso/saml2/idp/redirect"/>
90
+ <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost:8081/api/sso/saml2/idp/post"/>
91
+ </md:IDPSSODescriptor>
92
+ <md:Organization>
93
+ <md:OrganizationName xml:lang="en-US">Your Organization Name</md:OrganizationName>
94
+ <md:OrganizationDisplayName xml:lang="en-US">Your Organization DisplayName</md:OrganizationDisplayName>
95
+ <md:OrganizationURL xml:lang="en-US">http://localhost:8081</md:OrganizationURL>
96
+ </md:Organization>
97
+ <md:ContactPerson contactType="technical">
98
+ <md:GivenName>Technical Contact Name</md:GivenName>
99
+ <md:EmailAddress>technical_contact@gmail.com</md:EmailAddress>
100
+ </md:ContactPerson>
101
+ <md:ContactPerson contactType="support">
102
+ <md:GivenName>Support Contact Name</md:GivenName>
103
+ <md:EmailAddress>support_contact@gmail.com</md:EmailAddress>
104
+ </md:ContactPerson>
105
+ </md:EntityDescriptor>
106
+ `;
107
+ const idPk = `
108
+ -----BEGIN RSA PRIVATE KEY-----
109
+ MIIJKgIBAAKCAgEA+YIi6C8hA+NSB7dEcQf5OseCtL8wCohnnD8nnUTUdaMor9zm
110
+ JLhuY4ExNsHD2XO5A9RIOXerYdAVh8v6L2jUuZpIJzleoypDGlluLAjEpy52lstL
111
+ Hn7TytzuJjXmaTOXvWpo9oF52byqyvHKpyf27FFEL18bbVzVVZjh1oP7d3NryeF7
112
+ lH6YesCCm649veyC87gBUTbwFcXktLRJcHqb2eVm3+x3NTDkQKk6cP5qfHEYx0c2
113
+ AgPcujjOIYFbNYL0yUvFhqDJrHQGR2jp7/byyb1t4wy0EHnDoXmCCZ3FoHBEsyRS
114
+ JJl2naK2AhH1ocMAXVaYZJ5ni6+ATKSQXJQGGhY2AyyCqTHF2DuZP9q9wYEj5cOK
115
+ Km8W0eYHg9OZsimLWf8DVLZLtdfDY4SHyRbpVP5Hw3Kda2GJL84AX6q/NMIDabOK
116
+ EM1a9Zp6YB7EkwIVzLmW2kwciUZj6nnMr8kKkUHESDdKEyyTRss6FH9EZB+gyieP
117
+ u6g4ZauGYZX7KPThTL/cXjjXyKyaOR7U3xWX4nZF2aoq9masKjZTTjHyuQ7GKt+8
118
+ dhpIKooOJ6i6SE18sX50KS9h7GqBa5XLbfzi/mam2uzU+IXmJPRl/KpQEviKbL+k
119
+ 9h0gDW2oeeaEkt7r3KwzroNmau/hmDCyktSClCocF/sCzMZjMEiUPWYBcH0CAwEA
120
+ AQKCAgABJVzdriG7r9aXnHre/gdiArqR8/LXiYrYR935tfA33hj4vc38yzAOmvBL
121
+ 7RXmMMbfwqDWSrtpxpfiuMgcYaHgfFnqfDP4EeCfBVwhLaUhk3AN/z8IE9MLMnqR
122
+ iFvXjdobj5qNz0hs/JXYOsYQgHl82l6yzQAGP4/nRb17y71i7g/HrJZxtyciITI4
123
+ XtN/xM9RKT4wTk1J/E+xmMZhkt6WYJxZWO+vOdtChMR08mYwziAsAiK4XaYs4Mfp
124
+ lXuCwmg3aHauyJxEg3/n4g55AKxaytjvWwaUsMp6OmGjg6r9sqZOIFOUQXQvAylM
125
+ 1yJGrOuagiRPCf81wAeZ0oOrOS7R+4fF4Ypa+V7Cp6Ty3VPcw8BFpXJ6fRtf92kh
126
+ ix00DnFEK/TdndyBpFKdmf8f2SSFBLrPlmTfjdMAvShE5yFpeWyXQjftI5q/0d3U
127
+ Ug0MBby66yT/TZtTKVPdK6bG3fYvzgKCpZGrKgn+umq4XR+gh9S0ptmwNF5mzJy4
128
+ mol5CkazGPlOSwlBc4oKeepcqZ0TKCJwonub90CJeH8IKoyRsswShRl6YTRza1SB
129
+ Fx4Gis5xcaNp7eXnLBDgKV/1bhCUSvQ886r+Xo4nfhk9n8WrtaQFC4tFID1e8TAM
130
+ jYxZIBpCHOZHX/+BpC3FyqD4RbI12iudyz4KwS5Ps/wlIpVMQQKCAQEA/70X3Fz2
131
+ SJyPP9UdiiqLot1ppbagQGjG20yFnfRDhNY+q2U8N77yJUXWvE7YQ6OUTOaPuJX2
132
+ X7vulTSQ0YyFYp0B5G4QiFtvPOpBvn7OxrFKBKxwbOU7L2rAuXWYEIRuKuwBRMFU
133
+ oaar8gkKlnsUtUxrLM827gmL13i3GX2bmm6NhhGCKbSCoD51+UUGo7Ix5ZLznKmX
134
+ G1mq4IxtJe8vLk/9RT9CzRV7VO61EgEh7Iji7g4cDIiZV+B9gG8YMlTOcALPpgud
135
+ nF7SEvDuMH3dgOj+iSO9piJ53okU59Mk4Nyka3p3v6RABMcDYO1/wkbE83+Oobrx
136
+ RiRQHtBgo1r9cQKCAQEA+cNpxVCi/BkatlzKentRabnQjfxrEQdIdc9xqzr5k2xK
137
+ w9n+XGzeNT+HKI/S1KkfvJTQC0j9WBQ3uupf8Zg6/mNF84YCXpun3JXpvzc+4ya3
138
+ i1AXtdul/JYU5qhMrJI+I1WXrWAls5zbIs23iz1Fq530Mb7FUQ5jmO0p123AmMGG
139
+ hSTJDqvKDMpQXdUYQMqrSL/aNh8u7wpw2S052uj2bdbdgq1FboLzbwWTOsVYs3aS
140
+ HABb95263Cf3OdRr4lyN6khFMLhQPUhYnn6l2ob0kIZ7V2f8fxKvJoTTDTxWpUgF
141
+ FrdHigaDo09WYkIukj+YdSZY/ZEAu7lyMmY0l8HNzQKCAQEA7HE3jlWknp2hE7NG
142
+ DGgpkfqDouKmZuZ4dGjbYJ5ljntGldCTTDcOSce4MYH0ERU8F51TY6XCk+B9RRXE
143
+ jvkMmY/wH/Ji9q8SuY8cGbPEGY/wj0Ge8A9AGSbp6I4AecT21lg9FARq6sneT3hs
144
+ gZRqIPT2YgdzEcFhuWWyY67uHmn4DuxBG634147oI/7dlJs75rVm5oElY/QTOGic
145
+ wWXSiU8LKurCKDqkPHI2lt7VLougw9fntu7UV5sGbahJBr/B3W277hjvL5O7Rifb
146
+ EJpOINFKBCE3RlK5ujWjTnK4te1JVtVzwYtqZQBa71KlvEkR7s8QYBcm22LXcKXX
147
+ szB9AQKCAQEAwUua8DoX6UMEiV4G1gPaXhiQb1KLCgK48XQ6ZGqf/JgyxKBRWvZm
148
+ go9H6vxkDnFVPn1tBU7XwvLirqX02uUVwwrReEaeTtnob68V2AbJhMLSCd9Sekwj
149
+ ifgc9OYLcQM9U9tKJ8PhacBbV/QduIUTBl6YPmeGDdU0/4WMfE1UYORlV2XAtLn/
150
+ BScOS5A/1OUE6qiQGJLJn/ZUn7+ApwrkrN09UYUH1x9BhwqphzJ0E3AQY9tjUZ+g
151
+ ngHQM9FSLT20Fz0XTz1V3BfBfehGM3l+jNuHWX4Ay9eJ9iWVsQihhgjW512w4AFq
152
+ n1knYaQWptjRBNlIxfUSvDYpSxgOW+SBgQKCAQEA7ikfNUZDmhyShcmIl9Hgcral
153
+ o2M/ggUVwWd9AaJD+Y/WcGoR0DPGt8UGLGTBNBwbyTgHdDzsWA+02r5r+5ArhhnP
154
+ iWQ1soQI9FpZIUCyzAjTQpfpzo5dGqpQbW9LuHJOEbDyY2wG+lFhIm4JJBJ/vws1
155
+ yt9Y170VbPXmDdLevDLmlFOILdMJWWl3hrtlU3KEogqWKDOXciYtG5Ji0+512BqH
156
+ yY9+uVNb1eu6MLU5R5U9GdvOFZZjShIhOlpZVR1K21dg5frBCWBZ0pvu4fZf2FAV
157
+ lX6+ORENSjqJsQWTaeiMoAPOj8QxQuOwUCajbVkrCZV6D49E0D9XxmZcuKCAXg==
158
+ -----END RSA PRIVATE KEY-----
159
+
160
+ `;
161
+ const spPrivateKey = `
162
+ -----BEGIN RSA PRIVATE KEY-----
163
+ Proc-Type: 4,ENCRYPTED
164
+ DEK-Info: DES-EDE3-CBC,9C86371F0420A091
165
+
166
+ 77TqgiK/IYRgO5w3ZMyV81/gk0zN5wPTGWxoztNFjQKXCySFnrL30kUqlGituBxX
167
+ VgxwXbkoYMrd5MoDZKL5EJuf0H59hq92O0+3uwJA8QyZjOm4brQcjXKmIrkvihgs
168
+ FvpaJiGzp6kS/O7vFBDNTQWr9yY9Y3FBPcmOUWufpRp4Q5nhpSlqnMmIqZyWQUL/
169
+ YJSJETtzJVsk38lCqIxxDT3LtbGySahj0jRuRqspAZQeLTpnJqzNMC4vnJew9luC
170
+ R+UffrX7gVsnwOhNtyRzYaMsLnbRfXT8Jqx2gRHg36GxkOVgyU7e62nk9CzeC0WA
171
+ kHHCNVqqivRx9/EC0mQkkRgRzo3BZWp0o671sUsGTy57JhktiGfTnWMrl7ZfhAza
172
+ SZnjyTwuI1bTQipIkNI3aJBTP/o/gNUE1sj5D5FZlFdpq5ks2Vxww3GNx1FRrvWd
173
+ 98z5CNt78ZR0ihLmdz/EakEBKBUteQu/5zPLUlwmGuou4wPuEHG2BsjGzb/d5Zfc
174
+ ElIjUV+yrMmGHvBfPyPnDUrCUyLn18S1NZiCMCdN5PqCybjhk8oMPYZhWBqp8Ymr
175
+ yHIC7BCnTJhIvgQZR6M68NwVv0aBBgH/I/DB0jADo6/B5Eajwus9i6zSv8QIbqhw
176
+ fusKtI04vxc91aP0GWRr0J/O4mkxXYNPfa3a/I7sGTXGl0k0CygckE3fLXRy/WEk
177
+ ikZt4UHqg5ZQ8vc5NSAM5f5Yx/72CU1I6ehFtxHsyE5yndpZXWp2X2S4l31e8fLs
178
+ ddOoybroJgbyLrh7JT3Yac3XOEsKATWIvqU+hNYq6KwqLWev9jInHVgjzfyOKbmF
179
+ hkrzDDHaKULYZuTsUq5mLc1SzSu98lXYfXp1WE4XsH0X0VicPzf8ZH4Kutuig0VG
180
+ 5Kg9HB/Cin65VMm0ffEiTraO6johIlwFGRrtAs38ONKgsPCQUv7ee9SEGOHViNZq
181
+ NpWPr1KOzbI4wEB1ueKoZuEQ0a+tzfJgszJrM48bM82J6iEjN/PSOTsdTKJq9e47
182
+ dlUp+tqQsvGkbBOIOt5OOpkr8Z+8qbEd21ojF9Q0p0T4WMThRP6YBRKvt8mmFwRs
183
+ DjEhMiPa4L70Eqldfu2lWdI6ietfHrK97WXwQO1gF73LOnA+EdMXNxr1iLd0Tdke
184
+ z6fUSw3hKZL+I7nX6O40+KgkhXVSZOsRz5CEvo2iChIUrYGEGDl94K/ofqGu71Y+
185
+ G8KBvbha6EC7xcUrTYP5Gek5wsrw7cGgDZJjMsyXYFBZjQO1N6g9fncLmc5pB5Ix
186
+ W3gLfQS/My4daWNTvrYOgfA08J4M4ZWd0v5TglxOSV78psG4J4slppDySNFB2d/3
187
+ 7JiwWVm5SMk0StLWwb2azmTvBoinnrZJzPnPlOytxvE5uGJ/i0WAik7C99YgVJkS
188
+ 9hO3FJGasrOnHeiOvMZEdRuIVspKz9iMFx7hWHpVHTTyjwceEpaiEkhmqLM9QkKh
189
+ kCZqeWyVsKBIc0sse+CKNK8ik9eTeUlCklGMV1Q4kKjR6uuHUOLyjk/xhqslV4TS
190
+ jnnjCjsK5YzTa4hmbHhPZIW262KoFV9TqxYKkhP5ab7AXRSakrdrY2cwACWN4AMT
191
+ -----END RSA PRIVATE KEY-----
192
+ `;
193
+ const idpPrivateKey = `
194
+ -----BEGIN RSA PRIVATE KEY-----
195
+ Proc-Type: 4,ENCRYPTED
196
+ DEK-Info: DES-EDE3-CBC,116B0EBB2F2F0A9D
197
+
198
+ HMmUsJPVPTsq1e06yrrskfinY21OOHosfRzibLueBg9ByFFZ7+/oW/DKy1GcDeBc
199
+ ycL+3gylIoGUYuZ+DPC11ArjdxFqLFnHJb96rwy5h4sTP0lE+qHy+06AwsowUgp3
200
+ pdD2unPFeydpu5h/dqgoDzkGSucz0Ty/spHXNBvns0vJO18B7XlzXUtfH5aHco22
201
+ DyVY6FrJwMts9E4Rzs9JsxJJ7mi/6+Qsc0rOr8/6KKsRo1sKD6cvQIQ05dEvGrE9
202
+ /2fubHkRTl+zBqOVyQvC6iUtocwxlMP4KfmyYrD1wlQAnP/+smq2G+xf7uGc4X4P
203
+ 8q0jEy2P9n5ASlwZ3XCS9hZgp8VRAcXWOYjzzNouQp3NEP9d5D3wN4aFKa/JW6pk
204
+ a6VwraEweuyJqvZ7nnam1emW0ge0z7hJabR0+j0PnUxFIwkI5jO3HI5UiuUzuQFe
205
+ 2bTLA3XnJ7QD08ZKom0rmApbFrmm9BWBRTmt46NlQDy49VODPY4gFuQ/mpaFjaBy
206
+ fSNJaOSS/MDuAdPabNEh3l+yCGKtHIbPVIms76PxYf6o0VVxW96/Q25hrvyOJCxn
207
+ dVQyyJbQ1jGenu4ViDNrW9ZQfw4aJCPpY7lUQd09BGz2NMKgkrSl8bKSan4lvlF3
208
+ ok8BjfIw+pIrTyesPU5tF0YudDxwi8fbIG70iwrpsSt2wVIMa+Nz2lwFT1dV8be7
209
+ NARkkkhLWJYAsxsyVfdl+ucNSqhvo8xLITuG8CZnzKf0T2HMKnMNegFx/ipfM7ff
210
+ Mx5CjayN5Oy99MWsagYEutUGzCGPAuVpqYpJuuYa3lWbFk2XWihWkAiUwgRqIluE
211
+ M6LpO8l3LVXVjN1+6bK1GZpbfLay+E6vy4W38XMuXZSNpyhy6e+XggTPH2xbbwoi
212
+ OcAzcojhMaxVGpxm/aXyRxg9zBdrQjtqM/aCN91ri55bvOKxELVi+D/VcZKpd2CR
213
+ X/vWcqoGaK/6+vlPWMZSHCJkPa4KBT0aUcnEdeFWx2nmrwdrHvETzCYLAzVBSECV
214
+ ZoYH0xTkFr/RI2AOAzx701LSuYbnPoCq+w7TXtjPaooZdYVVgrYuI+j4JOlseFS7
215
+ 1c9iRiJVPBfnpUNIZdHLw19+k81IJ/FmumiuDhfLS5pwQmtuXkO3DWZDa3UPlV8e
216
+ 6dmZeP1XGwRLL9VpOKx7NCqZM+CdEt87CXpFFWXdw8tL+3K/2r8w4lHIzBKaVPSS
217
+ 5uFqXc1vzfP6Qeov31IjeLPE1pWTHNqRPdmvt9Scq9tKS3o18wmLBxOVinOE0cxQ
218
+ oddzPd0z5NxNYVayqZORwDdVv6CVXKnrvBSnOFFslZqv1G8/diE5BXxeaAPEMcZE
219
+ 3lD7MzdoEHK5oL2MXofLWZbNtMkOZLaLqY80zKT1UG3Gs8U44d44aLXO1dBL0HGX
220
+ dNfNUaH+IGZf2ccS6OR1RhwIazDZ8qk0XeUwQV588adwC3FUvscVA3eHZa95z4kX
221
+ xvHg+ylzRtKRfpSPzB2IVwgV9/rsOg0OmvwhV8+5IQpdcFr+hf2Bn6AVn6H9aX8A
222
+ JjycN6KMcHaFa0EUqagGm9tsQLmf/MGCj8sy9am1IbRmFCz5lB5A7P/YLPM2Csjg
223
+ -----END RSA PRIVATE KEY-----`;
224
+ const certificate = `
225
+ -----BEGIN CERTIFICATE-----
226
+ MIIDlzCCAn+gAwIBAgIJAO1ymQc33+bWMA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNV
227
+ BAYTAkhLMRMwEQYDVQQIDApTb21lLVN0YXRlMRowGAYDVQQKDBFJZGVudGl0eSBQ
228
+ cm92aWRlcjEUMBIGA1UECwwLRGV2ZWxvcG1lbnQxDDAKBgNVBAMMA0lEUDAeFw0x
229
+ NTA3MDUxODAyMjdaFw0xODA3MDQxODAyMjdaMGIxCzAJBgNVBAYTAkhLMRMwEQYD
230
+ VQQIDApTb21lLVN0YXRlMRowGAYDVQQKDBFJZGVudGl0eSBQcm92aWRlcjEUMBIG
231
+ A1UECwwLRGV2ZWxvcG1lbnQxDDAKBgNVBAMMA0lEUDCCASIwDQYJKoZIhvcNAQEB
232
+ BQADggEPADCCAQoCggEBAODZsWhCe+yG0PalQPTUoD7yko5MTWMCRxJ8hSm2k7mG
233
+ 3Eg/Y2v0EBdCmTw7iDCevRqUmbmFnq7MROyV4eriJzh0KabAdZf7/k6koghst3ZU
234
+ tWOwzshyxkBtWDwGmBpQGTGsKxJ8M1js3aSqNRXBT4OBWM9w2Glt1+8ty30RhYv3
235
+ pSF+/HHLH7Ac+vLSIAlokaFW34RWTcJ/8rADuRWlXih4GfnIu0W/ncm5nTSaJiRA
236
+ vr3dGDRO/khiXoJdbbOj7dHPULxVGbH9IbPK76TCwLbF7ikIMsPovVbTrpyL6vsb
237
+ VUKeEl/5GKppTwp9DLAOeoSYpCYkkDkYKu9TRQjF02MCAwEAAaNQME4wHQYDVR0O
238
+ BBYEFP2ut2AQdy6D1dwdwK740IHmbh38MB8GA1UdIwQYMBaAFP2ut2AQdy6D1dwd
239
+ wK740IHmbh38MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBANMZUoPN
240
+ mHzgja2PYkbvBYMHmpvUkVoiuvQ9cJPlqGTB2CRfG68BNNs/Clz8P7cIrAdkhCUw
241
+ i1rSBhDuslGFNrSaIpv6B10FpBuKwef3G7YrPWFNEN6khY7aHNWSTHqKgs1DrGef
242
+ 2B9hvkrnHWbQVSVXrBFKe1wTCqcgGcOpYoSK7L8C6iX6uIA/uZYnVQ4NgBrizJ0a
243
+ zkjdegz3hwO/gt4malEURy8D85/AAVt6PAzhpb9VJUGxSXr/EfntVUEz3L2gUFWW
244
+ k1CnZFyz0rIOEt/zPmeAY8BLyd/Tjxm4Y+gwNazKq5y9AJS+m858b/nM4QdCnUE4
245
+ yyoWAJDUHiAmvFA=
246
+ -----END CERTIFICATE-----
247
+ `;
248
+ const idpEncryptionKey = `
249
+ -----BEGIN RSA PRIVATE KEY-----
250
+ Proc-Type: 4,ENCRYPTED
251
+ DEK-Info: DES-EDE3-CBC,860FDB9F3BE14699
252
+
253
+ bMpTdWaAEqNciUFQhHYNv1F9N12aqOQd6cFbMozfRnNR19HW6QIPDmEOPSSCaaRy
254
+ QCnJhbpcSnaz9pvI7EzeJzdykDmR8Boos+0NSK9qIX0buBO55mfPr7hjx7bLFEVl
255
+ kkHk+k9F1rLyjyAGJrVoTNoWjyuMOFUCWR7ZxoYticwM/sL+Rbhn1FsfdkdfhFW0
256
+ 08OHTouRK33Aifx0A3MWxR0ILvw49E6urtbbIrskEzKzfWQug8gY1TJhI3sbsMsI
257
+ 1bS5Vg88TvilFFBGn0Yv6GEJjgOrsrKDGKtYGhuBfK4fd4rwnQKKvC6gTKeNXIfV
258
+ 7Qm1R20LUJXC8zv35pdKoVk+NdS/MGNXJRFgO3Kkp01aVf3n1oo2+AllS02AYyWt
259
+ 1svHecsRwbibXip8gSQsOtDdpqQrEDyqZlFHXEw/IcJE9vQWEJmpHD5GFhbKtttp
260
+ E0B3ZtNl6YcyUz0rSf9zjuMx/wReWdRb6H2WoIqoRS7vAUONDRPt7wvfjtLlDRVi
261
+ bc2RTN8yce/57lGnA1n8bxPV5+9VxCJOEipV3io/nrj+uNO8i/0rUpkKdZy8wy2C
262
+ Rksoxq4TxwegONz1HQcJVpJu0iBdu7B+BXVjxQQScvMQlOTbua8k+YdaCeZAb83j
263
+ JVX89/PFy+Xj7eGyzzBTqz7dV0Xkxq9mpiMYUCoyNL5Iq1jD9Xb5TzVW1Gbh8zCZ
264
+ YXjcZEQKeartaBC4/fRWyxqK3gJRX4SJkl4gYMQrPS2pbTzVCO+WLxSwIh3dOZpo
265
+ eErXLSrylIv9cE2Xrs0McXAR+hfGrqgtILBWwgbh2NhmUiFfLwUTUxU51eu7QZ2T
266
+ V1VFBX0QTmn2kM0JLSSC96mDUzbs6qfURUaXbuffF5cqdUjXgtzZj5SFEbIv4UFS
267
+ 0DAS+6i/jTGSz7aAp/uofOxhYkCqK/s2Cex2jQbDpcKXKiWzPdULOCjAh3fdCAp0
268
+ 3ua3fdAI7H8PslSDiPFrcY78OxZaWXzazEiun77WKbzrMloLMP5dpCPlUCOqxbZ0
269
+ ykSuo0M7p/UPY34yi3AMHS9grvQQ1DykMPoqKKEheI6nUGcQ1AFcdr307ILWRsPO
270
+ T6gHOLXZaR4+UEeYfkTKsjrMUhozx7JIyuLgTXA9TWC+tZ9WZpbJ7i3bpQ+RNwX2
271
+ AxQSwc9ZOcNxg8YCbGlJgJHnRVhA202kNT5ORplcRKqaOaO9LK7491gaaShjaspg
272
+ 4THDnH+HHFORmbgwyO9P74wuw+n6tI40Ia3qzRLVz6sJBQMtLEN+cvNoNi3KYkNj
273
+ GJM1iWfSz6PjrEGxbzQZKoFPPiZrVRnVfPhBNyT2OZj+TJii9CaukhmkkA2/AJmS
274
+ 5XoO3GNIaqOGYV9HLyh1++cn3NhjgFYe/Q3ORCTIg2Ltd8Qr6mYe0LcONQFgiv4c
275
+ AUOZtOq05fJDXE74R1JjYHPaQF6uZEbTF98jN9QZIfCEvDdv1nC83MvSwATi0j5S
276
+ LvdU/MSPaZ0VKzPc4JPwv72dveEPME6QyswKx9izioJVrQJr36YtmrhDlKR1WBny
277
+ ISbutnQPUN5fsaIsgKDIV3T7n6519t6brobcW5bdigmf5ebFeZJ16/lYy6V77UM5
278
+ -----END RSA PRIVATE KEY-----
279
+ `;
280
+ const spEncryptionKey = `
281
+ -----BEGIN RSA PRIVATE KEY-----
282
+ Proc-Type: 4,ENCRYPTED
283
+ DEK-Info: DES-EDE3-CBC,860FDB9F3BE14699
284
+
285
+ bMpTdWaAEqNciUFQhHYNv1F9N12aqOQd6cFbMozfRnNR19HW6QIPDmEOPSSCaaRy
286
+ QCnJhbpcSnaz9pvI7EzeJzdykDmR8Boos+0NSK9qIX0buBO55mfPr7hjx7bLFEVl
287
+ kkHk+k9F1rLyjyAGJrVoTNoWjyuMOFUCWR7ZxoYticwM/sL+Rbhn1FsfdkdfhFW0
288
+ 08OHTouRK33Aifx0A3MWxR0ILvw49E6urtbbIrskEzKzfWQug8gY1TJhI3sbsMsI
289
+ 1bS5Vg88TvilFFBGn0Yv6GEJjgOrsrKDGKtYGhuBfK4fd4rwnQKKvC6gTKeNXIfV
290
+ 7Qm1R20LUJXC8zv35pdKoVk+NdS/MGNXJRFgO3Kkp01aVf3n1oo2+AllS02AYyWt
291
+ 1svHecsRwbibXip8gSQsOtDdpqQrEDyqZlFHXEw/IcJE9vQWEJmpHD5GFhbKtttp
292
+ E0B3ZtNl6YcyUz0rSf9zjuMx/wReWdRb6H2WoIqoRS7vAUONDRPt7wvfjtLlDRVi
293
+ bc2RTN8yce/57lGnA1n8bxPV5+9VxCJOEipV3io/nrj+uNO8i/0rUpkKdZy8wy2C
294
+ Rksoxq4TxwegONz1HQcJVpJu0iBdu7B+BXVjxQQScvMQlOTbua8k+YdaCeZAb83j
295
+ JVX89/PFy+Xj7eGyzzBTqz7dV0Xkxq9mpiMYUCoyNL5Iq1jD9Xb5TzVW1Gbh8zCZ
296
+ YXjcZEQKeartaBC4/fRWyxqK3gJRX4SJkl4gYMQrPS2pbTzVCO+WLxSwIh3dOZpo
297
+ eErXLSrylIv9cE2Xrs0McXAR+hfGrqgtILBWwgbh2NhmUiFfLwUTUxU51eu7QZ2T
298
+ V1VFBX0QTmn2kM0JLSSC96mDUzbs6qfURUaXbuffF5cqdUjXgtzZj5SFEbIv4UFS
299
+ 0DAS+6i/jTGSz7aAp/uofOxhYkCqK/s2Cex2jQbDpcKXKiWzPdULOCjAh3fdCAp0
300
+ 3ua3fdAI7H8PslSDiPFrcY78OxZaWXzazEiun77WKbzrMloLMP5dpCPlUCOqxbZ0
301
+ ykSuo0M7p/UPY34yi3AMHS9grvQQ1DykMPoqKKEheI6nUGcQ1AFcdr307ILWRsPO
302
+ T6gHOLXZaR4+UEeYfkTKsjrMUhozx7JIyuLgTXA9TWC+tZ9WZpbJ7i3bpQ+RNwX2
303
+ AxQSwc9ZOcNxg8YCbGlJgJHnRVhA202kNT5ORplcRKqaOaO9LK7491gaaShjaspg
304
+ 4THDnH+HHFORmbgwyO9P74wuw+n6tI40Ia3qzRLVz6sJBQMtLEN+cvNoNi3KYkNj
305
+ GJM1iWfSz6PjrEGxbzQZKoFPPiZrVRnVfPhBNyT2OZj+TJii9CaukhmkkA2/AJmS
306
+ 5XoO3GNIaqOGYV9HLyh1++cn3NhjgFYe/Q3ORCTIg2Ltd8Qr6mYe0LcONQFgiv4c
307
+ AUOZtOq05fJDXE74R1JjYHPaQF6uZEbTF98jN9QZIfCEvDdv1nC83MvSwATi0j5S
308
+ LvdU/MSPaZ0VKzPc4JPwv72dveEPME6QyswKx9izioJVrQJr36YtmrhDlKR1WBny
309
+ ISbutnQPUN5fsaIsgKDIV3T7n6519t6brobcW5bdigmf5ebFeZJ16/lYy6V77UM5
310
+ -----END RSA PRIVATE KEY-----
311
+ `;
312
+ const generateRequestID = () => {
313
+ return "_" + randomUUID();
314
+ };
315
+ const createTemplateCallback =
316
+ (idp: any, sp: any, email: string) => (template: any) => {
317
+ const assertionConsumerServiceUrl =
318
+ sp.entityMeta.getAssertionConsumerService(
319
+ saml.Constants.wording.binding.post,
320
+ );
321
+
322
+ const nameIDFormat = idp.entitySetting.nameIDFormat;
323
+ const selectedNameIDFormat = Array.isArray(nameIDFormat)
324
+ ? nameIDFormat[0]
325
+ : nameIDFormat;
326
+
327
+ const id = generateRequestID();
328
+ const now = new Date();
329
+ const fiveMinutesLater = new Date(now.getTime() + 5 * 60 * 1000);
330
+ const tagValues = {
331
+ ID: id,
332
+ AssertionID: generateRequestID(),
333
+ Destination: assertionConsumerServiceUrl,
334
+ Audience: sp.entityMeta.getEntityID(),
335
+ EntityID: sp.entityMeta.getEntityID(),
336
+ SubjectRecipient: assertionConsumerServiceUrl,
337
+ Issuer: idp.entityMeta.getEntityID(),
338
+ IssueInstant: now.toISOString(),
339
+ AssertionConsumerServiceURL: assertionConsumerServiceUrl,
340
+ StatusCode: "urn:oasis:names:tc:SAML:2.0:status:Success",
341
+ ConditionsNotBefore: now.toISOString(),
342
+ ConditionsNotOnOrAfter: fiveMinutesLater.toISOString(),
343
+ SubjectConfirmationDataNotOnOrAfter: fiveMinutesLater.toISOString(),
344
+ NameIDFormat: selectedNameIDFormat,
345
+ NameID: email,
346
+ InResponseTo: "null",
347
+ AuthnStatement: "",
348
+ attrFirstName: "Test",
349
+ attrLastName: "User",
350
+ attrEmail: "test@email.com",
351
+ };
352
+
353
+ return {
354
+ id,
355
+ context: saml.SamlLib.replaceTagsByValue(template, tagValues),
356
+ };
357
+ };
358
+
359
+ const createMockSAMLIdP = (port: number) => {
360
+ const app: ExpressApp = express();
361
+ let server: ReturnType<typeof createServer> | undefined;
362
+
363
+ app.use(bodyParser.urlencoded({ extended: true }));
364
+ app.use(bodyParser.json());
365
+
366
+ const idp = saml.IdentityProvider({
367
+ metadata: idpMetadata,
368
+ privateKey: idPk,
369
+ isAssertionEncrypted: false,
370
+ privateKeyPass: "jXmKf9By6ruLnUdRo90G",
371
+ loginResponseTemplate: {
372
+ context:
373
+ '<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="{ID}" Version="2.0" IssueInstant="{IssueInstant}" Destination="{Destination}" InResponseTo="{InResponseTo}"><saml:Issuer>{Issuer}</saml:Issuer><samlp:Status><samlp:StatusCode Value="{StatusCode}"/></samlp:Status><saml:Assertion ID="{AssertionID}" Version="2.0" IssueInstant="{IssueInstant}" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"><saml:Issuer>{Issuer}</saml:Issuer><saml:Subject><saml:NameID Format="{NameIDFormat}">{NameID}</saml:NameID><saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"><saml:SubjectConfirmationData NotOnOrAfter="{SubjectConfirmationDataNotOnOrAfter}" Recipient="{SubjectRecipient}" InResponseTo="{InResponseTo}"/></saml:SubjectConfirmation></saml:Subject><saml:Conditions NotBefore="{ConditionsNotBefore}" NotOnOrAfter="{ConditionsNotOnOrAfter}"><saml:AudienceRestriction><saml:Audience>{Audience}</saml:Audience></saml:AudienceRestriction></saml:Conditions>{AttributeStatement}</saml:Assertion></samlp:Response>',
374
+ attributes: [
375
+ {
376
+ name: "firstName",
377
+ valueTag: "firstName",
378
+ nameFormat: "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
379
+ valueXsiType: "xs:string",
380
+ },
381
+ {
382
+ name: "lastName",
383
+ valueTag: "lastName",
384
+ nameFormat: "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
385
+ valueXsiType: "xs:string",
386
+ },
387
+ {
388
+ name: "email",
389
+ valueTag: "email",
390
+ nameFormat: "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
391
+ valueXsiType: "xs:string",
392
+ },
393
+ ],
394
+ },
395
+ });
396
+ const sp = saml.ServiceProvider({
397
+ metadata: spMetadata,
398
+ });
399
+ app.get(
400
+ "/api/sso/saml2/idp/post",
401
+ async (req: ExpressRequest, res: ExpressResponse) => {
402
+ const emailCase = req.query.emailCase as string;
403
+ const emailValue =
404
+ emailCase === "mixed" ? "TestUser@Example.com" : "test@email.com";
405
+ const user = {
406
+ email: emailValue,
407
+ emailAddress: emailValue,
408
+ famName: "hello world",
409
+ };
410
+ const { context, entityEndpoint } = await idp.createLoginResponse(
411
+ sp,
412
+ {} as any,
413
+ saml.Constants.wording.binding.post,
414
+ user,
415
+ createTemplateCallback(idp, sp, user.emailAddress),
416
+ );
417
+ res.status(200).send({ samlResponse: context, entityEndpoint });
418
+ },
419
+ );
420
+ app.get(
421
+ "/api/sso/saml2/idp/redirect",
422
+ async (req: ExpressRequest, res: ExpressResponse) => {
423
+ const emailCase = req.query.emailCase as string;
424
+ const emailValue =
425
+ emailCase === "mixed" ? "TestUser@Example.com" : "test@email.com";
426
+ const user = {
427
+ email: emailValue,
428
+ emailAddress: emailValue,
429
+ famName: "hello world",
430
+ };
431
+ const { context, entityEndpoint } = await idp.createLoginResponse(
432
+ sp,
433
+ {} as any,
434
+ saml.Constants.wording.binding.post,
435
+ user,
436
+ createTemplateCallback(idp, sp, user.emailAddress),
437
+ );
438
+ res.status(200).send({ samlResponse: context, entityEndpoint });
439
+ },
440
+ );
441
+ app.post("/api/sso/saml2/sp/acs", async (req: any, res: any) => {
442
+ try {
443
+ const parseResult = await sp.parseLoginResponse(
444
+ idp,
445
+ saml.Constants.wording.binding.post,
446
+ req,
447
+ );
448
+ const { extract } = parseResult;
449
+ const { attributes } = extract;
450
+ const relayState = req.body.RelayState;
451
+ if (relayState) {
452
+ return res.status(200).send({ relayState, attributes });
453
+ } else {
454
+ return res
455
+ .status(200)
456
+ .send({ extract, message: "RelayState is missing." });
457
+ }
458
+ } catch (error) {
459
+ console.error("Error handling SAML ACS endpoint:", error);
460
+ res.status(500).send({ error: "Failed to process SAML response." });
461
+ }
462
+ });
463
+ app.post(
464
+ "/api/sso/saml2/callback/:providerId",
465
+ async (req: ExpressRequest, res: ExpressResponse) => {
466
+ const { SAMLResponse, RelayState } = req.body;
467
+ try {
468
+ await sp.parseLoginResponse(idp, saml.Constants.wording.binding.post, {
469
+ body: { SAMLResponse },
470
+ });
471
+
472
+ res.redirect(302, RelayState || "http://localhost:3000/dashboard");
473
+ } catch (error) {
474
+ console.error("Error processing SAML callback:", error);
475
+ res.status(500).send({ error: "Failed to process SAML response" });
476
+ }
477
+ },
478
+ );
479
+ app.get(
480
+ "/api/sso/saml2/idp/metadata",
481
+ (req: ExpressRequest, res: ExpressResponse) => {
482
+ res.type("application/xml");
483
+ res.send(idpMetadata);
484
+ },
485
+ );
486
+ const start = () =>
487
+ new Promise<void>((resolve) => {
488
+ app.use(bodyParser.urlencoded({ extended: true }));
489
+ server = app.listen(port, () => {
490
+ console.log(`Mock SAML IdP running on port ${port}`);
491
+ resolve();
492
+ });
493
+ });
494
+
495
+ const stop = () =>
496
+ new Promise<void>((resolve, reject) => {
497
+ app.use(bodyParser.urlencoded({ extended: true }));
498
+ server?.close((err) => {
499
+ if (err) reject(err);
500
+ else resolve();
501
+ });
502
+ });
503
+
504
+ const metadataUrl = `http://localhost:${port}/idp/metadata`;
505
+
506
+ return { start, stop, metadataUrl };
507
+ };
508
+
509
+ // Shared mock SAML IdP for all tests
510
+ const sharedMockIdP = createMockSAMLIdP(8081);
511
+
512
+ beforeAll(async () => {
513
+ await sharedMockIdP.start();
514
+ });
515
+
516
+ afterAll(async () => {
517
+ await sharedMockIdP.stop();
518
+ });
519
+
520
+ describe("SAML SSO with defaultSSO array", async () => {
521
+ const data = {
522
+ user: [],
523
+ session: [],
524
+ verification: [],
525
+ account: [],
526
+ ssoProvider: [],
527
+ };
528
+
529
+ const memory = memoryAdapter(data);
530
+
531
+ const ssoOptions = {
532
+ defaultSSO: [
533
+ {
534
+ domain: "localhost:8081",
535
+ providerId: "default-saml",
536
+ samlConfig: {
537
+ issuer: "http://localhost:8081",
538
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
539
+ cert: certificate,
540
+ callbackUrl: "http://localhost:8081/dashboard",
541
+ wantAssertionsSigned: false,
542
+ signatureAlgorithm: "sha256",
543
+ digestAlgorithm: "sha256",
544
+ idpMetadata: {
545
+ metadata: idpMetadata,
546
+ },
547
+ spMetadata: {
548
+ metadata: spMetadata,
549
+ },
550
+ identifierFormat:
551
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
552
+ },
553
+ },
554
+ ],
555
+ provisionUser: vi
556
+ .fn()
557
+ .mockImplementation(async ({ user, userInfo, token, provider }) => {
558
+ return {
559
+ id: "provisioned-user-id",
560
+ email: userInfo.email,
561
+ name: userInfo.name,
562
+ attributes: userInfo.attributes,
563
+ };
564
+ }),
565
+ };
566
+
567
+ const auth = betterAuth({
568
+ database: memory,
569
+ baseURL: "http://localhost:3000",
570
+ emailAndPassword: {
571
+ enabled: true,
572
+ },
573
+ plugins: [sso(ssoOptions)],
574
+ });
575
+
576
+ it("should use default SAML SSO provider from array when no provider found in database", async () => {
577
+ const signInResponse = await auth.api.signInSSO({
578
+ body: {
579
+ providerId: "default-saml",
580
+ callbackURL: "http://localhost:3000/dashboard",
581
+ },
582
+ });
583
+
584
+ expect(signInResponse).toEqual({
585
+ url: expect.stringContaining("http://localhost:8081"),
586
+ redirect: true,
587
+ });
588
+ });
589
+ });
590
+
591
+ describe("SAML SSO with signed AuthnRequests", async () => {
592
+ // IdP metadata with WantAuthnRequestsSigned="true" for testing signed requests
593
+ const idpMetadataWithSignedRequests = `
594
+ <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="http://localhost:8081/api/sso/saml2/idp/metadata">
595
+ <md:IDPSSODescriptor WantAuthnRequestsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
596
+ <md:KeyDescriptor use="signing">
597
+ <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
598
+ <ds:X509Data>
599
+ <ds:X509Certificate>MIIFOjCCAyICCQCqP5DN+xQZDjANBgkqhkiG9w0BAQsFADBfMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzENMAsGA1UECgwEVGVzdDEdMBsGCSqGSIb3DQEJARYOdGVzdEBnbWFpbC5jb20wHhcNMjMxMTE5MTIzNzE3WhcNMzMxMTE2MTIzNzE3WjBfMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzENMAsGA1UECgwEVGVzdDEdMBsGCSqGSIb3DQEJARYOdGVzdEBnbWFpbC5jb20wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQD5giLoLyED41IHt0RxB/k6x4K0vzAKiGecPyedRNR1oyiv3OYkuG5jgTE2wcPZc7kD1Eg5d6th0BWHy/ovaNS5mkgnOV6jKkMaWW4sCMSnLnaWy0seftPK3O4mNeZpM5e9amj2gXnZvKrK8cqnJ/bsUUQvXxttXNVVmOHWg/t3c2vJ4XuUfph6wIKbrj297ILzuAFRNvAVxeS0tElwepvZ5Wbf7Hc1MORAqTpw/mp8cRjHRzYCA9y6OM4hgVs1gvTJS8WGoMmsdAZHaOnv9vLJvW3jDLQQecOheYIJncWgcESzJFIkmXadorYCEfWhwwBdVphknmeLr4BMpJBclAYaFjYDLIKpMcXYO5k/2r3BgSPlw4oqbxbR5geD05myKYtZ/wNUtku118NjhIfJFulU/kfDcp1rYYkvzgBfqr80wgNps4oQzVr1mnpgHsSTAhXMuZbaTByJRmPqecyvyQqRQcRIN0oTLJNGyzoUf0RkH6DKJ4+7qDhlq4Zhlfso9OFMv9xeONfIrJo5HtTfFZfidkXZqir2ZqwqNlNOMfK5DsYq37x2Gkgqig4nqLpITXyxfnQpL2HsaoFrlctt/OL+Zqba7NT4heYk9GX8qlAS+Ipsv6T2HSANbah55oSS3uvcrDOug2Zq7+GYMLKS1IKUKhwX+wLMxmMwSJQ9ZgFwfQIDAQABMA0GCSqGSIb3DQEBCwUAA4ICAQCkGPZdflocTSXIe5bbehsBn/IPdyb38eH2HaAvWqO2XNcDcq+6/uLc8BVK4JMa3AFS9xtBza7MOXN/lw/Ccb8uJGVNUE31+rTvsJaDtMCQkp+9aG04I1BonEHfSB0ANcTy/Gp+4hKyFCd6x35uyPO7CWX5Z8I87q9LF6Dte3/v1j7VZgDjAi9yHpBJv9Xje33AK1vF+WmEfDUOi8y2B8htVeoyS3owln3ZUbnmJdCmMp2BMRq63ymINwklEaYaNrp1L201bSqNdKZF2sNwROWyDX+WFYgufrnzPYb6HS8gYb4oEZmaG5cBM7Hs730/3BlbHKhxNTy1Io2TVCYcMQD+ieiVg5e5eGTwaPYGuVvY3NVhO8FaYBG7K2NT2hqutdCMaQpGyHEzbbbTY1afhbeMmWWqivRnVJNDv4kgBc2SE8JO82qHikIW9Om0cghC5xwTT+1JTtxxD1KeC1M1IwLzzuuMmwJSKAsv4duDqN+YRIP78J2SlrssqlsmoF8+48e7Vzr7JRT/Ya274P8RpUPNtxTR7WDmZ4tunqXjiBpz6l0uTtVXnj5UBo4HCyRjWJOGf15OCuQX03qz8tKn1IbZUf723qrmSF+cxBwHqpAywqhTSsaLjIXKnQ0UlMov7QWb0a5N07JZMdMSerbHvbXd/z9S1Ssea2+EGuTYuQur3A==</ds:X509Certificate>
600
+ </ds:X509Data>
601
+ </ds:KeyInfo>
602
+ </md:KeyDescriptor>
603
+ <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:8081/api/sso/saml2/idp/redirect"/>
604
+ <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost:8081/api/sso/saml2/idp/post"/>
605
+ </md:IDPSSODescriptor>
606
+ </md:EntityDescriptor>
607
+ `;
608
+
609
+ const data = {
610
+ user: [],
611
+ session: [],
612
+ verification: [],
613
+ account: [],
614
+ ssoProvider: [],
615
+ };
616
+
617
+ const memory = memoryAdapter(data);
618
+
619
+ const ssoOptions = {
620
+ defaultSSO: [
621
+ {
622
+ domain: "localhost:8081",
623
+ providerId: "signed-saml",
624
+ samlConfig: {
625
+ issuer: "http://localhost:8081",
626
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
627
+ cert: certificate,
628
+ callbackUrl: "http://localhost:8081/dashboard",
629
+ wantAssertionsSigned: false,
630
+ authnRequestsSigned: true,
631
+ signatureAlgorithm: "sha256",
632
+ digestAlgorithm: "sha256",
633
+ privateKey: idPk,
634
+ spMetadata: {
635
+ privateKey: idPk,
636
+ },
637
+ idpMetadata: {
638
+ metadata: idpMetadataWithSignedRequests,
639
+ },
640
+ identifierFormat:
641
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
642
+ },
643
+ },
644
+ ],
645
+ };
646
+
647
+ const auth = betterAuth({
648
+ database: memory,
649
+ baseURL: "http://localhost:3000",
650
+ emailAndPassword: {
651
+ enabled: true,
652
+ },
653
+ plugins: [sso(ssoOptions)],
654
+ });
655
+
656
+ it("should generate signed AuthnRequest when authnRequestsSigned is true", async () => {
657
+ const signInResponse = await auth.api.signInSSO({
658
+ body: {
659
+ providerId: "signed-saml",
660
+ callbackURL: "http://localhost:3000/dashboard",
661
+ },
662
+ });
663
+
664
+ expect(signInResponse).toEqual({
665
+ url: expect.stringContaining("http://localhost:8081"),
666
+ redirect: true,
667
+ });
668
+ // When authnRequestsSigned is true and privateKey is provided,
669
+ // samlify adds Signature and SigAlg parameters to the redirect URL
670
+ expect(signInResponse.url).toContain("Signature=");
671
+ expect(signInResponse.url).toContain("SigAlg=");
672
+ });
673
+ });
674
+
675
+ describe("SAML SSO without signed AuthnRequests", async () => {
676
+ const data = {
677
+ user: [],
678
+ session: [],
679
+ verification: [],
680
+ account: [],
681
+ ssoProvider: [],
682
+ };
683
+
684
+ const memory = memoryAdapter(data);
685
+
686
+ const ssoOptions = {
687
+ defaultSSO: [
688
+ {
689
+ domain: "localhost:8082",
690
+ providerId: "unsigned-saml",
691
+ samlConfig: {
692
+ issuer: "http://localhost:8082",
693
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
694
+ cert: certificate,
695
+ callbackUrl: "http://localhost:8082/dashboard",
696
+ wantAssertionsSigned: false,
697
+ authnRequestsSigned: false,
698
+ signatureAlgorithm: "sha256",
699
+ digestAlgorithm: "sha256",
700
+ idpMetadata: {
701
+ metadata: idpMetadata,
702
+ },
703
+ spMetadata: {
704
+ metadata: spMetadata,
705
+ },
706
+ identifierFormat:
707
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
708
+ },
709
+ },
710
+ ],
711
+ };
712
+
713
+ const auth = betterAuth({
714
+ database: memory,
715
+ baseURL: "http://localhost:3000",
716
+ emailAndPassword: {
717
+ enabled: true,
718
+ },
719
+ plugins: [sso(ssoOptions)],
720
+ });
721
+
722
+ it("should NOT include Signature in URL when authnRequestsSigned is false", async () => {
723
+ const signInResponse = await auth.api.signInSSO({
724
+ body: {
725
+ providerId: "unsigned-saml",
726
+ callbackURL: "http://localhost:3000/dashboard",
727
+ },
728
+ });
729
+
730
+ expect(signInResponse).toEqual({
731
+ url: expect.stringContaining("http://localhost:8081"),
732
+ redirect: true,
733
+ });
734
+ // When authnRequestsSigned is false (default), no Signature should be in the URL
735
+ expect(signInResponse.url).not.toContain("Signature=");
736
+ expect(signInResponse.url).not.toContain("SigAlg=");
737
+ });
738
+ });
739
+
740
+ describe("SAML SSO", async () => {
741
+ const data = {
742
+ user: [],
743
+ session: [],
744
+ verification: [],
745
+ account: [],
746
+ ssoProvider: [],
747
+ };
748
+
749
+ const memory = memoryAdapter(data);
750
+
751
+ const ssoOptions = {
752
+ provisionUser: vi
753
+ .fn()
754
+ .mockImplementation(async ({ user, userInfo, token, provider }) => {
755
+ return {
756
+ id: "provisioned-user-id",
757
+ email: userInfo.email,
758
+ name: userInfo.name,
759
+ attributes: userInfo.attributes,
760
+ };
761
+ }),
762
+ };
763
+
764
+ const auth = betterAuth({
765
+ database: memory,
766
+ baseURL: "http://localhost:3000",
767
+ emailAndPassword: {
768
+ enabled: true,
769
+ },
770
+ plugins: [sso(ssoOptions)],
771
+ });
772
+
773
+ const authClient = createAuthClient({
774
+ baseURL: "http://localhost:3000",
775
+ plugins: [bearer(), ssoClient()],
776
+ fetchOptions: {
777
+ customFetchImpl: async (url, init) => {
778
+ return auth.handler(new Request(url, init));
779
+ },
780
+ },
781
+ });
782
+
783
+ const testUser = {
784
+ email: "test@email.com",
785
+ password: "password",
786
+ name: "Test User",
787
+ };
788
+
789
+ beforeAll(async () => {
790
+ await authClient.signUp.email({
791
+ email: testUser.email,
792
+ password: testUser.password,
793
+ name: testUser.name,
794
+ });
795
+ });
796
+
797
+ beforeEach(() => {
798
+ data.user = [];
799
+ data.session = [];
800
+ data.verification = [];
801
+ data.account = [];
802
+ data.ssoProvider = [];
803
+
804
+ vi.clearAllMocks();
805
+ });
806
+
807
+ async function getAuthHeaders() {
808
+ const headers = new Headers();
809
+ await authClient.signUp.email({
810
+ email: testUser.email,
811
+ password: testUser.password,
812
+ name: testUser.name,
813
+ });
814
+ await authClient.signIn.email(testUser, {
815
+ throw: true,
816
+ onSuccess: setCookieToHeader(headers),
817
+ });
818
+ return headers;
819
+ }
820
+
821
+ it("should register a new SAML provider", async () => {
822
+ const headers = await getAuthHeaders();
823
+ await authClient.signIn.email(testUser, {
824
+ throw: true,
825
+ onSuccess: setCookieToHeader(headers),
826
+ });
827
+
828
+ const provider = await auth.api.registerSSOProvider({
829
+ body: {
830
+ providerId: "saml-provider-1",
831
+ issuer: "http://localhost:8081",
832
+ domain: "http://localhost:8081",
833
+ samlConfig: {
834
+ entryPoint: sharedMockIdP.metadataUrl,
835
+ cert: certificate,
836
+ callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
837
+ wantAssertionsSigned: false,
838
+ signatureAlgorithm: "sha256",
839
+ digestAlgorithm: "sha256",
840
+ idpMetadata: {
841
+ metadata: idpMetadata,
842
+ privateKey: idpPrivateKey,
843
+ privateKeyPass: "q9ALNhGT5EhfcRmp8Pg7e9zTQeP2x1bW",
844
+ isAssertionEncrypted: true,
845
+ encPrivateKey: idpEncryptionKey,
846
+ encPrivateKeyPass: "g7hGcRmp8PxT5QeP2q9Ehf1bWe9zTALN",
847
+ },
848
+ spMetadata: {
849
+ metadata: spMetadata,
850
+ binding: "post",
851
+ privateKey: spPrivateKey,
852
+ privateKeyPass: "VHOSp5RUiBcrsjrcAuXFwU1NKCkGA8px",
853
+ isAssertionEncrypted: true,
854
+ encPrivateKey: spEncryptionKey,
855
+ encPrivateKeyPass: "BXFNKpxrsjrCkGA8cAu5wUVHOSpci1RU",
856
+ },
857
+ identifierFormat:
858
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
859
+ },
860
+ },
861
+ headers,
862
+ });
863
+ expect(provider).toMatchObject({
864
+ id: expect.any(String),
865
+ issuer: "http://localhost:8081",
866
+ samlConfig: {
867
+ entryPoint: sharedMockIdP.metadataUrl,
868
+ cert: expect.any(String),
869
+ callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
870
+ wantAssertionsSigned: false,
871
+ signatureAlgorithm: "sha256",
872
+ digestAlgorithm: "sha256",
873
+ identifierFormat:
874
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
875
+ },
876
+ });
877
+ });
878
+ it("Should fetch sp metadata", async () => {
879
+ const headers = await getAuthHeaders();
880
+ await authClient.signIn.email(testUser, {
881
+ throw: true,
882
+ onSuccess: setCookieToHeader(headers),
883
+ });
884
+ const provider = await auth.api.registerSSOProvider({
885
+ body: {
886
+ providerId: "saml-provider-1",
887
+ issuer: "http://localhost:8081",
888
+ domain: "http://localhost:8081",
889
+ samlConfig: {
890
+ entryPoint: sharedMockIdP.metadataUrl,
891
+ cert: certificate,
892
+ callbackUrl: "http://localhost:8081/api/sso/saml2/sp/acs",
893
+ wantAssertionsSigned: false,
894
+ signatureAlgorithm: "sha256",
895
+ digestAlgorithm: "sha256",
896
+ idpMetadata: {
897
+ metadata: idpMetadata,
898
+ privateKey: idpPrivateKey,
899
+ privateKeyPass: "q9ALNhGT5EhfcRmp8Pg7e9zTQeP2x1bW",
900
+ isAssertionEncrypted: true,
901
+ encPrivateKey: idpEncryptionKey,
902
+ encPrivateKeyPass: "g7hGcRmp8PxT5QeP2q9Ehf1bWe9zTALN",
903
+ },
904
+ spMetadata: {
905
+ metadata: spMetadata,
906
+ binding: "post",
907
+ privateKey: spPrivateKey,
908
+ privateKeyPass: "VHOSp5RUiBcrsjrcAuXFwU1NKCkGA8px",
909
+ isAssertionEncrypted: true,
910
+ encPrivateKey: spEncryptionKey,
911
+ encPrivateKeyPass: "BXFNKpxrsjrCkGA8cAu5wUVHOSpci1RU",
912
+ },
913
+ identifierFormat:
914
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
915
+ },
916
+ },
917
+ headers,
918
+ });
919
+
920
+ const spMetadataRes = await auth.api.spMetadata({
921
+ query: {
922
+ providerId: provider.providerId,
923
+ },
924
+ });
925
+ const spMetadataResResValue = await spMetadataRes.text();
926
+ expect(spMetadataRes.status).toBe(200);
927
+ expect(spMetadataResResValue).toBe(spMetadata);
928
+ });
929
+ it("Should fetch sp metadata", async () => {
930
+ const headers = await getAuthHeaders();
931
+ await authClient.signIn.email(testUser, {
932
+ throw: true,
933
+ onSuccess: setCookieToHeader(headers),
934
+ });
935
+ const issuer = "http://localhost:8081";
936
+ const provider = await auth.api.registerSSOProvider({
937
+ body: {
938
+ providerId: "saml-provider-1",
939
+ issuer: issuer,
940
+ domain: issuer,
941
+ samlConfig: {
942
+ entryPoint: sharedMockIdP.metadataUrl,
943
+ cert: certificate,
944
+ callbackUrl: `${issuer}/api/sso/saml2/sp/acs`,
945
+ wantAssertionsSigned: false,
946
+ signatureAlgorithm: "sha256",
947
+ digestAlgorithm: "sha256",
948
+ idpMetadata: {
949
+ metadata: idpMetadata,
950
+ privateKey: idpPrivateKey,
951
+ privateKeyPass: "q9ALNhGT5EhfcRmp8Pg7e9zTQeP2x1bW",
952
+ isAssertionEncrypted: true,
953
+ encPrivateKey: idpEncryptionKey,
954
+ encPrivateKeyPass: "g7hGcRmp8PxT5QeP2q9Ehf1bWe9zTALN",
955
+ },
956
+ spMetadata: {
957
+ binding: "post",
958
+ privateKey: spPrivateKey,
959
+ privateKeyPass: "VHOSp5RUiBcrsjrcAuXFwU1NKCkGA8px",
960
+ isAssertionEncrypted: true,
961
+ encPrivateKey: spEncryptionKey,
962
+ encPrivateKeyPass: "BXFNKpxrsjrCkGA8cAu5wUVHOSpci1RU",
963
+ },
964
+ identifierFormat:
965
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
966
+ },
967
+ },
968
+ headers,
969
+ });
970
+
971
+ const spMetadataRes = await auth.api.spMetadata({
972
+ query: {
973
+ providerId: provider.providerId,
974
+ },
975
+ });
976
+ const spMetadataResResValue = await spMetadataRes.text();
977
+ expect(spMetadataRes.status).toBe(200);
978
+ expect(spMetadataResResValue).toBeDefined();
979
+ expect(spMetadataResResValue).toContain(
980
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
981
+ );
982
+ expect(spMetadataResResValue).toContain(
983
+ "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
984
+ );
985
+ expect(spMetadataResResValue).toContain(
986
+ `<EntityDescriptor entityID="${issuer}"`,
987
+ );
988
+ expect(spMetadataResResValue).toContain(
989
+ `Location="${issuer}/api/sso/saml2/sp/acs"`,
990
+ );
991
+ });
992
+ it("should initiate SAML login and handle response", async () => {
993
+ const headers = await getAuthHeaders();
994
+ await authClient.signIn.email(testUser, {
995
+ throw: true,
996
+ onSuccess: setCookieToHeader(headers),
997
+ });
998
+ await auth.api.registerSSOProvider({
999
+ body: {
1000
+ providerId: "saml-provider-1",
1001
+ issuer: "http://localhost:8081",
1002
+ domain: "http://localhost:8081",
1003
+ samlConfig: {
1004
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1005
+ cert: certificate,
1006
+ callbackUrl: "http://localhost:8081/dashboard",
1007
+ wantAssertionsSigned: false,
1008
+ signatureAlgorithm: "sha256",
1009
+ digestAlgorithm: "sha256",
1010
+ idpMetadata: {
1011
+ metadata: idpMetadata,
1012
+ privateKey: idpPrivateKey,
1013
+ privateKeyPass: "q9ALNhGT5EhfcRmp8Pg7e9zTQeP2x1bW",
1014
+ isAssertionEncrypted: true,
1015
+ encPrivateKey: idpEncryptionKey,
1016
+ encPrivateKeyPass: "g7hGcRmp8PxT5QeP2q9Ehf1bWe9zTALN",
1017
+ },
1018
+ spMetadata: {
1019
+ metadata: spMetadata,
1020
+ binding: "post",
1021
+ privateKey: spPrivateKey,
1022
+ privateKeyPass: "VHOSp5RUiBcrsjrcAuXFwU1NKCkGA8px",
1023
+ isAssertionEncrypted: true,
1024
+ encPrivateKey: spEncryptionKey,
1025
+ encPrivateKeyPass: "BXFNKpxrsjrCkGA8cAu5wUVHOSpci1RU",
1026
+ },
1027
+ identifierFormat:
1028
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1029
+ },
1030
+ },
1031
+ headers,
1032
+ });
1033
+
1034
+ const signInResponse = await auth.api.signInSSO({
1035
+ body: {
1036
+ providerId: "saml-provider-1",
1037
+ callbackURL: "http://localhost:3000/dashboard",
1038
+ },
1039
+ });
1040
+
1041
+ expect(signInResponse).toEqual({
1042
+ url: expect.stringContaining("http://localhost:8081"),
1043
+ redirect: true,
1044
+ });
1045
+ let samlResponse: any;
1046
+ await betterFetch(signInResponse?.url as string, {
1047
+ onSuccess: async (context) => {
1048
+ samlResponse = await context.data;
1049
+ },
1050
+ });
1051
+ let redirectLocation = "";
1052
+ await betterFetch(
1053
+ "http://localhost:8081/api/sso/saml2/callback/saml-provider-1",
1054
+ {
1055
+ method: "POST",
1056
+ redirect: "manual",
1057
+ headers: {
1058
+ "Content-Type": "application/x-www-form-urlencoded",
1059
+ },
1060
+ body: new URLSearchParams({
1061
+ SAMLResponse: samlResponse.samlResponse,
1062
+ }),
1063
+ onError: (context) => {
1064
+ expect(context.response.status).toBe(302);
1065
+ redirectLocation = context.response.headers.get("location") || "";
1066
+ },
1067
+ },
1068
+ );
1069
+ expect(redirectLocation).toBe("http://localhost:3000/dashboard");
1070
+ });
1071
+
1072
+ it("should not allow creating a provider if limit is set to 0", async () => {
1073
+ const { auth, signInWithTestUser } = await getTestInstance({
1074
+ plugins: [sso({ providersLimit: 0 })],
1075
+ });
1076
+ const { headers } = await signInWithTestUser();
1077
+ await expect(
1078
+ auth.api.registerSSOProvider({
1079
+ body: {
1080
+ providerId: "saml-provider-1",
1081
+ issuer: "http://localhost:8081",
1082
+ domain: "http://localhost:8081",
1083
+ samlConfig: {
1084
+ entryPoint: sharedMockIdP.metadataUrl,
1085
+ cert: certificate,
1086
+ callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
1087
+ wantAssertionsSigned: false,
1088
+ signatureAlgorithm: "sha256",
1089
+ digestAlgorithm: "sha256",
1090
+ spMetadata: {
1091
+ metadata: spMetadata,
1092
+ },
1093
+ },
1094
+ },
1095
+ headers,
1096
+ }),
1097
+ ).rejects.toMatchObject({
1098
+ status: "FORBIDDEN",
1099
+ body: { message: "SSO provider registration is disabled" },
1100
+ });
1101
+ });
1102
+
1103
+ it("should not allow creating a provider if limit is reached", async () => {
1104
+ const { auth, signInWithTestUser } = await getTestInstance({
1105
+ plugins: [sso({ providersLimit: 1 })],
1106
+ });
1107
+ const { headers } = await signInWithTestUser();
1108
+
1109
+ await auth.api.registerSSOProvider({
1110
+ body: {
1111
+ providerId: "saml-provider-1",
1112
+ issuer: "http://localhost:8081",
1113
+ domain: "http://localhost:8081",
1114
+ samlConfig: {
1115
+ entryPoint: sharedMockIdP.metadataUrl,
1116
+ cert: certificate,
1117
+ callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
1118
+ wantAssertionsSigned: false,
1119
+ signatureAlgorithm: "sha256",
1120
+ digestAlgorithm: "sha256",
1121
+ spMetadata: {
1122
+ metadata: spMetadata,
1123
+ },
1124
+ },
1125
+ },
1126
+ headers,
1127
+ });
1128
+
1129
+ await expect(
1130
+ auth.api.registerSSOProvider({
1131
+ body: {
1132
+ providerId: "saml-provider-2",
1133
+ issuer: "http://localhost:8081",
1134
+ domain: "http://localhost:8081",
1135
+ samlConfig: {
1136
+ entryPoint: sharedMockIdP.metadataUrl,
1137
+ cert: certificate,
1138
+ callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
1139
+ wantAssertionsSigned: false,
1140
+ signatureAlgorithm: "sha256",
1141
+ digestAlgorithm: "sha256",
1142
+ spMetadata: {
1143
+ metadata: spMetadata,
1144
+ },
1145
+ },
1146
+ },
1147
+ headers,
1148
+ }),
1149
+ ).rejects.toMatchObject({
1150
+ status: "FORBIDDEN",
1151
+ body: {
1152
+ message: "You have reached the maximum number of SSO providers",
1153
+ },
1154
+ });
1155
+ });
1156
+
1157
+ it("should not allow creating a provider if limit from function is reached", async () => {
1158
+ const { auth, signInWithTestUser } = await getTestInstance({
1159
+ plugins: [
1160
+ sso({
1161
+ providersLimit: async (user) => {
1162
+ return user.email === "pro@example.com" ? 2 : 1;
1163
+ },
1164
+ }),
1165
+ ],
1166
+ });
1167
+ const { headers } = await signInWithTestUser();
1168
+
1169
+ await auth.api.registerSSOProvider({
1170
+ body: {
1171
+ providerId: "saml-provider-1",
1172
+ issuer: "http://localhost:8081",
1173
+ domain: "http://localhost:8081",
1174
+ samlConfig: {
1175
+ entryPoint: sharedMockIdP.metadataUrl,
1176
+ cert: certificate,
1177
+ callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
1178
+ wantAssertionsSigned: false,
1179
+ signatureAlgorithm: "sha256",
1180
+ digestAlgorithm: "sha256",
1181
+ spMetadata: {
1182
+ metadata: spMetadata,
1183
+ },
1184
+ },
1185
+ },
1186
+ headers,
1187
+ });
1188
+
1189
+ await expect(
1190
+ auth.api.registerSSOProvider({
1191
+ body: {
1192
+ providerId: "saml-provider-2",
1193
+ issuer: "http://localhost:8081",
1194
+ domain: "http://localhost:8081",
1195
+ samlConfig: {
1196
+ entryPoint: sharedMockIdP.metadataUrl,
1197
+ cert: certificate,
1198
+ callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
1199
+ wantAssertionsSigned: false,
1200
+ signatureAlgorithm: "sha256",
1201
+ digestAlgorithm: "sha256",
1202
+ spMetadata: {
1203
+ metadata: spMetadata,
1204
+ },
1205
+ },
1206
+ },
1207
+ headers,
1208
+ }),
1209
+ ).rejects.toMatchObject({
1210
+ status: "FORBIDDEN",
1211
+ body: {
1212
+ message: "You have reached the maximum number of SSO providers",
1213
+ },
1214
+ });
1215
+ });
1216
+
1217
+ it("should not allow creating a provider with duplicate providerId", async () => {
1218
+ const headers = await getAuthHeaders();
1219
+ await authClient.signIn.email(testUser, {
1220
+ throw: true,
1221
+ onSuccess: setCookieToHeader(headers),
1222
+ });
1223
+
1224
+ await auth.api.registerSSOProvider({
1225
+ body: {
1226
+ providerId: "duplicate-provider",
1227
+ issuer: "http://localhost:8081",
1228
+ domain: "http://localhost:8081",
1229
+ samlConfig: {
1230
+ entryPoint: sharedMockIdP.metadataUrl,
1231
+ cert: certificate,
1232
+ callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
1233
+ spMetadata: {
1234
+ metadata: spMetadata,
1235
+ },
1236
+ },
1237
+ },
1238
+ headers,
1239
+ });
1240
+
1241
+ await expect(
1242
+ auth.api.registerSSOProvider({
1243
+ body: {
1244
+ providerId: "duplicate-provider",
1245
+ issuer: "http://localhost:8082",
1246
+ domain: "http://localhost:8082",
1247
+ samlConfig: {
1248
+ entryPoint: sharedMockIdP.metadataUrl,
1249
+ cert: certificate,
1250
+ callbackUrl: "http://localhost:8082/api/sso/saml2/callback",
1251
+ spMetadata: {
1252
+ metadata: spMetadata,
1253
+ },
1254
+ },
1255
+ },
1256
+ headers,
1257
+ }),
1258
+ ).rejects.toMatchObject({
1259
+ status: "UNPROCESSABLE_ENTITY",
1260
+ body: {
1261
+ message: "SSO provider with this providerId already exists",
1262
+ },
1263
+ });
1264
+ });
1265
+
1266
+ it("should initiate SAML login and validate RelayState", async () => {
1267
+ const { auth, signInWithTestUser } = await getTestInstance({
1268
+ plugins: [sso()],
1269
+ });
1270
+
1271
+ const { headers } = await signInWithTestUser();
1272
+ await auth.api.registerSSOProvider({
1273
+ body: {
1274
+ providerId: "saml-provider-1",
1275
+ issuer: "http://localhost:8081",
1276
+ domain: "http://localhost:8081",
1277
+ samlConfig: {
1278
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1279
+ cert: certificate,
1280
+ callbackUrl: "http://localhost:3000/dashboard",
1281
+ wantAssertionsSigned: false,
1282
+ signatureAlgorithm: "sha256",
1283
+ digestAlgorithm: "sha256",
1284
+ idpMetadata: {
1285
+ metadata: idpMetadata,
1286
+ },
1287
+ spMetadata: {
1288
+ metadata: spMetadata,
1289
+ },
1290
+ identifierFormat:
1291
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1292
+ },
1293
+ },
1294
+ headers,
1295
+ });
1296
+
1297
+ const response = await auth.api.signInSSO({
1298
+ body: {
1299
+ providerId: "saml-provider-1",
1300
+ callbackURL: "http://localhost:3000/dashboard",
1301
+ },
1302
+ returnHeaders: true,
1303
+ });
1304
+
1305
+ const signInResponse = response.response;
1306
+ expect(signInResponse).toEqual({
1307
+ url: expect.stringContaining("http://localhost:8081"),
1308
+ redirect: true,
1309
+ });
1310
+
1311
+ let samlResponse: any;
1312
+ await betterFetch(signInResponse?.url, {
1313
+ onSuccess: async (context) => {
1314
+ samlResponse = await context.data;
1315
+ },
1316
+ });
1317
+
1318
+ const samlRedirectUrl = new URL(signInResponse?.url);
1319
+ const callbackResponse = await auth.api.callbackSSOSAML({
1320
+ method: "POST",
1321
+ body: {
1322
+ SAMLResponse: samlResponse.samlResponse,
1323
+ RelayState: samlRedirectUrl.searchParams.get("RelayState") ?? "",
1324
+ },
1325
+ headers: {
1326
+ Cookie: response.headers.get("set-cookie") ?? "",
1327
+ },
1328
+ params: {
1329
+ providerId: "saml-provider-1",
1330
+ },
1331
+ asResponse: true,
1332
+ });
1333
+
1334
+ expect(callbackResponse.headers.get("location")).toContain("dashboard");
1335
+ });
1336
+
1337
+ it("should initiate SAML login and fallback to callbackUrl on invalid RelayState", async () => {
1338
+ const { auth, signInWithTestUser } = await getTestInstance({
1339
+ plugins: [sso()],
1340
+ });
1341
+
1342
+ const { headers } = await signInWithTestUser();
1343
+ await auth.api.registerSSOProvider({
1344
+ body: {
1345
+ providerId: "saml-provider-1",
1346
+ issuer: "http://localhost:8081",
1347
+ domain: "http://localhost:8081",
1348
+ samlConfig: {
1349
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1350
+ cert: certificate,
1351
+ callbackUrl: "http://localhost:3000/dashboard",
1352
+ wantAssertionsSigned: false,
1353
+ signatureAlgorithm: "sha256",
1354
+ digestAlgorithm: "sha256",
1355
+ idpMetadata: {
1356
+ metadata: idpMetadata,
1357
+ },
1358
+ spMetadata: {
1359
+ metadata: spMetadata,
1360
+ },
1361
+ identifierFormat:
1362
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1363
+ },
1364
+ },
1365
+ headers,
1366
+ });
1367
+
1368
+ const response = await auth.api.signInSSO({
1369
+ body: {
1370
+ providerId: "saml-provider-1",
1371
+ callbackURL: "http://localhost:3000/dashboard",
1372
+ },
1373
+ returnHeaders: true,
1374
+ });
1375
+
1376
+ const signInResponse = response.response;
1377
+ expect(signInResponse).toEqual({
1378
+ url: expect.stringContaining("http://localhost:8081"),
1379
+ redirect: true,
1380
+ });
1381
+
1382
+ let samlResponse: any;
1383
+ await betterFetch(signInResponse?.url, {
1384
+ onSuccess: async (context) => {
1385
+ samlResponse = await context.data;
1386
+ },
1387
+ });
1388
+
1389
+ const callbackResponse = await auth.api.callbackSSOSAML({
1390
+ method: "POST",
1391
+ body: {
1392
+ SAMLResponse: samlResponse.samlResponse,
1393
+ RelayState: "not-the-right-relay-state",
1394
+ },
1395
+ headers: {
1396
+ Cookie: response.headers.get("set-cookie") ?? "",
1397
+ },
1398
+ params: {
1399
+ providerId: "saml-provider-1",
1400
+ },
1401
+ asResponse: true,
1402
+ });
1403
+
1404
+ expect(callbackResponse.status).toBe(302);
1405
+ expect(callbackResponse.headers.get("location")).toBe(
1406
+ "http://localhost:3000/dashboard",
1407
+ );
1408
+ });
1409
+
1410
+ it("should initiate SAML login and signup user when disableImplicitSignUp is true but requestSignup is explicitly enabled", async () => {
1411
+ const { auth, signInWithTestUser } = await getTestInstance({
1412
+ plugins: [sso({ disableImplicitSignUp: true })],
1413
+ });
1414
+
1415
+ const { headers } = await signInWithTestUser();
1416
+ await auth.api.registerSSOProvider({
1417
+ body: {
1418
+ providerId: "saml-provider-1",
1419
+ issuer: "http://localhost:8081",
1420
+ domain: "http://localhost:8081",
1421
+ samlConfig: {
1422
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1423
+ cert: certificate,
1424
+ callbackUrl: "http://localhost:3000/dashboard",
1425
+ wantAssertionsSigned: false,
1426
+ signatureAlgorithm: "sha256",
1427
+ digestAlgorithm: "sha256",
1428
+ idpMetadata: {
1429
+ metadata: idpMetadata,
1430
+ },
1431
+ spMetadata: {
1432
+ metadata: spMetadata,
1433
+ },
1434
+ identifierFormat:
1435
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1436
+ },
1437
+ },
1438
+ headers,
1439
+ });
1440
+
1441
+ const response = await auth.api.signInSSO({
1442
+ body: {
1443
+ providerId: "saml-provider-1",
1444
+ callbackURL: "http://localhost:3000/dashboard",
1445
+ requestSignUp: true,
1446
+ },
1447
+ returnHeaders: true,
1448
+ });
1449
+
1450
+ const signInResponse = response.response;
1451
+ expect(signInResponse).toEqual({
1452
+ url: expect.stringContaining("http://localhost:8081"),
1453
+ redirect: true,
1454
+ });
1455
+
1456
+ let samlResponse: any;
1457
+ await betterFetch(signInResponse?.url, {
1458
+ onSuccess: async (context) => {
1459
+ samlResponse = await context.data;
1460
+ },
1461
+ });
1462
+
1463
+ const samlRedirectUrl = new URL(signInResponse?.url);
1464
+ const callbackResponse = await auth.api.callbackSSOSAML({
1465
+ method: "POST",
1466
+ body: {
1467
+ SAMLResponse: samlResponse.samlResponse,
1468
+ RelayState: samlRedirectUrl.searchParams.get("RelayState") ?? "",
1469
+ },
1470
+ headers: {
1471
+ Cookie: response.headers.get("set-cookie") ?? "",
1472
+ },
1473
+ params: {
1474
+ providerId: "saml-provider-1",
1475
+ },
1476
+ asResponse: true,
1477
+ });
1478
+
1479
+ expect(callbackResponse.headers.get("location")).toContain("dashboard");
1480
+ });
1481
+
1482
+ it("should reject SAML sign-in when disableImplicitSignUp is true and user doesn't exist", async () => {
1483
+ const { auth: authWithDisabledSignUp, signInWithTestUser } =
1484
+ await getTestInstance({
1485
+ plugins: [sso({ disableImplicitSignUp: true })],
1486
+ });
1487
+
1488
+ const { headers } = await signInWithTestUser();
1489
+
1490
+ // Register SAML provider
1491
+ await authWithDisabledSignUp.api.registerSSOProvider({
1492
+ body: {
1493
+ providerId: "saml-test-provider",
1494
+ issuer: "http://localhost:8081",
1495
+ domain: "http://localhost:8081",
1496
+ samlConfig: {
1497
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1498
+ cert: certificate,
1499
+ callbackUrl: "http://localhost:3000/dashboard",
1500
+ wantAssertionsSigned: false,
1501
+ signatureAlgorithm: "sha256",
1502
+ digestAlgorithm: "sha256",
1503
+ idpMetadata: {
1504
+ metadata: idpMetadata,
1505
+ },
1506
+ spMetadata: {
1507
+ metadata: spMetadata,
1508
+ },
1509
+ identifierFormat:
1510
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1511
+ },
1512
+ },
1513
+ headers: headers,
1514
+ });
1515
+
1516
+ // Identity Provider-initiated: Get SAML response directly from IdP
1517
+ // The mock IdP will return test@email.com, which doesn't exist in the DB
1518
+ let samlResponse: any;
1519
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
1520
+ onSuccess: async (context) => {
1521
+ samlResponse = await context.data;
1522
+ },
1523
+ });
1524
+
1525
+ const response = await authWithDisabledSignUp.handler(
1526
+ new Request(
1527
+ "http://localhost:3000/api/auth/sso/saml2/callback/saml-test-provider",
1528
+ {
1529
+ method: "POST",
1530
+ headers: {
1531
+ "Content-Type": "application/x-www-form-urlencoded",
1532
+ },
1533
+ body: new URLSearchParams({
1534
+ SAMLResponse: samlResponse.samlResponse,
1535
+ RelayState: "http://localhost:3000/dashboard",
1536
+ }),
1537
+ },
1538
+ ),
1539
+ );
1540
+
1541
+ expect(response.status).toBe(302);
1542
+ const redirectLocation = response.headers.get("location") || "";
1543
+ expect(redirectLocation).toContain("error=signup_disabled");
1544
+ });
1545
+
1546
+ it("should reject SAML ACS (IdP-initiated) when disableImplicitSignUp is true and user doesn't exist", async () => {
1547
+ const { auth: authWithDisabledSignUp, signInWithTestUser } =
1548
+ await getTestInstance({
1549
+ plugins: [sso({ disableImplicitSignUp: true })],
1550
+ });
1551
+
1552
+ const { headers } = await signInWithTestUser();
1553
+
1554
+ await authWithDisabledSignUp.api.registerSSOProvider({
1555
+ body: {
1556
+ providerId: "saml-acs-test-provider",
1557
+ issuer: "http://localhost:8081",
1558
+ domain: "http://localhost:8081",
1559
+ samlConfig: {
1560
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1561
+ cert: certificate,
1562
+ callbackUrl: "http://localhost:3000/dashboard",
1563
+ wantAssertionsSigned: false,
1564
+ signatureAlgorithm: "sha256",
1565
+ digestAlgorithm: "sha256",
1566
+ idpMetadata: {
1567
+ metadata: idpMetadata,
1568
+ },
1569
+ spMetadata: {
1570
+ metadata: spMetadata,
1571
+ },
1572
+ identifierFormat:
1573
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1574
+ },
1575
+ },
1576
+ headers: headers,
1577
+ });
1578
+
1579
+ let samlResponse: any;
1580
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
1581
+ onSuccess: async (context) => {
1582
+ samlResponse = await context.data;
1583
+ },
1584
+ });
1585
+
1586
+ const response = await authWithDisabledSignUp.handler(
1587
+ new Request(
1588
+ "http://localhost:3000/api/auth/sso/saml2/sp/acs/saml-acs-test-provider",
1589
+ {
1590
+ method: "POST",
1591
+ headers: {
1592
+ "Content-Type": "application/x-www-form-urlencoded",
1593
+ },
1594
+ body: new URLSearchParams({
1595
+ SAMLResponse: samlResponse.samlResponse,
1596
+ RelayState: "http://localhost:3000/dashboard",
1597
+ }),
1598
+ },
1599
+ ),
1600
+ );
1601
+
1602
+ expect(response.status).toBe(302);
1603
+ const redirectLocation = response.headers.get("location") || "";
1604
+ expect(redirectLocation).toContain("error=signup_disabled");
1605
+ });
1606
+
1607
+ it("should deny account linking when provider is not trusted and domain is not verified", async () => {
1608
+ const { auth: authUntrusted, signInWithTestUser } = await getTestInstance({
1609
+ account: {
1610
+ accountLinking: {
1611
+ enabled: true,
1612
+ trustedProviders: [],
1613
+ },
1614
+ },
1615
+ plugins: [sso()],
1616
+ });
1617
+
1618
+ const { headers } = await signInWithTestUser();
1619
+
1620
+ await authUntrusted.api.registerSSOProvider({
1621
+ body: {
1622
+ providerId: "untrusted-saml-provider",
1623
+ issuer: "http://localhost:8081",
1624
+ domain: "http://localhost:8081",
1625
+ samlConfig: {
1626
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1627
+ cert: certificate,
1628
+ callbackUrl: "http://localhost:3000/dashboard",
1629
+ wantAssertionsSigned: false,
1630
+ signatureAlgorithm: "sha256",
1631
+ digestAlgorithm: "sha256",
1632
+ idpMetadata: {
1633
+ metadata: idpMetadata,
1634
+ },
1635
+ spMetadata: {
1636
+ metadata: spMetadata,
1637
+ },
1638
+ identifierFormat:
1639
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1640
+ },
1641
+ },
1642
+ headers,
1643
+ });
1644
+
1645
+ const ctx = await authUntrusted.$context;
1646
+ await ctx.adapter.create({
1647
+ model: "user",
1648
+ data: {
1649
+ id: "existing-user-id",
1650
+ email: "test@email.com",
1651
+ name: "Existing User",
1652
+ emailVerified: true,
1653
+ createdAt: new Date(),
1654
+ updatedAt: new Date(),
1655
+ },
1656
+ });
1657
+
1658
+ let samlResponse: any;
1659
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
1660
+ onSuccess: async (context) => {
1661
+ samlResponse = await context.data;
1662
+ },
1663
+ });
1664
+
1665
+ const response = await authUntrusted.handler(
1666
+ new Request(
1667
+ "http://localhost:3000/api/auth/sso/saml2/callback/untrusted-saml-provider",
1668
+ {
1669
+ method: "POST",
1670
+ headers: {
1671
+ "Content-Type": "application/x-www-form-urlencoded",
1672
+ },
1673
+ body: new URLSearchParams({
1674
+ SAMLResponse: samlResponse.samlResponse,
1675
+ }),
1676
+ },
1677
+ ),
1678
+ );
1679
+
1680
+ expect(response.status).toBe(302);
1681
+ const redirectLocation = response.headers.get("location") || "";
1682
+ expect(redirectLocation).toContain("error=account_not_linked");
1683
+ });
1684
+
1685
+ it("should allow account linking when provider is in trustedProviders", async () => {
1686
+ const { auth: authWithTrusted, signInWithTestUser } = await getTestInstance(
1687
+ {
1688
+ account: {
1689
+ accountLinking: {
1690
+ enabled: true,
1691
+ trustedProviders: ["trusted-saml-provider"],
1692
+ },
1693
+ },
1694
+ plugins: [sso()],
1695
+ },
1696
+ );
1697
+
1698
+ const { headers } = await signInWithTestUser();
1699
+
1700
+ await authWithTrusted.api.registerSSOProvider({
1701
+ body: {
1702
+ providerId: "trusted-saml-provider",
1703
+ issuer: "http://localhost:8081",
1704
+ domain: "http://localhost:8081",
1705
+ samlConfig: {
1706
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1707
+ cert: certificate,
1708
+ callbackUrl: "http://localhost:3000/dashboard",
1709
+ wantAssertionsSigned: false,
1710
+ signatureAlgorithm: "sha256",
1711
+ digestAlgorithm: "sha256",
1712
+ idpMetadata: {
1713
+ metadata: idpMetadata,
1714
+ },
1715
+ spMetadata: {
1716
+ metadata: spMetadata,
1717
+ },
1718
+ identifierFormat:
1719
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1720
+ },
1721
+ },
1722
+ headers,
1723
+ });
1724
+
1725
+ const ctx = await authWithTrusted.$context;
1726
+ await ctx.adapter.create({
1727
+ model: "user",
1728
+ data: {
1729
+ id: "existing-user-id-2",
1730
+ email: "test@email.com",
1731
+ name: "Existing User",
1732
+ emailVerified: true,
1733
+ createdAt: new Date(),
1734
+ updatedAt: new Date(),
1735
+ },
1736
+ });
1737
+
1738
+ let samlResponse: any;
1739
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
1740
+ onSuccess: async (context) => {
1741
+ samlResponse = await context.data;
1742
+ },
1743
+ });
1744
+
1745
+ const response = await authWithTrusted.handler(
1746
+ new Request(
1747
+ "http://localhost:3000/api/auth/sso/saml2/callback/trusted-saml-provider",
1748
+ {
1749
+ method: "POST",
1750
+ headers: {
1751
+ "Content-Type": "application/x-www-form-urlencoded",
1752
+ },
1753
+ body: new URLSearchParams({
1754
+ SAMLResponse: samlResponse.samlResponse,
1755
+ }),
1756
+ },
1757
+ ),
1758
+ );
1759
+
1760
+ expect(response.status).toBe(302);
1761
+ const redirectLocation = response.headers.get("location") || "";
1762
+ expect(redirectLocation).not.toContain("error");
1763
+ expect(redirectLocation).toContain("dashboard");
1764
+ });
1765
+
1766
+ it("should reject unsolicited SAML response when allowIdpInitiated is false", async () => {
1767
+ const { auth, signInWithTestUser } = await getTestInstance({
1768
+ plugins: [
1769
+ sso({
1770
+ saml: {
1771
+ enableInResponseToValidation: true,
1772
+ allowIdpInitiated: false,
1773
+ },
1774
+ }),
1775
+ ],
1776
+ });
1777
+
1778
+ const { headers } = await signInWithTestUser();
1779
+
1780
+ await auth.api.registerSSOProvider({
1781
+ body: {
1782
+ providerId: "strict-saml-provider",
1783
+ issuer: "http://localhost:8081",
1784
+ domain: "http://localhost:8081",
1785
+ samlConfig: {
1786
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1787
+ cert: certificate,
1788
+ callbackUrl: "http://localhost:3000/dashboard",
1789
+ wantAssertionsSigned: false,
1790
+ signatureAlgorithm: "sha256",
1791
+ digestAlgorithm: "sha256",
1792
+ idpMetadata: {
1793
+ metadata: idpMetadata,
1794
+ },
1795
+ spMetadata: {
1796
+ metadata: spMetadata,
1797
+ },
1798
+ identifierFormat:
1799
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1800
+ },
1801
+ },
1802
+ headers,
1803
+ });
1804
+
1805
+ let samlResponse: any;
1806
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
1807
+ onSuccess: async (context) => {
1808
+ samlResponse = await context.data;
1809
+ },
1810
+ });
1811
+
1812
+ const response = await auth.handler(
1813
+ new Request(
1814
+ "http://localhost:3000/api/auth/sso/saml2/callback/strict-saml-provider",
1815
+ {
1816
+ method: "POST",
1817
+ headers: {
1818
+ "Content-Type": "application/x-www-form-urlencoded",
1819
+ },
1820
+ body: new URLSearchParams({
1821
+ SAMLResponse: samlResponse.samlResponse,
1822
+ }),
1823
+ },
1824
+ ),
1825
+ );
1826
+
1827
+ expect(response.status).toBe(302);
1828
+ const redirectLocation = response.headers.get("location") || "";
1829
+ expect(redirectLocation).toContain("error=unsolicited_response");
1830
+ });
1831
+
1832
+ it("should allow unsolicited SAML response when allowIdpInitiated is true (default)", async () => {
1833
+ const { auth, signInWithTestUser } = await getTestInstance({
1834
+ plugins: [
1835
+ sso({
1836
+ saml: {
1837
+ enableInResponseToValidation: true,
1838
+ allowIdpInitiated: true,
1839
+ },
1840
+ }),
1841
+ ],
1842
+ });
1843
+
1844
+ const { headers } = await signInWithTestUser();
1845
+
1846
+ await auth.api.registerSSOProvider({
1847
+ body: {
1848
+ providerId: "permissive-saml-provider",
1849
+ issuer: "http://localhost:8081",
1850
+ domain: "http://localhost:8081",
1851
+ samlConfig: {
1852
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1853
+ cert: certificate,
1854
+ callbackUrl: "http://localhost:3000/dashboard",
1855
+ wantAssertionsSigned: false,
1856
+ signatureAlgorithm: "sha256",
1857
+ digestAlgorithm: "sha256",
1858
+ idpMetadata: {
1859
+ metadata: idpMetadata,
1860
+ },
1861
+ spMetadata: {
1862
+ metadata: spMetadata,
1863
+ },
1864
+ identifierFormat:
1865
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1866
+ },
1867
+ },
1868
+ headers,
1869
+ });
1870
+
1871
+ let samlResponse: any;
1872
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
1873
+ onSuccess: async (context) => {
1874
+ samlResponse = await context.data;
1875
+ },
1876
+ });
1877
+
1878
+ const response = await auth.handler(
1879
+ new Request(
1880
+ "http://localhost:3000/api/auth/sso/saml2/callback/permissive-saml-provider",
1881
+ {
1882
+ method: "POST",
1883
+ headers: {
1884
+ "Content-Type": "application/x-www-form-urlencoded",
1885
+ },
1886
+ body: new URLSearchParams({
1887
+ SAMLResponse: samlResponse.samlResponse,
1888
+ }),
1889
+ },
1890
+ ),
1891
+ );
1892
+
1893
+ expect(response.status).toBe(302);
1894
+ const redirectLocation = response.headers.get("location") || "";
1895
+ expect(redirectLocation).not.toContain("error=unsolicited_response");
1896
+ });
1897
+
1898
+ it("should skip InResponseTo validation when not explicitly enabled (backward compatibility)", async () => {
1899
+ const { auth, signInWithTestUser } = await getTestInstance({
1900
+ plugins: [sso()],
1901
+ });
1902
+
1903
+ const { headers } = await signInWithTestUser();
1904
+
1905
+ await auth.api.registerSSOProvider({
1906
+ body: {
1907
+ providerId: "legacy-saml-provider",
1908
+ issuer: "http://localhost:8081",
1909
+ domain: "http://localhost:8081",
1910
+ samlConfig: {
1911
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1912
+ cert: certificate,
1913
+ callbackUrl: "http://localhost:3000/dashboard",
1914
+ wantAssertionsSigned: false,
1915
+ signatureAlgorithm: "sha256",
1916
+ digestAlgorithm: "sha256",
1917
+ idpMetadata: {
1918
+ metadata: idpMetadata,
1919
+ },
1920
+ spMetadata: {
1921
+ metadata: spMetadata,
1922
+ },
1923
+ identifierFormat:
1924
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1925
+ },
1926
+ },
1927
+ headers,
1928
+ });
1929
+
1930
+ let samlResponse: any;
1931
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
1932
+ onSuccess: async (context) => {
1933
+ samlResponse = await context.data;
1934
+ },
1935
+ });
1936
+
1937
+ const response = await auth.handler(
1938
+ new Request(
1939
+ "http://localhost:3000/api/auth/sso/saml2/callback/legacy-saml-provider",
1940
+ {
1941
+ method: "POST",
1942
+ headers: {
1943
+ "Content-Type": "application/x-www-form-urlencoded",
1944
+ },
1945
+ body: new URLSearchParams({
1946
+ SAMLResponse: samlResponse.samlResponse,
1947
+ }),
1948
+ },
1949
+ ),
1950
+ );
1951
+
1952
+ expect(response.status).toBe(302);
1953
+ const redirectLocation = response.headers.get("location") || "";
1954
+ expect(redirectLocation).not.toContain("error=");
1955
+ });
1956
+
1957
+ it("should use verification table for InResponseTo validation", async () => {
1958
+ const { auth, signInWithTestUser } = await getTestInstance({
1959
+ plugins: [
1960
+ sso({
1961
+ saml: {
1962
+ enableInResponseToValidation: true,
1963
+ allowIdpInitiated: false,
1964
+ },
1965
+ }),
1966
+ ],
1967
+ });
1968
+
1969
+ const { headers } = await signInWithTestUser();
1970
+
1971
+ await auth.api.registerSSOProvider({
1972
+ body: {
1973
+ providerId: "db-fallback-provider",
1974
+ issuer: "http://localhost:8081",
1975
+ domain: "http://localhost:8081",
1976
+ samlConfig: {
1977
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1978
+ cert: certificate,
1979
+ callbackUrl: "http://localhost:3000/dashboard",
1980
+ wantAssertionsSigned: false,
1981
+ signatureAlgorithm: "sha256",
1982
+ digestAlgorithm: "sha256",
1983
+ idpMetadata: {
1984
+ metadata: idpMetadata,
1985
+ },
1986
+ spMetadata: {
1987
+ metadata: spMetadata,
1988
+ },
1989
+ identifierFormat:
1990
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1991
+ },
1992
+ },
1993
+ headers,
1994
+ });
1995
+
1996
+ // Try to use an unsolicited response - should be rejected since allowIdpInitiated is false
1997
+ // This proves the validation is working via the verification table fallback
1998
+ let samlResponse: any;
1999
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
2000
+ onSuccess: async (context) => {
2001
+ samlResponse = await context.data;
2002
+ },
2003
+ });
2004
+
2005
+ const response = await auth.handler(
2006
+ new Request(
2007
+ "http://localhost:3000/api/auth/sso/saml2/callback/db-fallback-provider",
2008
+ {
2009
+ method: "POST",
2010
+ headers: {
2011
+ "Content-Type": "application/x-www-form-urlencoded",
2012
+ },
2013
+ body: new URLSearchParams({
2014
+ SAMLResponse: samlResponse.samlResponse,
2015
+ }),
2016
+ },
2017
+ ),
2018
+ );
2019
+
2020
+ // Should reject unsolicited response, proving validation is active
2021
+ expect(response.status).toBe(302);
2022
+ const redirectLocation = response.headers.get("location") || "";
2023
+ expect(redirectLocation).toContain("error=unsolicited_response");
2024
+ });
2025
+ });
2026
+
2027
+ describe("SAML SSO with custom fields", () => {
2028
+ const ssoOptions = {
2029
+ modelName: "sso_provider",
2030
+ fields: {
2031
+ issuer: "the_issuer",
2032
+ oidcConfig: "oidc_config",
2033
+ samlConfig: "saml_config",
2034
+ userId: "user_id",
2035
+ providerId: "provider_id",
2036
+ organizationId: "organization_id",
2037
+ domain: "the_domain",
2038
+ },
2039
+ };
2040
+
2041
+ const data = {
2042
+ user: [],
2043
+ session: [],
2044
+ verification: [],
2045
+ account: [],
2046
+ sso_provider: [],
2047
+ };
2048
+
2049
+ const memory = memoryAdapter(data);
2050
+
2051
+ const auth = betterAuth({
2052
+ database: memory,
2053
+ baseURL: "http://localhost:3000",
2054
+ emailAndPassword: {
2055
+ enabled: true,
2056
+ },
2057
+ plugins: [sso(ssoOptions)],
2058
+ });
2059
+
2060
+ const authClient = createAuthClient({
2061
+ baseURL: "http://localhost:3000",
2062
+ plugins: [bearer(), ssoClient()],
2063
+ fetchOptions: {
2064
+ customFetchImpl: async (url, init) => {
2065
+ return auth.handler(new Request(url, init));
2066
+ },
2067
+ },
2068
+ });
2069
+
2070
+ const testUser = {
2071
+ email: "test@email.com",
2072
+ password: "password",
2073
+ name: "Test User",
2074
+ };
2075
+
2076
+ beforeAll(async () => {
2077
+ await authClient.signUp.email({
2078
+ email: testUser.email,
2079
+ password: testUser.password,
2080
+ name: testUser.name,
2081
+ });
2082
+ });
2083
+
2084
+ beforeEach(() => {
2085
+ data.user = [];
2086
+ data.session = [];
2087
+ data.verification = [];
2088
+ data.account = [];
2089
+ data.sso_provider = [];
2090
+
2091
+ vi.clearAllMocks();
2092
+ });
2093
+
2094
+ async function getAuthHeaders() {
2095
+ const headers = new Headers();
2096
+ await authClient.signUp.email({
2097
+ email: testUser.email,
2098
+ password: testUser.password,
2099
+ name: testUser.name,
2100
+ });
2101
+ await authClient.signIn.email(testUser, {
2102
+ throw: true,
2103
+ onSuccess: setCookieToHeader(headers),
2104
+ });
2105
+ return headers;
2106
+ }
2107
+
2108
+ it("should register a new SAML provider", async () => {
2109
+ const headers = await getAuthHeaders();
2110
+
2111
+ const provider = await auth.api.registerSSOProvider({
2112
+ body: {
2113
+ providerId: "saml-provider-1",
2114
+ issuer: "http://localhost:8081",
2115
+ domain: "http://localhost:8081",
2116
+ samlConfig: {
2117
+ entryPoint: sharedMockIdP.metadataUrl,
2118
+ cert: certificate,
2119
+ callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
2120
+ wantAssertionsSigned: false,
2121
+ signatureAlgorithm: "sha256",
2122
+ digestAlgorithm: "sha256",
2123
+ idpMetadata: {
2124
+ metadata: idpMetadata,
2125
+ privateKey: idpPrivateKey,
2126
+ privateKeyPass: "q9ALNhGT5EhfcRmp8Pg7e9zTQeP2x1bW",
2127
+ isAssertionEncrypted: true,
2128
+ encPrivateKey: idpEncryptionKey,
2129
+ encPrivateKeyPass: "g7hGcRmp8PxT5QeP2q9Ehf1bWe9zTALN",
2130
+ },
2131
+ spMetadata: {
2132
+ metadata: spMetadata,
2133
+ binding: "post",
2134
+ privateKey: spPrivateKey,
2135
+ privateKeyPass: "VHOSp5RUiBcrsjrcAuXFwU1NKCkGA8px",
2136
+ isAssertionEncrypted: true,
2137
+ encPrivateKey: spEncryptionKey,
2138
+ encPrivateKeyPass: "BXFNKpxrsjrCkGA8cAu5wUVHOSpci1RU",
2139
+ },
2140
+ identifierFormat:
2141
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2142
+ },
2143
+ },
2144
+ headers,
2145
+ });
2146
+ expect(provider).toMatchObject({
2147
+ id: expect.any(String),
2148
+ issuer: "http://localhost:8081",
2149
+ samlConfig: {
2150
+ entryPoint: sharedMockIdP.metadataUrl,
2151
+ cert: expect.any(String),
2152
+ callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
2153
+ wantAssertionsSigned: false,
2154
+ signatureAlgorithm: "sha256",
2155
+ digestAlgorithm: "sha256",
2156
+ identifierFormat:
2157
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2158
+ },
2159
+ });
2160
+ });
2161
+ });
2162
+
2163
+ import { safeJsonParse } from "./utils";
2164
+
2165
+ describe("safeJsonParse", () => {
2166
+ it("returns object as-is when value is already an object", () => {
2167
+ const obj = { a: 1, nested: { b: 2 } };
2168
+ const result = safeJsonParse<typeof obj>(obj);
2169
+ expect(result).toBe(obj); // same reference
2170
+ expect(result).toEqual({ a: 1, nested: { b: 2 } });
2171
+ });
2172
+
2173
+ it("parses stringified JSON when value is a string", () => {
2174
+ const json = '{"a":1,"nested":{"b":2}}';
2175
+ const result = safeJsonParse<{ a: number; nested: { b: number } }>(json);
2176
+ expect(result).toEqual({ a: 1, nested: { b: 2 } });
2177
+ });
2178
+
2179
+ it("returns null for null input", () => {
2180
+ const result = safeJsonParse<{ a: number }>(null);
2181
+ expect(result).toBeNull();
2182
+ });
2183
+
2184
+ it("returns null for undefined input", () => {
2185
+ const result = safeJsonParse<{ a: number }>(undefined);
2186
+ expect(result).toBeNull();
2187
+ });
2188
+
2189
+ it("throws error for invalid JSON string", () => {
2190
+ expect(() => safeJsonParse<{ a: number }>("not valid json")).toThrow(
2191
+ "Failed to parse JSON",
2192
+ );
2193
+ });
2194
+
2195
+ it("handles empty object", () => {
2196
+ const obj = {};
2197
+ const result = safeJsonParse<typeof obj>(obj);
2198
+ expect(result).toBe(obj);
2199
+ });
2200
+
2201
+ it("handles empty string JSON", () => {
2202
+ const result = safeJsonParse<Record<string, never>>("{}");
2203
+ expect(result).toEqual({});
2204
+ });
2205
+ });
2206
+
2207
+ describe("SSO Provider Config Parsing", () => {
2208
+ it("returns parsed SAML config and avoids [object Object] in response", async () => {
2209
+ const data = {
2210
+ user: [] as any[],
2211
+ session: [] as any[],
2212
+ verification: [] as any[],
2213
+ account: [] as any[],
2214
+ ssoProvider: [] as any[],
2215
+ };
2216
+
2217
+ const memory = memoryAdapter(data);
2218
+
2219
+ const auth = betterAuth({
2220
+ database: memory,
2221
+ baseURL: "http://localhost:3000",
2222
+ emailAndPassword: { enabled: true },
2223
+ plugins: [sso()],
2224
+ });
2225
+
2226
+ const authClient = createAuthClient({
2227
+ baseURL: "http://localhost:3000",
2228
+ plugins: [bearer(), ssoClient()],
2229
+ fetchOptions: {
2230
+ customFetchImpl: async (url, init) =>
2231
+ auth.handler(new Request(url, init)),
2232
+ },
2233
+ });
2234
+
2235
+ const headers = new Headers();
2236
+ await authClient.signUp.email({
2237
+ email: "test@example.com",
2238
+ password: "password123",
2239
+ name: "Test User",
2240
+ });
2241
+ await authClient.signIn.email(
2242
+ { email: "test@example.com", password: "password123" },
2243
+ { onSuccess: setCookieToHeader(headers) },
2244
+ );
2245
+
2246
+ const provider = await auth.api.registerSSOProvider({
2247
+ body: {
2248
+ providerId: "saml-config-provider",
2249
+ issuer: "http://localhost:8081",
2250
+ domain: "example.com",
2251
+ samlConfig: {
2252
+ entryPoint: "http://localhost:8081/sso",
2253
+ cert: "test-cert",
2254
+ callbackUrl: "http://localhost:3000/callback",
2255
+ spMetadata: {
2256
+ entityID: "test-entity",
2257
+ },
2258
+ },
2259
+ },
2260
+ headers,
2261
+ });
2262
+
2263
+ expect(provider.samlConfig).toBeDefined();
2264
+ expect(typeof provider.samlConfig).toBe("object");
2265
+ expect(provider.samlConfig?.entryPoint).toBe("http://localhost:8081/sso");
2266
+ expect(provider.samlConfig?.cert).toBe("test-cert");
2267
+
2268
+ const serialized = JSON.stringify(provider.samlConfig);
2269
+ expect(serialized).not.toContain("[object Object]");
2270
+
2271
+ expect(provider.samlConfig?.spMetadata?.entityID).toBe("test-entity");
2272
+ });
2273
+
2274
+ it("returns parsed OIDC config and avoids [object Object] in response", async () => {
2275
+ const { OAuth2Server } = await import("oauth2-mock-server");
2276
+ const oidcServer = new OAuth2Server();
2277
+
2278
+ await oidcServer.issuer.keys.generate("RS256");
2279
+ await oidcServer.start(8082, "localhost");
2280
+
2281
+ try {
2282
+ const data = {
2283
+ user: [] as any[],
2284
+ session: [] as any[],
2285
+ verification: [] as any[],
2286
+ account: [] as any[],
2287
+ ssoProvider: [] as any[],
2288
+ };
2289
+
2290
+ const memory = memoryAdapter(data);
2291
+
2292
+ const auth = betterAuth({
2293
+ database: memory,
2294
+ trustedOrigins: ["http://localhost:8082"],
2295
+ baseURL: "http://localhost:3000",
2296
+ emailAndPassword: { enabled: true },
2297
+ plugins: [sso()],
2298
+ });
2299
+
2300
+ const authClient = createAuthClient({
2301
+ baseURL: "http://localhost:3000",
2302
+ plugins: [bearer(), ssoClient()],
2303
+ fetchOptions: {
2304
+ customFetchImpl: async (url, init) =>
2305
+ auth.handler(new Request(url, init)),
2306
+ },
2307
+ });
2308
+
2309
+ const headers = new Headers();
2310
+ await authClient.signUp.email({
2311
+ email: "test@example.com",
2312
+ password: "password123",
2313
+ name: "Test User",
2314
+ });
2315
+ await authClient.signIn.email(
2316
+ { email: "test@example.com", password: "password123" },
2317
+ { onSuccess: setCookieToHeader(headers) },
2318
+ );
2319
+
2320
+ const provider = await auth.api.registerSSOProvider({
2321
+ body: {
2322
+ providerId: "oidc-config-provider",
2323
+ issuer: oidcServer.issuer.url!,
2324
+ domain: "example.com",
2325
+ oidcConfig: {
2326
+ clientId: "test-client",
2327
+ clientSecret: "test-secret",
2328
+ tokenEndpointAuthentication: "client_secret_basic",
2329
+ mapping: {
2330
+ id: "sub",
2331
+ email: "email",
2332
+ name: "name",
2333
+ },
2334
+ },
2335
+ },
2336
+ headers,
2337
+ });
2338
+
2339
+ expect(provider.oidcConfig).toBeDefined();
2340
+ expect(typeof provider.oidcConfig).toBe("object");
2341
+ expect(provider.oidcConfig?.clientId).toBe("test-client");
2342
+ expect(provider.oidcConfig?.clientSecret).toBe("test-secret");
2343
+
2344
+ const serialized = JSON.stringify(provider.oidcConfig);
2345
+ expect(serialized).not.toContain("[object Object]");
2346
+
2347
+ expect(provider.oidcConfig?.mapping?.id).toBe("sub");
2348
+ } finally {
2349
+ await oidcServer.stop().catch(() => {});
2350
+ }
2351
+ });
2352
+ });
2353
+
2354
+ describe("SAML SSO - IdP Initiated Flow", () => {
2355
+ it("should handle IdP-initiated flow with GET after POST redirect", async () => {
2356
+ const { auth, signInWithTestUser } = await getTestInstance({
2357
+ plugins: [sso()],
2358
+ });
2359
+
2360
+ const { headers } = await signInWithTestUser();
2361
+
2362
+ await auth.api.registerSSOProvider({
2363
+ body: {
2364
+ providerId: "idp-initiated-provider",
2365
+ issuer: "http://localhost:8081",
2366
+ domain: "http://localhost:8081",
2367
+ samlConfig: {
2368
+ entryPoint: sharedMockIdP.metadataUrl.replace(
2369
+ "/idp/metadata",
2370
+ "/idp/post",
2371
+ ),
2372
+ cert: certificate,
2373
+ callbackUrl: "http://localhost:3000/dashboard",
2374
+ wantAssertionsSigned: false,
2375
+ signatureAlgorithm: "sha256",
2376
+ digestAlgorithm: "sha256",
2377
+ idpMetadata: {
2378
+ metadata: idpMetadata,
2379
+ },
2380
+ spMetadata: {
2381
+ metadata: spMetadata,
2382
+ },
2383
+ identifierFormat:
2384
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2385
+ },
2386
+ },
2387
+ headers,
2388
+ });
2389
+
2390
+ let samlResponse:
2391
+ | { samlResponse: string; entityEndpoint?: string }
2392
+ | undefined;
2393
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
2394
+ onSuccess: async (context) => {
2395
+ samlResponse = context.data as {
2396
+ samlResponse: string;
2397
+ entityEndpoint?: string;
2398
+ };
2399
+ },
2400
+ });
2401
+
2402
+ if (!samlResponse?.samlResponse) {
2403
+ throw new Error("Failed to get SAML response from mock IdP");
2404
+ }
2405
+
2406
+ const postResponse = await auth.api.callbackSSOSAML({
2407
+ method: "POST",
2408
+ body: {
2409
+ SAMLResponse: samlResponse.samlResponse,
2410
+ RelayState: "http://localhost:3000/dashboard",
2411
+ },
2412
+ params: {
2413
+ providerId: "idp-initiated-provider",
2414
+ },
2415
+ asResponse: true,
2416
+ });
2417
+
2418
+ expect(postResponse).toBeInstanceOf(Response);
2419
+ expect(postResponse.status).toBe(302);
2420
+ const redirectLocation = postResponse.headers.get("location");
2421
+ expect(redirectLocation).toBe("http://localhost:3000/dashboard");
2422
+
2423
+ const cookieHeader = postResponse.headers.get("set-cookie");
2424
+ const getResponse = await auth.api.callbackSSOSAML({
2425
+ method: "GET",
2426
+ query: {
2427
+ RelayState: "http://localhost:3000/dashboard",
2428
+ },
2429
+ params: {
2430
+ providerId: "idp-initiated-provider",
2431
+ },
2432
+ headers: cookieHeader ? { cookie: cookieHeader } : undefined,
2433
+ asResponse: true,
2434
+ });
2435
+
2436
+ expect(getResponse).toBeInstanceOf(Response);
2437
+ expect(getResponse.status).toBe(302);
2438
+ const getRedirectLocation = getResponse.headers.get("location");
2439
+ expect(getRedirectLocation).toBe("http://localhost:3000/dashboard");
2440
+ });
2441
+
2442
+ it("should reject direct GET request without session", async () => {
2443
+ const { auth } = await getTestInstance({
2444
+ plugins: [sso()],
2445
+ });
2446
+
2447
+ const getResponse = await auth.api
2448
+ .callbackSSOSAML({
2449
+ method: "GET",
2450
+ params: {
2451
+ providerId: "test-provider",
2452
+ },
2453
+ asResponse: true,
2454
+ })
2455
+ .catch((e) => {
2456
+ if (e instanceof APIError && e.status === "FOUND") {
2457
+ return new Response(null, {
2458
+ status: e.statusCode,
2459
+ headers: e.headers || new Headers(),
2460
+ });
2461
+ }
2462
+ throw e;
2463
+ });
2464
+
2465
+ expect(getResponse).toBeInstanceOf(Response);
2466
+ expect(getResponse.status).toBe(302);
2467
+ const redirectLocation = getResponse.headers.get("location");
2468
+ expect(redirectLocation).toContain("/error");
2469
+ expect(redirectLocation).toContain("error=invalid_request");
2470
+ });
2471
+
2472
+ it("should prevent redirect loop when callbackUrl points to callback route", async () => {
2473
+ const { auth, signInWithTestUser } = await getTestInstance({
2474
+ plugins: [sso()],
2475
+ });
2476
+
2477
+ const { headers } = await signInWithTestUser();
2478
+
2479
+ const callbackRouteUrl =
2480
+ "http://localhost:3000/api/auth/sso/saml2/callback/loop-test-provider";
2481
+
2482
+ await auth.api.registerSSOProvider({
2483
+ body: {
2484
+ providerId: "loop-test-provider",
2485
+ issuer: "http://localhost:8081",
2486
+ domain: "http://localhost:8081",
2487
+ samlConfig: {
2488
+ entryPoint: sharedMockIdP.metadataUrl.replace(
2489
+ "/idp/metadata",
2490
+ "/idp/post",
2491
+ ),
2492
+ cert: certificate,
2493
+ callbackUrl: callbackRouteUrl,
2494
+ wantAssertionsSigned: false,
2495
+ signatureAlgorithm: "sha256",
2496
+ digestAlgorithm: "sha256",
2497
+ idpMetadata: {
2498
+ metadata: idpMetadata,
2499
+ },
2500
+ spMetadata: {
2501
+ metadata: spMetadata,
2502
+ },
2503
+ identifierFormat:
2504
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2505
+ },
2506
+ },
2507
+ headers,
2508
+ });
2509
+
2510
+ let samlResponse:
2511
+ | { samlResponse: string; entityEndpoint?: string }
2512
+ | undefined;
2513
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
2514
+ onSuccess: async (context) => {
2515
+ samlResponse = context.data as {
2516
+ samlResponse: string;
2517
+ entityEndpoint?: string;
2518
+ };
2519
+ },
2520
+ });
2521
+
2522
+ if (!samlResponse?.samlResponse) {
2523
+ throw new Error("Failed to get SAML response from mock IdP");
2524
+ }
2525
+
2526
+ const postResponse = await auth.api.callbackSSOSAML({
2527
+ method: "POST",
2528
+ body: {
2529
+ SAMLResponse: samlResponse.samlResponse,
2530
+ },
2531
+ params: {
2532
+ providerId: "loop-test-provider",
2533
+ },
2534
+ asResponse: true,
2535
+ });
2536
+
2537
+ expect(postResponse).toBeInstanceOf(Response);
2538
+ expect(postResponse.status).toBe(302);
2539
+ const redirectLocation = postResponse.headers.get("location");
2540
+ expect(redirectLocation).not.toBe(callbackRouteUrl);
2541
+ expect(redirectLocation).toBe("http://localhost:3000");
2542
+ });
2543
+
2544
+ it("should handle GET request with RelayState in query", async () => {
2545
+ const { auth, signInWithTestUser } = await getTestInstance({
2546
+ plugins: [sso()],
2547
+ });
2548
+
2549
+ const { headers } = await signInWithTestUser();
2550
+
2551
+ await auth.api.registerSSOProvider({
2552
+ body: {
2553
+ providerId: "relaystate-provider",
2554
+ issuer: "http://localhost:8081",
2555
+ domain: "http://localhost:8081",
2556
+ samlConfig: {
2557
+ entryPoint: sharedMockIdP.metadataUrl.replace(
2558
+ "/idp/metadata",
2559
+ "/idp/post",
2560
+ ),
2561
+ cert: certificate,
2562
+ callbackUrl: "http://localhost:3000/dashboard",
2563
+ wantAssertionsSigned: false,
2564
+ signatureAlgorithm: "sha256",
2565
+ digestAlgorithm: "sha256",
2566
+ idpMetadata: {
2567
+ metadata: idpMetadata,
2568
+ },
2569
+ spMetadata: {
2570
+ metadata: spMetadata,
2571
+ },
2572
+ identifierFormat:
2573
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2574
+ },
2575
+ },
2576
+ headers,
2577
+ });
2578
+
2579
+ let samlResponse:
2580
+ | { samlResponse: string; entityEndpoint?: string }
2581
+ | undefined;
2582
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
2583
+ onSuccess: async (context) => {
2584
+ samlResponse = context.data as {
2585
+ samlResponse: string;
2586
+ entityEndpoint?: string;
2587
+ };
2588
+ },
2589
+ });
2590
+
2591
+ if (!samlResponse?.samlResponse) {
2592
+ throw new Error("Failed to get SAML response from mock IdP");
2593
+ }
2594
+
2595
+ const postResponse = await auth.api.callbackSSOSAML({
2596
+ method: "POST",
2597
+ body: {
2598
+ SAMLResponse: samlResponse.samlResponse,
2599
+ RelayState: "http://localhost:3000/custom-path",
2600
+ },
2601
+ params: {
2602
+ providerId: "relaystate-provider",
2603
+ },
2604
+ asResponse: true,
2605
+ });
2606
+
2607
+ const cookieHeader = postResponse.headers.get("set-cookie");
2608
+ const getResponse = await auth.api.callbackSSOSAML({
2609
+ method: "GET",
2610
+ query: {
2611
+ RelayState: "http://localhost:3000/custom-path",
2612
+ },
2613
+ params: {
2614
+ providerId: "relaystate-provider",
2615
+ },
2616
+ headers: cookieHeader ? { cookie: cookieHeader } : undefined,
2617
+ asResponse: true,
2618
+ });
2619
+
2620
+ expect(getResponse).toBeInstanceOf(Response);
2621
+ expect(getResponse.status).toBe(302);
2622
+ const redirectLocation = getResponse.headers.get("location");
2623
+ expect(redirectLocation).toBe("http://localhost:3000/custom-path");
2624
+ });
2625
+
2626
+ it("should handle GET request when POST redirects to callback URL (original issue scenario)", async () => {
2627
+ const { auth, signInWithTestUser } = await getTestInstance({
2628
+ plugins: [sso()],
2629
+ });
2630
+
2631
+ const { headers } = await signInWithTestUser();
2632
+
2633
+ const callbackRouteUrl =
2634
+ "http://localhost:3000/api/auth/sso/saml2/callback/issue-6615-provider";
2635
+
2636
+ await auth.api.registerSSOProvider({
2637
+ body: {
2638
+ providerId: "issue-6615-provider",
2639
+ issuer: "http://localhost:8081",
2640
+ domain: "http://localhost:8081",
2641
+ samlConfig: {
2642
+ entryPoint: sharedMockIdP.metadataUrl.replace(
2643
+ "/idp/metadata",
2644
+ "/idp/post",
2645
+ ),
2646
+ cert: certificate,
2647
+ callbackUrl: "http://localhost:3000/dashboard",
2648
+ wantAssertionsSigned: false,
2649
+ signatureAlgorithm: "sha256",
2650
+ digestAlgorithm: "sha256",
2651
+ idpMetadata: {
2652
+ metadata: idpMetadata,
2653
+ },
2654
+ spMetadata: {
2655
+ metadata: spMetadata,
2656
+ },
2657
+ identifierFormat:
2658
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2659
+ },
2660
+ },
2661
+ headers,
2662
+ });
2663
+
2664
+ let samlResponse:
2665
+ | { samlResponse: string; entityEndpoint?: string }
2666
+ | undefined;
2667
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
2668
+ onSuccess: async (context) => {
2669
+ samlResponse = context.data as {
2670
+ samlResponse: string;
2671
+ entityEndpoint?: string;
2672
+ };
2673
+ },
2674
+ });
2675
+
2676
+ if (!samlResponse?.samlResponse) {
2677
+ throw new Error("Failed to get SAML response from mock IdP");
2678
+ }
2679
+
2680
+ const postResponse = await auth.api.callbackSSOSAML({
2681
+ method: "POST",
2682
+ body: {
2683
+ SAMLResponse: samlResponse.samlResponse,
2684
+ RelayState: callbackRouteUrl,
2685
+ },
2686
+ params: {
2687
+ providerId: "issue-6615-provider",
2688
+ },
2689
+ asResponse: true,
2690
+ });
2691
+
2692
+ expect(postResponse).toBeInstanceOf(Response);
2693
+ expect(postResponse.status).toBe(302);
2694
+ const postRedirectLocation = postResponse.headers.get("location");
2695
+ expect(postRedirectLocation).not.toBe(callbackRouteUrl);
2696
+ expect(postRedirectLocation).toBe("http://localhost:3000/dashboard");
2697
+
2698
+ const cookieHeader = postResponse.headers.get("set-cookie");
2699
+ const getResponse = await auth.api.callbackSSOSAML({
2700
+ method: "GET",
2701
+ params: {
2702
+ providerId: "issue-6615-provider",
2703
+ },
2704
+ headers: cookieHeader ? { cookie: cookieHeader } : undefined,
2705
+ asResponse: true,
2706
+ });
2707
+
2708
+ expect(getResponse).toBeInstanceOf(Response);
2709
+ expect(getResponse.status).toBe(302);
2710
+ const getRedirectLocation = getResponse.headers.get("location");
2711
+ expect(getRedirectLocation).toBe("http://localhost:3000");
2712
+ });
2713
+
2714
+ it("should prevent open redirect with malicious RelayState URL", async () => {
2715
+ const { auth, signInWithTestUser } = await getTestInstance({
2716
+ plugins: [sso()],
2717
+ });
2718
+
2719
+ const { headers } = await signInWithTestUser();
2720
+
2721
+ await auth.api.registerSSOProvider({
2722
+ body: {
2723
+ providerId: "open-redirect-test-provider",
2724
+ issuer: "http://localhost:8081",
2725
+ domain: "http://localhost:8081",
2726
+ samlConfig: {
2727
+ entryPoint: sharedMockIdP.metadataUrl.replace(
2728
+ "/idp/metadata",
2729
+ "/idp/post",
2730
+ ),
2731
+ cert: certificate,
2732
+ callbackUrl: "http://localhost:3000/dashboard",
2733
+ wantAssertionsSigned: false,
2734
+ signatureAlgorithm: "sha256",
2735
+ digestAlgorithm: "sha256",
2736
+ idpMetadata: {
2737
+ metadata: idpMetadata,
2738
+ },
2739
+ spMetadata: {
2740
+ metadata: spMetadata,
2741
+ },
2742
+ identifierFormat:
2743
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2744
+ },
2745
+ },
2746
+ headers,
2747
+ });
2748
+
2749
+ let samlResponse:
2750
+ | { samlResponse: string; entityEndpoint?: string }
2751
+ | undefined;
2752
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
2753
+ onSuccess: async (context) => {
2754
+ samlResponse = context.data as {
2755
+ samlResponse: string;
2756
+ entityEndpoint?: string;
2757
+ };
2758
+ },
2759
+ });
2760
+
2761
+ if (!samlResponse?.samlResponse) {
2762
+ throw new Error("Failed to get SAML response from mock IdP");
2763
+ }
2764
+
2765
+ // Test POST with malicious RelayState - raw RelayState is not trusted
2766
+ // Falls back to parsedSamlConfig.callbackUrl
2767
+ const postResponse = await auth.api.callbackSSOSAML({
2768
+ method: "POST",
2769
+ body: {
2770
+ SAMLResponse: samlResponse.samlResponse,
2771
+ RelayState: "https://evil.com/phishing",
2772
+ },
2773
+ params: {
2774
+ providerId: "open-redirect-test-provider",
2775
+ },
2776
+ asResponse: true,
2777
+ });
2778
+
2779
+ expect(postResponse).toBeInstanceOf(Response);
2780
+ expect(postResponse.status).toBe(302);
2781
+ const postRedirectLocation = postResponse.headers.get("location");
2782
+ // Should NOT redirect to evil.com - raw RelayState is ignored
2783
+ expect(postRedirectLocation).not.toContain("evil.com");
2784
+ // Falls back to samlConfig.callbackUrl
2785
+ expect(postRedirectLocation).toBe("http://localhost:3000/dashboard");
2786
+ });
2787
+
2788
+ it("should prevent open redirect via GET with malicious RelayState", async () => {
2789
+ const { auth, signInWithTestUser } = await getTestInstance({
2790
+ plugins: [sso()],
2791
+ });
2792
+
2793
+ const { headers } = await signInWithTestUser();
2794
+
2795
+ await auth.api.registerSSOProvider({
2796
+ body: {
2797
+ providerId: "open-redirect-get-provider",
2798
+ issuer: "http://localhost:8081",
2799
+ domain: "http://localhost:8081",
2800
+ samlConfig: {
2801
+ entryPoint: sharedMockIdP.metadataUrl.replace(
2802
+ "/idp/metadata",
2803
+ "/idp/post",
2804
+ ),
2805
+ cert: certificate,
2806
+ callbackUrl: "http://localhost:3000/dashboard",
2807
+ wantAssertionsSigned: false,
2808
+ signatureAlgorithm: "sha256",
2809
+ digestAlgorithm: "sha256",
2810
+ idpMetadata: {
2811
+ metadata: idpMetadata,
2812
+ },
2813
+ spMetadata: {
2814
+ metadata: spMetadata,
2815
+ },
2816
+ identifierFormat:
2817
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2818
+ },
2819
+ },
2820
+ headers,
2821
+ });
2822
+
2823
+ let samlResponse:
2824
+ | { samlResponse: string; entityEndpoint?: string }
2825
+ | undefined;
2826
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
2827
+ onSuccess: async (context) => {
2828
+ samlResponse = context.data as {
2829
+ samlResponse: string;
2830
+ entityEndpoint?: string;
2831
+ };
2832
+ },
2833
+ });
2834
+
2835
+ if (!samlResponse?.samlResponse) {
2836
+ throw new Error("Failed to get SAML response from mock IdP");
2837
+ }
2838
+
2839
+ // First do POST to establish session
2840
+ const postResponse = await auth.api.callbackSSOSAML({
2841
+ method: "POST",
2842
+ body: {
2843
+ SAMLResponse: samlResponse.samlResponse,
2844
+ },
2845
+ params: {
2846
+ providerId: "open-redirect-get-provider",
2847
+ },
2848
+ asResponse: true,
2849
+ });
2850
+
2851
+ const cookieHeader = postResponse.headers.get("set-cookie");
2852
+
2853
+ // Test GET with malicious RelayState in query params
2854
+ const getResponse = await auth.api.callbackSSOSAML({
2855
+ method: "GET",
2856
+ query: {
2857
+ RelayState: "https://evil.com/steal-cookies",
2858
+ },
2859
+ params: {
2860
+ providerId: "open-redirect-get-provider",
2861
+ },
2862
+ headers: cookieHeader ? { cookie: cookieHeader } : undefined,
2863
+ asResponse: true,
2864
+ });
2865
+
2866
+ expect(getResponse).toBeInstanceOf(Response);
2867
+ expect(getResponse.status).toBe(302);
2868
+ const getRedirectLocation = getResponse.headers.get("location");
2869
+ // Should NOT redirect to evil.com
2870
+ expect(getRedirectLocation).not.toContain("evil.com");
2871
+ expect(getRedirectLocation).toBe("http://localhost:3000");
2872
+ });
2873
+
2874
+ it("should allow relative path redirects", async () => {
2875
+ const { auth, signInWithTestUser } = await getTestInstance({
2876
+ plugins: [sso()],
2877
+ });
2878
+
2879
+ const { headers } = await signInWithTestUser();
2880
+
2881
+ await auth.api.registerSSOProvider({
2882
+ body: {
2883
+ providerId: "relative-path-provider",
2884
+ issuer: "http://localhost:8081",
2885
+ domain: "http://localhost:8081",
2886
+ samlConfig: {
2887
+ entryPoint: sharedMockIdP.metadataUrl.replace(
2888
+ "/idp/metadata",
2889
+ "/idp/post",
2890
+ ),
2891
+ cert: certificate,
2892
+ callbackUrl: "http://localhost:3000/dashboard",
2893
+ wantAssertionsSigned: false,
2894
+ signatureAlgorithm: "sha256",
2895
+ digestAlgorithm: "sha256",
2896
+ idpMetadata: {
2897
+ metadata: idpMetadata,
2898
+ },
2899
+ spMetadata: {
2900
+ metadata: spMetadata,
2901
+ },
2902
+ identifierFormat:
2903
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2904
+ },
2905
+ },
2906
+ headers,
2907
+ });
2908
+
2909
+ let samlResponse:
2910
+ | { samlResponse: string; entityEndpoint?: string }
2911
+ | undefined;
2912
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
2913
+ onSuccess: async (context) => {
2914
+ samlResponse = context.data as {
2915
+ samlResponse: string;
2916
+ entityEndpoint?: string;
2917
+ };
2918
+ },
2919
+ });
2920
+
2921
+ if (!samlResponse?.samlResponse) {
2922
+ throw new Error("Failed to get SAML response from mock IdP");
2923
+ }
2924
+
2925
+ const postResponse = await auth.api.callbackSSOSAML({
2926
+ method: "POST",
2927
+ body: {
2928
+ SAMLResponse: samlResponse.samlResponse,
2929
+ RelayState: "/dashboard/settings",
2930
+ },
2931
+ params: {
2932
+ providerId: "relative-path-provider",
2933
+ },
2934
+ asResponse: true,
2935
+ });
2936
+
2937
+ expect(postResponse).toBeInstanceOf(Response);
2938
+ expect(postResponse.status).toBe(302);
2939
+ const redirectLocation = postResponse.headers.get("location");
2940
+ expect(redirectLocation).toBe("http://localhost:3000/dashboard");
2941
+ });
2942
+
2943
+ it("should block protocol-relative URL attacks (//evil.com)", async () => {
2944
+ const { auth, signInWithTestUser } = await getTestInstance({
2945
+ plugins: [sso()],
2946
+ });
2947
+
2948
+ const { headers } = await signInWithTestUser();
2949
+
2950
+ await auth.api.registerSSOProvider({
2951
+ body: {
2952
+ providerId: "protocol-relative-provider",
2953
+ issuer: "http://localhost:8081",
2954
+ domain: "http://localhost:8081",
2955
+ samlConfig: {
2956
+ entryPoint: sharedMockIdP.metadataUrl.replace(
2957
+ "/idp/metadata",
2958
+ "/idp/post",
2959
+ ),
2960
+ cert: certificate,
2961
+ callbackUrl: "http://localhost:3000/dashboard",
2962
+ wantAssertionsSigned: false,
2963
+ signatureAlgorithm: "sha256",
2964
+ digestAlgorithm: "sha256",
2965
+ idpMetadata: {
2966
+ metadata: idpMetadata,
2967
+ },
2968
+ spMetadata: {
2969
+ metadata: spMetadata,
2970
+ },
2971
+ identifierFormat:
2972
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2973
+ },
2974
+ },
2975
+ headers,
2976
+ });
2977
+
2978
+ let samlResponse:
2979
+ | { samlResponse: string; entityEndpoint?: string }
2980
+ | undefined;
2981
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
2982
+ onSuccess: async (context) => {
2983
+ samlResponse = context.data as {
2984
+ samlResponse: string;
2985
+ entityEndpoint?: string;
2986
+ };
2987
+ },
2988
+ });
2989
+
2990
+ if (!samlResponse?.samlResponse) {
2991
+ throw new Error("Failed to get SAML response from mock IdP");
2992
+ }
2993
+
2994
+ // Test POST with protocol-relative URL - raw RelayState is not trusted
2995
+ // Falls back to parsedSamlConfig.callbackUrl
2996
+ const postResponse = await auth.api.callbackSSOSAML({
2997
+ method: "POST",
2998
+ body: {
2999
+ SAMLResponse: samlResponse.samlResponse,
3000
+ RelayState: "//evil.com/phishing",
3001
+ },
3002
+ params: {
3003
+ providerId: "protocol-relative-provider",
3004
+ },
3005
+ asResponse: true,
3006
+ });
3007
+
3008
+ expect(postResponse).toBeInstanceOf(Response);
3009
+ expect(postResponse.status).toBe(302);
3010
+ const redirectLocation = postResponse.headers.get("location");
3011
+ // Should NOT redirect to evil.com - raw RelayState is ignored
3012
+ expect(redirectLocation).not.toContain("evil.com");
3013
+ // Falls back to samlConfig.callbackUrl
3014
+ expect(redirectLocation).toBe("http://localhost:3000/dashboard");
3015
+ });
3016
+ });
3017
+
3018
+ describe("SAML SSO - Timestamp Validation", () => {
3019
+ describe("Valid assertions within time window", () => {
3020
+ it("should accept assertion with current NotBefore and future NotOnOrAfter", () => {
3021
+ const now = new Date();
3022
+ const fiveMinutesFromNow = new Date(Date.now() + 5 * 60 * 1000);
3023
+ expect(() =>
3024
+ validateSAMLTimestamp({
3025
+ notBefore: now.toISOString(),
3026
+ notOnOrAfter: fiveMinutesFromNow.toISOString(),
3027
+ }),
3028
+ ).not.toThrow();
3029
+ });
3030
+
3031
+ it("should accept assertion within clock skew tolerance (expired 2 min ago with 5 min skew)", () => {
3032
+ const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000).toISOString();
3033
+ expect(() =>
3034
+ validateSAMLTimestamp({ notOnOrAfter: twoMinutesAgo }),
3035
+ ).not.toThrow();
3036
+ });
3037
+
3038
+ it("should accept assertion with NotBefore slightly in future (within clock skew)", () => {
3039
+ const twoMinutesFromNow = new Date(
3040
+ Date.now() + 2 * 60 * 1000,
3041
+ ).toISOString();
3042
+ expect(() =>
3043
+ validateSAMLTimestamp({ notBefore: twoMinutesFromNow }),
3044
+ ).not.toThrow();
3045
+ });
3046
+ });
3047
+
3048
+ describe("NotBefore validation (future-dated assertions)", () => {
3049
+ it("should reject assertion with NotBefore too far in future (beyond clock skew)", () => {
3050
+ const tenMinutesFromNow = new Date(
3051
+ Date.now() + 10 * 60 * 1000,
3052
+ ).toISOString();
3053
+ expect(() =>
3054
+ validateSAMLTimestamp({ notBefore: tenMinutesFromNow }),
3055
+ ).toThrow("SAML assertion is not yet valid");
3056
+ });
3057
+
3058
+ it("should reject with custom strict clock skew (1 second)", () => {
3059
+ const threeSecondsFromNow = new Date(Date.now() + 3 * 1000).toISOString();
3060
+ expect(() =>
3061
+ validateSAMLTimestamp(
3062
+ { notBefore: threeSecondsFromNow },
3063
+ { clockSkew: 1000 },
3064
+ ),
3065
+ ).toThrow("SAML assertion is not yet valid");
3066
+ });
3067
+ });
3068
+
3069
+ describe("NotOnOrAfter validation (expired assertions)", () => {
3070
+ it("should reject expired assertion (NotOnOrAfter in past beyond clock skew)", () => {
3071
+ const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString();
3072
+ expect(() =>
3073
+ validateSAMLTimestamp({ notOnOrAfter: tenMinutesAgo }),
3074
+ ).toThrow("SAML assertion has expired");
3075
+ });
3076
+
3077
+ it("should reject with custom strict clock skew (1 second)", () => {
3078
+ const threeSecondsAgo = new Date(Date.now() - 3 * 1000).toISOString();
3079
+ expect(() =>
3080
+ validateSAMLTimestamp(
3081
+ { notOnOrAfter: threeSecondsAgo },
3082
+ { clockSkew: 1000 },
3083
+ ),
3084
+ ).toThrow("SAML assertion has expired");
3085
+ });
3086
+ });
3087
+
3088
+ describe("Boundary conditions (exactly at window edges)", () => {
3089
+ const FIXED_TIME = new Date("2024-01-15T12:00:00.000Z").getTime();
3090
+
3091
+ beforeEach(() => {
3092
+ vi.useFakeTimers();
3093
+ vi.setSystemTime(FIXED_TIME);
3094
+ });
3095
+
3096
+ afterEach(() => {
3097
+ vi.useRealTimers();
3098
+ });
3099
+
3100
+ it("should accept assertion expiring exactly at clock skew boundary", () => {
3101
+ const exactlyAtBoundary = new Date(
3102
+ FIXED_TIME - DEFAULT_CLOCK_SKEW_MS,
3103
+ ).toISOString();
3104
+ expect(() =>
3105
+ validateSAMLTimestamp({ notOnOrAfter: exactlyAtBoundary }),
3106
+ ).not.toThrow();
3107
+ });
3108
+
3109
+ it("should reject assertion expiring 1ms beyond clock skew boundary", () => {
3110
+ const justPastBoundary = new Date(
3111
+ FIXED_TIME - DEFAULT_CLOCK_SKEW_MS - 1,
3112
+ ).toISOString();
3113
+ expect(() =>
3114
+ validateSAMLTimestamp({ notOnOrAfter: justPastBoundary }),
3115
+ ).toThrow("SAML assertion has expired");
3116
+ });
3117
+
3118
+ it("should accept assertion with NotBefore exactly at clock skew boundary", () => {
3119
+ const exactlyAtBoundary = new Date(
3120
+ FIXED_TIME + DEFAULT_CLOCK_SKEW_MS,
3121
+ ).toISOString();
3122
+ expect(() =>
3123
+ validateSAMLTimestamp({ notBefore: exactlyAtBoundary }),
3124
+ ).not.toThrow();
3125
+ });
3126
+
3127
+ it("should reject assertion with NotBefore 1ms beyond clock skew boundary", () => {
3128
+ const justPastBoundary = new Date(
3129
+ FIXED_TIME + DEFAULT_CLOCK_SKEW_MS + 1,
3130
+ ).toISOString();
3131
+ expect(() =>
3132
+ validateSAMLTimestamp({ notBefore: justPastBoundary }),
3133
+ ).toThrow("SAML assertion is not yet valid");
3134
+ });
3135
+ });
3136
+
3137
+ describe("Missing timestamps behavior", () => {
3138
+ it("should accept missing timestamps when requireTimestamps is false (default)", () => {
3139
+ expect(() =>
3140
+ validateSAMLTimestamp(undefined, { requireTimestamps: false }),
3141
+ ).not.toThrow();
3142
+ });
3143
+
3144
+ it("should accept empty conditions when requireTimestamps is false", () => {
3145
+ expect(() =>
3146
+ validateSAMLTimestamp({}, { requireTimestamps: false }),
3147
+ ).not.toThrow();
3148
+ });
3149
+
3150
+ it("should reject missing timestamps when requireTimestamps is true", () => {
3151
+ expect(() =>
3152
+ validateSAMLTimestamp(undefined, { requireTimestamps: true }),
3153
+ ).toThrow("SAML assertion missing required timestamp conditions");
3154
+ });
3155
+
3156
+ it("should reject empty conditions when requireTimestamps is true", () => {
3157
+ expect(() =>
3158
+ validateSAMLTimestamp({}, { requireTimestamps: true }),
3159
+ ).toThrow("SAML assertion missing required timestamp conditions");
3160
+ });
3161
+
3162
+ it("should accept assertions with only NotBefore (valid)", () => {
3163
+ const now = new Date().toISOString();
3164
+ expect(() => validateSAMLTimestamp({ notBefore: now })).not.toThrow();
3165
+ });
3166
+
3167
+ it("should accept assertions with only NotOnOrAfter (valid, in future)", () => {
3168
+ const future = new Date(Date.now() + 10 * 60 * 1000).toISOString();
3169
+ expect(() =>
3170
+ validateSAMLTimestamp({ notOnOrAfter: future }),
3171
+ ).not.toThrow();
3172
+ });
3173
+ });
3174
+
3175
+ describe("Custom clock skew configuration", () => {
3176
+ it("should use custom clockSkew when provided", () => {
3177
+ const twoSecondsAgo = new Date(Date.now() - 2 * 1000).toISOString();
3178
+
3179
+ expect(() =>
3180
+ validateSAMLTimestamp(
3181
+ { notOnOrAfter: twoSecondsAgo },
3182
+ { clockSkew: 1000 },
3183
+ ),
3184
+ ).toThrow("SAML assertion has expired");
3185
+
3186
+ expect(() =>
3187
+ validateSAMLTimestamp(
3188
+ { notOnOrAfter: twoSecondsAgo },
3189
+ { clockSkew: 5 * 60 * 1000 },
3190
+ ),
3191
+ ).not.toThrow();
3192
+ });
3193
+
3194
+ it("should use default 5 minute clock skew when not specified", () => {
3195
+ const fourMinutesAgo = new Date(Date.now() - 4 * 60 * 1000).toISOString();
3196
+ expect(() =>
3197
+ validateSAMLTimestamp({ notOnOrAfter: fourMinutesAgo }),
3198
+ ).not.toThrow();
3199
+
3200
+ const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000).toISOString();
3201
+ expect(() =>
3202
+ validateSAMLTimestamp({ notOnOrAfter: sixMinutesAgo }),
3203
+ ).toThrow("SAML assertion has expired");
3204
+ });
3205
+ });
3206
+
3207
+ describe("Malformed timestamp handling", () => {
3208
+ it("should reject malformed NotBefore timestamp", () => {
3209
+ expect(() =>
3210
+ validateSAMLTimestamp({ notBefore: "not-a-valid-date" }),
3211
+ ).toThrow("SAML assertion has invalid NotBefore timestamp");
3212
+ });
3213
+
3214
+ it("should reject malformed NotOnOrAfter timestamp", () => {
3215
+ expect(() =>
3216
+ validateSAMLTimestamp({ notOnOrAfter: "invalid-timestamp" }),
3217
+ ).toThrow("SAML assertion has invalid NotOnOrAfter timestamp");
3218
+ });
3219
+
3220
+ it("should treat empty string timestamps as missing (falsy values)", () => {
3221
+ expect(() => validateSAMLTimestamp({ notBefore: "" })).not.toThrow();
3222
+ expect(() => validateSAMLTimestamp({ notOnOrAfter: "" })).not.toThrow();
3223
+ });
3224
+
3225
+ it("should reject garbage data in timestamps", () => {
3226
+ expect(() =>
3227
+ validateSAMLTimestamp({
3228
+ notBefore: "abc123xyz",
3229
+ notOnOrAfter: "!@#$%^&*()",
3230
+ }),
3231
+ ).toThrow("SAML assertion has invalid NotBefore timestamp");
3232
+ });
3233
+
3234
+ it("should accept valid ISO 8601 timestamps", () => {
3235
+ const now = new Date();
3236
+ const future = new Date(Date.now() + 10 * 60 * 1000);
3237
+ expect(() =>
3238
+ validateSAMLTimestamp({
3239
+ notBefore: now.toISOString(),
3240
+ notOnOrAfter: future.toISOString(),
3241
+ }),
3242
+ ).not.toThrow();
3243
+ });
3244
+ });
3245
+ });
3246
+
3247
+ describe("SAML ACS Origin Check Bypass", () => {
3248
+ describe("Positive: SAML endpoints allow external IdP origins", () => {
3249
+ it("should allow SAML callback POST from external IdP origin", async () => {
3250
+ const { auth, signInWithTestUser } = await getTestInstance({
3251
+ plugins: [sso()],
3252
+ });
3253
+ const { headers } = await signInWithTestUser();
3254
+
3255
+ // Register SAML provider with full config
3256
+ await auth.api.registerSSOProvider({
3257
+ body: {
3258
+ providerId: "origin-bypass-callback",
3259
+ issuer: "http://localhost:8081",
3260
+ domain: "origin-bypass.com",
3261
+ samlConfig: {
3262
+ entryPoint: sharedMockIdP.metadataUrl,
3263
+ cert: certificate,
3264
+ callbackUrl: "http://localhost:8081/api/auth/sso/saml2/callback",
3265
+ wantAssertionsSigned: false,
3266
+ signatureAlgorithm: "sha256",
3267
+ digestAlgorithm: "sha256",
3268
+ spMetadata: {
3269
+ metadata: spMetadata,
3270
+ },
3271
+ },
3272
+ },
3273
+ headers,
3274
+ });
3275
+
3276
+ // POST to callback with external Origin header (simulating IdP POST)
3277
+ // Origin check should be bypassed for SAML callback endpoints
3278
+ const callbackRes = await auth.handler(
3279
+ new Request(
3280
+ "http://localhost:8081/api/auth/sso/saml2/callback/origin-bypass-callback",
3281
+ {
3282
+ method: "POST",
3283
+ headers: {
3284
+ "Content-Type": "application/x-www-form-urlencoded",
3285
+ Origin: "http://external-idp.example.com", // External IdP origin - would normally be blocked
3286
+ Cookie: headers.get("cookie") || "",
3287
+ },
3288
+ body: new URLSearchParams({
3289
+ SAMLResponse: Buffer.from("<fake-saml-response/>").toString(
3290
+ "base64",
3291
+ ),
3292
+ RelayState: "",
3293
+ }).toString(),
3294
+ },
3295
+ ),
3296
+ );
3297
+
3298
+ // Should NOT return 403 Forbidden (origin check bypassed)
3299
+ // May return other errors (400, 500) due to invalid SAML response, but NOT origin rejection
3300
+ expect(callbackRes.status).not.toBe(403);
3301
+ });
3302
+
3303
+ it("should allow ACS endpoint POST from external IdP origin", async () => {
3304
+ const { auth, signInWithTestUser } = await getTestInstance({
3305
+ plugins: [sso()],
3306
+ });
3307
+ const { headers } = await signInWithTestUser();
3308
+
3309
+ // Register SAML provider with full config
3310
+ await auth.api.registerSSOProvider({
3311
+ body: {
3312
+ providerId: "origin-bypass-acs",
3313
+ issuer: "http://localhost:8081",
3314
+ domain: "origin-bypass-acs.com",
3315
+ samlConfig: {
3316
+ entryPoint: sharedMockIdP.metadataUrl,
3317
+ cert: certificate,
3318
+ callbackUrl: "http://localhost:8081/api/auth/sso/saml2/sp/acs",
3319
+ wantAssertionsSigned: false,
3320
+ signatureAlgorithm: "sha256",
3321
+ digestAlgorithm: "sha256",
3322
+ spMetadata: {
3323
+ metadata: spMetadata,
3324
+ },
3325
+ },
3326
+ },
3327
+ headers,
3328
+ });
3329
+
3330
+ // POST to ACS with external Origin header
3331
+ const acsRes = await auth.handler(
3332
+ new Request(
3333
+ "http://localhost:8081/api/auth/sso/saml2/sp/acs/origin-bypass-acs",
3334
+ {
3335
+ method: "POST",
3336
+ headers: {
3337
+ "Content-Type": "application/x-www-form-urlencoded",
3338
+ Origin: "http://idp.external.com", // External IdP origin
3339
+ Cookie: headers.get("cookie") || "",
3340
+ },
3341
+ body: new URLSearchParams({
3342
+ SAMLResponse: Buffer.from("<fake-saml-response/>").toString(
3343
+ "base64",
3344
+ ),
3345
+ }).toString(),
3346
+ },
3347
+ ),
3348
+ );
3349
+
3350
+ // Should NOT return 403 Forbidden
3351
+ expect(acsRes.status).not.toBe(403);
3352
+ });
3353
+ });
3354
+
3355
+ describe("Negative: Non-SAML endpoints remain protected", () => {
3356
+ it("should block POST to sign-up with untrusted origin when origin check is enabled", async () => {
3357
+ const { auth } = await getTestInstance({
3358
+ plugins: [sso()],
3359
+ advanced: {
3360
+ disableCSRFCheck: false,
3361
+ disableOriginCheck: false,
3362
+ },
3363
+ });
3364
+
3365
+ // Origin check applies when cookies are present and check is enabled
3366
+ const signUpRes = await auth.handler(
3367
+ new Request("http://localhost:8081/api/auth/sign-up/email", {
3368
+ method: "POST",
3369
+ headers: {
3370
+ "Content-Type": "application/json",
3371
+ Origin: "http://attacker.com",
3372
+ Cookie: "better-auth.session_token=fake-session",
3373
+ },
3374
+ body: JSON.stringify({
3375
+ email: "victim@example.com",
3376
+ password: "password123",
3377
+ name: "Victim",
3378
+ }),
3379
+ }),
3380
+ );
3381
+
3382
+ expect(signUpRes.status).toBe(403);
3383
+ });
3384
+ });
3385
+
3386
+ describe("Edge cases", () => {
3387
+ it("should allow GET requests to SAML metadata regardless of origin", async () => {
3388
+ const { auth } = await getTestInstance({
3389
+ plugins: [sso()],
3390
+ });
3391
+
3392
+ // GET requests always bypass origin check
3393
+ const metadataRes = await auth.handler(
3394
+ new Request("http://localhost:8081/api/auth/sso/saml2/sp/metadata", {
3395
+ method: "GET",
3396
+ headers: {
3397
+ Origin: "http://any-origin.com",
3398
+ },
3399
+ }),
3400
+ );
3401
+
3402
+ expect(metadataRes.status).not.toBe(403);
3403
+ });
3404
+
3405
+ it("should not redirect to malicious RelayState URLs", async () => {
3406
+ const { auth, signInWithTestUser } = await getTestInstance({
3407
+ plugins: [sso()],
3408
+ });
3409
+ const { headers } = await signInWithTestUser();
3410
+
3411
+ await auth.api.registerSSOProvider({
3412
+ body: {
3413
+ providerId: "relay-security-test",
3414
+ issuer: "http://localhost:8081",
3415
+ domain: "relay-security.com",
3416
+ samlConfig: {
3417
+ entryPoint: sharedMockIdP.metadataUrl,
3418
+ cert: certificate,
3419
+ callbackUrl: "http://localhost:8081/api/auth/sso/saml2/callback",
3420
+ wantAssertionsSigned: false,
3421
+ signatureAlgorithm: "sha256",
3422
+ digestAlgorithm: "sha256",
3423
+ spMetadata: {
3424
+ metadata: spMetadata,
3425
+ },
3426
+ },
3427
+ },
3428
+ headers,
3429
+ });
3430
+
3431
+ // Even with origin bypass, malicious RelayState should be rejected
3432
+ const callbackRes = await auth.handler(
3433
+ new Request(
3434
+ "http://localhost:8081/api/auth/sso/saml2/callback/relay-security-test",
3435
+ {
3436
+ method: "POST",
3437
+ headers: {
3438
+ "Content-Type": "application/x-www-form-urlencoded",
3439
+ Origin: "http://idp.example.com",
3440
+ },
3441
+ body: new URLSearchParams({
3442
+ SAMLResponse: Buffer.from("<fake-saml-response/>").toString(
3443
+ "base64",
3444
+ ),
3445
+ RelayState: "http://malicious-site.com/steal-token",
3446
+ }).toString(),
3447
+ },
3448
+ ),
3449
+ );
3450
+
3451
+ // Should NOT redirect to malicious URL
3452
+ if (callbackRes.status === 302) {
3453
+ const location = callbackRes.headers.get("Location");
3454
+ expect(location).not.toContain("malicious-site.com");
3455
+ }
3456
+ });
3457
+ });
3458
+ });
3459
+
3460
+ describe("SAML Response Security", () => {
3461
+ it("should reject forged/unsigned SAML responses", async () => {
3462
+ const { auth, signInWithTestUser } = await getTestInstance({
3463
+ plugins: [sso()],
3464
+ });
3465
+ const { headers } = await signInWithTestUser();
3466
+
3467
+ await auth.api.registerSSOProvider({
3468
+ body: {
3469
+ providerId: "security-test-provider",
3470
+ issuer: "http://localhost:8081",
3471
+ domain: "security-test.com",
3472
+ samlConfig: {
3473
+ entryPoint: sharedMockIdP.metadataUrl,
3474
+ cert: certificate,
3475
+ callbackUrl: "http://localhost:8081/api/auth/sso/saml2/callback",
3476
+ wantAssertionsSigned: false,
3477
+ signatureAlgorithm: "sha256",
3478
+ digestAlgorithm: "sha256",
3479
+ spMetadata: {
3480
+ metadata: spMetadata,
3481
+ },
3482
+ },
3483
+ },
3484
+ headers,
3485
+ });
3486
+
3487
+ const forgedSAMLResponse = `
3488
+ <samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
3489
+ <saml:Assertion>
3490
+ <saml:Subject>
3491
+ <saml2:NameID xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">attacker@evil.com</saml2:NameID>
3492
+ </saml:Subject>
3493
+ </saml:Assertion>
3494
+ </samlp:Response>
3495
+ `;
3496
+
3497
+ const callbackRes = await auth.handler(
3498
+ new Request(
3499
+ "http://localhost:8081/api/auth/sso/saml2/callback/security-test-provider",
3500
+ {
3501
+ method: "POST",
3502
+ headers: {
3503
+ "Content-Type": "application/x-www-form-urlencoded",
3504
+ },
3505
+ body: new URLSearchParams({
3506
+ SAMLResponse: Buffer.from(forgedSAMLResponse).toString("base64"),
3507
+ RelayState: "",
3508
+ }).toString(),
3509
+ },
3510
+ ),
3511
+ );
3512
+
3513
+ expect(callbackRes.status).toBe(400);
3514
+ const body = await callbackRes.json();
3515
+ expect(body.message).toBe("Invalid SAML response");
3516
+ });
3517
+
3518
+ it("should reject SAML response with tampered nameID", async () => {
3519
+ const { auth, signInWithTestUser } = await getTestInstance({
3520
+ plugins: [sso()],
3521
+ });
3522
+ const { headers } = await signInWithTestUser();
3523
+
3524
+ await auth.api.registerSSOProvider({
3525
+ body: {
3526
+ providerId: "tamper-test-provider",
3527
+ issuer: "http://localhost:8081",
3528
+ domain: "tamper-test.com",
3529
+ samlConfig: {
3530
+ entryPoint: sharedMockIdP.metadataUrl,
3531
+ cert: certificate,
3532
+ callbackUrl: "http://localhost:8081/api/auth/sso/saml2/callback",
3533
+ wantAssertionsSigned: false,
3534
+ signatureAlgorithm: "sha256",
3535
+ digestAlgorithm: "sha256",
3536
+ spMetadata: {
3537
+ metadata: spMetadata,
3538
+ },
3539
+ },
3540
+ },
3541
+ headers,
3542
+ });
3543
+
3544
+ const tamperedResponse = `<?xml version="1.0"?>
3545
+ <samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">
3546
+ <saml2:NameID xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">admin@victim.com</saml2:NameID>
3547
+ </samlp:Response>`;
3548
+
3549
+ const callbackRes = await auth.handler(
3550
+ new Request(
3551
+ "http://localhost:8081/api/auth/sso/saml2/callback/tamper-test-provider",
3552
+ {
3553
+ method: "POST",
3554
+ headers: {
3555
+ "Content-Type": "application/x-www-form-urlencoded",
3556
+ },
3557
+ body: new URLSearchParams({
3558
+ SAMLResponse: Buffer.from(tamperedResponse).toString("base64"),
3559
+ RelayState: "",
3560
+ }).toString(),
3561
+ },
3562
+ ),
3563
+ );
3564
+
3565
+ expect(callbackRes.status).toBe(400);
3566
+ });
3567
+ });
3568
+
3569
+ describe("SAML SSO - Size Limit Validation", () => {
3570
+ it("should export default size limit constants", async () => {
3571
+ const { DEFAULT_MAX_SAML_RESPONSE_SIZE, DEFAULT_MAX_SAML_METADATA_SIZE } =
3572
+ await import("./constants");
3573
+
3574
+ expect(DEFAULT_MAX_SAML_RESPONSE_SIZE).toBe(256 * 1024);
3575
+ expect(DEFAULT_MAX_SAML_METADATA_SIZE).toBe(100 * 1024);
3576
+ });
3577
+ });
3578
+
3579
+ describe("SAML SSO - Assertion Replay Protection", () => {
3580
+ it("should reject replayed SAML assertion (same assertion submitted twice)", async () => {
3581
+ const { auth, signInWithTestUser } = await getTestInstance({
3582
+ plugins: [sso()],
3583
+ });
3584
+
3585
+ const { headers } = await signInWithTestUser();
3586
+
3587
+ await auth.api.registerSSOProvider({
3588
+ body: {
3589
+ providerId: "replay-test-provider",
3590
+ issuer: "http://localhost:8081",
3591
+ domain: "http://localhost:8081",
3592
+ samlConfig: {
3593
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
3594
+ cert: certificate,
3595
+ callbackUrl: "http://localhost:3000/dashboard",
3596
+ wantAssertionsSigned: false,
3597
+ signatureAlgorithm: "sha256",
3598
+ digestAlgorithm: "sha256",
3599
+ idpMetadata: {
3600
+ metadata: idpMetadata,
3601
+ },
3602
+ spMetadata: {
3603
+ metadata: spMetadata,
3604
+ },
3605
+ identifierFormat:
3606
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
3607
+ },
3608
+ },
3609
+ headers,
3610
+ });
3611
+
3612
+ let samlResponse: any;
3613
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
3614
+ onSuccess: async (context) => {
3615
+ samlResponse = await context.data;
3616
+ },
3617
+ });
3618
+
3619
+ const firstResponse = await auth.handler(
3620
+ new Request(
3621
+ "http://localhost:3000/api/auth/sso/saml2/callback/replay-test-provider",
3622
+ {
3623
+ method: "POST",
3624
+ headers: {
3625
+ "Content-Type": "application/x-www-form-urlencoded",
3626
+ },
3627
+ body: new URLSearchParams({
3628
+ SAMLResponse: samlResponse.samlResponse,
3629
+ RelayState: "http://localhost:3000/dashboard",
3630
+ }),
3631
+ },
3632
+ ),
3633
+ );
3634
+
3635
+ expect(firstResponse.status).toBe(302);
3636
+ const firstLocation = firstResponse.headers.get("location") || "";
3637
+ expect(firstLocation).not.toContain("error");
3638
+
3639
+ const replayResponse = await auth.handler(
3640
+ new Request(
3641
+ "http://localhost:3000/api/auth/sso/saml2/callback/replay-test-provider",
3642
+ {
3643
+ method: "POST",
3644
+ headers: {
3645
+ "Content-Type": "application/x-www-form-urlencoded",
3646
+ },
3647
+ body: new URLSearchParams({
3648
+ SAMLResponse: samlResponse.samlResponse,
3649
+ RelayState: "http://localhost:3000/dashboard",
3650
+ }),
3651
+ },
3652
+ ),
3653
+ );
3654
+
3655
+ expect(replayResponse.status).toBe(302);
3656
+ const replayLocation = replayResponse.headers.get("location") || "";
3657
+ expect(replayLocation).toContain("error=replay_detected");
3658
+ });
3659
+
3660
+ it("should reject replayed SAML assertion on ACS endpoint", async () => {
3661
+ const { auth, signInWithTestUser } = await getTestInstance({
3662
+ plugins: [sso()],
3663
+ });
3664
+
3665
+ const { headers } = await signInWithTestUser();
3666
+
3667
+ await auth.api.registerSSOProvider({
3668
+ body: {
3669
+ providerId: "acs-replay-test-provider",
3670
+ issuer: "http://localhost:8081",
3671
+ domain: "http://localhost:8081",
3672
+ samlConfig: {
3673
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
3674
+ cert: certificate,
3675
+ callbackUrl: "http://localhost:3000/dashboard",
3676
+ wantAssertionsSigned: false,
3677
+ signatureAlgorithm: "sha256",
3678
+ digestAlgorithm: "sha256",
3679
+ idpMetadata: {
3680
+ metadata: idpMetadata,
3681
+ },
3682
+ spMetadata: {
3683
+ metadata: spMetadata,
3684
+ },
3685
+ identifierFormat:
3686
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
3687
+ },
3688
+ },
3689
+ headers,
3690
+ });
3691
+
3692
+ let samlResponse: any;
3693
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
3694
+ onSuccess: async (context) => {
3695
+ samlResponse = await context.data;
3696
+ },
3697
+ });
3698
+
3699
+ const firstResponse = await auth.handler(
3700
+ new Request(
3701
+ "http://localhost:3000/api/auth/sso/saml2/sp/acs/acs-replay-test-provider",
3702
+ {
3703
+ method: "POST",
3704
+ headers: {
3705
+ "Content-Type": "application/x-www-form-urlencoded",
3706
+ },
3707
+ body: new URLSearchParams({
3708
+ SAMLResponse: samlResponse.samlResponse,
3709
+ RelayState: "http://localhost:3000/dashboard",
3710
+ }),
3711
+ },
3712
+ ),
3713
+ );
3714
+
3715
+ expect(firstResponse.status).toBe(302);
3716
+ const firstLocation = firstResponse.headers.get("location") || "";
3717
+ expect(firstLocation).not.toContain("error");
3718
+
3719
+ const replayResponse = await auth.handler(
3720
+ new Request(
3721
+ "http://localhost:3000/api/auth/sso/saml2/sp/acs/acs-replay-test-provider",
3722
+ {
3723
+ method: "POST",
3724
+ headers: {
3725
+ "Content-Type": "application/x-www-form-urlencoded",
3726
+ },
3727
+ body: new URLSearchParams({
3728
+ SAMLResponse: samlResponse.samlResponse,
3729
+ RelayState: "http://localhost:3000/dashboard",
3730
+ }),
3731
+ },
3732
+ ),
3733
+ );
3734
+
3735
+ expect(replayResponse.status).toBe(302);
3736
+ const replayLocation = replayResponse.headers.get("location") || "";
3737
+ expect(replayLocation).toContain("error=replay_detected");
3738
+ });
3739
+
3740
+ it("should reject cross-endpoint replay (callback → ACS)", async () => {
3741
+ const { auth, signInWithTestUser } = await getTestInstance({
3742
+ plugins: [sso()],
3743
+ });
3744
+
3745
+ const { headers } = await signInWithTestUser();
3746
+
3747
+ await auth.api.registerSSOProvider({
3748
+ body: {
3749
+ providerId: "cross-endpoint-provider",
3750
+ issuer: "http://localhost:8081",
3751
+ domain: "http://localhost:8081",
3752
+ samlConfig: {
3753
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
3754
+ cert: certificate,
3755
+ callbackUrl: "http://localhost:3000/dashboard",
3756
+ wantAssertionsSigned: false,
3757
+ signatureAlgorithm: "sha256",
3758
+ digestAlgorithm: "sha256",
3759
+ idpMetadata: {
3760
+ metadata: idpMetadata,
3761
+ },
3762
+ spMetadata: {
3763
+ metadata: spMetadata,
3764
+ },
3765
+ identifierFormat:
3766
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
3767
+ },
3768
+ },
3769
+ headers,
3770
+ });
3771
+
3772
+ let samlResponse: any;
3773
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
3774
+ onSuccess: async (context) => {
3775
+ samlResponse = await context.data;
3776
+ },
3777
+ });
3778
+
3779
+ const callbackResponse = await auth.handler(
3780
+ new Request(
3781
+ "http://localhost:3000/api/auth/sso/saml2/callback/cross-endpoint-provider",
3782
+ {
3783
+ method: "POST",
3784
+ headers: {
3785
+ "Content-Type": "application/x-www-form-urlencoded",
3786
+ },
3787
+ body: new URLSearchParams({
3788
+ SAMLResponse: samlResponse.samlResponse,
3789
+ RelayState: "http://localhost:3000/dashboard",
3790
+ }),
3791
+ },
3792
+ ),
3793
+ );
3794
+
3795
+ expect(callbackResponse.status).toBe(302);
3796
+ expect(callbackResponse.headers.get("location")).not.toContain("error");
3797
+
3798
+ const acsReplayResponse = await auth.handler(
3799
+ new Request(
3800
+ "http://localhost:3000/api/auth/sso/saml2/sp/acs/cross-endpoint-provider",
3801
+ {
3802
+ method: "POST",
3803
+ headers: {
3804
+ "Content-Type": "application/x-www-form-urlencoded",
3805
+ },
3806
+ body: new URLSearchParams({
3807
+ SAMLResponse: samlResponse.samlResponse,
3808
+ RelayState: "http://localhost:3000/dashboard",
3809
+ }),
3810
+ },
3811
+ ),
3812
+ );
3813
+
3814
+ expect(acsReplayResponse.status).toBe(302);
3815
+ const acsLocation = acsReplayResponse.headers.get("location") || "";
3816
+ expect(acsLocation).toContain("error=replay_detected");
3817
+ });
3818
+ });
3819
+
3820
+ describe("SAML SSO - Single Assertion Validation", () => {
3821
+ it("should reject SAML response with multiple assertions on callback endpoint", async () => {
3822
+ const { auth, signInWithTestUser } = await getTestInstance({
3823
+ plugins: [sso()],
3824
+ });
3825
+
3826
+ const { headers } = await signInWithTestUser();
3827
+
3828
+ await auth.api.registerSSOProvider({
3829
+ body: {
3830
+ providerId: "multi-assertion-callback-provider",
3831
+ issuer: "http://localhost:8081",
3832
+ domain: "http://localhost:8081",
3833
+ samlConfig: {
3834
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
3835
+ cert: certificate,
3836
+ callbackUrl: "http://localhost:3000/dashboard",
3837
+ wantAssertionsSigned: false,
3838
+ signatureAlgorithm: "sha256",
3839
+ digestAlgorithm: "sha256",
3840
+ idpMetadata: {
3841
+ metadata: idpMetadata,
3842
+ },
3843
+ spMetadata: {
3844
+ metadata: spMetadata,
3845
+ },
3846
+ identifierFormat:
3847
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
3848
+ },
3849
+ },
3850
+ headers,
3851
+ });
3852
+
3853
+ const multiAssertionResponse = `
3854
+ <saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
3855
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
3856
+ <saml2p:Status>
3857
+ <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
3858
+ </saml2p:Status>
3859
+ <saml2:Assertion ID="assertion-1">
3860
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
3861
+ <saml2:Subject>
3862
+ <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">legitimate@example.com</saml2:NameID>
3863
+ </saml2:Subject>
3864
+ </saml2:Assertion>
3865
+ <saml2:Assertion ID="assertion-2">
3866
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
3867
+ <saml2:Subject>
3868
+ <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">attacker@evil.com</saml2:NameID>
3869
+ </saml2:Subject>
3870
+ </saml2:Assertion>
3871
+ </saml2p:Response>
3872
+ `;
3873
+
3874
+ const encodedResponse = Buffer.from(multiAssertionResponse).toString(
3875
+ "base64",
3876
+ );
3877
+
3878
+ await expect(
3879
+ auth.api.callbackSSOSAML({
3880
+ body: {
3881
+ SAMLResponse: encodedResponse,
3882
+ RelayState: "http://localhost:3000/dashboard",
3883
+ },
3884
+ params: {
3885
+ providerId: "multi-assertion-callback-provider",
3886
+ },
3887
+ }),
3888
+ ).rejects.toMatchObject({
3889
+ body: {
3890
+ code: "SAML_MULTIPLE_ASSERTIONS",
3891
+ },
3892
+ });
3893
+ });
3894
+
3895
+ it("should reject SAML response with multiple assertions on ACS endpoint", async () => {
3896
+ const { auth, signInWithTestUser } = await getTestInstance({
3897
+ plugins: [sso()],
3898
+ });
3899
+
3900
+ const { headers } = await signInWithTestUser();
3901
+
3902
+ await auth.api.registerSSOProvider({
3903
+ body: {
3904
+ providerId: "multi-assertion-acs-provider",
3905
+ issuer: "http://localhost:8081",
3906
+ domain: "http://localhost:8081",
3907
+ samlConfig: {
3908
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
3909
+ cert: certificate,
3910
+ callbackUrl: "http://localhost:3000/dashboard",
3911
+ wantAssertionsSigned: false,
3912
+ signatureAlgorithm: "sha256",
3913
+ digestAlgorithm: "sha256",
3914
+ idpMetadata: {
3915
+ metadata: idpMetadata,
3916
+ },
3917
+ spMetadata: {
3918
+ metadata: spMetadata,
3919
+ },
3920
+ identifierFormat:
3921
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
3922
+ },
3923
+ },
3924
+ headers,
3925
+ });
3926
+
3927
+ const multiAssertionResponse = `
3928
+ <saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
3929
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
3930
+ <saml2p:Status>
3931
+ <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
3932
+ </saml2p:Status>
3933
+ <saml2:Assertion ID="assertion-1">
3934
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
3935
+ <saml2:Subject>
3936
+ <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">legitimate@example.com</saml2:NameID>
3937
+ </saml2:Subject>
3938
+ </saml2:Assertion>
3939
+ <saml2:Assertion ID="assertion-2">
3940
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
3941
+ <saml2:Subject>
3942
+ <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">attacker@evil.com</saml2:NameID>
3943
+ </saml2:Subject>
3944
+ </saml2:Assertion>
3945
+ </saml2p:Response>
3946
+ `;
3947
+
3948
+ const encodedResponse = Buffer.from(multiAssertionResponse).toString(
3949
+ "base64",
3950
+ );
3951
+
3952
+ const response = await auth.handler(
3953
+ new Request(
3954
+ "http://localhost:3000/api/auth/sso/saml2/sp/acs/multi-assertion-acs-provider",
3955
+ {
3956
+ method: "POST",
3957
+ headers: {
3958
+ "Content-Type": "application/x-www-form-urlencoded",
3959
+ },
3960
+ body: new URLSearchParams({
3961
+ SAMLResponse: encodedResponse,
3962
+ RelayState: "http://localhost:3000/dashboard",
3963
+ }),
3964
+ },
3965
+ ),
3966
+ );
3967
+
3968
+ expect(response.status).toBe(302);
3969
+ const location = response.headers.get("location") || "";
3970
+ expect(location).toContain("error=multiple_assertions");
3971
+ });
3972
+
3973
+ it("should reject SAML response with no assertions", async () => {
3974
+ const { auth, signInWithTestUser } = await getTestInstance({
3975
+ plugins: [sso()],
3976
+ });
3977
+
3978
+ const { headers } = await signInWithTestUser();
3979
+
3980
+ await auth.api.registerSSOProvider({
3981
+ body: {
3982
+ providerId: "no-assertion-provider",
3983
+ issuer: "http://localhost:8081",
3984
+ domain: "http://localhost:8081",
3985
+ samlConfig: {
3986
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
3987
+ cert: certificate,
3988
+ callbackUrl: "http://localhost:3000/dashboard",
3989
+ wantAssertionsSigned: false,
3990
+ signatureAlgorithm: "sha256",
3991
+ digestAlgorithm: "sha256",
3992
+ idpMetadata: {
3993
+ metadata: idpMetadata,
3994
+ },
3995
+ spMetadata: {
3996
+ metadata: spMetadata,
3997
+ },
3998
+ identifierFormat:
3999
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
4000
+ },
4001
+ },
4002
+ headers,
4003
+ });
4004
+
4005
+ const noAssertionResponse = `
4006
+ <saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
4007
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
4008
+ <saml2p:Status>
4009
+ <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
4010
+ </saml2p:Status>
4011
+ </saml2p:Response>
4012
+ `;
4013
+
4014
+ const encodedResponse = Buffer.from(noAssertionResponse).toString("base64");
4015
+
4016
+ await expect(
4017
+ auth.api.callbackSSOSAML({
4018
+ body: {
4019
+ SAMLResponse: encodedResponse,
4020
+ RelayState: "http://localhost:3000/dashboard",
4021
+ },
4022
+ params: {
4023
+ providerId: "no-assertion-provider",
4024
+ },
4025
+ }),
4026
+ ).rejects.toMatchObject({
4027
+ body: {
4028
+ code: "SAML_NO_ASSERTION",
4029
+ },
4030
+ });
4031
+ });
4032
+
4033
+ it("should reject SAML response with XSW-style assertion injection in Extensions", async () => {
4034
+ const { auth, signInWithTestUser } = await getTestInstance({
4035
+ plugins: [sso()],
4036
+ });
4037
+
4038
+ const { headers } = await signInWithTestUser();
4039
+
4040
+ await auth.api.registerSSOProvider({
4041
+ body: {
4042
+ providerId: "xsw-injection-provider",
4043
+ issuer: "http://localhost:8081",
4044
+ domain: "http://localhost:8081",
4045
+ samlConfig: {
4046
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
4047
+ cert: certificate,
4048
+ callbackUrl: "http://localhost:3000/dashboard",
4049
+ wantAssertionsSigned: false,
4050
+ signatureAlgorithm: "sha256",
4051
+ digestAlgorithm: "sha256",
4052
+ idpMetadata: {
4053
+ metadata: idpMetadata,
4054
+ },
4055
+ spMetadata: {
4056
+ metadata: spMetadata,
4057
+ },
4058
+ identifierFormat:
4059
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
4060
+ },
4061
+ },
4062
+ headers,
4063
+ });
4064
+
4065
+ const xswInjectionResponse = `
4066
+ <saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
4067
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
4068
+ <saml2p:Status>
4069
+ <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
4070
+ </saml2p:Status>
4071
+ <saml2p:Extensions>
4072
+ <saml2:Assertion ID="injected-assertion">
4073
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
4074
+ <saml2:Subject>
4075
+ <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">attacker@evil.com</saml2:NameID>
4076
+ </saml2:Subject>
4077
+ </saml2:Assertion>
4078
+ </saml2p:Extensions>
4079
+ <saml2:Assertion ID="legitimate-assertion">
4080
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
4081
+ <saml2:Subject>
4082
+ <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">user@example.com</saml2:NameID>
4083
+ </saml2:Subject>
4084
+ </saml2:Assertion>
4085
+ </saml2p:Response>
4086
+ `;
4087
+
4088
+ const encodedResponse =
4089
+ Buffer.from(xswInjectionResponse).toString("base64");
4090
+
4091
+ await expect(
4092
+ auth.api.callbackSSOSAML({
4093
+ body: {
4094
+ SAMLResponse: encodedResponse,
4095
+ RelayState: "http://localhost:3000/dashboard",
4096
+ },
4097
+ params: {
4098
+ providerId: "xsw-injection-provider",
4099
+ },
4100
+ }),
4101
+ ).rejects.toMatchObject({
4102
+ body: {
4103
+ code: "SAML_MULTIPLE_ASSERTIONS",
4104
+ },
4105
+ });
4106
+ });
4107
+
4108
+ it("should accept valid SAML response with exactly one assertion", async () => {
4109
+ const { auth, signInWithTestUser } = await getTestInstance({
4110
+ plugins: [sso()],
4111
+ });
4112
+
4113
+ const { headers } = await signInWithTestUser();
4114
+
4115
+ await auth.api.registerSSOProvider({
4116
+ body: {
4117
+ providerId: "single-assertion-provider",
4118
+ issuer: "http://localhost:8081",
4119
+ domain: "http://localhost:8081",
4120
+ samlConfig: {
4121
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
4122
+ cert: certificate,
4123
+ callbackUrl: "http://localhost:3000/dashboard",
4124
+ wantAssertionsSigned: false,
4125
+ signatureAlgorithm: "sha256",
4126
+ digestAlgorithm: "sha256",
4127
+ idpMetadata: {
4128
+ metadata: idpMetadata,
4129
+ },
4130
+ spMetadata: {
4131
+ metadata: spMetadata,
4132
+ },
4133
+ identifierFormat:
4134
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
4135
+ },
4136
+ },
4137
+ headers,
4138
+ });
4139
+
4140
+ let samlResponse: any;
4141
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
4142
+ onSuccess: async (context) => {
4143
+ samlResponse = await context.data;
4144
+ },
4145
+ });
4146
+
4147
+ const response = await auth.handler(
4148
+ new Request(
4149
+ "http://localhost:3000/api/auth/sso/saml2/callback/single-assertion-provider",
4150
+ {
4151
+ method: "POST",
4152
+ headers: {
4153
+ "Content-Type": "application/x-www-form-urlencoded",
4154
+ },
4155
+ body: new URLSearchParams({
4156
+ SAMLResponse: samlResponse.samlResponse,
4157
+ RelayState: "http://localhost:3000/dashboard",
4158
+ }),
4159
+ },
4160
+ ),
4161
+ );
4162
+
4163
+ expect(response.status).toBe(302);
4164
+ expect(response.headers.get("location")).not.toContain("error");
4165
+ });
4166
+
4167
+ it("should normalize email to lowercase in SAML authentication to prevent duplicate creation", async () => {
4168
+ const { auth, client, signInWithTestUser, db } = await getTestInstance({
4169
+ plugins: [sso()],
4170
+ });
4171
+
4172
+ const { headers } = await signInWithTestUser();
4173
+
4174
+ await auth.api.registerSSOProvider({
4175
+ body: {
4176
+ providerId: "email-case-provider",
4177
+ issuer: "http://localhost:8081",
4178
+ domain: "example.com",
4179
+ samlConfig: {
4180
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
4181
+ cert: certificate,
4182
+ callbackUrl: "http://localhost:3000/dashboard",
4183
+ wantAssertionsSigned: false,
4184
+ signatureAlgorithm: "sha256",
4185
+ digestAlgorithm: "sha256",
4186
+ idpMetadata: {
4187
+ metadata: idpMetadata,
4188
+ },
4189
+ spMetadata: {
4190
+ metadata: spMetadata,
4191
+ },
4192
+ identifierFormat:
4193
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
4194
+ mapping: {
4195
+ id: "nameID",
4196
+ email: "nameID",
4197
+ name: "displayName",
4198
+ },
4199
+ },
4200
+ },
4201
+ headers,
4202
+ });
4203
+
4204
+ let samlResponse1: { samlResponse: string } | undefined;
4205
+ await betterFetch(
4206
+ "http://localhost:8081/api/sso/saml2/idp/post?emailCase=mixed",
4207
+ {
4208
+ onSuccess: async (context) => {
4209
+ samlResponse1 = context.data as { samlResponse: string };
4210
+ },
4211
+ },
4212
+ );
4213
+
4214
+ expect(samlResponse1?.samlResponse).toBeDefined();
4215
+
4216
+ const firstCallbackResponse = await auth.handler(
4217
+ new Request(
4218
+ "http://localhost:3000/api/auth/sso/saml2/callback/email-case-provider",
4219
+ {
4220
+ method: "POST",
4221
+ headers: {
4222
+ "Content-Type": "application/x-www-form-urlencoded",
4223
+ },
4224
+ body: new URLSearchParams({
4225
+ SAMLResponse: samlResponse1!.samlResponse,
4226
+ RelayState: "http://localhost:3000/dashboard",
4227
+ }),
4228
+ },
4229
+ ),
4230
+ );
4231
+
4232
+ expect(firstCallbackResponse.status).toBe(302);
4233
+ expect(firstCallbackResponse.headers.get("location")).toContain(
4234
+ "dashboard",
4235
+ );
4236
+ expect(firstCallbackResponse.headers.get("location")).not.toContain(
4237
+ "error",
4238
+ );
4239
+
4240
+ const firstCookies = parseSetCookieHeader(
4241
+ firstCallbackResponse.headers.get("set-cookie") ?? "",
4242
+ );
4243
+ const firstSessionToken = firstCookies.get(
4244
+ "better-auth.session_token",
4245
+ )?.value;
4246
+ expect(firstSessionToken).toBeDefined();
4247
+
4248
+ const firstSession = await client.getSession({
4249
+ fetchOptions: {
4250
+ headers: {
4251
+ Cookie: `better-auth.session_token=${firstSessionToken}`,
4252
+ },
4253
+ },
4254
+ });
4255
+
4256
+ expect(firstSession.data?.user.email).toBe("testuser@example.com");
4257
+ const firstUserId = firstSession.data?.user.id;
4258
+ expect(firstUserId).toBeDefined();
4259
+
4260
+ let samlResponse2: { samlResponse: string } | undefined;
4261
+ await betterFetch(
4262
+ "http://localhost:8081/api/sso/saml2/idp/post?emailCase=mixed",
4263
+ {
4264
+ onSuccess: async (context) => {
4265
+ samlResponse2 = context.data as { samlResponse: string };
4266
+ },
4267
+ },
4268
+ );
4269
+
4270
+ const secondCallbackResponse = await auth.handler(
4271
+ new Request(
4272
+ "http://localhost:3000/api/auth/sso/saml2/callback/email-case-provider",
4273
+ {
4274
+ method: "POST",
4275
+ headers: {
4276
+ "Content-Type": "application/x-www-form-urlencoded",
4277
+ },
4278
+ body: new URLSearchParams({
4279
+ SAMLResponse: samlResponse2!.samlResponse,
4280
+ RelayState: "http://localhost:3000/dashboard",
4281
+ }),
4282
+ },
4283
+ ),
4284
+ );
4285
+
4286
+ expect(secondCallbackResponse.status).toBe(302);
4287
+ expect(secondCallbackResponse.headers.get("location")).toContain(
4288
+ "dashboard",
4289
+ );
4290
+ expect(secondCallbackResponse.headers.get("location")).not.toContain(
4291
+ "error",
4292
+ );
4293
+
4294
+ const secondCookies = parseSetCookieHeader(
4295
+ secondCallbackResponse.headers.get("set-cookie") ?? "",
4296
+ );
4297
+ const secondSessionToken = secondCookies.get(
4298
+ "better-auth.session_token",
4299
+ )?.value;
4300
+ expect(secondSessionToken).toBeDefined();
4301
+
4302
+ const secondSession = await client.getSession({
4303
+ fetchOptions: {
4304
+ headers: {
4305
+ Cookie: `better-auth.session_token=${secondSessionToken}`,
4306
+ },
4307
+ },
4308
+ });
4309
+
4310
+ expect(secondSession.data?.user.id).toBe(firstUserId);
4311
+ expect(secondSession.data?.user.email).toBe("testuser@example.com");
4312
+
4313
+ const users = (await db.findMany({ model: "user" })) as {
4314
+ email: string;
4315
+ }[];
4316
+ const samlUsers = users.filter((u) => u.email === "testuser@example.com");
4317
+ expect(samlUsers).toHaveLength(1);
4318
+ });
4319
+ });