@better-auth/sso 1.4.17 → 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-XUgmj4eH.d.mts → index-CBBJTszO.d.mts} +395 -16
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1130 -598
- package/dist/index.mjs.map +1 -0
- package/package.json +16 -14
- package/src/client.ts +5 -1
- package/src/index.ts +49 -6
- package/src/linking/org-assignment.test.ts +1 -1
- package/src/linking/org-assignment.ts +20 -13
- package/src/oidc.test.ts +112 -0
- 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 +220 -50
- package/src/saml-state.ts +78 -0
- package/src/saml.test.ts +1588 -233
- package/src/types.ts +8 -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
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type { createServer } from "node:http";
|
|
3
|
-
import { base64 } from "@better-auth/utils/base64";
|
|
4
3
|
import { betterFetch } from "@better-fetch/fetch";
|
|
5
4
|
import { betterAuth } from "better-auth";
|
|
6
5
|
import { memoryAdapter } from "better-auth/adapters/memory";
|
|
6
|
+
import { APIError } from "better-auth/api";
|
|
7
7
|
import { createAuthClient } from "better-auth/client";
|
|
8
|
-
import { setCookieToHeader } from "better-auth/cookies";
|
|
8
|
+
import { parseSetCookieHeader, setCookieToHeader } from "better-auth/cookies";
|
|
9
9
|
import { bearer } from "better-auth/plugins";
|
|
10
10
|
import { getTestInstance } from "better-auth/test";
|
|
11
11
|
import bodyParser from "body-parser";
|
|
@@ -399,7 +399,14 @@ const createMockSAMLIdP = (port: number) => {
|
|
|
399
399
|
app.get(
|
|
400
400
|
"/api/sso/saml2/idp/post",
|
|
401
401
|
async (req: ExpressRequest, res: ExpressResponse) => {
|
|
402
|
-
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
|
+
};
|
|
403
410
|
const { context, entityEndpoint } = await idp.createLoginResponse(
|
|
404
411
|
sp,
|
|
405
412
|
{} as any,
|
|
@@ -413,7 +420,14 @@ const createMockSAMLIdP = (port: number) => {
|
|
|
413
420
|
app.get(
|
|
414
421
|
"/api/sso/saml2/idp/redirect",
|
|
415
422
|
async (req: ExpressRequest, res: ExpressResponse) => {
|
|
416
|
-
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
|
+
};
|
|
417
431
|
const { context, entityEndpoint } = await idp.createLoginResponse(
|
|
418
432
|
sp,
|
|
419
433
|
{} as any,
|
|
@@ -574,6 +588,155 @@ describe("SAML SSO with defaultSSO array", async () => {
|
|
|
574
588
|
});
|
|
575
589
|
});
|
|
576
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
|
+
|
|
577
740
|
describe("SAML SSO", async () => {
|
|
578
741
|
const data = {
|
|
579
742
|
user: [],
|
|
@@ -1100,18 +1263,15 @@ describe("SAML SSO", async () => {
|
|
|
1100
1263
|
});
|
|
1101
1264
|
});
|
|
1102
1265
|
|
|
1103
|
-
it("should
|
|
1104
|
-
const { auth
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
});
|
|
1266
|
+
it("should initiate SAML login and validate RelayState", async () => {
|
|
1267
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
1268
|
+
plugins: [sso()],
|
|
1269
|
+
});
|
|
1108
1270
|
|
|
1109
1271
|
const { headers } = await signInWithTestUser();
|
|
1110
|
-
|
|
1111
|
-
// Register SAML provider
|
|
1112
|
-
await authWithDisabledSignUp.api.registerSSOProvider({
|
|
1272
|
+
await auth.api.registerSSOProvider({
|
|
1113
1273
|
body: {
|
|
1114
|
-
providerId: "saml-
|
|
1274
|
+
providerId: "saml-provider-1",
|
|
1115
1275
|
issuer: "http://localhost:8081",
|
|
1116
1276
|
domain: "http://localhost:8081",
|
|
1117
1277
|
samlConfig: {
|
|
@@ -1131,50 +1291,58 @@ describe("SAML SSO", async () => {
|
|
|
1131
1291
|
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
1132
1292
|
},
|
|
1133
1293
|
},
|
|
1134
|
-
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,
|
|
1135
1309
|
});
|
|
1136
1310
|
|
|
1137
|
-
// Identity Provider-initiated: Get SAML response directly from IdP
|
|
1138
|
-
// The mock IdP will return test@email.com, which doesn't exist in the DB
|
|
1139
1311
|
let samlResponse: any;
|
|
1140
|
-
await betterFetch(
|
|
1312
|
+
await betterFetch(signInResponse?.url, {
|
|
1141
1313
|
onSuccess: async (context) => {
|
|
1142
1314
|
samlResponse = await context.data;
|
|
1143
1315
|
},
|
|
1144
1316
|
});
|
|
1145
1317
|
|
|
1146
|
-
const
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
);
|
|
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
|
+
});
|
|
1161
1333
|
|
|
1162
|
-
expect(
|
|
1163
|
-
const redirectLocation = response.headers.get("location") || "";
|
|
1164
|
-
expect(redirectLocation).toContain("error=signup_disabled");
|
|
1334
|
+
expect(callbackResponse.headers.get("location")).toContain("dashboard");
|
|
1165
1335
|
});
|
|
1166
1336
|
|
|
1167
|
-
it("should
|
|
1168
|
-
const { auth
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
});
|
|
1337
|
+
it("should initiate SAML login and fallback to callbackUrl on invalid RelayState", async () => {
|
|
1338
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
1339
|
+
plugins: [sso()],
|
|
1340
|
+
});
|
|
1172
1341
|
|
|
1173
1342
|
const { headers } = await signInWithTestUser();
|
|
1174
|
-
|
|
1175
|
-
await authWithDisabledSignUp.api.registerSSOProvider({
|
|
1343
|
+
await auth.api.registerSSOProvider({
|
|
1176
1344
|
body: {
|
|
1177
|
-
providerId: "saml-
|
|
1345
|
+
providerId: "saml-provider-1",
|
|
1178
1346
|
issuer: "http://localhost:8081",
|
|
1179
1347
|
domain: "http://localhost:8081",
|
|
1180
1348
|
samlConfig: {
|
|
@@ -1194,53 +1362,60 @@ describe("SAML SSO", async () => {
|
|
|
1194
1362
|
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
1195
1363
|
},
|
|
1196
1364
|
},
|
|
1197
|
-
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,
|
|
1198
1380
|
});
|
|
1199
1381
|
|
|
1200
1382
|
let samlResponse: any;
|
|
1201
|
-
await betterFetch(
|
|
1383
|
+
await betterFetch(signInResponse?.url, {
|
|
1202
1384
|
onSuccess: async (context) => {
|
|
1203
1385
|
samlResponse = await context.data;
|
|
1204
1386
|
},
|
|
1205
1387
|
});
|
|
1206
1388
|
|
|
1207
|
-
const
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
);
|
|
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
|
+
});
|
|
1222
1403
|
|
|
1223
|
-
expect(
|
|
1224
|
-
|
|
1225
|
-
|
|
1404
|
+
expect(callbackResponse.status).toBe(302);
|
|
1405
|
+
expect(callbackResponse.headers.get("location")).toBe(
|
|
1406
|
+
"http://localhost:3000/dashboard",
|
|
1407
|
+
);
|
|
1226
1408
|
});
|
|
1227
1409
|
|
|
1228
|
-
it("should
|
|
1229
|
-
const { auth
|
|
1230
|
-
|
|
1231
|
-
accountLinking: {
|
|
1232
|
-
enabled: true,
|
|
1233
|
-
trustedProviders: [],
|
|
1234
|
-
},
|
|
1235
|
-
},
|
|
1236
|
-
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 })],
|
|
1237
1413
|
});
|
|
1238
1414
|
|
|
1239
1415
|
const { headers } = await signInWithTestUser();
|
|
1240
|
-
|
|
1241
|
-
await authUntrusted.api.registerSSOProvider({
|
|
1416
|
+
await auth.api.registerSSOProvider({
|
|
1242
1417
|
body: {
|
|
1243
|
-
providerId: "
|
|
1418
|
+
providerId: "saml-provider-1",
|
|
1244
1419
|
issuer: "http://localhost:8081",
|
|
1245
1420
|
domain: "http://localhost:8081",
|
|
1246
1421
|
samlConfig: {
|
|
@@ -1263,14 +1438,218 @@ describe("SAML SSO", async () => {
|
|
|
1263
1438
|
headers,
|
|
1264
1439
|
});
|
|
1265
1440
|
|
|
1266
|
-
const
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
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,
|
|
1274
1653
|
createdAt: new Date(),
|
|
1275
1654
|
updatedAt: new Date(),
|
|
1276
1655
|
},
|
|
@@ -1293,7 +1672,6 @@ describe("SAML SSO", async () => {
|
|
|
1293
1672
|
},
|
|
1294
1673
|
body: new URLSearchParams({
|
|
1295
1674
|
SAMLResponse: samlResponse.samlResponse,
|
|
1296
|
-
RelayState: "http://localhost:3000/dashboard",
|
|
1297
1675
|
}),
|
|
1298
1676
|
},
|
|
1299
1677
|
),
|
|
@@ -1374,7 +1752,6 @@ describe("SAML SSO", async () => {
|
|
|
1374
1752
|
},
|
|
1375
1753
|
body: new URLSearchParams({
|
|
1376
1754
|
SAMLResponse: samlResponse.samlResponse,
|
|
1377
|
-
RelayState: "http://localhost:3000/dashboard",
|
|
1378
1755
|
}),
|
|
1379
1756
|
},
|
|
1380
1757
|
),
|
|
@@ -1442,7 +1819,6 @@ describe("SAML SSO", async () => {
|
|
|
1442
1819
|
},
|
|
1443
1820
|
body: new URLSearchParams({
|
|
1444
1821
|
SAMLResponse: samlResponse.samlResponse,
|
|
1445
|
-
RelayState: "http://localhost:3000/dashboard",
|
|
1446
1822
|
}),
|
|
1447
1823
|
},
|
|
1448
1824
|
),
|
|
@@ -1509,7 +1885,6 @@ describe("SAML SSO", async () => {
|
|
|
1509
1885
|
},
|
|
1510
1886
|
body: new URLSearchParams({
|
|
1511
1887
|
SAMLResponse: samlResponse.samlResponse,
|
|
1512
|
-
RelayState: "http://localhost:3000/dashboard",
|
|
1513
1888
|
}),
|
|
1514
1889
|
},
|
|
1515
1890
|
),
|
|
@@ -1569,7 +1944,6 @@ describe("SAML SSO", async () => {
|
|
|
1569
1944
|
},
|
|
1570
1945
|
body: new URLSearchParams({
|
|
1571
1946
|
SAMLResponse: samlResponse.samlResponse,
|
|
1572
|
-
RelayState: "http://localhost:3000/dashboard",
|
|
1573
1947
|
}),
|
|
1574
1948
|
},
|
|
1575
1949
|
),
|
|
@@ -1638,7 +2012,6 @@ describe("SAML SSO", async () => {
|
|
|
1638
2012
|
},
|
|
1639
2013
|
body: new URLSearchParams({
|
|
1640
2014
|
SAMLResponse: samlResponse.samlResponse,
|
|
1641
|
-
RelayState: "http://localhost:3000/dashboard",
|
|
1642
2015
|
}),
|
|
1643
2016
|
},
|
|
1644
2017
|
),
|
|
@@ -1978,8 +2351,8 @@ describe("SSO Provider Config Parsing", () => {
|
|
|
1978
2351
|
});
|
|
1979
2352
|
});
|
|
1980
2353
|
|
|
1981
|
-
describe("SAML SSO -
|
|
1982
|
-
it("should
|
|
2354
|
+
describe("SAML SSO - IdP Initiated Flow", () => {
|
|
2355
|
+
it("should handle IdP-initiated flow with GET after POST redirect", async () => {
|
|
1983
2356
|
const { auth, signInWithTestUser } = await getTestInstance({
|
|
1984
2357
|
plugins: [sso()],
|
|
1985
2358
|
});
|
|
@@ -1988,11 +2361,14 @@ describe("SAML SSO - Signature Validation Security", () => {
|
|
|
1988
2361
|
|
|
1989
2362
|
await auth.api.registerSSOProvider({
|
|
1990
2363
|
body: {
|
|
1991
|
-
providerId: "
|
|
2364
|
+
providerId: "idp-initiated-provider",
|
|
1992
2365
|
issuer: "http://localhost:8081",
|
|
1993
2366
|
domain: "http://localhost:8081",
|
|
1994
2367
|
samlConfig: {
|
|
1995
|
-
entryPoint:
|
|
2368
|
+
entryPoint: sharedMockIdP.metadataUrl.replace(
|
|
2369
|
+
"/idp/metadata",
|
|
2370
|
+
"/idp/post",
|
|
2371
|
+
),
|
|
1996
2372
|
cert: certificate,
|
|
1997
2373
|
callbackUrl: "http://localhost:3000/dashboard",
|
|
1998
2374
|
wantAssertionsSigned: false,
|
|
@@ -2011,64 +2387,110 @@ describe("SAML SSO - Signature Validation Security", () => {
|
|
|
2011
2387
|
headers,
|
|
2012
2388
|
});
|
|
2013
2389
|
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
<saml2:Conditions>
|
|
2026
|
-
<saml2:AudienceRestriction>
|
|
2027
|
-
<saml2:Audience>http://localhost:3001</saml2:Audience>
|
|
2028
|
-
</saml2:AudienceRestriction>
|
|
2029
|
-
</saml2:Conditions>
|
|
2030
|
-
<saml2:AuthnStatement>
|
|
2031
|
-
<saml2:AuthnContext>
|
|
2032
|
-
<saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml2:AuthnContextClassRef>
|
|
2033
|
-
</saml2:AuthnContext>
|
|
2034
|
-
</saml2:AuthnStatement>
|
|
2035
|
-
</saml2:Assertion>
|
|
2036
|
-
</saml2p:Response>
|
|
2037
|
-
`;
|
|
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
|
+
});
|
|
2038
2401
|
|
|
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,47 +2507,511 @@ 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
|
-
|
|
2522
|
+
if (!samlResponse?.samlResponse) {
|
|
2523
|
+
throw new Error("Failed to get SAML response from mock IdP");
|
|
2524
|
+
}
|
|
2115
2525
|
|
|
2116
|
-
await
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2526
|
+
const postResponse = await auth.api.callbackSSOSAML({
|
|
2527
|
+
method: "POST",
|
|
2528
|
+
body: {
|
|
2529
|
+
SAMLResponse: samlResponse.samlResponse,
|
|
2530
|
+
},
|
|
2531
|
+
params: {
|
|
2532
|
+
providerId: "loop-test-provider",
|
|
2533
|
+
},
|
|
2534
|
+
asResponse: true,
|
|
2535
|
+
});
|
|
2536
|
+
|
|
2537
|
+
expect(postResponse).toBeInstanceOf(Response);
|
|
2538
|
+
expect(postResponse.status).toBe(302);
|
|
2539
|
+
const redirectLocation = postResponse.headers.get("location");
|
|
2540
|
+
expect(redirectLocation).not.toBe(callbackRouteUrl);
|
|
2541
|
+
expect(redirectLocation).toBe("http://localhost:3000");
|
|
2542
|
+
});
|
|
2543
|
+
|
|
2544
|
+
it("should handle GET request with RelayState in query", async () => {
|
|
2545
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
2546
|
+
plugins: [sso()],
|
|
2547
|
+
});
|
|
2548
|
+
|
|
2549
|
+
const { headers } = await signInWithTestUser();
|
|
2550
|
+
|
|
2551
|
+
await auth.api.registerSSOProvider({
|
|
2552
|
+
body: {
|
|
2553
|
+
providerId: "relaystate-provider",
|
|
2554
|
+
issuer: "http://localhost:8081",
|
|
2555
|
+
domain: "http://localhost:8081",
|
|
2556
|
+
samlConfig: {
|
|
2557
|
+
entryPoint: sharedMockIdP.metadataUrl.replace(
|
|
2558
|
+
"/idp/metadata",
|
|
2559
|
+
"/idp/post",
|
|
2560
|
+
),
|
|
2561
|
+
cert: certificate,
|
|
2562
|
+
callbackUrl: "http://localhost:3000/dashboard",
|
|
2563
|
+
wantAssertionsSigned: false,
|
|
2564
|
+
signatureAlgorithm: "sha256",
|
|
2565
|
+
digestAlgorithm: "sha256",
|
|
2566
|
+
idpMetadata: {
|
|
2567
|
+
metadata: idpMetadata,
|
|
2568
|
+
},
|
|
2569
|
+
spMetadata: {
|
|
2570
|
+
metadata: spMetadata,
|
|
2571
|
+
},
|
|
2572
|
+
identifierFormat:
|
|
2573
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
2121
2574
|
},
|
|
2122
|
-
|
|
2123
|
-
|
|
2575
|
+
},
|
|
2576
|
+
headers,
|
|
2577
|
+
});
|
|
2578
|
+
|
|
2579
|
+
let samlResponse:
|
|
2580
|
+
| { samlResponse: string; entityEndpoint?: string }
|
|
2581
|
+
| undefined;
|
|
2582
|
+
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
|
|
2583
|
+
onSuccess: async (context) => {
|
|
2584
|
+
samlResponse = context.data as {
|
|
2585
|
+
samlResponse: string;
|
|
2586
|
+
entityEndpoint?: string;
|
|
2587
|
+
};
|
|
2588
|
+
},
|
|
2589
|
+
});
|
|
2590
|
+
|
|
2591
|
+
if (!samlResponse?.samlResponse) {
|
|
2592
|
+
throw new Error("Failed to get SAML response from mock IdP");
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
const postResponse = await auth.api.callbackSSOSAML({
|
|
2596
|
+
method: "POST",
|
|
2597
|
+
body: {
|
|
2598
|
+
SAMLResponse: samlResponse.samlResponse,
|
|
2599
|
+
RelayState: "http://localhost:3000/custom-path",
|
|
2600
|
+
},
|
|
2601
|
+
params: {
|
|
2602
|
+
providerId: "relaystate-provider",
|
|
2603
|
+
},
|
|
2604
|
+
asResponse: true,
|
|
2605
|
+
});
|
|
2606
|
+
|
|
2607
|
+
const cookieHeader = postResponse.headers.get("set-cookie");
|
|
2608
|
+
const getResponse = await auth.api.callbackSSOSAML({
|
|
2609
|
+
method: "GET",
|
|
2610
|
+
query: {
|
|
2611
|
+
RelayState: "http://localhost:3000/custom-path",
|
|
2612
|
+
},
|
|
2613
|
+
params: {
|
|
2614
|
+
providerId: "relaystate-provider",
|
|
2615
|
+
},
|
|
2616
|
+
headers: cookieHeader ? { cookie: cookieHeader } : undefined,
|
|
2617
|
+
asResponse: true,
|
|
2618
|
+
});
|
|
2619
|
+
|
|
2620
|
+
expect(getResponse).toBeInstanceOf(Response);
|
|
2621
|
+
expect(getResponse.status).toBe(302);
|
|
2622
|
+
const redirectLocation = getResponse.headers.get("location");
|
|
2623
|
+
expect(redirectLocation).toBe("http://localhost:3000/custom-path");
|
|
2624
|
+
});
|
|
2625
|
+
|
|
2626
|
+
it("should handle GET request when POST redirects to callback URL (original issue scenario)", async () => {
|
|
2627
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
2628
|
+
plugins: [sso()],
|
|
2629
|
+
});
|
|
2630
|
+
|
|
2631
|
+
const { headers } = await signInWithTestUser();
|
|
2632
|
+
|
|
2633
|
+
const callbackRouteUrl =
|
|
2634
|
+
"http://localhost:3000/api/auth/sso/saml2/callback/issue-6615-provider";
|
|
2635
|
+
|
|
2636
|
+
await auth.api.registerSSOProvider({
|
|
2637
|
+
body: {
|
|
2638
|
+
providerId: "issue-6615-provider",
|
|
2639
|
+
issuer: "http://localhost:8081",
|
|
2640
|
+
domain: "http://localhost:8081",
|
|
2641
|
+
samlConfig: {
|
|
2642
|
+
entryPoint: sharedMockIdP.metadataUrl.replace(
|
|
2643
|
+
"/idp/metadata",
|
|
2644
|
+
"/idp/post",
|
|
2645
|
+
),
|
|
2646
|
+
cert: certificate,
|
|
2647
|
+
callbackUrl: "http://localhost:3000/dashboard",
|
|
2648
|
+
wantAssertionsSigned: false,
|
|
2649
|
+
signatureAlgorithm: "sha256",
|
|
2650
|
+
digestAlgorithm: "sha256",
|
|
2651
|
+
idpMetadata: {
|
|
2652
|
+
metadata: idpMetadata,
|
|
2653
|
+
},
|
|
2654
|
+
spMetadata: {
|
|
2655
|
+
metadata: spMetadata,
|
|
2656
|
+
},
|
|
2657
|
+
identifierFormat:
|
|
2658
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
2124
2659
|
},
|
|
2125
|
-
}
|
|
2126
|
-
|
|
2127
|
-
|
|
2660
|
+
},
|
|
2661
|
+
headers,
|
|
2662
|
+
});
|
|
2663
|
+
|
|
2664
|
+
let samlResponse:
|
|
2665
|
+
| { samlResponse: string; entityEndpoint?: string }
|
|
2666
|
+
| undefined;
|
|
2667
|
+
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
|
|
2668
|
+
onSuccess: async (context) => {
|
|
2669
|
+
samlResponse = context.data as {
|
|
2670
|
+
samlResponse: string;
|
|
2671
|
+
entityEndpoint?: string;
|
|
2672
|
+
};
|
|
2673
|
+
},
|
|
2674
|
+
});
|
|
2675
|
+
|
|
2676
|
+
if (!samlResponse?.samlResponse) {
|
|
2677
|
+
throw new Error("Failed to get SAML response from mock IdP");
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
const postResponse = await auth.api.callbackSSOSAML({
|
|
2681
|
+
method: "POST",
|
|
2682
|
+
body: {
|
|
2683
|
+
SAMLResponse: samlResponse.samlResponse,
|
|
2684
|
+
RelayState: callbackRouteUrl,
|
|
2685
|
+
},
|
|
2686
|
+
params: {
|
|
2687
|
+
providerId: "issue-6615-provider",
|
|
2688
|
+
},
|
|
2689
|
+
asResponse: true,
|
|
2690
|
+
});
|
|
2691
|
+
|
|
2692
|
+
expect(postResponse).toBeInstanceOf(Response);
|
|
2693
|
+
expect(postResponse.status).toBe(302);
|
|
2694
|
+
const postRedirectLocation = postResponse.headers.get("location");
|
|
2695
|
+
expect(postRedirectLocation).not.toBe(callbackRouteUrl);
|
|
2696
|
+
expect(postRedirectLocation).toBe("http://localhost:3000/dashboard");
|
|
2697
|
+
|
|
2698
|
+
const cookieHeader = postResponse.headers.get("set-cookie");
|
|
2699
|
+
const getResponse = await auth.api.callbackSSOSAML({
|
|
2700
|
+
method: "GET",
|
|
2701
|
+
params: {
|
|
2702
|
+
providerId: "issue-6615-provider",
|
|
2703
|
+
},
|
|
2704
|
+
headers: cookieHeader ? { cookie: cookieHeader } : undefined,
|
|
2705
|
+
asResponse: true,
|
|
2706
|
+
});
|
|
2707
|
+
|
|
2708
|
+
expect(getResponse).toBeInstanceOf(Response);
|
|
2709
|
+
expect(getResponse.status).toBe(302);
|
|
2710
|
+
const getRedirectLocation = getResponse.headers.get("location");
|
|
2711
|
+
expect(getRedirectLocation).toBe("http://localhost:3000");
|
|
2712
|
+
});
|
|
2713
|
+
|
|
2714
|
+
it("should prevent open redirect with malicious RelayState URL", async () => {
|
|
2715
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
2716
|
+
plugins: [sso()],
|
|
2717
|
+
});
|
|
2718
|
+
|
|
2719
|
+
const { headers } = await signInWithTestUser();
|
|
2720
|
+
|
|
2721
|
+
await auth.api.registerSSOProvider({
|
|
2722
|
+
body: {
|
|
2723
|
+
providerId: "open-redirect-test-provider",
|
|
2724
|
+
issuer: "http://localhost:8081",
|
|
2725
|
+
domain: "http://localhost:8081",
|
|
2726
|
+
samlConfig: {
|
|
2727
|
+
entryPoint: sharedMockIdP.metadataUrl.replace(
|
|
2728
|
+
"/idp/metadata",
|
|
2729
|
+
"/idp/post",
|
|
2730
|
+
),
|
|
2731
|
+
cert: certificate,
|
|
2732
|
+
callbackUrl: "http://localhost:3000/dashboard",
|
|
2733
|
+
wantAssertionsSigned: false,
|
|
2734
|
+
signatureAlgorithm: "sha256",
|
|
2735
|
+
digestAlgorithm: "sha256",
|
|
2736
|
+
idpMetadata: {
|
|
2737
|
+
metadata: idpMetadata,
|
|
2738
|
+
},
|
|
2739
|
+
spMetadata: {
|
|
2740
|
+
metadata: spMetadata,
|
|
2741
|
+
},
|
|
2742
|
+
identifierFormat:
|
|
2743
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
2744
|
+
},
|
|
2745
|
+
},
|
|
2746
|
+
headers,
|
|
2747
|
+
});
|
|
2748
|
+
|
|
2749
|
+
let samlResponse:
|
|
2750
|
+
| { samlResponse: string; entityEndpoint?: string }
|
|
2751
|
+
| undefined;
|
|
2752
|
+
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
|
|
2753
|
+
onSuccess: async (context) => {
|
|
2754
|
+
samlResponse = context.data as {
|
|
2755
|
+
samlResponse: string;
|
|
2756
|
+
entityEndpoint?: string;
|
|
2757
|
+
};
|
|
2758
|
+
},
|
|
2759
|
+
});
|
|
2760
|
+
|
|
2761
|
+
if (!samlResponse?.samlResponse) {
|
|
2762
|
+
throw new Error("Failed to get SAML response from mock IdP");
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
// Test POST with malicious RelayState - raw RelayState is not trusted
|
|
2766
|
+
// Falls back to parsedSamlConfig.callbackUrl
|
|
2767
|
+
const postResponse = await auth.api.callbackSSOSAML({
|
|
2768
|
+
method: "POST",
|
|
2769
|
+
body: {
|
|
2770
|
+
SAMLResponse: samlResponse.samlResponse,
|
|
2771
|
+
RelayState: "https://evil.com/phishing",
|
|
2772
|
+
},
|
|
2773
|
+
params: {
|
|
2774
|
+
providerId: "open-redirect-test-provider",
|
|
2775
|
+
},
|
|
2776
|
+
asResponse: true,
|
|
2777
|
+
});
|
|
2778
|
+
|
|
2779
|
+
expect(postResponse).toBeInstanceOf(Response);
|
|
2780
|
+
expect(postResponse.status).toBe(302);
|
|
2781
|
+
const postRedirectLocation = postResponse.headers.get("location");
|
|
2782
|
+
// Should NOT redirect to evil.com - raw RelayState is ignored
|
|
2783
|
+
expect(postRedirectLocation).not.toContain("evil.com");
|
|
2784
|
+
// Falls back to samlConfig.callbackUrl
|
|
2785
|
+
expect(postRedirectLocation).toBe("http://localhost:3000/dashboard");
|
|
2786
|
+
});
|
|
2787
|
+
|
|
2788
|
+
it("should prevent open redirect via GET with malicious RelayState", async () => {
|
|
2789
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
2790
|
+
plugins: [sso()],
|
|
2791
|
+
});
|
|
2792
|
+
|
|
2793
|
+
const { headers } = await signInWithTestUser();
|
|
2794
|
+
|
|
2795
|
+
await auth.api.registerSSOProvider({
|
|
2796
|
+
body: {
|
|
2797
|
+
providerId: "open-redirect-get-provider",
|
|
2798
|
+
issuer: "http://localhost:8081",
|
|
2799
|
+
domain: "http://localhost:8081",
|
|
2800
|
+
samlConfig: {
|
|
2801
|
+
entryPoint: sharedMockIdP.metadataUrl.replace(
|
|
2802
|
+
"/idp/metadata",
|
|
2803
|
+
"/idp/post",
|
|
2804
|
+
),
|
|
2805
|
+
cert: certificate,
|
|
2806
|
+
callbackUrl: "http://localhost:3000/dashboard",
|
|
2807
|
+
wantAssertionsSigned: false,
|
|
2808
|
+
signatureAlgorithm: "sha256",
|
|
2809
|
+
digestAlgorithm: "sha256",
|
|
2810
|
+
idpMetadata: {
|
|
2811
|
+
metadata: idpMetadata,
|
|
2812
|
+
},
|
|
2813
|
+
spMetadata: {
|
|
2814
|
+
metadata: spMetadata,
|
|
2815
|
+
},
|
|
2816
|
+
identifierFormat:
|
|
2817
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
2818
|
+
},
|
|
2819
|
+
},
|
|
2820
|
+
headers,
|
|
2128
2821
|
});
|
|
2822
|
+
|
|
2823
|
+
let samlResponse:
|
|
2824
|
+
| { samlResponse: string; entityEndpoint?: string }
|
|
2825
|
+
| undefined;
|
|
2826
|
+
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
|
|
2827
|
+
onSuccess: async (context) => {
|
|
2828
|
+
samlResponse = context.data as {
|
|
2829
|
+
samlResponse: string;
|
|
2830
|
+
entityEndpoint?: string;
|
|
2831
|
+
};
|
|
2832
|
+
},
|
|
2833
|
+
});
|
|
2834
|
+
|
|
2835
|
+
if (!samlResponse?.samlResponse) {
|
|
2836
|
+
throw new Error("Failed to get SAML response from mock IdP");
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
// First do POST to establish session
|
|
2840
|
+
const postResponse = await auth.api.callbackSSOSAML({
|
|
2841
|
+
method: "POST",
|
|
2842
|
+
body: {
|
|
2843
|
+
SAMLResponse: samlResponse.samlResponse,
|
|
2844
|
+
},
|
|
2845
|
+
params: {
|
|
2846
|
+
providerId: "open-redirect-get-provider",
|
|
2847
|
+
},
|
|
2848
|
+
asResponse: true,
|
|
2849
|
+
});
|
|
2850
|
+
|
|
2851
|
+
const cookieHeader = postResponse.headers.get("set-cookie");
|
|
2852
|
+
|
|
2853
|
+
// Test GET with malicious RelayState in query params
|
|
2854
|
+
const getResponse = await auth.api.callbackSSOSAML({
|
|
2855
|
+
method: "GET",
|
|
2856
|
+
query: {
|
|
2857
|
+
RelayState: "https://evil.com/steal-cookies",
|
|
2858
|
+
},
|
|
2859
|
+
params: {
|
|
2860
|
+
providerId: "open-redirect-get-provider",
|
|
2861
|
+
},
|
|
2862
|
+
headers: cookieHeader ? { cookie: cookieHeader } : undefined,
|
|
2863
|
+
asResponse: true,
|
|
2864
|
+
});
|
|
2865
|
+
|
|
2866
|
+
expect(getResponse).toBeInstanceOf(Response);
|
|
2867
|
+
expect(getResponse.status).toBe(302);
|
|
2868
|
+
const getRedirectLocation = getResponse.headers.get("location");
|
|
2869
|
+
// Should NOT redirect to evil.com
|
|
2870
|
+
expect(getRedirectLocation).not.toContain("evil.com");
|
|
2871
|
+
expect(getRedirectLocation).toBe("http://localhost:3000");
|
|
2872
|
+
});
|
|
2873
|
+
|
|
2874
|
+
it("should allow relative path redirects", async () => {
|
|
2875
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
2876
|
+
plugins: [sso()],
|
|
2877
|
+
});
|
|
2878
|
+
|
|
2879
|
+
const { headers } = await signInWithTestUser();
|
|
2880
|
+
|
|
2881
|
+
await auth.api.registerSSOProvider({
|
|
2882
|
+
body: {
|
|
2883
|
+
providerId: "relative-path-provider",
|
|
2884
|
+
issuer: "http://localhost:8081",
|
|
2885
|
+
domain: "http://localhost:8081",
|
|
2886
|
+
samlConfig: {
|
|
2887
|
+
entryPoint: sharedMockIdP.metadataUrl.replace(
|
|
2888
|
+
"/idp/metadata",
|
|
2889
|
+
"/idp/post",
|
|
2890
|
+
),
|
|
2891
|
+
cert: certificate,
|
|
2892
|
+
callbackUrl: "http://localhost:3000/dashboard",
|
|
2893
|
+
wantAssertionsSigned: false,
|
|
2894
|
+
signatureAlgorithm: "sha256",
|
|
2895
|
+
digestAlgorithm: "sha256",
|
|
2896
|
+
idpMetadata: {
|
|
2897
|
+
metadata: idpMetadata,
|
|
2898
|
+
},
|
|
2899
|
+
spMetadata: {
|
|
2900
|
+
metadata: spMetadata,
|
|
2901
|
+
},
|
|
2902
|
+
identifierFormat:
|
|
2903
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
2904
|
+
},
|
|
2905
|
+
},
|
|
2906
|
+
headers,
|
|
2907
|
+
});
|
|
2908
|
+
|
|
2909
|
+
let samlResponse:
|
|
2910
|
+
| { samlResponse: string; entityEndpoint?: string }
|
|
2911
|
+
| undefined;
|
|
2912
|
+
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
|
|
2913
|
+
onSuccess: async (context) => {
|
|
2914
|
+
samlResponse = context.data as {
|
|
2915
|
+
samlResponse: string;
|
|
2916
|
+
entityEndpoint?: string;
|
|
2917
|
+
};
|
|
2918
|
+
},
|
|
2919
|
+
});
|
|
2920
|
+
|
|
2921
|
+
if (!samlResponse?.samlResponse) {
|
|
2922
|
+
throw new Error("Failed to get SAML response from mock IdP");
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2925
|
+
const postResponse = await auth.api.callbackSSOSAML({
|
|
2926
|
+
method: "POST",
|
|
2927
|
+
body: {
|
|
2928
|
+
SAMLResponse: samlResponse.samlResponse,
|
|
2929
|
+
RelayState: "/dashboard/settings",
|
|
2930
|
+
},
|
|
2931
|
+
params: {
|
|
2932
|
+
providerId: "relative-path-provider",
|
|
2933
|
+
},
|
|
2934
|
+
asResponse: true,
|
|
2935
|
+
});
|
|
2936
|
+
|
|
2937
|
+
expect(postResponse).toBeInstanceOf(Response);
|
|
2938
|
+
expect(postResponse.status).toBe(302);
|
|
2939
|
+
const redirectLocation = postResponse.headers.get("location");
|
|
2940
|
+
expect(redirectLocation).toBe("http://localhost:3000/dashboard");
|
|
2941
|
+
});
|
|
2942
|
+
|
|
2943
|
+
it("should block protocol-relative URL attacks (//evil.com)", async () => {
|
|
2944
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
2945
|
+
plugins: [sso()],
|
|
2946
|
+
});
|
|
2947
|
+
|
|
2948
|
+
const { headers } = await signInWithTestUser();
|
|
2949
|
+
|
|
2950
|
+
await auth.api.registerSSOProvider({
|
|
2951
|
+
body: {
|
|
2952
|
+
providerId: "protocol-relative-provider",
|
|
2953
|
+
issuer: "http://localhost:8081",
|
|
2954
|
+
domain: "http://localhost:8081",
|
|
2955
|
+
samlConfig: {
|
|
2956
|
+
entryPoint: sharedMockIdP.metadataUrl.replace(
|
|
2957
|
+
"/idp/metadata",
|
|
2958
|
+
"/idp/post",
|
|
2959
|
+
),
|
|
2960
|
+
cert: certificate,
|
|
2961
|
+
callbackUrl: "http://localhost:3000/dashboard",
|
|
2962
|
+
wantAssertionsSigned: false,
|
|
2963
|
+
signatureAlgorithm: "sha256",
|
|
2964
|
+
digestAlgorithm: "sha256",
|
|
2965
|
+
idpMetadata: {
|
|
2966
|
+
metadata: idpMetadata,
|
|
2967
|
+
},
|
|
2968
|
+
spMetadata: {
|
|
2969
|
+
metadata: spMetadata,
|
|
2970
|
+
},
|
|
2971
|
+
identifierFormat:
|
|
2972
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
2973
|
+
},
|
|
2974
|
+
},
|
|
2975
|
+
headers,
|
|
2976
|
+
});
|
|
2977
|
+
|
|
2978
|
+
let samlResponse:
|
|
2979
|
+
| { samlResponse: string; entityEndpoint?: string }
|
|
2980
|
+
| undefined;
|
|
2981
|
+
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
|
|
2982
|
+
onSuccess: async (context) => {
|
|
2983
|
+
samlResponse = context.data as {
|
|
2984
|
+
samlResponse: string;
|
|
2985
|
+
entityEndpoint?: string;
|
|
2986
|
+
};
|
|
2987
|
+
},
|
|
2988
|
+
});
|
|
2989
|
+
|
|
2990
|
+
if (!samlResponse?.samlResponse) {
|
|
2991
|
+
throw new Error("Failed to get SAML response from mock IdP");
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
// Test POST with protocol-relative URL - raw RelayState is not trusted
|
|
2995
|
+
// Falls back to parsedSamlConfig.callbackUrl
|
|
2996
|
+
const postResponse = await auth.api.callbackSSOSAML({
|
|
2997
|
+
method: "POST",
|
|
2998
|
+
body: {
|
|
2999
|
+
SAMLResponse: samlResponse.samlResponse,
|
|
3000
|
+
RelayState: "//evil.com/phishing",
|
|
3001
|
+
},
|
|
3002
|
+
params: {
|
|
3003
|
+
providerId: "protocol-relative-provider",
|
|
3004
|
+
},
|
|
3005
|
+
asResponse: true,
|
|
3006
|
+
});
|
|
3007
|
+
|
|
3008
|
+
expect(postResponse).toBeInstanceOf(Response);
|
|
3009
|
+
expect(postResponse.status).toBe(302);
|
|
3010
|
+
const redirectLocation = postResponse.headers.get("location");
|
|
3011
|
+
// Should NOT redirect to evil.com - raw RelayState is ignored
|
|
3012
|
+
expect(redirectLocation).not.toContain("evil.com");
|
|
3013
|
+
// Falls back to samlConfig.callbackUrl
|
|
3014
|
+
expect(redirectLocation).toBe("http://localhost:3000/dashboard");
|
|
2129
3015
|
});
|
|
2130
3016
|
});
|
|
2131
3017
|
|
|
@@ -2278,83 +3164,405 @@ describe("SAML SSO - Timestamp Validation", () => {
|
|
|
2278
3164
|
expect(() => validateSAMLTimestamp({ notBefore: now })).not.toThrow();
|
|
2279
3165
|
});
|
|
2280
3166
|
|
|
2281
|
-
it("should accept assertions with only NotOnOrAfter (valid, in future)", () => {
|
|
2282
|
-
const future = new Date(Date.now() + 10 * 60 * 1000).toISOString();
|
|
2283
|
-
expect(() =>
|
|
2284
|
-
validateSAMLTimestamp({ notOnOrAfter: future }),
|
|
2285
|
-
).not.toThrow();
|
|
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);
|
|
2286
3383
|
});
|
|
2287
3384
|
});
|
|
2288
3385
|
|
|
2289
|
-
describe("
|
|
2290
|
-
it("should
|
|
2291
|
-
const
|
|
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
|
+
});
|
|
2292
3391
|
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
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
|
+
);
|
|
2299
3401
|
|
|
2300
|
-
expect(()
|
|
2301
|
-
validateSAMLTimestamp(
|
|
2302
|
-
{ notOnOrAfter: twoSecondsAgo },
|
|
2303
|
-
{ clockSkew: 5 * 60 * 1000 },
|
|
2304
|
-
),
|
|
2305
|
-
).not.toThrow();
|
|
3402
|
+
expect(metadataRes.status).not.toBe(403);
|
|
2306
3403
|
});
|
|
2307
3404
|
|
|
2308
|
-
it("should
|
|
2309
|
-
const
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
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();
|
|
2313
3410
|
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
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
|
+
}
|
|
2318
3456
|
});
|
|
2319
3457
|
});
|
|
3458
|
+
});
|
|
2320
3459
|
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
).toThrow("SAML assertion has invalid NotBefore timestamp");
|
|
3460
|
+
describe("SAML Response Security", () => {
|
|
3461
|
+
it("should reject forged/unsigned SAML responses", async () => {
|
|
3462
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
3463
|
+
plugins: [sso()],
|
|
2326
3464
|
});
|
|
3465
|
+
const { headers } = await signInWithTestUser();
|
|
2327
3466
|
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
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,
|
|
2332
3485
|
});
|
|
2333
3486
|
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
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
|
+
`;
|
|
2338
3496
|
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
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()],
|
|
2346
3521
|
});
|
|
3522
|
+
const { headers } = await signInWithTestUser();
|
|
2347
3523
|
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
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,
|
|
2357
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);
|
|
2358
3566
|
});
|
|
2359
3567
|
});
|
|
2360
3568
|
|
|
@@ -2408,7 +3616,6 @@ describe("SAML SSO - Assertion Replay Protection", () => {
|
|
|
2408
3616
|
},
|
|
2409
3617
|
});
|
|
2410
3618
|
|
|
2411
|
-
// First submission should succeed
|
|
2412
3619
|
const firstResponse = await auth.handler(
|
|
2413
3620
|
new Request(
|
|
2414
3621
|
"http://localhost:3000/api/auth/sso/saml2/callback/replay-test-provider",
|
|
@@ -2429,7 +3636,6 @@ describe("SAML SSO - Assertion Replay Protection", () => {
|
|
|
2429
3636
|
const firstLocation = firstResponse.headers.get("location") || "";
|
|
2430
3637
|
expect(firstLocation).not.toContain("error");
|
|
2431
3638
|
|
|
2432
|
-
// Second submission (replay) should be rejected
|
|
2433
3639
|
const replayResponse = await auth.handler(
|
|
2434
3640
|
new Request(
|
|
2435
3641
|
"http://localhost:3000/api/auth/sso/saml2/callback/replay-test-provider",
|
|
@@ -2490,7 +3696,6 @@ describe("SAML SSO - Assertion Replay Protection", () => {
|
|
|
2490
3696
|
},
|
|
2491
3697
|
});
|
|
2492
3698
|
|
|
2493
|
-
// First submission to ACS endpoint should succeed
|
|
2494
3699
|
const firstResponse = await auth.handler(
|
|
2495
3700
|
new Request(
|
|
2496
3701
|
"http://localhost:3000/api/auth/sso/saml2/sp/acs/acs-replay-test-provider",
|
|
@@ -2511,7 +3716,6 @@ describe("SAML SSO - Assertion Replay Protection", () => {
|
|
|
2511
3716
|
const firstLocation = firstResponse.headers.get("location") || "";
|
|
2512
3717
|
expect(firstLocation).not.toContain("error");
|
|
2513
3718
|
|
|
2514
|
-
// Second submission (replay) to ACS endpoint should be rejected
|
|
2515
3719
|
const replayResponse = await auth.handler(
|
|
2516
3720
|
new Request(
|
|
2517
3721
|
"http://localhost:3000/api/auth/sso/saml2/sp/acs/acs-replay-test-provider",
|
|
@@ -2572,7 +3776,6 @@ describe("SAML SSO - Assertion Replay Protection", () => {
|
|
|
2572
3776
|
},
|
|
2573
3777
|
});
|
|
2574
3778
|
|
|
2575
|
-
// First: Submit to callback endpoint (should succeed)
|
|
2576
3779
|
const callbackResponse = await auth.handler(
|
|
2577
3780
|
new Request(
|
|
2578
3781
|
"http://localhost:3000/api/auth/sso/saml2/callback/cross-endpoint-provider",
|
|
@@ -2592,7 +3795,6 @@ describe("SAML SSO - Assertion Replay Protection", () => {
|
|
|
2592
3795
|
expect(callbackResponse.status).toBe(302);
|
|
2593
3796
|
expect(callbackResponse.headers.get("location")).not.toContain("error");
|
|
2594
3797
|
|
|
2595
|
-
// Second: Replay same assertion to ACS endpoint (should be rejected)
|
|
2596
3798
|
const acsReplayResponse = await auth.handler(
|
|
2597
3799
|
new Request(
|
|
2598
3800
|
"http://localhost:3000/api/auth/sso/saml2/sp/acs/cross-endpoint-provider",
|
|
@@ -2961,4 +4163,157 @@ describe("SAML SSO - Single Assertion Validation", () => {
|
|
|
2961
4163
|
expect(response.status).toBe(302);
|
|
2962
4164
|
expect(response.headers.get("location")).not.toContain("error");
|
|
2963
4165
|
});
|
|
4166
|
+
|
|
4167
|
+
it("should normalize email to lowercase in SAML authentication to prevent duplicate creation", async () => {
|
|
4168
|
+
const { auth, client, signInWithTestUser, db } = await getTestInstance({
|
|
4169
|
+
plugins: [sso()],
|
|
4170
|
+
});
|
|
4171
|
+
|
|
4172
|
+
const { headers } = await signInWithTestUser();
|
|
4173
|
+
|
|
4174
|
+
await auth.api.registerSSOProvider({
|
|
4175
|
+
body: {
|
|
4176
|
+
providerId: "email-case-provider",
|
|
4177
|
+
issuer: "http://localhost:8081",
|
|
4178
|
+
domain: "example.com",
|
|
4179
|
+
samlConfig: {
|
|
4180
|
+
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
|
|
4181
|
+
cert: certificate,
|
|
4182
|
+
callbackUrl: "http://localhost:3000/dashboard",
|
|
4183
|
+
wantAssertionsSigned: false,
|
|
4184
|
+
signatureAlgorithm: "sha256",
|
|
4185
|
+
digestAlgorithm: "sha256",
|
|
4186
|
+
idpMetadata: {
|
|
4187
|
+
metadata: idpMetadata,
|
|
4188
|
+
},
|
|
4189
|
+
spMetadata: {
|
|
4190
|
+
metadata: spMetadata,
|
|
4191
|
+
},
|
|
4192
|
+
identifierFormat:
|
|
4193
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
4194
|
+
mapping: {
|
|
4195
|
+
id: "nameID",
|
|
4196
|
+
email: "nameID",
|
|
4197
|
+
name: "displayName",
|
|
4198
|
+
},
|
|
4199
|
+
},
|
|
4200
|
+
},
|
|
4201
|
+
headers,
|
|
4202
|
+
});
|
|
4203
|
+
|
|
4204
|
+
let samlResponse1: { samlResponse: string } | undefined;
|
|
4205
|
+
await betterFetch(
|
|
4206
|
+
"http://localhost:8081/api/sso/saml2/idp/post?emailCase=mixed",
|
|
4207
|
+
{
|
|
4208
|
+
onSuccess: async (context) => {
|
|
4209
|
+
samlResponse1 = context.data as { samlResponse: string };
|
|
4210
|
+
},
|
|
4211
|
+
},
|
|
4212
|
+
);
|
|
4213
|
+
|
|
4214
|
+
expect(samlResponse1?.samlResponse).toBeDefined();
|
|
4215
|
+
|
|
4216
|
+
const firstCallbackResponse = await auth.handler(
|
|
4217
|
+
new Request(
|
|
4218
|
+
"http://localhost:3000/api/auth/sso/saml2/callback/email-case-provider",
|
|
4219
|
+
{
|
|
4220
|
+
method: "POST",
|
|
4221
|
+
headers: {
|
|
4222
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
4223
|
+
},
|
|
4224
|
+
body: new URLSearchParams({
|
|
4225
|
+
SAMLResponse: samlResponse1!.samlResponse,
|
|
4226
|
+
RelayState: "http://localhost:3000/dashboard",
|
|
4227
|
+
}),
|
|
4228
|
+
},
|
|
4229
|
+
),
|
|
4230
|
+
);
|
|
4231
|
+
|
|
4232
|
+
expect(firstCallbackResponse.status).toBe(302);
|
|
4233
|
+
expect(firstCallbackResponse.headers.get("location")).toContain(
|
|
4234
|
+
"dashboard",
|
|
4235
|
+
);
|
|
4236
|
+
expect(firstCallbackResponse.headers.get("location")).not.toContain(
|
|
4237
|
+
"error",
|
|
4238
|
+
);
|
|
4239
|
+
|
|
4240
|
+
const firstCookies = parseSetCookieHeader(
|
|
4241
|
+
firstCallbackResponse.headers.get("set-cookie") ?? "",
|
|
4242
|
+
);
|
|
4243
|
+
const firstSessionToken = firstCookies.get(
|
|
4244
|
+
"better-auth.session_token",
|
|
4245
|
+
)?.value;
|
|
4246
|
+
expect(firstSessionToken).toBeDefined();
|
|
4247
|
+
|
|
4248
|
+
const firstSession = await client.getSession({
|
|
4249
|
+
fetchOptions: {
|
|
4250
|
+
headers: {
|
|
4251
|
+
Cookie: `better-auth.session_token=${firstSessionToken}`,
|
|
4252
|
+
},
|
|
4253
|
+
},
|
|
4254
|
+
});
|
|
4255
|
+
|
|
4256
|
+
expect(firstSession.data?.user.email).toBe("testuser@example.com");
|
|
4257
|
+
const firstUserId = firstSession.data?.user.id;
|
|
4258
|
+
expect(firstUserId).toBeDefined();
|
|
4259
|
+
|
|
4260
|
+
let samlResponse2: { samlResponse: string } | undefined;
|
|
4261
|
+
await betterFetch(
|
|
4262
|
+
"http://localhost:8081/api/sso/saml2/idp/post?emailCase=mixed",
|
|
4263
|
+
{
|
|
4264
|
+
onSuccess: async (context) => {
|
|
4265
|
+
samlResponse2 = context.data as { samlResponse: string };
|
|
4266
|
+
},
|
|
4267
|
+
},
|
|
4268
|
+
);
|
|
4269
|
+
|
|
4270
|
+
const secondCallbackResponse = await auth.handler(
|
|
4271
|
+
new Request(
|
|
4272
|
+
"http://localhost:3000/api/auth/sso/saml2/callback/email-case-provider",
|
|
4273
|
+
{
|
|
4274
|
+
method: "POST",
|
|
4275
|
+
headers: {
|
|
4276
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
4277
|
+
},
|
|
4278
|
+
body: new URLSearchParams({
|
|
4279
|
+
SAMLResponse: samlResponse2!.samlResponse,
|
|
4280
|
+
RelayState: "http://localhost:3000/dashboard",
|
|
4281
|
+
}),
|
|
4282
|
+
},
|
|
4283
|
+
),
|
|
4284
|
+
);
|
|
4285
|
+
|
|
4286
|
+
expect(secondCallbackResponse.status).toBe(302);
|
|
4287
|
+
expect(secondCallbackResponse.headers.get("location")).toContain(
|
|
4288
|
+
"dashboard",
|
|
4289
|
+
);
|
|
4290
|
+
expect(secondCallbackResponse.headers.get("location")).not.toContain(
|
|
4291
|
+
"error",
|
|
4292
|
+
);
|
|
4293
|
+
|
|
4294
|
+
const secondCookies = parseSetCookieHeader(
|
|
4295
|
+
secondCallbackResponse.headers.get("set-cookie") ?? "",
|
|
4296
|
+
);
|
|
4297
|
+
const secondSessionToken = secondCookies.get(
|
|
4298
|
+
"better-auth.session_token",
|
|
4299
|
+
)?.value;
|
|
4300
|
+
expect(secondSessionToken).toBeDefined();
|
|
4301
|
+
|
|
4302
|
+
const secondSession = await client.getSession({
|
|
4303
|
+
fetchOptions: {
|
|
4304
|
+
headers: {
|
|
4305
|
+
Cookie: `better-auth.session_token=${secondSessionToken}`,
|
|
4306
|
+
},
|
|
4307
|
+
},
|
|
4308
|
+
});
|
|
4309
|
+
|
|
4310
|
+
expect(secondSession.data?.user.id).toBe(firstUserId);
|
|
4311
|
+
expect(secondSession.data?.user.email).toBe("testuser@example.com");
|
|
4312
|
+
|
|
4313
|
+
const users = (await db.findMany({ model: "user" })) as {
|
|
4314
|
+
email: string;
|
|
4315
|
+
}[];
|
|
4316
|
+
const samlUsers = users.filter((u) => u.email === "testuser@example.com");
|
|
4317
|
+
expect(samlUsers).toHaveLength(1);
|
|
4318
|
+
});
|
|
2964
4319
|
});
|