@better-auth/sso 1.4.7 → 1.4.8-beta.2

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
@@ -25,13 +25,9 @@ import {
25
25
  it,
26
26
  vi,
27
27
  } from "vitest";
28
- import {
29
- createInMemoryAuthnRequestStore,
30
- DEFAULT_CLOCK_SKEW_MS,
31
- sso,
32
- validateSAMLTimestamp,
33
- } from ".";
28
+ import { sso, validateSAMLTimestamp } from ".";
34
29
  import { ssoClient } from "./client";
30
+ import { DEFAULT_CLOCK_SKEW_MS } from "./constants";
35
31
 
36
32
  const spMetadata = `
37
33
  <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="http://localhost:3001/api/sso/saml2/sp/metadata">
@@ -495,6 +491,17 @@ const createMockSAMLIdP = (port: number) => {
495
491
  return { start, stop, metadataUrl };
496
492
  };
497
493
 
494
+ // Shared mock SAML IdP for all tests
495
+ const sharedMockIdP = createMockSAMLIdP(8081);
496
+
497
+ beforeAll(async () => {
498
+ await sharedMockIdP.start();
499
+ });
500
+
501
+ afterAll(async () => {
502
+ await sharedMockIdP.stop();
503
+ });
504
+
498
505
  describe("SAML SSO with defaultSSO array", async () => {
499
506
  const data = {
500
507
  user: [],
@@ -505,7 +512,6 @@ describe("SAML SSO with defaultSSO array", async () => {
505
512
  };
506
513
 
507
514
  const memory = memoryAdapter(data);
508
- const mockIdP = createMockSAMLIdP(8081); // Different port from your main app
509
515
 
510
516
  const ssoOptions = {
511
517
  defaultSSO: [
@@ -552,14 +558,6 @@ describe("SAML SSO with defaultSSO array", async () => {
552
558
  plugins: [sso(ssoOptions)],
553
559
  });
554
560
 
555
- beforeAll(async () => {
556
- await mockIdP.start();
557
- });
558
-
559
- afterAll(async () => {
560
- await mockIdP.stop();
561
- });
562
-
563
561
  it("should use default SAML SSO provider from array when no provider found in database", async () => {
564
562
  const signInResponse = await auth.api.signInSSO({
565
563
  body: {
@@ -585,7 +583,6 @@ describe("SAML SSO", async () => {
585
583
  };
586
584
 
587
585
  const memory = memoryAdapter(data);
588
- const mockIdP = createMockSAMLIdP(8081); // Different port from your main app
589
586
 
590
587
  const ssoOptions = {
591
588
  provisionUser: vi
@@ -626,7 +623,6 @@ describe("SAML SSO", async () => {
626
623
  };
627
624
 
628
625
  beforeAll(async () => {
629
- await mockIdP.start();
630
626
  await authClient.signUp.email({
631
627
  email: testUser.email,
632
628
  password: testUser.password,
@@ -634,10 +630,6 @@ describe("SAML SSO", async () => {
634
630
  });
635
631
  });
636
632
 
637
- afterAll(async () => {
638
- await mockIdP.stop();
639
- });
640
-
641
633
  beforeEach(() => {
642
634
  data.user = [];
643
635
  data.session = [];
@@ -675,7 +667,7 @@ describe("SAML SSO", async () => {
675
667
  issuer: "http://localhost:8081",
676
668
  domain: "http://localhost:8081",
677
669
  samlConfig: {
678
- entryPoint: mockIdP.metadataUrl,
670
+ entryPoint: sharedMockIdP.metadataUrl,
679
671
  cert: certificate,
680
672
  callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
681
673
  wantAssertionsSigned: false,
@@ -708,7 +700,7 @@ describe("SAML SSO", async () => {
708
700
  id: expect.any(String),
709
701
  issuer: "http://localhost:8081",
710
702
  samlConfig: {
711
- entryPoint: mockIdP.metadataUrl,
703
+ entryPoint: sharedMockIdP.metadataUrl,
712
704
  cert: expect.any(String),
713
705
  callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
714
706
  wantAssertionsSigned: false,
@@ -731,7 +723,7 @@ describe("SAML SSO", async () => {
731
723
  issuer: "http://localhost:8081",
732
724
  domain: "http://localhost:8081",
733
725
  samlConfig: {
734
- entryPoint: mockIdP.metadataUrl,
726
+ entryPoint: sharedMockIdP.metadataUrl,
735
727
  cert: certificate,
736
728
  callbackUrl: "http://localhost:8081/api/sso/saml2/sp/acs",
737
729
  wantAssertionsSigned: false,
@@ -783,7 +775,7 @@ describe("SAML SSO", async () => {
783
775
  issuer: issuer,
784
776
  domain: issuer,
785
777
  samlConfig: {
786
- entryPoint: mockIdP.metadataUrl,
778
+ entryPoint: sharedMockIdP.metadataUrl,
787
779
  cert: certificate,
788
780
  callbackUrl: `${issuer}/api/sso/saml2/sp/acs`,
789
781
  wantAssertionsSigned: false,
@@ -925,7 +917,7 @@ describe("SAML SSO", async () => {
925
917
  issuer: "http://localhost:8081",
926
918
  domain: "http://localhost:8081",
927
919
  samlConfig: {
928
- entryPoint: mockIdP.metadataUrl,
920
+ entryPoint: sharedMockIdP.metadataUrl,
929
921
  cert: certificate,
930
922
  callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
931
923
  wantAssertionsSigned: false,
@@ -956,7 +948,7 @@ describe("SAML SSO", async () => {
956
948
  issuer: "http://localhost:8081",
957
949
  domain: "http://localhost:8081",
958
950
  samlConfig: {
959
- entryPoint: mockIdP.metadataUrl,
951
+ entryPoint: sharedMockIdP.metadataUrl,
960
952
  cert: certificate,
961
953
  callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
962
954
  wantAssertionsSigned: false,
@@ -977,7 +969,7 @@ describe("SAML SSO", async () => {
977
969
  issuer: "http://localhost:8081",
978
970
  domain: "http://localhost:8081",
979
971
  samlConfig: {
980
- entryPoint: mockIdP.metadataUrl,
972
+ entryPoint: sharedMockIdP.metadataUrl,
981
973
  cert: certificate,
982
974
  callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
983
975
  wantAssertionsSigned: false,
@@ -1016,7 +1008,7 @@ describe("SAML SSO", async () => {
1016
1008
  issuer: "http://localhost:8081",
1017
1009
  domain: "http://localhost:8081",
1018
1010
  samlConfig: {
1019
- entryPoint: mockIdP.metadataUrl,
1011
+ entryPoint: sharedMockIdP.metadataUrl,
1020
1012
  cert: certificate,
1021
1013
  callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
1022
1014
  wantAssertionsSigned: false,
@@ -1037,7 +1029,7 @@ describe("SAML SSO", async () => {
1037
1029
  issuer: "http://localhost:8081",
1038
1030
  domain: "http://localhost:8081",
1039
1031
  samlConfig: {
1040
- entryPoint: mockIdP.metadataUrl,
1032
+ entryPoint: sharedMockIdP.metadataUrl,
1041
1033
  cert: certificate,
1042
1034
  callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
1043
1035
  wantAssertionsSigned: false,
@@ -1071,7 +1063,7 @@ describe("SAML SSO", async () => {
1071
1063
  issuer: "http://localhost:8081",
1072
1064
  domain: "http://localhost:8081",
1073
1065
  samlConfig: {
1074
- entryPoint: mockIdP.metadataUrl,
1066
+ entryPoint: sharedMockIdP.metadataUrl,
1075
1067
  cert: certificate,
1076
1068
  callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
1077
1069
  spMetadata: {
@@ -1089,7 +1081,7 @@ describe("SAML SSO", async () => {
1089
1081
  issuer: "http://localhost:8082",
1090
1082
  domain: "http://localhost:8082",
1091
1083
  samlConfig: {
1092
- entryPoint: mockIdP.metadataUrl,
1084
+ entryPoint: sharedMockIdP.metadataUrl,
1093
1085
  cert: certificate,
1094
1086
  callbackUrl: "http://localhost:8082/api/sso/saml2/callback",
1095
1087
  spMetadata: {
@@ -1150,25 +1142,86 @@ describe("SAML SSO", async () => {
1150
1142
  },
1151
1143
  });
1152
1144
 
1153
- // Attempt to complete SAML callback - should fail because test@email.com doesn't exist
1154
- // and disableImplicitSignUp is true
1155
- await expect(
1156
- authWithDisabledSignUp.api.callbackSSOSAML({
1157
- body: {
1158
- SAMLResponse: samlResponse.samlResponse,
1159
- RelayState: "http://localhost:3000/dashboard",
1160
- },
1161
- params: {
1162
- providerId: "saml-test-provider",
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
+ }),
1163
1157
  },
1164
- }),
1165
- ).rejects.toMatchObject({
1166
- status: "UNAUTHORIZED",
1158
+ ),
1159
+ );
1160
+
1161
+ expect(response.status).toBe(302);
1162
+ const redirectLocation = response.headers.get("location") || "";
1163
+ expect(redirectLocation).toContain("error=signup_disabled");
1164
+ });
1165
+
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
+ });
1171
+
1172
+ const { headers } = await signInWithTestUser();
1173
+
1174
+ await authWithDisabledSignUp.api.registerSSOProvider({
1167
1175
  body: {
1168
- message:
1169
- "User not found and implicit sign up is disabled for this provider",
1176
+ providerId: "saml-acs-test-provider",
1177
+ issuer: "http://localhost:8081",
1178
+ domain: "http://localhost:8081",
1179
+ samlConfig: {
1180
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1181
+ cert: certificate,
1182
+ callbackUrl: "http://localhost:3000/dashboard",
1183
+ wantAssertionsSigned: false,
1184
+ signatureAlgorithm: "sha256",
1185
+ digestAlgorithm: "sha256",
1186
+ idpMetadata: {
1187
+ metadata: idpMetadata,
1188
+ },
1189
+ spMetadata: {
1190
+ metadata: spMetadata,
1191
+ },
1192
+ identifierFormat:
1193
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1194
+ },
1170
1195
  },
1196
+ headers: headers,
1171
1197
  });
1198
+
1199
+ let samlResponse: any;
1200
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
1201
+ onSuccess: async (context) => {
1202
+ samlResponse = await context.data;
1203
+ },
1204
+ });
1205
+
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
+ );
1221
+
1222
+ expect(response.status).toBe(302);
1223
+ const redirectLocation = response.headers.get("location") || "";
1224
+ expect(redirectLocation).toContain("error=signup_disabled");
1172
1225
  });
1173
1226
 
1174
1227
  it("should deny account linking when provider is not trusted and domain is not verified", async () => {
@@ -1526,78 +1579,7 @@ describe("SAML SSO", async () => {
1526
1579
  expect(redirectLocation).not.toContain("error=");
1527
1580
  });
1528
1581
 
1529
- it("should enable validation automatically when custom authnRequestStore is provided", async () => {
1530
- const customStore = createInMemoryAuthnRequestStore();
1531
-
1532
- const { auth, signInWithTestUser } = await getTestInstance({
1533
- plugins: [
1534
- sso({
1535
- saml: {
1536
- authnRequestStore: customStore,
1537
- allowIdpInitiated: false,
1538
- },
1539
- }),
1540
- ],
1541
- });
1542
-
1543
- const { headers } = await signInWithTestUser();
1544
-
1545
- await auth.api.registerSSOProvider({
1546
- body: {
1547
- providerId: "custom-store-provider",
1548
- issuer: "http://localhost:8081",
1549
- domain: "http://localhost:8081",
1550
- samlConfig: {
1551
- entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1552
- cert: certificate,
1553
- callbackUrl: "http://localhost:3000/dashboard",
1554
- wantAssertionsSigned: false,
1555
- signatureAlgorithm: "sha256",
1556
- digestAlgorithm: "sha256",
1557
- idpMetadata: {
1558
- metadata: idpMetadata,
1559
- },
1560
- spMetadata: {
1561
- metadata: spMetadata,
1562
- },
1563
- identifierFormat:
1564
- "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1565
- },
1566
- },
1567
- headers,
1568
- });
1569
-
1570
- let samlResponse: any;
1571
- await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
1572
- onSuccess: async (context) => {
1573
- samlResponse = await context.data;
1574
- },
1575
- });
1576
-
1577
- const response = await auth.handler(
1578
- new Request(
1579
- "http://localhost:3000/api/auth/sso/saml2/callback/custom-store-provider",
1580
- {
1581
- method: "POST",
1582
- headers: {
1583
- "Content-Type": "application/x-www-form-urlencoded",
1584
- },
1585
- body: new URLSearchParams({
1586
- SAMLResponse: samlResponse.samlResponse,
1587
- RelayState: "http://localhost:3000/dashboard",
1588
- }),
1589
- },
1590
- ),
1591
- );
1592
-
1593
- expect(response.status).toBe(302);
1594
- const redirectLocation = response.headers.get("location") || "";
1595
- expect(redirectLocation).toContain("error=unsolicited_response");
1596
- });
1597
-
1598
- it("should use verification table for InResponseTo validation when no custom store is provided", async () => {
1599
- // When enableInResponseToValidation is true and no custom authnRequestStore is provided,
1600
- // the plugin uses the verification table (database) for storing AuthnRequest IDs
1582
+ it("should use verification table for InResponseTo validation", async () => {
1601
1583
  const { auth, signInWithTestUser } = await getTestInstance({
1602
1584
  plugins: [
1603
1585
  sso({
@@ -1691,7 +1673,6 @@ describe("SAML SSO with custom fields", () => {
1691
1673
  };
1692
1674
 
1693
1675
  const memory = memoryAdapter(data);
1694
- const mockIdP = createMockSAMLIdP(8081); // Different port from your main app
1695
1676
 
1696
1677
  const auth = betterAuth({
1697
1678
  database: memory,
@@ -1719,7 +1700,6 @@ describe("SAML SSO with custom fields", () => {
1719
1700
  };
1720
1701
 
1721
1702
  beforeAll(async () => {
1722
- await mockIdP.start();
1723
1703
  await authClient.signUp.email({
1724
1704
  email: testUser.email,
1725
1705
  password: testUser.password,
@@ -1727,10 +1707,6 @@ describe("SAML SSO with custom fields", () => {
1727
1707
  });
1728
1708
  });
1729
1709
 
1730
- afterAll(async () => {
1731
- await mockIdP.stop();
1732
- });
1733
-
1734
1710
  beforeEach(() => {
1735
1711
  data.user = [];
1736
1712
  data.session = [];
@@ -1764,7 +1740,7 @@ describe("SAML SSO with custom fields", () => {
1764
1740
  issuer: "http://localhost:8081",
1765
1741
  domain: "http://localhost:8081",
1766
1742
  samlConfig: {
1767
- entryPoint: mockIdP.metadataUrl,
1743
+ entryPoint: sharedMockIdP.metadataUrl,
1768
1744
  cert: certificate,
1769
1745
  callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
1770
1746
  wantAssertionsSigned: false,
@@ -1797,7 +1773,7 @@ describe("SAML SSO with custom fields", () => {
1797
1773
  id: expect.any(String),
1798
1774
  issuer: "http://localhost:8081",
1799
1775
  samlConfig: {
1800
- entryPoint: mockIdP.metadataUrl,
1776
+ entryPoint: sharedMockIdP.metadataUrl,
1801
1777
  cert: expect.any(String),
1802
1778
  callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
1803
1779
  wantAssertionsSigned: false,
@@ -2383,3 +2359,250 @@ describe("SAML SSO - Timestamp Validation", () => {
2383
2359
  });
2384
2360
  });
2385
2361
  });
2362
+
2363
+ describe("SAML SSO - Assertion Replay Protection", () => {
2364
+ it("should reject replayed SAML assertion (same assertion submitted twice)", async () => {
2365
+ const { auth, signInWithTestUser } = await getTestInstance({
2366
+ plugins: [sso()],
2367
+ });
2368
+
2369
+ const { headers } = await signInWithTestUser();
2370
+
2371
+ await auth.api.registerSSOProvider({
2372
+ body: {
2373
+ providerId: "replay-test-provider",
2374
+ issuer: "http://localhost:8081",
2375
+ domain: "http://localhost:8081",
2376
+ samlConfig: {
2377
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
2378
+ cert: certificate,
2379
+ callbackUrl: "http://localhost:3000/dashboard",
2380
+ wantAssertionsSigned: false,
2381
+ signatureAlgorithm: "sha256",
2382
+ digestAlgorithm: "sha256",
2383
+ idpMetadata: {
2384
+ metadata: idpMetadata,
2385
+ },
2386
+ spMetadata: {
2387
+ metadata: spMetadata,
2388
+ },
2389
+ identifierFormat:
2390
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2391
+ },
2392
+ },
2393
+ headers,
2394
+ });
2395
+
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
+ });
2402
+
2403
+ // First submission should succeed
2404
+ const firstResponse = await auth.handler(
2405
+ new Request(
2406
+ "http://localhost:3000/api/auth/sso/saml2/callback/replay-test-provider",
2407
+ {
2408
+ method: "POST",
2409
+ headers: {
2410
+ "Content-Type": "application/x-www-form-urlencoded",
2411
+ },
2412
+ body: new URLSearchParams({
2413
+ SAMLResponse: samlResponse.samlResponse,
2414
+ RelayState: "http://localhost:3000/dashboard",
2415
+ }),
2416
+ },
2417
+ ),
2418
+ );
2419
+
2420
+ expect(firstResponse.status).toBe(302);
2421
+ const firstLocation = firstResponse.headers.get("location") || "";
2422
+ expect(firstLocation).not.toContain("error");
2423
+
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",
2432
+ },
2433
+ body: new URLSearchParams({
2434
+ SAMLResponse: samlResponse.samlResponse,
2435
+ RelayState: "http://localhost:3000/dashboard",
2436
+ }),
2437
+ },
2438
+ ),
2439
+ );
2440
+
2441
+ expect(replayResponse.status).toBe(302);
2442
+ const replayLocation = replayResponse.headers.get("location") || "";
2443
+ expect(replayLocation).toContain("error=replay_detected");
2444
+ });
2445
+
2446
+ it("should reject replayed SAML assertion on ACS endpoint", async () => {
2447
+ const { auth, signInWithTestUser } = await getTestInstance({
2448
+ plugins: [sso()],
2449
+ });
2450
+
2451
+ const { headers } = await signInWithTestUser();
2452
+
2453
+ await auth.api.registerSSOProvider({
2454
+ body: {
2455
+ providerId: "acs-replay-test-provider",
2456
+ issuer: "http://localhost:8081",
2457
+ domain: "http://localhost:8081",
2458
+ samlConfig: {
2459
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
2460
+ cert: certificate,
2461
+ callbackUrl: "http://localhost:3000/dashboard",
2462
+ wantAssertionsSigned: false,
2463
+ signatureAlgorithm: "sha256",
2464
+ digestAlgorithm: "sha256",
2465
+ idpMetadata: {
2466
+ metadata: idpMetadata,
2467
+ },
2468
+ spMetadata: {
2469
+ metadata: spMetadata,
2470
+ },
2471
+ identifierFormat:
2472
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2473
+ },
2474
+ },
2475
+ headers,
2476
+ });
2477
+
2478
+ let samlResponse: any;
2479
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
2480
+ onSuccess: async (context) => {
2481
+ samlResponse = await context.data;
2482
+ },
2483
+ });
2484
+
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",
2493
+ },
2494
+ body: new URLSearchParams({
2495
+ SAMLResponse: samlResponse.samlResponse,
2496
+ RelayState: "http://localhost:3000/dashboard",
2497
+ }),
2498
+ },
2499
+ ),
2500
+ );
2501
+
2502
+ expect(firstResponse.status).toBe(302);
2503
+ const firstLocation = firstResponse.headers.get("location") || "";
2504
+ expect(firstLocation).not.toContain("error");
2505
+
2506
+ // Second submission (replay) to ACS endpoint should be rejected
2507
+ const replayResponse = await auth.handler(
2508
+ new Request(
2509
+ "http://localhost:3000/api/auth/sso/saml2/sp/acs/acs-replay-test-provider",
2510
+ {
2511
+ method: "POST",
2512
+ headers: {
2513
+ "Content-Type": "application/x-www-form-urlencoded",
2514
+ },
2515
+ body: new URLSearchParams({
2516
+ SAMLResponse: samlResponse.samlResponse,
2517
+ RelayState: "http://localhost:3000/dashboard",
2518
+ }),
2519
+ },
2520
+ ),
2521
+ );
2522
+
2523
+ expect(replayResponse.status).toBe(302);
2524
+ const replayLocation = replayResponse.headers.get("location") || "";
2525
+ expect(replayLocation).toContain("error=replay_detected");
2526
+ });
2527
+
2528
+ it("should reject cross-endpoint replay (callback → ACS)", async () => {
2529
+ const { auth, signInWithTestUser } = await getTestInstance({
2530
+ plugins: [sso()],
2531
+ });
2532
+
2533
+ const { headers } = await signInWithTestUser();
2534
+
2535
+ await auth.api.registerSSOProvider({
2536
+ body: {
2537
+ providerId: "cross-endpoint-provider",
2538
+ issuer: "http://localhost:8081",
2539
+ domain: "http://localhost:8081",
2540
+ samlConfig: {
2541
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
2542
+ cert: certificate,
2543
+ callbackUrl: "http://localhost:3000/dashboard",
2544
+ wantAssertionsSigned: false,
2545
+ signatureAlgorithm: "sha256",
2546
+ digestAlgorithm: "sha256",
2547
+ idpMetadata: {
2548
+ metadata: idpMetadata,
2549
+ },
2550
+ spMetadata: {
2551
+ metadata: spMetadata,
2552
+ },
2553
+ identifierFormat:
2554
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2555
+ },
2556
+ },
2557
+ headers,
2558
+ });
2559
+
2560
+ let samlResponse: any;
2561
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
2562
+ onSuccess: async (context) => {
2563
+ samlResponse = await context.data;
2564
+ },
2565
+ });
2566
+
2567
+ // First: Submit to callback endpoint (should succeed)
2568
+ const callbackResponse = await auth.handler(
2569
+ new Request(
2570
+ "http://localhost:3000/api/auth/sso/saml2/callback/cross-endpoint-provider",
2571
+ {
2572
+ method: "POST",
2573
+ headers: {
2574
+ "Content-Type": "application/x-www-form-urlencoded",
2575
+ },
2576
+ body: new URLSearchParams({
2577
+ SAMLResponse: samlResponse.samlResponse,
2578
+ RelayState: "http://localhost:3000/dashboard",
2579
+ }),
2580
+ },
2581
+ ),
2582
+ );
2583
+
2584
+ expect(callbackResponse.status).toBe(302);
2585
+ expect(callbackResponse.headers.get("location")).not.toContain("error");
2586
+
2587
+ // Second: Replay same assertion to ACS endpoint (should be rejected)
2588
+ const acsReplayResponse = await auth.handler(
2589
+ new Request(
2590
+ "http://localhost:3000/api/auth/sso/saml2/sp/acs/cross-endpoint-provider",
2591
+ {
2592
+ method: "POST",
2593
+ headers: {
2594
+ "Content-Type": "application/x-www-form-urlencoded",
2595
+ },
2596
+ body: new URLSearchParams({
2597
+ SAMLResponse: samlResponse.samlResponse,
2598
+ RelayState: "http://localhost:3000/dashboard",
2599
+ }),
2600
+ },
2601
+ ),
2602
+ );
2603
+
2604
+ expect(acsReplayResponse.status).toBe(302);
2605
+ const acsLocation = acsReplayResponse.headers.get("location") || "";
2606
+ expect(acsLocation).toContain("error=replay_detected");
2607
+ });
2608
+ });