@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/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 user = { emailAddress: "test@email.com", famName: "hello world" };
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 user = { emailAddress: "test@email.com", famName: "hello world" };
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 reject SAML sign-in when disableImplicitSignUp is true and user doesn't exist", async () => {
1103
- const { auth: authWithDisabledSignUp, signInWithTestUser } =
1104
- await getTestInstance({
1105
- plugins: [sso({ disableImplicitSignUp: true })],
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-test-provider",
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: 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("http://localhost:8081/api/sso/saml2/idp/post", {
1312
+ await betterFetch(signInResponse?.url, {
1140
1313
  onSuccess: async (context) => {
1141
1314
  samlResponse = await context.data;
1142
1315
  },
1143
1316
  });
1144
1317
 
1145
- const response = await authWithDisabledSignUp.handler(
1146
- new Request(
1147
- "http://localhost:3000/api/auth/sso/saml2/callback/saml-test-provider",
1148
- {
1149
- method: "POST",
1150
- headers: {
1151
- "Content-Type": "application/x-www-form-urlencoded",
1152
- },
1153
- body: new URLSearchParams({
1154
- SAMLResponse: samlResponse.samlResponse,
1155
- RelayState: "http://localhost:3000/dashboard",
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(response.status).toBe(302);
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 reject SAML ACS (IdP-initiated) when disableImplicitSignUp is true and user doesn't exist", async () => {
1167
- const { auth: authWithDisabledSignUp, signInWithTestUser } =
1168
- await getTestInstance({
1169
- plugins: [sso({ disableImplicitSignUp: true })],
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-acs-test-provider",
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: 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("http://localhost:8081/api/sso/saml2/idp/post", {
1383
+ await betterFetch(signInResponse?.url, {
1201
1384
  onSuccess: async (context) => {
1202
1385
  samlResponse = await context.data;
1203
1386
  },
1204
1387
  });
1205
1388
 
1206
- const response = await authWithDisabledSignUp.handler(
1207
- new Request(
1208
- "http://localhost:3000/api/auth/sso/saml2/sp/acs/saml-acs-test-provider",
1209
- {
1210
- method: "POST",
1211
- headers: {
1212
- "Content-Type": "application/x-www-form-urlencoded",
1213
- },
1214
- body: new URLSearchParams({
1215
- SAMLResponse: samlResponse.samlResponse,
1216
- RelayState: "http://localhost:3000/dashboard",
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(response.status).toBe(302);
1223
- const redirectLocation = response.headers.get("location") || "";
1224
- expect(redirectLocation).toContain("error=signup_disabled");
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 deny account linking when provider is not trusted and domain is not verified", async () => {
1228
- const { auth: authUntrusted, signInWithTestUser } = await getTestInstance({
1229
- account: {
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: "untrusted-saml-provider",
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 ctx = await authUntrusted.$context;
1266
- await ctx.adapter.create({
1267
- model: "user",
1268
- data: {
1269
- id: "existing-user-id",
1270
- email: "test@email.com",
1271
- name: "Existing User",
1272
- emailVerified: true,
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 - Signature Validation Security", () => {
1981
- it("should reject unsigned SAML response with forged NameID", async () => {
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: "security-test-provider",
2364
+ providerId: "idp-initiated-provider",
1991
2365
  issuer: "http://localhost:8081",
1992
2366
  domain: "http://localhost:8081",
1993
2367
  samlConfig: {
1994
- entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
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
- const forgedSamlResponse = `
2014
- <saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
2015
- <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2016
- <saml2p:Status>
2017
- <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
2018
- </saml2p:Status>
2019
- <saml2:Assertion>
2020
- <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2021
- <saml2:Subject>
2022
- <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">attacker-forged@evil.com</saml2:NameID>
2023
- </saml2:Subject>
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
- const encodedForgedResponse =
2039
- Buffer.from(forgedSamlResponse).toString("base64");
2402
+ if (!samlResponse?.samlResponse) {
2403
+ throw new Error("Failed to get SAML response from mock IdP");
2404
+ }
2040
2405
 
2041
- await expect(
2042
- auth.api.callbackSSOSAML({
2043
- body: {
2044
- SAMLResponse: encodedForgedResponse,
2045
- RelayState: "http://localhost:3000/dashboard",
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: "security-test-provider",
2451
+ providerId: "test-provider",
2049
2452
  },
2050
- }),
2051
- ).rejects.toMatchObject({
2052
- status: "BAD_REQUEST",
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 reject SAML response with invalid signature", async () => {
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: "invalid-sig-provider",
2484
+ providerId: "loop-test-provider",
2066
2485
  issuer: "http://localhost:8081",
2067
2486
  domain: "http://localhost:8081",
2068
2487
  samlConfig: {
2069
- entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
2488
+ entryPoint: sharedMockIdP.metadataUrl.replace(
2489
+ "/idp/metadata",
2490
+ "/idp/post",
2491
+ ),
2070
2492
  cert: certificate,
2071
- callbackUrl: "http://localhost:3000/dashboard",
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
- const responseWithBadSignature = `
2089
- <saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
2090
- <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2091
- <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
2092
- <ds:SignedInfo>
2093
- <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
2094
- <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
2095
- <ds:Reference>
2096
- <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
2097
- <ds:DigestValue>FAKE_DIGEST_VALUE</ds:DigestValue>
2098
- </ds:Reference>
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
- const encodedBadSigResponse = Buffer.from(
2115
- responseWithBadSignature,
2116
- ).toString("base64");
2522
+ if (!samlResponse?.samlResponse) {
2523
+ throw new Error("Failed to get SAML response from mock IdP");
2524
+ }
2117
2525
 
2118
- await expect(
2119
- auth.api.callbackSSOSAML({
2120
- body: {
2121
- SAMLResponse: encodedBadSigResponse,
2122
- RelayState: "http://localhost:3000/dashboard",
2123
- },
2124
- params: {
2125
- providerId: "invalid-sig-provider",
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
- describe("SAML SSO - Timestamp Validation", () => {
2135
- describe("Valid assertions within time window", () => {
2136
- it("should accept assertion with current NotBefore and future NotOnOrAfter", () => {
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
- it("should accept assertion within clock skew tolerance (expired 2 min ago with 5 min skew)", () => {
2148
- const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000).toISOString();
2149
- expect(() =>
2150
- validateSAMLTimestamp({ notOnOrAfter: twoMinutesAgo }),
2151
- ).not.toThrow();
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
- it("should accept assertion with NotBefore slightly in future (within clock skew)", () => {
2155
- const twoMinutesFromNow = new Date(
2156
- Date.now() + 2 * 60 * 1000,
2157
- ).toISOString();
2158
- expect(() =>
2159
- validateSAMLTimestamp({ notBefore: twoMinutesFromNow }),
2160
- ).not.toThrow();
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
- describe("NotBefore validation (future-dated assertions)", () => {
2165
- it("should reject assertion with NotBefore too far in future (beyond clock skew)", () => {
2166
- const tenMinutesFromNow = new Date(
2167
- Date.now() + 10 * 60 * 1000,
2168
- ).toISOString();
2169
- expect(() =>
2170
- validateSAMLTimestamp({ notBefore: tenMinutesFromNow }),
2171
- ).toThrow("SAML assertion is not yet valid");
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
- it("should reject with custom strict clock skew (1 second)", () => {
2175
- const threeSecondsFromNow = new Date(Date.now() + 3 * 1000).toISOString();
2176
- expect(() =>
2177
- validateSAMLTimestamp(
2178
- { notBefore: threeSecondsFromNow },
2179
- { clockSkew: 1000 },
2180
- ),
2181
- ).toThrow("SAML assertion is not yet valid");
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
- describe("NotOnOrAfter validation (expired assertions)", () => {
2186
- it("should reject expired assertion (NotOnOrAfter in past beyond clock skew)", () => {
2187
- const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString();
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
- it("should reject with custom strict clock skew (1 second)", () => {
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
- describe("Boundary conditions (exactly at window edges)", () => {
2205
- const FIXED_TIME = new Date("2024-01-15T12:00:00.000Z").getTime();
2633
+ const callbackRouteUrl =
2634
+ "http://localhost:3000/api/auth/sso/saml2/callback/issue-6615-provider";
2206
2635
 
2207
- beforeEach(() => {
2208
- vi.useFakeTimers();
2209
- vi.setSystemTime(FIXED_TIME);
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
- afterEach(() => {
2213
- vi.useRealTimers();
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
- it("should accept assertion expiring exactly at clock skew boundary", () => {
2217
- const exactlyAtBoundary = new Date(
2218
- FIXED_TIME - DEFAULT_CLOCK_SKEW_MS,
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
- it("should reject assertion expiring 1ms beyond clock skew boundary", () => {
2226
- const justPastBoundary = new Date(
2227
- FIXED_TIME - DEFAULT_CLOCK_SKEW_MS - 1,
2228
- ).toISOString();
2229
- expect(() =>
2230
- validateSAMLTimestamp({ notOnOrAfter: justPastBoundary }),
2231
- ).toThrow("SAML assertion has expired");
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
- it("should accept assertion with NotBefore exactly at clock skew boundary", () => {
2235
- const exactlyAtBoundary = new Date(
2236
- FIXED_TIME + DEFAULT_CLOCK_SKEW_MS,
2237
- ).toISOString();
2238
- expect(() =>
2239
- validateSAMLTimestamp({ notBefore: exactlyAtBoundary }),
2240
- ).not.toThrow();
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
- it("should reject assertion with NotBefore 1ms beyond clock skew boundary", () => {
2244
- const justPastBoundary = new Date(
2245
- FIXED_TIME + DEFAULT_CLOCK_SKEW_MS + 1,
2246
- ).toISOString();
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
- describe("Missing timestamps behavior", () => {
2254
- it("should accept missing timestamps when requireTimestamps is false (default)", () => {
2255
- expect(() =>
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
- it("should accept empty conditions when requireTimestamps is false", () => {
2261
- expect(() =>
2262
- validateSAMLTimestamp({}, { requireTimestamps: false }),
2263
- ).not.toThrow();
2264
- });
2719
+ const { headers } = await signInWithTestUser();
2265
2720
 
2266
- it("should reject missing timestamps when requireTimestamps is true", () => {
2267
- expect(() =>
2268
- validateSAMLTimestamp(undefined, { requireTimestamps: true }),
2269
- ).toThrow("SAML assertion missing required timestamp conditions");
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
- it("should reject empty conditions when requireTimestamps is true", () => {
2273
- expect(() =>
2274
- validateSAMLTimestamp({}, { requireTimestamps: true }),
2275
- ).toThrow("SAML assertion missing required timestamp conditions");
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
- it("should accept assertions with only NotBefore (valid)", () => {
2279
- const now = new Date().toISOString();
2280
- expect(() => validateSAMLTimestamp({ notBefore: now })).not.toThrow();
2281
- });
2761
+ if (!samlResponse?.samlResponse) {
2762
+ throw new Error("Failed to get SAML response from mock IdP");
2763
+ }
2282
2764
 
2283
- it("should accept assertions with only NotOnOrAfter (valid, in future)", () => {
2284
- const future = new Date(Date.now() + 10 * 60 * 1000).toISOString();
2285
- expect(() =>
2286
- validateSAMLTimestamp({ notOnOrAfter: future }),
2287
- ).not.toThrow();
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
- describe("Custom clock skew configuration", () => {
2292
- it("should use custom clockSkew when provided", () => {
2293
- const twoSecondsAgo = new Date(Date.now() - 2 * 1000).toISOString();
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
- expect(() =>
2296
- validateSAMLTimestamp(
2297
- { notOnOrAfter: twoSecondsAgo },
2298
- { clockSkew: 1000 },
2299
- ),
2300
- ).toThrow("SAML assertion has expired");
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
- { notOnOrAfter: twoSecondsAgo },
2305
- { clockSkew: 5 * 60 * 1000 },
2306
- ),
3024
+ validateSAMLTimestamp({
3025
+ notBefore: now.toISOString(),
3026
+ notOnOrAfter: fiveMinutesFromNow.toISOString(),
3027
+ }),
2307
3028
  ).not.toThrow();
2308
3029
  });
2309
3030
 
2310
- it("should use default 5 minute clock skew when not specified", () => {
2311
- const fourMinutesAgo = new Date(Date.now() - 4 * 60 * 1000).toISOString();
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: fourMinutesAgo }),
3034
+ validateSAMLTimestamp({ notOnOrAfter: twoMinutesAgo }),
2314
3035
  ).not.toThrow();
3036
+ });
2315
3037
 
2316
- const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000).toISOString();
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({ notOnOrAfter: sixMinutesAgo }),
2319
- ).toThrow("SAML assertion has expired");
3043
+ validateSAMLTimestamp({ notBefore: twoMinutesFromNow }),
3044
+ ).not.toThrow();
2320
3045
  });
2321
3046
  });
2322
3047
 
2323
- describe("Malformed timestamp handling", () => {
2324
- it("should reject malformed NotBefore timestamp", () => {
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: "not-a-valid-date" }),
2327
- ).toThrow("SAML assertion has invalid NotBefore timestamp");
3054
+ validateSAMLTimestamp({ notBefore: tenMinutesFromNow }),
3055
+ ).toThrow("SAML assertion is not yet valid");
2328
3056
  });
2329
3057
 
2330
- it("should reject malformed NotOnOrAfter timestamp", () => {
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({ notOnOrAfter: "invalid-timestamp" }),
2333
- ).toThrow("SAML assertion has invalid NotOnOrAfter timestamp");
3061
+ validateSAMLTimestamp(
3062
+ { notBefore: threeSecondsFromNow },
3063
+ { clockSkew: 1000 },
3064
+ ),
3065
+ ).toThrow("SAML assertion is not yet valid");
2334
3066
  });
3067
+ });
2335
3068
 
2336
- it("should treat empty string timestamps as missing (falsy values)", () => {
2337
- expect(() => validateSAMLTimestamp({ notBefore: "" })).not.toThrow();
2338
- expect(() => validateSAMLTimestamp({ notOnOrAfter: "" })).not.toThrow();
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 garbage data in timestamps", () => {
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
- notBefore: "abc123xyz",
2345
- notOnOrAfter: "!@#$%^&*()",
2346
- }),
2347
- ).toThrow("SAML assertion has invalid NotBefore timestamp");
3080
+ validateSAMLTimestamp(
3081
+ { notOnOrAfter: threeSecondsAgo },
3082
+ { clockSkew: 1000 },
3083
+ ),
3084
+ ).toThrow("SAML assertion has expired");
2348
3085
  });
3086
+ });
2349
3087
 
2350
- it("should accept valid ISO 8601 timestamps", () => {
2351
- const now = new Date();
2352
- const future = new Date(Date.now() + 10 * 60 * 1000);
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
- describe("SAML SSO - Assertion Replay Protection", () => {
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: "replay-test-provider",
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
- let samlResponse: any;
2397
- await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
2398
- onSuccess: async (context) => {
2399
- samlResponse = await context.data;
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
- // First submission should succeed
2404
- const firstResponse = await auth.handler(
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/callback/replay-test-provider",
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: samlResponse.samlResponse,
3961
+ SAMLResponse: encodedResponse,
2414
3962
  RelayState: "http://localhost:3000/dashboard",
2415
3963
  }),
2416
3964
  },
2417
3965
  ),
2418
3966
  );
2419
3967
 
2420
- expect(firstResponse.status).toBe(302);
2421
- const firstLocation = firstResponse.headers.get("location") || "";
2422
- expect(firstLocation).not.toContain("error");
3968
+ expect(response.status).toBe(302);
3969
+ const location = response.headers.get("location") || "";
3970
+ expect(location).toContain("error=multiple_assertions");
3971
+ });
2423
3972
 
2424
- // Second submission (replay) should be rejected
2425
- const replayResponse = await auth.handler(
2426
- new Request(
2427
- "http://localhost:3000/api/auth/sso/saml2/callback/replay-test-provider",
2428
- {
2429
- method: "POST",
2430
- headers: {
2431
- "Content-Type": "application/x-www-form-urlencoded",
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
- body: new URLSearchParams({
2434
- SAMLResponse: samlResponse.samlResponse,
2435
- RelayState: "http://localhost:3000/dashboard",
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
- expect(replayResponse.status).toBe(302);
2442
- const replayLocation = replayResponse.headers.get("location") || "";
2443
- expect(replayLocation).toContain("error=replay_detected");
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 replayed SAML assertion on ACS endpoint", async () => {
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: "acs-replay-test-provider",
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
- let samlResponse: any;
2479
- await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
2480
- onSuccess: async (context) => {
2481
- samlResponse = await context.data;
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
- // First submission to ACS endpoint should succeed
2486
- const firstResponse = await auth.handler(
2487
- new Request(
2488
- "http://localhost:3000/api/auth/sso/saml2/sp/acs/acs-replay-test-provider",
2489
- {
2490
- method: "POST",
2491
- headers: {
2492
- "Content-Type": "application/x-www-form-urlencoded",
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
- body: new URLSearchParams({
2495
- SAMLResponse: samlResponse.samlResponse,
2496
- RelayState: "http://localhost:3000/dashboard",
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
- expect(firstResponse.status).toBe(302);
2503
- const firstLocation = firstResponse.headers.get("location") || "";
2504
- expect(firstLocation).not.toContain("error");
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
- // Second submission (replay) to ACS endpoint should be rejected
2507
- const replayResponse = await auth.handler(
4147
+ const response = await auth.handler(
2508
4148
  new Request(
2509
- "http://localhost:3000/api/auth/sso/saml2/sp/acs/acs-replay-test-provider",
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(replayResponse.status).toBe(302);
2524
- const replayLocation = replayResponse.headers.get("location") || "";
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 reject cross-endpoint replay (callback ACS)", async () => {
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: "cross-endpoint-provider",
4176
+ providerId: "email-case-provider",
2538
4177
  issuer: "http://localhost:8081",
2539
- domain: "http://localhost:8081",
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: any;
2561
- await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
2562
- onSuccess: async (context) => {
2563
- samlResponse = await context.data;
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
- // First: Submit to callback endpoint (should succeed)
2568
- const callbackResponse = await auth.handler(
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/cross-endpoint-provider",
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.samlResponse,
4225
+ SAMLResponse: samlResponse1!.samlResponse,
2578
4226
  RelayState: "http://localhost:3000/dashboard",
2579
4227
  }),
2580
4228
  },
2581
4229
  ),
2582
4230
  );
2583
4231
 
2584
- expect(callbackResponse.status).toBe(302);
2585
- expect(callbackResponse.headers.get("location")).not.toContain("error");
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
- // Second: Replay same assertion to ACS endpoint (should be rejected)
2588
- const acsReplayResponse = await auth.handler(
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/sp/acs/cross-endpoint-provider",
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.samlResponse,
4279
+ SAMLResponse: samlResponse2!.samlResponse,
2598
4280
  RelayState: "http://localhost:3000/dashboard",
2599
4281
  }),
2600
4282
  },
2601
4283
  ),
2602
4284
  );
2603
4285
 
2604
- expect(acsReplayResponse.status).toBe(302);
2605
- const acsLocation = acsReplayResponse.headers.get("location") || "";
2606
- expect(acsLocation).toContain("error=replay_detected");
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
  });