@better-auth/sso 1.5.0-beta.1 → 1.5.0-beta.10
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.
- package/.turbo/turbo-build.log +13 -9
- package/LICENSE.md +15 -12
- package/dist/client.d.mts +7 -2
- package/dist/client.mjs +7 -2
- package/dist/client.mjs.map +1 -0
- package/dist/{index-CvpS40sl.d.mts → index-CBBJTszO.d.mts} +429 -19
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +1107 -489
- package/dist/index.mjs.map +1 -0
- package/package.json +17 -14
- package/src/client.ts +5 -1
- package/src/constants.ts +16 -0
- package/src/index.ts +55 -6
- package/src/linking/org-assignment.test.ts +1 -1
- package/src/linking/org-assignment.ts +20 -13
- package/src/oidc.test.ts +113 -1
- package/src/providers.test.ts +1326 -0
- package/src/routes/providers.ts +565 -0
- package/src/routes/schemas.ts +96 -0
- package/src/routes/sso.ts +285 -65
- package/src/saml/algorithms.ts +1 -31
- package/src/saml/assertions.test.ts +239 -0
- package/src/saml/assertions.ts +62 -0
- package/src/saml/index.ts +2 -0
- package/src/saml/parser.ts +56 -0
- package/src/saml-state.ts +78 -0
- package/src/saml.test.ts +2133 -422
- package/src/types.ts +20 -0
- package/src/utils.test.ts +103 -0
- package/src/utils.ts +45 -5
- package/tsconfig.json +3 -0
- package/tsdown.config.ts +1 -0
package/src/saml.test.ts
CHANGED
|
@@ -3,8 +3,9 @@ import type { createServer } from "node:http";
|
|
|
3
3
|
import { betterFetch } from "@better-fetch/fetch";
|
|
4
4
|
import { betterAuth } from "better-auth";
|
|
5
5
|
import { memoryAdapter } from "better-auth/adapters/memory";
|
|
6
|
+
import { APIError } from "better-auth/api";
|
|
6
7
|
import { createAuthClient } from "better-auth/client";
|
|
7
|
-
import { setCookieToHeader } from "better-auth/cookies";
|
|
8
|
+
import { parseSetCookieHeader, setCookieToHeader } from "better-auth/cookies";
|
|
8
9
|
import { bearer } from "better-auth/plugins";
|
|
9
10
|
import { getTestInstance } from "better-auth/test";
|
|
10
11
|
import bodyParser from "body-parser";
|
|
@@ -398,7 +399,14 @@ const createMockSAMLIdP = (port: number) => {
|
|
|
398
399
|
app.get(
|
|
399
400
|
"/api/sso/saml2/idp/post",
|
|
400
401
|
async (req: ExpressRequest, res: ExpressResponse) => {
|
|
401
|
-
const
|
|
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
|
+
};
|
|
402
410
|
const { context, entityEndpoint } = await idp.createLoginResponse(
|
|
403
411
|
sp,
|
|
404
412
|
{} as any,
|
|
@@ -412,7 +420,14 @@ const createMockSAMLIdP = (port: number) => {
|
|
|
412
420
|
app.get(
|
|
413
421
|
"/api/sso/saml2/idp/redirect",
|
|
414
422
|
async (req: ExpressRequest, res: ExpressResponse) => {
|
|
415
|
-
const
|
|
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
|
+
};
|
|
416
431
|
const { context, entityEndpoint } = await idp.createLoginResponse(
|
|
417
432
|
sp,
|
|
418
433
|
{} as any,
|
|
@@ -573,6 +588,155 @@ describe("SAML SSO with defaultSSO array", async () => {
|
|
|
573
588
|
});
|
|
574
589
|
});
|
|
575
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
|
+
|
|
576
740
|
describe("SAML SSO", async () => {
|
|
577
741
|
const data = {
|
|
578
742
|
user: [],
|
|
@@ -1099,18 +1263,15 @@ describe("SAML SSO", async () => {
|
|
|
1099
1263
|
});
|
|
1100
1264
|
});
|
|
1101
1265
|
|
|
1102
|
-
it("should
|
|
1103
|
-
const { auth
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
});
|
|
1266
|
+
it("should initiate SAML login and validate RelayState", async () => {
|
|
1267
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
1268
|
+
plugins: [sso()],
|
|
1269
|
+
});
|
|
1107
1270
|
|
|
1108
1271
|
const { headers } = await signInWithTestUser();
|
|
1109
|
-
|
|
1110
|
-
// Register SAML provider
|
|
1111
|
-
await authWithDisabledSignUp.api.registerSSOProvider({
|
|
1272
|
+
await auth.api.registerSSOProvider({
|
|
1112
1273
|
body: {
|
|
1113
|
-
providerId: "saml-
|
|
1274
|
+
providerId: "saml-provider-1",
|
|
1114
1275
|
issuer: "http://localhost:8081",
|
|
1115
1276
|
domain: "http://localhost:8081",
|
|
1116
1277
|
samlConfig: {
|
|
@@ -1130,50 +1291,58 @@ describe("SAML SSO", async () => {
|
|
|
1130
1291
|
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
1131
1292
|
},
|
|
1132
1293
|
},
|
|
1133
|
-
headers
|
|
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,
|
|
1134
1309
|
});
|
|
1135
1310
|
|
|
1136
|
-
// Identity Provider-initiated: Get SAML response directly from IdP
|
|
1137
|
-
// The mock IdP will return test@email.com, which doesn't exist in the DB
|
|
1138
1311
|
let samlResponse: any;
|
|
1139
|
-
await betterFetch(
|
|
1312
|
+
await betterFetch(signInResponse?.url, {
|
|
1140
1313
|
onSuccess: async (context) => {
|
|
1141
1314
|
samlResponse = await context.data;
|
|
1142
1315
|
},
|
|
1143
1316
|
});
|
|
1144
1317
|
|
|
1145
|
-
const
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
);
|
|
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
|
+
});
|
|
1160
1333
|
|
|
1161
|
-
expect(
|
|
1162
|
-
const redirectLocation = response.headers.get("location") || "";
|
|
1163
|
-
expect(redirectLocation).toContain("error=signup_disabled");
|
|
1334
|
+
expect(callbackResponse.headers.get("location")).toContain("dashboard");
|
|
1164
1335
|
});
|
|
1165
1336
|
|
|
1166
|
-
it("should
|
|
1167
|
-
const { auth
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
});
|
|
1337
|
+
it("should initiate SAML login and fallback to callbackUrl on invalid RelayState", async () => {
|
|
1338
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
1339
|
+
plugins: [sso()],
|
|
1340
|
+
});
|
|
1171
1341
|
|
|
1172
1342
|
const { headers } = await signInWithTestUser();
|
|
1173
|
-
|
|
1174
|
-
await authWithDisabledSignUp.api.registerSSOProvider({
|
|
1343
|
+
await auth.api.registerSSOProvider({
|
|
1175
1344
|
body: {
|
|
1176
|
-
providerId: "saml-
|
|
1345
|
+
providerId: "saml-provider-1",
|
|
1177
1346
|
issuer: "http://localhost:8081",
|
|
1178
1347
|
domain: "http://localhost:8081",
|
|
1179
1348
|
samlConfig: {
|
|
@@ -1193,53 +1362,60 @@ describe("SAML SSO", async () => {
|
|
|
1193
1362
|
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
1194
1363
|
},
|
|
1195
1364
|
},
|
|
1196
|
-
headers
|
|
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,
|
|
1197
1380
|
});
|
|
1198
1381
|
|
|
1199
1382
|
let samlResponse: any;
|
|
1200
|
-
await betterFetch(
|
|
1383
|
+
await betterFetch(signInResponse?.url, {
|
|
1201
1384
|
onSuccess: async (context) => {
|
|
1202
1385
|
samlResponse = await context.data;
|
|
1203
1386
|
},
|
|
1204
1387
|
});
|
|
1205
1388
|
|
|
1206
|
-
const
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
);
|
|
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
|
+
});
|
|
1221
1403
|
|
|
1222
|
-
expect(
|
|
1223
|
-
|
|
1224
|
-
|
|
1404
|
+
expect(callbackResponse.status).toBe(302);
|
|
1405
|
+
expect(callbackResponse.headers.get("location")).toBe(
|
|
1406
|
+
"http://localhost:3000/dashboard",
|
|
1407
|
+
);
|
|
1225
1408
|
});
|
|
1226
1409
|
|
|
1227
|
-
it("should
|
|
1228
|
-
const { auth
|
|
1229
|
-
|
|
1230
|
-
accountLinking: {
|
|
1231
|
-
enabled: true,
|
|
1232
|
-
trustedProviders: [],
|
|
1233
|
-
},
|
|
1234
|
-
},
|
|
1235
|
-
plugins: [sso()],
|
|
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 })],
|
|
1236
1413
|
});
|
|
1237
1414
|
|
|
1238
1415
|
const { headers } = await signInWithTestUser();
|
|
1239
|
-
|
|
1240
|
-
await authUntrusted.api.registerSSOProvider({
|
|
1416
|
+
await auth.api.registerSSOProvider({
|
|
1241
1417
|
body: {
|
|
1242
|
-
providerId: "
|
|
1418
|
+
providerId: "saml-provider-1",
|
|
1243
1419
|
issuer: "http://localhost:8081",
|
|
1244
1420
|
domain: "http://localhost:8081",
|
|
1245
1421
|
samlConfig: {
|
|
@@ -1262,14 +1438,218 @@ describe("SAML SSO", async () => {
|
|
|
1262
1438
|
headers,
|
|
1263
1439
|
});
|
|
1264
1440
|
|
|
1265
|
-
const
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
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,
|
|
1273
1653
|
createdAt: new Date(),
|
|
1274
1654
|
updatedAt: new Date(),
|
|
1275
1655
|
},
|
|
@@ -1292,7 +1672,6 @@ describe("SAML SSO", async () => {
|
|
|
1292
1672
|
},
|
|
1293
1673
|
body: new URLSearchParams({
|
|
1294
1674
|
SAMLResponse: samlResponse.samlResponse,
|
|
1295
|
-
RelayState: "http://localhost:3000/dashboard",
|
|
1296
1675
|
}),
|
|
1297
1676
|
},
|
|
1298
1677
|
),
|
|
@@ -1373,7 +1752,6 @@ describe("SAML SSO", async () => {
|
|
|
1373
1752
|
},
|
|
1374
1753
|
body: new URLSearchParams({
|
|
1375
1754
|
SAMLResponse: samlResponse.samlResponse,
|
|
1376
|
-
RelayState: "http://localhost:3000/dashboard",
|
|
1377
1755
|
}),
|
|
1378
1756
|
},
|
|
1379
1757
|
),
|
|
@@ -1441,7 +1819,6 @@ describe("SAML SSO", async () => {
|
|
|
1441
1819
|
},
|
|
1442
1820
|
body: new URLSearchParams({
|
|
1443
1821
|
SAMLResponse: samlResponse.samlResponse,
|
|
1444
|
-
RelayState: "http://localhost:3000/dashboard",
|
|
1445
1822
|
}),
|
|
1446
1823
|
},
|
|
1447
1824
|
),
|
|
@@ -1508,7 +1885,6 @@ describe("SAML SSO", async () => {
|
|
|
1508
1885
|
},
|
|
1509
1886
|
body: new URLSearchParams({
|
|
1510
1887
|
SAMLResponse: samlResponse.samlResponse,
|
|
1511
|
-
RelayState: "http://localhost:3000/dashboard",
|
|
1512
1888
|
}),
|
|
1513
1889
|
},
|
|
1514
1890
|
),
|
|
@@ -1568,7 +1944,6 @@ describe("SAML SSO", async () => {
|
|
|
1568
1944
|
},
|
|
1569
1945
|
body: new URLSearchParams({
|
|
1570
1946
|
SAMLResponse: samlResponse.samlResponse,
|
|
1571
|
-
RelayState: "http://localhost:3000/dashboard",
|
|
1572
1947
|
}),
|
|
1573
1948
|
},
|
|
1574
1949
|
),
|
|
@@ -1637,7 +2012,6 @@ describe("SAML SSO", async () => {
|
|
|
1637
2012
|
},
|
|
1638
2013
|
body: new URLSearchParams({
|
|
1639
2014
|
SAMLResponse: samlResponse.samlResponse,
|
|
1640
|
-
RelayState: "http://localhost:3000/dashboard",
|
|
1641
2015
|
}),
|
|
1642
2016
|
},
|
|
1643
2017
|
),
|
|
@@ -1977,8 +2351,8 @@ describe("SSO Provider Config Parsing", () => {
|
|
|
1977
2351
|
});
|
|
1978
2352
|
});
|
|
1979
2353
|
|
|
1980
|
-
describe("SAML SSO -
|
|
1981
|
-
it("should
|
|
2354
|
+
describe("SAML SSO - IdP Initiated Flow", () => {
|
|
2355
|
+
it("should handle IdP-initiated flow with GET after POST redirect", async () => {
|
|
1982
2356
|
const { auth, signInWithTestUser } = await getTestInstance({
|
|
1983
2357
|
plugins: [sso()],
|
|
1984
2358
|
});
|
|
@@ -1987,11 +2361,14 @@ describe("SAML SSO - Signature Validation Security", () => {
|
|
|
1987
2361
|
|
|
1988
2362
|
await auth.api.registerSSOProvider({
|
|
1989
2363
|
body: {
|
|
1990
|
-
providerId: "
|
|
2364
|
+
providerId: "idp-initiated-provider",
|
|
1991
2365
|
issuer: "http://localhost:8081",
|
|
1992
2366
|
domain: "http://localhost:8081",
|
|
1993
2367
|
samlConfig: {
|
|
1994
|
-
entryPoint:
|
|
2368
|
+
entryPoint: sharedMockIdP.metadataUrl.replace(
|
|
2369
|
+
"/idp/metadata",
|
|
2370
|
+
"/idp/post",
|
|
2371
|
+
),
|
|
1995
2372
|
cert: certificate,
|
|
1996
2373
|
callbackUrl: "http://localhost:3000/dashboard",
|
|
1997
2374
|
wantAssertionsSigned: false,
|
|
@@ -2010,65 +2387,110 @@ describe("SAML SSO - Signature Validation Security", () => {
|
|
|
2010
2387
|
headers,
|
|
2011
2388
|
});
|
|
2012
2389
|
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
<saml2:Conditions>
|
|
2025
|
-
<saml2:AudienceRestriction>
|
|
2026
|
-
<saml2:Audience>http://localhost:3001</saml2:Audience>
|
|
2027
|
-
</saml2:AudienceRestriction>
|
|
2028
|
-
</saml2:Conditions>
|
|
2029
|
-
<saml2:AuthnStatement>
|
|
2030
|
-
<saml2:AuthnContext>
|
|
2031
|
-
<saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml2:AuthnContextClassRef>
|
|
2032
|
-
</saml2:AuthnContext>
|
|
2033
|
-
</saml2:AuthnStatement>
|
|
2034
|
-
</saml2:Assertion>
|
|
2035
|
-
</saml2p:Response>
|
|
2036
|
-
`;
|
|
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
|
+
});
|
|
2037
2401
|
|
|
2038
|
-
|
|
2039
|
-
|
|
2402
|
+
if (!samlResponse?.samlResponse) {
|
|
2403
|
+
throw new Error("Failed to get SAML response from mock IdP");
|
|
2404
|
+
}
|
|
2040
2405
|
|
|
2041
|
-
await
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
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",
|
|
2047
2450
|
params: {
|
|
2048
|
-
providerId: "
|
|
2451
|
+
providerId: "test-provider",
|
|
2049
2452
|
},
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
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");
|
|
2054
2470
|
});
|
|
2055
2471
|
|
|
2056
|
-
it("should
|
|
2472
|
+
it("should prevent redirect loop when callbackUrl points to callback route", async () => {
|
|
2057
2473
|
const { auth, signInWithTestUser } = await getTestInstance({
|
|
2058
2474
|
plugins: [sso()],
|
|
2059
2475
|
});
|
|
2060
2476
|
|
|
2061
2477
|
const { headers } = await signInWithTestUser();
|
|
2062
2478
|
|
|
2479
|
+
const callbackRouteUrl =
|
|
2480
|
+
"http://localhost:3000/api/auth/sso/saml2/callback/loop-test-provider";
|
|
2481
|
+
|
|
2063
2482
|
await auth.api.registerSSOProvider({
|
|
2064
2483
|
body: {
|
|
2065
|
-
providerId: "
|
|
2484
|
+
providerId: "loop-test-provider",
|
|
2066
2485
|
issuer: "http://localhost:8081",
|
|
2067
2486
|
domain: "http://localhost:8081",
|
|
2068
2487
|
samlConfig: {
|
|
2069
|
-
entryPoint:
|
|
2488
|
+
entryPoint: sharedMockIdP.metadataUrl.replace(
|
|
2489
|
+
"/idp/metadata",
|
|
2490
|
+
"/idp/post",
|
|
2491
|
+
),
|
|
2070
2492
|
cert: certificate,
|
|
2071
|
-
callbackUrl:
|
|
2493
|
+
callbackUrl: callbackRouteUrl,
|
|
2072
2494
|
wantAssertionsSigned: false,
|
|
2073
2495
|
signatureAlgorithm: "sha256",
|
|
2074
2496
|
digestAlgorithm: "sha256",
|
|
@@ -2085,283 +2507,1392 @@ describe("SAML SSO - Signature Validation Security", () => {
|
|
|
2085
2507
|
headers,
|
|
2086
2508
|
});
|
|
2087
2509
|
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
</ds:SignedInfo>
|
|
2100
|
-
<ds:SignatureValue>INVALID_SIGNATURE_VALUE_THAT_SHOULD_FAIL_VERIFICATION</ds:SignatureValue>
|
|
2101
|
-
</ds:Signature>
|
|
2102
|
-
<saml2p:Status>
|
|
2103
|
-
<saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
|
|
2104
|
-
</saml2p:Status>
|
|
2105
|
-
<saml2:Assertion>
|
|
2106
|
-
<saml2:Issuer>http://localhost:8081</saml2:Issuer>
|
|
2107
|
-
<saml2:Subject>
|
|
2108
|
-
<saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">forged-admin@company.com</saml2:NameID>
|
|
2109
|
-
</saml2:Subject>
|
|
2110
|
-
</saml2:Assertion>
|
|
2111
|
-
</saml2p:Response>
|
|
2112
|
-
`;
|
|
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
|
+
});
|
|
2113
2521
|
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2522
|
+
if (!samlResponse?.samlResponse) {
|
|
2523
|
+
throw new Error("Failed to get SAML response from mock IdP");
|
|
2524
|
+
}
|
|
2117
2525
|
|
|
2118
|
-
await
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
}),
|
|
2128
|
-
).rejects.toMatchObject({
|
|
2129
|
-
status: "BAD_REQUEST",
|
|
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,
|
|
2130
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");
|
|
2131
2542
|
});
|
|
2132
|
-
});
|
|
2133
2543
|
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
const now = new Date();
|
|
2138
|
-
const fiveMinutesFromNow = new Date(Date.now() + 5 * 60 * 1000);
|
|
2139
|
-
expect(() =>
|
|
2140
|
-
validateSAMLTimestamp({
|
|
2141
|
-
notBefore: now.toISOString(),
|
|
2142
|
-
notOnOrAfter: fiveMinutesFromNow.toISOString(),
|
|
2143
|
-
}),
|
|
2144
|
-
).not.toThrow();
|
|
2544
|
+
it("should handle GET request with RelayState in query", async () => {
|
|
2545
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
2546
|
+
plugins: [sso()],
|
|
2145
2547
|
});
|
|
2146
2548
|
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
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,
|
|
2152
2577
|
});
|
|
2153
2578
|
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
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
|
+
},
|
|
2161
2589
|
});
|
|
2162
|
-
});
|
|
2163
2590
|
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
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,
|
|
2172
2605
|
});
|
|
2173
2606
|
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
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,
|
|
2182
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");
|
|
2183
2624
|
});
|
|
2184
2625
|
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
expect(() =>
|
|
2189
|
-
validateSAMLTimestamp({ notOnOrAfter: tenMinutesAgo }),
|
|
2190
|
-
).toThrow("SAML assertion has expired");
|
|
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()],
|
|
2191
2629
|
});
|
|
2192
2630
|
|
|
2193
|
-
|
|
2194
|
-
const threeSecondsAgo = new Date(Date.now() - 3 * 1000).toISOString();
|
|
2195
|
-
expect(() =>
|
|
2196
|
-
validateSAMLTimestamp(
|
|
2197
|
-
{ notOnOrAfter: threeSecondsAgo },
|
|
2198
|
-
{ clockSkew: 1000 },
|
|
2199
|
-
),
|
|
2200
|
-
).toThrow("SAML assertion has expired");
|
|
2201
|
-
});
|
|
2202
|
-
});
|
|
2631
|
+
const { headers } = await signInWithTestUser();
|
|
2203
2632
|
|
|
2204
|
-
|
|
2205
|
-
|
|
2633
|
+
const callbackRouteUrl =
|
|
2634
|
+
"http://localhost:3000/api/auth/sso/saml2/callback/issue-6615-provider";
|
|
2206
2635
|
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
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,
|
|
2210
2662
|
});
|
|
2211
2663
|
|
|
2212
|
-
|
|
2213
|
-
|
|
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
|
+
},
|
|
2214
2674
|
});
|
|
2215
2675
|
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
).toISOString();
|
|
2220
|
-
expect(() =>
|
|
2221
|
-
validateSAMLTimestamp({ notOnOrAfter: exactlyAtBoundary }),
|
|
2222
|
-
).not.toThrow();
|
|
2223
|
-
});
|
|
2676
|
+
if (!samlResponse?.samlResponse) {
|
|
2677
|
+
throw new Error("Failed to get SAML response from mock IdP");
|
|
2678
|
+
}
|
|
2224
2679
|
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
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,
|
|
2232
2690
|
});
|
|
2233
2691
|
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
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,
|
|
2241
2706
|
});
|
|
2242
2707
|
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
expect(() =>
|
|
2248
|
-
validateSAMLTimestamp({ notBefore: justPastBoundary }),
|
|
2249
|
-
).toThrow("SAML assertion is not yet valid");
|
|
2250
|
-
});
|
|
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");
|
|
2251
2712
|
});
|
|
2252
2713
|
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
validateSAMLTimestamp(undefined, { requireTimestamps: false }),
|
|
2257
|
-
).not.toThrow();
|
|
2714
|
+
it("should prevent open redirect with malicious RelayState URL", async () => {
|
|
2715
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
2716
|
+
plugins: [sso()],
|
|
2258
2717
|
});
|
|
2259
2718
|
|
|
2260
|
-
|
|
2261
|
-
expect(() =>
|
|
2262
|
-
validateSAMLTimestamp({}, { requireTimestamps: false }),
|
|
2263
|
-
).not.toThrow();
|
|
2264
|
-
});
|
|
2719
|
+
const { headers } = await signInWithTestUser();
|
|
2265
2720
|
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
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,
|
|
2270
2747
|
});
|
|
2271
2748
|
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
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
|
+
},
|
|
2276
2759
|
});
|
|
2277
2760
|
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
});
|
|
2761
|
+
if (!samlResponse?.samlResponse) {
|
|
2762
|
+
throw new Error("Failed to get SAML response from mock IdP");
|
|
2763
|
+
}
|
|
2282
2764
|
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
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,
|
|
2288
2777
|
});
|
|
2289
|
-
});
|
|
2290
2778
|
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
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
|
+
});
|
|
2294
2787
|
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
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
|
+
});
|
|
2301
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);
|
|
2302
3023
|
expect(() =>
|
|
2303
|
-
validateSAMLTimestamp(
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
),
|
|
3024
|
+
validateSAMLTimestamp({
|
|
3025
|
+
notBefore: now.toISOString(),
|
|
3026
|
+
notOnOrAfter: fiveMinutesFromNow.toISOString(),
|
|
3027
|
+
}),
|
|
2307
3028
|
).not.toThrow();
|
|
2308
3029
|
});
|
|
2309
3030
|
|
|
2310
|
-
it("should
|
|
2311
|
-
const
|
|
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();
|
|
2312
3033
|
expect(() =>
|
|
2313
|
-
validateSAMLTimestamp({ notOnOrAfter:
|
|
3034
|
+
validateSAMLTimestamp({ notOnOrAfter: twoMinutesAgo }),
|
|
2314
3035
|
).not.toThrow();
|
|
3036
|
+
});
|
|
2315
3037
|
|
|
2316
|
-
|
|
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();
|
|
2317
3042
|
expect(() =>
|
|
2318
|
-
validateSAMLTimestamp({
|
|
2319
|
-
).toThrow(
|
|
3043
|
+
validateSAMLTimestamp({ notBefore: twoMinutesFromNow }),
|
|
3044
|
+
).not.toThrow();
|
|
2320
3045
|
});
|
|
2321
3046
|
});
|
|
2322
3047
|
|
|
2323
|
-
describe("
|
|
2324
|
-
it("should reject
|
|
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();
|
|
2325
3053
|
expect(() =>
|
|
2326
|
-
validateSAMLTimestamp({ notBefore:
|
|
2327
|
-
).toThrow("SAML assertion
|
|
3054
|
+
validateSAMLTimestamp({ notBefore: tenMinutesFromNow }),
|
|
3055
|
+
).toThrow("SAML assertion is not yet valid");
|
|
2328
3056
|
});
|
|
2329
3057
|
|
|
2330
|
-
it("should reject
|
|
3058
|
+
it("should reject with custom strict clock skew (1 second)", () => {
|
|
3059
|
+
const threeSecondsFromNow = new Date(Date.now() + 3 * 1000).toISOString();
|
|
2331
3060
|
expect(() =>
|
|
2332
|
-
validateSAMLTimestamp(
|
|
2333
|
-
|
|
3061
|
+
validateSAMLTimestamp(
|
|
3062
|
+
{ notBefore: threeSecondsFromNow },
|
|
3063
|
+
{ clockSkew: 1000 },
|
|
3064
|
+
),
|
|
3065
|
+
).toThrow("SAML assertion is not yet valid");
|
|
2334
3066
|
});
|
|
3067
|
+
});
|
|
2335
3068
|
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
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");
|
|
2339
3075
|
});
|
|
2340
3076
|
|
|
2341
|
-
it("should reject
|
|
3077
|
+
it("should reject with custom strict clock skew (1 second)", () => {
|
|
3078
|
+
const threeSecondsAgo = new Date(Date.now() - 3 * 1000).toISOString();
|
|
2342
3079
|
expect(() =>
|
|
2343
|
-
validateSAMLTimestamp(
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
).toThrow("SAML assertion has
|
|
3080
|
+
validateSAMLTimestamp(
|
|
3081
|
+
{ notOnOrAfter: threeSecondsAgo },
|
|
3082
|
+
{ clockSkew: 1000 },
|
|
3083
|
+
),
|
|
3084
|
+
).toThrow("SAML assertion has expired");
|
|
2348
3085
|
});
|
|
3086
|
+
});
|
|
2349
3087
|
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
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();
|
|
2353
3104
|
expect(() =>
|
|
2354
|
-
validateSAMLTimestamp({
|
|
2355
|
-
notBefore: now.toISOString(),
|
|
2356
|
-
notOnOrAfter: future.toISOString(),
|
|
2357
|
-
}),
|
|
3105
|
+
validateSAMLTimestamp({ notOnOrAfter: exactlyAtBoundary }),
|
|
2358
3106
|
).not.toThrow();
|
|
2359
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
|
+
});
|
|
2360
3893
|
});
|
|
2361
|
-
});
|
|
2362
3894
|
|
|
2363
|
-
|
|
2364
|
-
it("should reject replayed SAML assertion (same assertion submitted twice)", async () => {
|
|
3895
|
+
it("should reject SAML response with multiple assertions on ACS endpoint", async () => {
|
|
2365
3896
|
const { auth, signInWithTestUser } = await getTestInstance({
|
|
2366
3897
|
plugins: [sso()],
|
|
2367
3898
|
});
|
|
@@ -2370,7 +3901,7 @@ describe("SAML SSO - Assertion Replay Protection", () => {
|
|
|
2370
3901
|
|
|
2371
3902
|
await auth.api.registerSSOProvider({
|
|
2372
3903
|
body: {
|
|
2373
|
-
providerId: "
|
|
3904
|
+
providerId: "multi-assertion-acs-provider",
|
|
2374
3905
|
issuer: "http://localhost:8081",
|
|
2375
3906
|
domain: "http://localhost:8081",
|
|
2376
3907
|
samlConfig: {
|
|
@@ -2393,57 +3924,113 @@ describe("SAML SSO - Assertion Replay Protection", () => {
|
|
|
2393
3924
|
headers,
|
|
2394
3925
|
});
|
|
2395
3926
|
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
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
|
+
`;
|
|
2402
3947
|
|
|
2403
|
-
|
|
2404
|
-
|
|
3948
|
+
const encodedResponse = Buffer.from(multiAssertionResponse).toString(
|
|
3949
|
+
"base64",
|
|
3950
|
+
);
|
|
3951
|
+
|
|
3952
|
+
const response = await auth.handler(
|
|
2405
3953
|
new Request(
|
|
2406
|
-
"http://localhost:3000/api/auth/sso/saml2/
|
|
3954
|
+
"http://localhost:3000/api/auth/sso/saml2/sp/acs/multi-assertion-acs-provider",
|
|
2407
3955
|
{
|
|
2408
3956
|
method: "POST",
|
|
2409
3957
|
headers: {
|
|
2410
3958
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
2411
3959
|
},
|
|
2412
3960
|
body: new URLSearchParams({
|
|
2413
|
-
SAMLResponse:
|
|
3961
|
+
SAMLResponse: encodedResponse,
|
|
2414
3962
|
RelayState: "http://localhost:3000/dashboard",
|
|
2415
3963
|
}),
|
|
2416
3964
|
},
|
|
2417
3965
|
),
|
|
2418
3966
|
);
|
|
2419
3967
|
|
|
2420
|
-
expect(
|
|
2421
|
-
const
|
|
2422
|
-
expect(
|
|
3968
|
+
expect(response.status).toBe(302);
|
|
3969
|
+
const location = response.headers.get("location") || "";
|
|
3970
|
+
expect(location).toContain("error=multiple_assertions");
|
|
3971
|
+
});
|
|
2423
3972
|
|
|
2424
|
-
|
|
2425
|
-
const
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
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,
|
|
2432
3994
|
},
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
3995
|
+
spMetadata: {
|
|
3996
|
+
metadata: spMetadata,
|
|
3997
|
+
},
|
|
3998
|
+
identifierFormat:
|
|
3999
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
2437
4000
|
},
|
|
2438
|
-
|
|
2439
|
-
|
|
4001
|
+
},
|
|
4002
|
+
headers,
|
|
4003
|
+
});
|
|
2440
4004
|
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
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
|
+
});
|
|
2444
4031
|
});
|
|
2445
4032
|
|
|
2446
|
-
it("should reject
|
|
4033
|
+
it("should reject SAML response with XSW-style assertion injection in Extensions", async () => {
|
|
2447
4034
|
const { auth, signInWithTestUser } = await getTestInstance({
|
|
2448
4035
|
plugins: [sso()],
|
|
2449
4036
|
});
|
|
@@ -2452,7 +4039,7 @@ describe("SAML SSO - Assertion Replay Protection", () => {
|
|
|
2452
4039
|
|
|
2453
4040
|
await auth.api.registerSSOProvider({
|
|
2454
4041
|
body: {
|
|
2455
|
-
providerId: "
|
|
4042
|
+
providerId: "xsw-injection-provider",
|
|
2456
4043
|
issuer: "http://localhost:8081",
|
|
2457
4044
|
domain: "http://localhost:8081",
|
|
2458
4045
|
samlConfig: {
|
|
@@ -2475,38 +4062,91 @@ describe("SAML SSO - Assertion Replay Protection", () => {
|
|
|
2475
4062
|
headers,
|
|
2476
4063
|
});
|
|
2477
4064
|
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
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",
|
|
2482
4104
|
},
|
|
2483
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
|
+
});
|
|
2484
4112
|
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
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,
|
|
2493
4129
|
},
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
4130
|
+
spMetadata: {
|
|
4131
|
+
metadata: spMetadata,
|
|
4132
|
+
},
|
|
4133
|
+
identifierFormat:
|
|
4134
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
2498
4135
|
},
|
|
2499
|
-
|
|
2500
|
-
|
|
4136
|
+
},
|
|
4137
|
+
headers,
|
|
4138
|
+
});
|
|
2501
4139
|
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
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
|
+
});
|
|
2505
4146
|
|
|
2506
|
-
|
|
2507
|
-
const replayResponse = await auth.handler(
|
|
4147
|
+
const response = await auth.handler(
|
|
2508
4148
|
new Request(
|
|
2509
|
-
"http://localhost:3000/api/auth/sso/saml2/
|
|
4149
|
+
"http://localhost:3000/api/auth/sso/saml2/callback/single-assertion-provider",
|
|
2510
4150
|
{
|
|
2511
4151
|
method: "POST",
|
|
2512
4152
|
headers: {
|
|
@@ -2520,13 +4160,12 @@ describe("SAML SSO - Assertion Replay Protection", () => {
|
|
|
2520
4160
|
),
|
|
2521
4161
|
);
|
|
2522
4162
|
|
|
2523
|
-
expect(
|
|
2524
|
-
|
|
2525
|
-
expect(replayLocation).toContain("error=replay_detected");
|
|
4163
|
+
expect(response.status).toBe(302);
|
|
4164
|
+
expect(response.headers.get("location")).not.toContain("error");
|
|
2526
4165
|
});
|
|
2527
4166
|
|
|
2528
|
-
it("should
|
|
2529
|
-
const { auth, signInWithTestUser } = await getTestInstance({
|
|
4167
|
+
it("should normalize email to lowercase in SAML authentication to prevent duplicate creation", async () => {
|
|
4168
|
+
const { auth, client, signInWithTestUser, db } = await getTestInstance({
|
|
2530
4169
|
plugins: [sso()],
|
|
2531
4170
|
});
|
|
2532
4171
|
|
|
@@ -2534,9 +4173,9 @@ describe("SAML SSO - Assertion Replay Protection", () => {
|
|
|
2534
4173
|
|
|
2535
4174
|
await auth.api.registerSSOProvider({
|
|
2536
4175
|
body: {
|
|
2537
|
-
providerId: "
|
|
4176
|
+
providerId: "email-case-provider",
|
|
2538
4177
|
issuer: "http://localhost:8081",
|
|
2539
|
-
domain: "
|
|
4178
|
+
domain: "example.com",
|
|
2540
4179
|
samlConfig: {
|
|
2541
4180
|
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
|
|
2542
4181
|
cert: certificate,
|
|
@@ -2552,57 +4191,129 @@ describe("SAML SSO - Assertion Replay Protection", () => {
|
|
|
2552
4191
|
},
|
|
2553
4192
|
identifierFormat:
|
|
2554
4193
|
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
4194
|
+
mapping: {
|
|
4195
|
+
id: "nameID",
|
|
4196
|
+
email: "nameID",
|
|
4197
|
+
name: "displayName",
|
|
4198
|
+
},
|
|
2555
4199
|
},
|
|
2556
4200
|
},
|
|
2557
4201
|
headers,
|
|
2558
4202
|
});
|
|
2559
4203
|
|
|
2560
|
-
let samlResponse:
|
|
2561
|
-
await betterFetch(
|
|
2562
|
-
|
|
2563
|
-
|
|
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
|
+
},
|
|
2564
4211
|
},
|
|
2565
|
-
|
|
4212
|
+
);
|
|
2566
4213
|
|
|
2567
|
-
|
|
2568
|
-
|
|
4214
|
+
expect(samlResponse1?.samlResponse).toBeDefined();
|
|
4215
|
+
|
|
4216
|
+
const firstCallbackResponse = await auth.handler(
|
|
2569
4217
|
new Request(
|
|
2570
|
-
"http://localhost:3000/api/auth/sso/saml2/callback/
|
|
4218
|
+
"http://localhost:3000/api/auth/sso/saml2/callback/email-case-provider",
|
|
2571
4219
|
{
|
|
2572
4220
|
method: "POST",
|
|
2573
4221
|
headers: {
|
|
2574
4222
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
2575
4223
|
},
|
|
2576
4224
|
body: new URLSearchParams({
|
|
2577
|
-
SAMLResponse: samlResponse
|
|
4225
|
+
SAMLResponse: samlResponse1!.samlResponse,
|
|
2578
4226
|
RelayState: "http://localhost:3000/dashboard",
|
|
2579
4227
|
}),
|
|
2580
4228
|
},
|
|
2581
4229
|
),
|
|
2582
4230
|
);
|
|
2583
4231
|
|
|
2584
|
-
expect(
|
|
2585
|
-
expect(
|
|
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
|
+
);
|
|
2586
4239
|
|
|
2587
|
-
|
|
2588
|
-
|
|
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(
|
|
2589
4271
|
new Request(
|
|
2590
|
-
"http://localhost:3000/api/auth/sso/saml2/
|
|
4272
|
+
"http://localhost:3000/api/auth/sso/saml2/callback/email-case-provider",
|
|
2591
4273
|
{
|
|
2592
4274
|
method: "POST",
|
|
2593
4275
|
headers: {
|
|
2594
4276
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
2595
4277
|
},
|
|
2596
4278
|
body: new URLSearchParams({
|
|
2597
|
-
SAMLResponse: samlResponse
|
|
4279
|
+
SAMLResponse: samlResponse2!.samlResponse,
|
|
2598
4280
|
RelayState: "http://localhost:3000/dashboard",
|
|
2599
4281
|
}),
|
|
2600
4282
|
},
|
|
2601
4283
|
),
|
|
2602
4284
|
);
|
|
2603
4285
|
|
|
2604
|
-
expect(
|
|
2605
|
-
|
|
2606
|
-
|
|
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);
|
|
2607
4318
|
});
|
|
2608
4319
|
});
|