@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/.turbo/turbo-build.log +6 -6
- package/dist/client.d.mts +1 -1
- package/dist/{index-B9WMxRdD.d.mts → index-DNWhGQW-.d.mts} +81 -69
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +464 -265
- package/package.json +3 -3
- package/src/constants.ts +42 -0
- package/src/domain-verification.test.ts +1 -0
- package/src/index.ts +39 -11
- package/src/linking/index.ts +2 -0
- package/src/linking/org-assignment.ts +158 -0
- package/src/linking/types.ts +10 -0
- package/src/routes/sso.ts +338 -332
- package/src/saml/algorithms.test.ts +205 -0
- package/src/saml/algorithms.ts +259 -0
- package/src/saml/index.ts +9 -0
- package/src/saml.test.ts +350 -127
- package/src/types.ts +24 -16
- package/src/authn-request-store.ts +0 -76
- package/src/authn-request.test.ts +0 -99
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
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
|
-
)
|
|
1166
|
-
|
|
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
|
-
|
|
1169
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
+
});
|