@better-auth/sso 1.5.0-beta.6 → 1.5.0-beta.8
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 +8 -8
- package/dist/client.d.mts +1 -1
- package/dist/{index-BLMoKtp1.d.mts → index-BT0wtuq1.d.mts} +6 -3
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +116 -14
- package/package.json +5 -5
- package/src/index.ts +22 -0
- package/src/oidc.test.ts +1 -1
- package/src/routes/sso.ts +139 -14
- package/src/saml-state.ts +78 -0
- package/src/saml.test.ts +1136 -97
package/src/saml.test.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type { createServer } from "node:http";
|
|
3
|
-
import { base64 } from "@better-auth/utils/base64";
|
|
4
3
|
import { betterFetch } from "@better-fetch/fetch";
|
|
5
4
|
import { betterAuth } from "better-auth";
|
|
6
5
|
import { memoryAdapter } from "better-auth/adapters/memory";
|
|
6
|
+
import { APIError } from "better-auth/api";
|
|
7
7
|
import { createAuthClient } from "better-auth/client";
|
|
8
8
|
import { setCookieToHeader } from "better-auth/cookies";
|
|
9
9
|
import { bearer } from "better-auth/plugins";
|
|
@@ -1100,6 +1100,222 @@ describe("SAML SSO", async () => {
|
|
|
1100
1100
|
});
|
|
1101
1101
|
});
|
|
1102
1102
|
|
|
1103
|
+
it("should initiate SAML login and validate RelayState", async () => {
|
|
1104
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
1105
|
+
plugins: [sso()],
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
const { headers } = await signInWithTestUser();
|
|
1109
|
+
await auth.api.registerSSOProvider({
|
|
1110
|
+
body: {
|
|
1111
|
+
providerId: "saml-provider-1",
|
|
1112
|
+
issuer: "http://localhost:8081",
|
|
1113
|
+
domain: "http://localhost:8081",
|
|
1114
|
+
samlConfig: {
|
|
1115
|
+
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
|
|
1116
|
+
cert: certificate,
|
|
1117
|
+
callbackUrl: "http://localhost:3000/dashboard",
|
|
1118
|
+
wantAssertionsSigned: false,
|
|
1119
|
+
signatureAlgorithm: "sha256",
|
|
1120
|
+
digestAlgorithm: "sha256",
|
|
1121
|
+
idpMetadata: {
|
|
1122
|
+
metadata: idpMetadata,
|
|
1123
|
+
},
|
|
1124
|
+
spMetadata: {
|
|
1125
|
+
metadata: spMetadata,
|
|
1126
|
+
},
|
|
1127
|
+
identifierFormat:
|
|
1128
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
1129
|
+
},
|
|
1130
|
+
},
|
|
1131
|
+
headers,
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
const response = await auth.api.signInSSO({
|
|
1135
|
+
body: {
|
|
1136
|
+
providerId: "saml-provider-1",
|
|
1137
|
+
callbackURL: "http://localhost:3000/dashboard",
|
|
1138
|
+
},
|
|
1139
|
+
returnHeaders: true,
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
const signInResponse = response.response;
|
|
1143
|
+
expect(signInResponse).toEqual({
|
|
1144
|
+
url: expect.stringContaining("http://localhost:8081"),
|
|
1145
|
+
redirect: true,
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
let samlResponse: any;
|
|
1149
|
+
await betterFetch(signInResponse?.url, {
|
|
1150
|
+
onSuccess: async (context) => {
|
|
1151
|
+
samlResponse = await context.data;
|
|
1152
|
+
},
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
const samlRedirectUrl = new URL(signInResponse?.url);
|
|
1156
|
+
const callbackResponse = await auth.api.callbackSSOSAML({
|
|
1157
|
+
method: "POST",
|
|
1158
|
+
body: {
|
|
1159
|
+
SAMLResponse: samlResponse.samlResponse,
|
|
1160
|
+
RelayState: samlRedirectUrl.searchParams.get("RelayState") ?? "",
|
|
1161
|
+
},
|
|
1162
|
+
headers: {
|
|
1163
|
+
Cookie: response.headers.get("set-cookie") ?? "",
|
|
1164
|
+
},
|
|
1165
|
+
params: {
|
|
1166
|
+
providerId: "saml-provider-1",
|
|
1167
|
+
},
|
|
1168
|
+
asResponse: true,
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
expect(callbackResponse.headers.get("location")).toContain("dashboard");
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
it("should initiate SAML login and fallback to callbackUrl on invalid RelayState", async () => {
|
|
1175
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
1176
|
+
plugins: [sso()],
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
const { headers } = await signInWithTestUser();
|
|
1180
|
+
await auth.api.registerSSOProvider({
|
|
1181
|
+
body: {
|
|
1182
|
+
providerId: "saml-provider-1",
|
|
1183
|
+
issuer: "http://localhost:8081",
|
|
1184
|
+
domain: "http://localhost:8081",
|
|
1185
|
+
samlConfig: {
|
|
1186
|
+
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
|
|
1187
|
+
cert: certificate,
|
|
1188
|
+
callbackUrl: "http://localhost:3000/dashboard",
|
|
1189
|
+
wantAssertionsSigned: false,
|
|
1190
|
+
signatureAlgorithm: "sha256",
|
|
1191
|
+
digestAlgorithm: "sha256",
|
|
1192
|
+
idpMetadata: {
|
|
1193
|
+
metadata: idpMetadata,
|
|
1194
|
+
},
|
|
1195
|
+
spMetadata: {
|
|
1196
|
+
metadata: spMetadata,
|
|
1197
|
+
},
|
|
1198
|
+
identifierFormat:
|
|
1199
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
1200
|
+
},
|
|
1201
|
+
},
|
|
1202
|
+
headers,
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
const response = await auth.api.signInSSO({
|
|
1206
|
+
body: {
|
|
1207
|
+
providerId: "saml-provider-1",
|
|
1208
|
+
callbackURL: "http://localhost:3000/dashboard",
|
|
1209
|
+
},
|
|
1210
|
+
returnHeaders: true,
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
const signInResponse = response.response;
|
|
1214
|
+
expect(signInResponse).toEqual({
|
|
1215
|
+
url: expect.stringContaining("http://localhost:8081"),
|
|
1216
|
+
redirect: true,
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
let samlResponse: any;
|
|
1220
|
+
await betterFetch(signInResponse?.url, {
|
|
1221
|
+
onSuccess: async (context) => {
|
|
1222
|
+
samlResponse = await context.data;
|
|
1223
|
+
},
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
const callbackResponse = await auth.api.callbackSSOSAML({
|
|
1227
|
+
method: "POST",
|
|
1228
|
+
body: {
|
|
1229
|
+
SAMLResponse: samlResponse.samlResponse,
|
|
1230
|
+
RelayState: "not-the-right-relay-state",
|
|
1231
|
+
},
|
|
1232
|
+
headers: {
|
|
1233
|
+
Cookie: response.headers.get("set-cookie") ?? "",
|
|
1234
|
+
},
|
|
1235
|
+
params: {
|
|
1236
|
+
providerId: "saml-provider-1",
|
|
1237
|
+
},
|
|
1238
|
+
asResponse: true,
|
|
1239
|
+
});
|
|
1240
|
+
|
|
1241
|
+
expect(callbackResponse.status).toBe(302);
|
|
1242
|
+
expect(callbackResponse.headers.get("location")).toBe(
|
|
1243
|
+
"http://localhost:3000/dashboard",
|
|
1244
|
+
);
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
it("should initiate SAML login and signup user when disableImplicitSignUp is true but requestSignup is explicitly enabled", async () => {
|
|
1248
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
1249
|
+
plugins: [sso({ disableImplicitSignUp: true })],
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
const { headers } = await signInWithTestUser();
|
|
1253
|
+
await auth.api.registerSSOProvider({
|
|
1254
|
+
body: {
|
|
1255
|
+
providerId: "saml-provider-1",
|
|
1256
|
+
issuer: "http://localhost:8081",
|
|
1257
|
+
domain: "http://localhost:8081",
|
|
1258
|
+
samlConfig: {
|
|
1259
|
+
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
|
|
1260
|
+
cert: certificate,
|
|
1261
|
+
callbackUrl: "http://localhost:3000/dashboard",
|
|
1262
|
+
wantAssertionsSigned: false,
|
|
1263
|
+
signatureAlgorithm: "sha256",
|
|
1264
|
+
digestAlgorithm: "sha256",
|
|
1265
|
+
idpMetadata: {
|
|
1266
|
+
metadata: idpMetadata,
|
|
1267
|
+
},
|
|
1268
|
+
spMetadata: {
|
|
1269
|
+
metadata: spMetadata,
|
|
1270
|
+
},
|
|
1271
|
+
identifierFormat:
|
|
1272
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
1273
|
+
},
|
|
1274
|
+
},
|
|
1275
|
+
headers,
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
const response = await auth.api.signInSSO({
|
|
1279
|
+
body: {
|
|
1280
|
+
providerId: "saml-provider-1",
|
|
1281
|
+
callbackURL: "http://localhost:3000/dashboard",
|
|
1282
|
+
requestSignUp: true,
|
|
1283
|
+
},
|
|
1284
|
+
returnHeaders: true,
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
const signInResponse = response.response;
|
|
1288
|
+
expect(signInResponse).toEqual({
|
|
1289
|
+
url: expect.stringContaining("http://localhost:8081"),
|
|
1290
|
+
redirect: true,
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
let samlResponse: any;
|
|
1294
|
+
await betterFetch(signInResponse?.url, {
|
|
1295
|
+
onSuccess: async (context) => {
|
|
1296
|
+
samlResponse = await context.data;
|
|
1297
|
+
},
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
const samlRedirectUrl = new URL(signInResponse?.url);
|
|
1301
|
+
const callbackResponse = await auth.api.callbackSSOSAML({
|
|
1302
|
+
method: "POST",
|
|
1303
|
+
body: {
|
|
1304
|
+
SAMLResponse: samlResponse.samlResponse,
|
|
1305
|
+
RelayState: samlRedirectUrl.searchParams.get("RelayState") ?? "",
|
|
1306
|
+
},
|
|
1307
|
+
headers: {
|
|
1308
|
+
Cookie: response.headers.get("set-cookie") ?? "",
|
|
1309
|
+
},
|
|
1310
|
+
params: {
|
|
1311
|
+
providerId: "saml-provider-1",
|
|
1312
|
+
},
|
|
1313
|
+
asResponse: true,
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
expect(callbackResponse.headers.get("location")).toContain("dashboard");
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1103
1319
|
it("should reject SAML sign-in when disableImplicitSignUp is true and user doesn't exist", async () => {
|
|
1104
1320
|
const { auth: authWithDisabledSignUp, signInWithTestUser } =
|
|
1105
1321
|
await getTestInstance({
|
|
@@ -1293,7 +1509,6 @@ describe("SAML SSO", async () => {
|
|
|
1293
1509
|
},
|
|
1294
1510
|
body: new URLSearchParams({
|
|
1295
1511
|
SAMLResponse: samlResponse.samlResponse,
|
|
1296
|
-
RelayState: "http://localhost:3000/dashboard",
|
|
1297
1512
|
}),
|
|
1298
1513
|
},
|
|
1299
1514
|
),
|
|
@@ -1374,7 +1589,6 @@ describe("SAML SSO", async () => {
|
|
|
1374
1589
|
},
|
|
1375
1590
|
body: new URLSearchParams({
|
|
1376
1591
|
SAMLResponse: samlResponse.samlResponse,
|
|
1377
|
-
RelayState: "http://localhost:3000/dashboard",
|
|
1378
1592
|
}),
|
|
1379
1593
|
},
|
|
1380
1594
|
),
|
|
@@ -1442,7 +1656,6 @@ describe("SAML SSO", async () => {
|
|
|
1442
1656
|
},
|
|
1443
1657
|
body: new URLSearchParams({
|
|
1444
1658
|
SAMLResponse: samlResponse.samlResponse,
|
|
1445
|
-
RelayState: "http://localhost:3000/dashboard",
|
|
1446
1659
|
}),
|
|
1447
1660
|
},
|
|
1448
1661
|
),
|
|
@@ -1509,7 +1722,6 @@ describe("SAML SSO", async () => {
|
|
|
1509
1722
|
},
|
|
1510
1723
|
body: new URLSearchParams({
|
|
1511
1724
|
SAMLResponse: samlResponse.samlResponse,
|
|
1512
|
-
RelayState: "http://localhost:3000/dashboard",
|
|
1513
1725
|
}),
|
|
1514
1726
|
},
|
|
1515
1727
|
),
|
|
@@ -1569,7 +1781,6 @@ describe("SAML SSO", async () => {
|
|
|
1569
1781
|
},
|
|
1570
1782
|
body: new URLSearchParams({
|
|
1571
1783
|
SAMLResponse: samlResponse.samlResponse,
|
|
1572
|
-
RelayState: "http://localhost:3000/dashboard",
|
|
1573
1784
|
}),
|
|
1574
1785
|
},
|
|
1575
1786
|
),
|
|
@@ -1638,7 +1849,6 @@ describe("SAML SSO", async () => {
|
|
|
1638
1849
|
},
|
|
1639
1850
|
body: new URLSearchParams({
|
|
1640
1851
|
SAMLResponse: samlResponse.samlResponse,
|
|
1641
|
-
RelayState: "http://localhost:3000/dashboard",
|
|
1642
1852
|
}),
|
|
1643
1853
|
},
|
|
1644
1854
|
),
|
|
@@ -1978,8 +2188,8 @@ describe("SSO Provider Config Parsing", () => {
|
|
|
1978
2188
|
});
|
|
1979
2189
|
});
|
|
1980
2190
|
|
|
1981
|
-
describe("SAML SSO -
|
|
1982
|
-
it("should
|
|
2191
|
+
describe("SAML SSO - IdP Initiated Flow", () => {
|
|
2192
|
+
it("should handle IdP-initiated flow with GET after POST redirect", async () => {
|
|
1983
2193
|
const { auth, signInWithTestUser } = await getTestInstance({
|
|
1984
2194
|
plugins: [sso()],
|
|
1985
2195
|
});
|
|
@@ -1988,11 +2198,14 @@ describe("SAML SSO - Signature Validation Security", () => {
|
|
|
1988
2198
|
|
|
1989
2199
|
await auth.api.registerSSOProvider({
|
|
1990
2200
|
body: {
|
|
1991
|
-
providerId: "
|
|
2201
|
+
providerId: "idp-initiated-provider",
|
|
1992
2202
|
issuer: "http://localhost:8081",
|
|
1993
2203
|
domain: "http://localhost:8081",
|
|
1994
2204
|
samlConfig: {
|
|
1995
|
-
entryPoint:
|
|
2205
|
+
entryPoint: sharedMockIdP.metadataUrl.replace(
|
|
2206
|
+
"/idp/metadata",
|
|
2207
|
+
"/idp/post",
|
|
2208
|
+
),
|
|
1996
2209
|
cert: certificate,
|
|
1997
2210
|
callbackUrl: "http://localhost:3000/dashboard",
|
|
1998
2211
|
wantAssertionsSigned: false,
|
|
@@ -2011,64 +2224,110 @@ describe("SAML SSO - Signature Validation Security", () => {
|
|
|
2011
2224
|
headers,
|
|
2012
2225
|
});
|
|
2013
2226
|
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
<saml2:Conditions>
|
|
2026
|
-
<saml2:AudienceRestriction>
|
|
2027
|
-
<saml2:Audience>http://localhost:3001</saml2:Audience>
|
|
2028
|
-
</saml2:AudienceRestriction>
|
|
2029
|
-
</saml2:Conditions>
|
|
2030
|
-
<saml2:AuthnStatement>
|
|
2031
|
-
<saml2:AuthnContext>
|
|
2032
|
-
<saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml2:AuthnContextClassRef>
|
|
2033
|
-
</saml2:AuthnContext>
|
|
2034
|
-
</saml2:AuthnStatement>
|
|
2035
|
-
</saml2:Assertion>
|
|
2036
|
-
</saml2p:Response>
|
|
2037
|
-
`;
|
|
2227
|
+
let samlResponse:
|
|
2228
|
+
| { samlResponse: string; entityEndpoint?: string }
|
|
2229
|
+
| undefined;
|
|
2230
|
+
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
|
|
2231
|
+
onSuccess: async (context) => {
|
|
2232
|
+
samlResponse = context.data as {
|
|
2233
|
+
samlResponse: string;
|
|
2234
|
+
entityEndpoint?: string;
|
|
2235
|
+
};
|
|
2236
|
+
},
|
|
2237
|
+
});
|
|
2038
2238
|
|
|
2039
|
-
|
|
2239
|
+
if (!samlResponse?.samlResponse) {
|
|
2240
|
+
throw new Error("Failed to get SAML response from mock IdP");
|
|
2241
|
+
}
|
|
2040
2242
|
|
|
2041
|
-
await
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
)
|
|
2052
|
-
|
|
2243
|
+
const postResponse = await auth.api.callbackSSOSAML({
|
|
2244
|
+
method: "POST",
|
|
2245
|
+
body: {
|
|
2246
|
+
SAMLResponse: samlResponse.samlResponse,
|
|
2247
|
+
RelayState: "http://localhost:3000/dashboard",
|
|
2248
|
+
},
|
|
2249
|
+
params: {
|
|
2250
|
+
providerId: "idp-initiated-provider",
|
|
2251
|
+
},
|
|
2252
|
+
asResponse: true,
|
|
2253
|
+
});
|
|
2254
|
+
|
|
2255
|
+
expect(postResponse).toBeInstanceOf(Response);
|
|
2256
|
+
expect(postResponse.status).toBe(302);
|
|
2257
|
+
const redirectLocation = postResponse.headers.get("location");
|
|
2258
|
+
expect(redirectLocation).toBe("http://localhost:3000/dashboard");
|
|
2259
|
+
|
|
2260
|
+
const cookieHeader = postResponse.headers.get("set-cookie");
|
|
2261
|
+
const getResponse = await auth.api.callbackSSOSAML({
|
|
2262
|
+
method: "GET",
|
|
2263
|
+
query: {
|
|
2264
|
+
RelayState: "http://localhost:3000/dashboard",
|
|
2265
|
+
},
|
|
2266
|
+
params: {
|
|
2267
|
+
providerId: "idp-initiated-provider",
|
|
2268
|
+
},
|
|
2269
|
+
headers: cookieHeader ? { cookie: cookieHeader } : undefined,
|
|
2270
|
+
asResponse: true,
|
|
2271
|
+
});
|
|
2272
|
+
|
|
2273
|
+
expect(getResponse).toBeInstanceOf(Response);
|
|
2274
|
+
expect(getResponse.status).toBe(302);
|
|
2275
|
+
const getRedirectLocation = getResponse.headers.get("location");
|
|
2276
|
+
expect(getRedirectLocation).toBe("http://localhost:3000/dashboard");
|
|
2277
|
+
});
|
|
2278
|
+
|
|
2279
|
+
it("should reject direct GET request without session", async () => {
|
|
2280
|
+
const { auth } = await getTestInstance({
|
|
2281
|
+
plugins: [sso()],
|
|
2053
2282
|
});
|
|
2283
|
+
|
|
2284
|
+
const getResponse = await auth.api
|
|
2285
|
+
.callbackSSOSAML({
|
|
2286
|
+
method: "GET",
|
|
2287
|
+
params: {
|
|
2288
|
+
providerId: "test-provider",
|
|
2289
|
+
},
|
|
2290
|
+
asResponse: true,
|
|
2291
|
+
})
|
|
2292
|
+
.catch((e) => {
|
|
2293
|
+
if (e instanceof APIError && e.status === "FOUND") {
|
|
2294
|
+
return new Response(null, {
|
|
2295
|
+
status: e.statusCode,
|
|
2296
|
+
headers: e.headers || new Headers(),
|
|
2297
|
+
});
|
|
2298
|
+
}
|
|
2299
|
+
throw e;
|
|
2300
|
+
});
|
|
2301
|
+
|
|
2302
|
+
expect(getResponse).toBeInstanceOf(Response);
|
|
2303
|
+
expect(getResponse.status).toBe(302);
|
|
2304
|
+
const redirectLocation = getResponse.headers.get("location");
|
|
2305
|
+
expect(redirectLocation).toContain("/error");
|
|
2306
|
+
expect(redirectLocation).toContain("error=invalid_request");
|
|
2054
2307
|
});
|
|
2055
2308
|
|
|
2056
|
-
it("should
|
|
2309
|
+
it("should prevent redirect loop when callbackUrl points to callback route", async () => {
|
|
2057
2310
|
const { auth, signInWithTestUser } = await getTestInstance({
|
|
2058
2311
|
plugins: [sso()],
|
|
2059
2312
|
});
|
|
2060
2313
|
|
|
2061
2314
|
const { headers } = await signInWithTestUser();
|
|
2062
2315
|
|
|
2316
|
+
const callbackRouteUrl =
|
|
2317
|
+
"http://localhost:3000/api/auth/sso/saml2/callback/loop-test-provider";
|
|
2318
|
+
|
|
2063
2319
|
await auth.api.registerSSOProvider({
|
|
2064
2320
|
body: {
|
|
2065
|
-
providerId: "
|
|
2321
|
+
providerId: "loop-test-provider",
|
|
2066
2322
|
issuer: "http://localhost:8081",
|
|
2067
2323
|
domain: "http://localhost:8081",
|
|
2068
2324
|
samlConfig: {
|
|
2069
|
-
entryPoint:
|
|
2325
|
+
entryPoint: sharedMockIdP.metadataUrl.replace(
|
|
2326
|
+
"/idp/metadata",
|
|
2327
|
+
"/idp/post",
|
|
2328
|
+
),
|
|
2070
2329
|
cert: certificate,
|
|
2071
|
-
callbackUrl:
|
|
2330
|
+
callbackUrl: callbackRouteUrl,
|
|
2072
2331
|
wantAssertionsSigned: false,
|
|
2073
2332
|
signatureAlgorithm: "sha256",
|
|
2074
2333
|
digestAlgorithm: "sha256",
|
|
@@ -2085,49 +2344,513 @@ describe("SAML SSO - Signature Validation Security", () => {
|
|
|
2085
2344
|
headers,
|
|
2086
2345
|
});
|
|
2087
2346
|
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
</ds:SignedInfo>
|
|
2100
|
-
<ds:SignatureValue>INVALID_SIGNATURE_VALUE_THAT_SHOULD_FAIL_VERIFICATION</ds:SignatureValue>
|
|
2101
|
-
</ds:Signature>
|
|
2102
|
-
<saml2p:Status>
|
|
2103
|
-
<saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
|
|
2104
|
-
</saml2p:Status>
|
|
2105
|
-
<saml2:Assertion>
|
|
2106
|
-
<saml2:Issuer>http://localhost:8081</saml2:Issuer>
|
|
2107
|
-
<saml2:Subject>
|
|
2108
|
-
<saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">forged-admin@company.com</saml2:NameID>
|
|
2109
|
-
</saml2:Subject>
|
|
2110
|
-
</saml2:Assertion>
|
|
2111
|
-
</saml2p:Response>
|
|
2112
|
-
`;
|
|
2347
|
+
let samlResponse:
|
|
2348
|
+
| { samlResponse: string; entityEndpoint?: string }
|
|
2349
|
+
| undefined;
|
|
2350
|
+
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
|
|
2351
|
+
onSuccess: async (context) => {
|
|
2352
|
+
samlResponse = context.data as {
|
|
2353
|
+
samlResponse: string;
|
|
2354
|
+
entityEndpoint?: string;
|
|
2355
|
+
};
|
|
2356
|
+
},
|
|
2357
|
+
});
|
|
2113
2358
|
|
|
2114
|
-
|
|
2359
|
+
if (!samlResponse?.samlResponse) {
|
|
2360
|
+
throw new Error("Failed to get SAML response from mock IdP");
|
|
2361
|
+
}
|
|
2115
2362
|
|
|
2116
|
-
await
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
}),
|
|
2126
|
-
).rejects.toMatchObject({
|
|
2127
|
-
status: "BAD_REQUEST",
|
|
2363
|
+
const postResponse = await auth.api.callbackSSOSAML({
|
|
2364
|
+
method: "POST",
|
|
2365
|
+
body: {
|
|
2366
|
+
SAMLResponse: samlResponse.samlResponse,
|
|
2367
|
+
},
|
|
2368
|
+
params: {
|
|
2369
|
+
providerId: "loop-test-provider",
|
|
2370
|
+
},
|
|
2371
|
+
asResponse: true,
|
|
2128
2372
|
});
|
|
2373
|
+
|
|
2374
|
+
expect(postResponse).toBeInstanceOf(Response);
|
|
2375
|
+
expect(postResponse.status).toBe(302);
|
|
2376
|
+
const redirectLocation = postResponse.headers.get("location");
|
|
2377
|
+
expect(redirectLocation).not.toBe(callbackRouteUrl);
|
|
2378
|
+
expect(redirectLocation).toBe("http://localhost:3000");
|
|
2129
2379
|
});
|
|
2130
|
-
|
|
2380
|
+
|
|
2381
|
+
it("should handle GET request with RelayState in query", async () => {
|
|
2382
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
2383
|
+
plugins: [sso()],
|
|
2384
|
+
});
|
|
2385
|
+
|
|
2386
|
+
const { headers } = await signInWithTestUser();
|
|
2387
|
+
|
|
2388
|
+
await auth.api.registerSSOProvider({
|
|
2389
|
+
body: {
|
|
2390
|
+
providerId: "relaystate-provider",
|
|
2391
|
+
issuer: "http://localhost:8081",
|
|
2392
|
+
domain: "http://localhost:8081",
|
|
2393
|
+
samlConfig: {
|
|
2394
|
+
entryPoint: sharedMockIdP.metadataUrl.replace(
|
|
2395
|
+
"/idp/metadata",
|
|
2396
|
+
"/idp/post",
|
|
2397
|
+
),
|
|
2398
|
+
cert: certificate,
|
|
2399
|
+
callbackUrl: "http://localhost:3000/dashboard",
|
|
2400
|
+
wantAssertionsSigned: false,
|
|
2401
|
+
signatureAlgorithm: "sha256",
|
|
2402
|
+
digestAlgorithm: "sha256",
|
|
2403
|
+
idpMetadata: {
|
|
2404
|
+
metadata: idpMetadata,
|
|
2405
|
+
},
|
|
2406
|
+
spMetadata: {
|
|
2407
|
+
metadata: spMetadata,
|
|
2408
|
+
},
|
|
2409
|
+
identifierFormat:
|
|
2410
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
2411
|
+
},
|
|
2412
|
+
},
|
|
2413
|
+
headers,
|
|
2414
|
+
});
|
|
2415
|
+
|
|
2416
|
+
let samlResponse:
|
|
2417
|
+
| { samlResponse: string; entityEndpoint?: string }
|
|
2418
|
+
| undefined;
|
|
2419
|
+
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
|
|
2420
|
+
onSuccess: async (context) => {
|
|
2421
|
+
samlResponse = context.data as {
|
|
2422
|
+
samlResponse: string;
|
|
2423
|
+
entityEndpoint?: string;
|
|
2424
|
+
};
|
|
2425
|
+
},
|
|
2426
|
+
});
|
|
2427
|
+
|
|
2428
|
+
if (!samlResponse?.samlResponse) {
|
|
2429
|
+
throw new Error("Failed to get SAML response from mock IdP");
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
const postResponse = await auth.api.callbackSSOSAML({
|
|
2433
|
+
method: "POST",
|
|
2434
|
+
body: {
|
|
2435
|
+
SAMLResponse: samlResponse.samlResponse,
|
|
2436
|
+
RelayState: "http://localhost:3000/custom-path",
|
|
2437
|
+
},
|
|
2438
|
+
params: {
|
|
2439
|
+
providerId: "relaystate-provider",
|
|
2440
|
+
},
|
|
2441
|
+
asResponse: true,
|
|
2442
|
+
});
|
|
2443
|
+
|
|
2444
|
+
const cookieHeader = postResponse.headers.get("set-cookie");
|
|
2445
|
+
const getResponse = await auth.api.callbackSSOSAML({
|
|
2446
|
+
method: "GET",
|
|
2447
|
+
query: {
|
|
2448
|
+
RelayState: "http://localhost:3000/custom-path",
|
|
2449
|
+
},
|
|
2450
|
+
params: {
|
|
2451
|
+
providerId: "relaystate-provider",
|
|
2452
|
+
},
|
|
2453
|
+
headers: cookieHeader ? { cookie: cookieHeader } : undefined,
|
|
2454
|
+
asResponse: true,
|
|
2455
|
+
});
|
|
2456
|
+
|
|
2457
|
+
expect(getResponse).toBeInstanceOf(Response);
|
|
2458
|
+
expect(getResponse.status).toBe(302);
|
|
2459
|
+
const redirectLocation = getResponse.headers.get("location");
|
|
2460
|
+
expect(redirectLocation).toBe("http://localhost:3000/custom-path");
|
|
2461
|
+
});
|
|
2462
|
+
|
|
2463
|
+
it("should handle GET request when POST redirects to callback URL (original issue scenario)", async () => {
|
|
2464
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
2465
|
+
plugins: [sso()],
|
|
2466
|
+
});
|
|
2467
|
+
|
|
2468
|
+
const { headers } = await signInWithTestUser();
|
|
2469
|
+
|
|
2470
|
+
const callbackRouteUrl =
|
|
2471
|
+
"http://localhost:3000/api/auth/sso/saml2/callback/issue-6615-provider";
|
|
2472
|
+
|
|
2473
|
+
await auth.api.registerSSOProvider({
|
|
2474
|
+
body: {
|
|
2475
|
+
providerId: "issue-6615-provider",
|
|
2476
|
+
issuer: "http://localhost:8081",
|
|
2477
|
+
domain: "http://localhost:8081",
|
|
2478
|
+
samlConfig: {
|
|
2479
|
+
entryPoint: sharedMockIdP.metadataUrl.replace(
|
|
2480
|
+
"/idp/metadata",
|
|
2481
|
+
"/idp/post",
|
|
2482
|
+
),
|
|
2483
|
+
cert: certificate,
|
|
2484
|
+
callbackUrl: "http://localhost:3000/dashboard",
|
|
2485
|
+
wantAssertionsSigned: false,
|
|
2486
|
+
signatureAlgorithm: "sha256",
|
|
2487
|
+
digestAlgorithm: "sha256",
|
|
2488
|
+
idpMetadata: {
|
|
2489
|
+
metadata: idpMetadata,
|
|
2490
|
+
},
|
|
2491
|
+
spMetadata: {
|
|
2492
|
+
metadata: spMetadata,
|
|
2493
|
+
},
|
|
2494
|
+
identifierFormat:
|
|
2495
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
2496
|
+
},
|
|
2497
|
+
},
|
|
2498
|
+
headers,
|
|
2499
|
+
});
|
|
2500
|
+
|
|
2501
|
+
let samlResponse:
|
|
2502
|
+
| { samlResponse: string; entityEndpoint?: string }
|
|
2503
|
+
| undefined;
|
|
2504
|
+
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
|
|
2505
|
+
onSuccess: async (context) => {
|
|
2506
|
+
samlResponse = context.data as {
|
|
2507
|
+
samlResponse: string;
|
|
2508
|
+
entityEndpoint?: string;
|
|
2509
|
+
};
|
|
2510
|
+
},
|
|
2511
|
+
});
|
|
2512
|
+
|
|
2513
|
+
if (!samlResponse?.samlResponse) {
|
|
2514
|
+
throw new Error("Failed to get SAML response from mock IdP");
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
const postResponse = await auth.api.callbackSSOSAML({
|
|
2518
|
+
method: "POST",
|
|
2519
|
+
body: {
|
|
2520
|
+
SAMLResponse: samlResponse.samlResponse,
|
|
2521
|
+
RelayState: callbackRouteUrl,
|
|
2522
|
+
},
|
|
2523
|
+
params: {
|
|
2524
|
+
providerId: "issue-6615-provider",
|
|
2525
|
+
},
|
|
2526
|
+
asResponse: true,
|
|
2527
|
+
});
|
|
2528
|
+
|
|
2529
|
+
expect(postResponse).toBeInstanceOf(Response);
|
|
2530
|
+
expect(postResponse.status).toBe(302);
|
|
2531
|
+
const postRedirectLocation = postResponse.headers.get("location");
|
|
2532
|
+
expect(postRedirectLocation).not.toBe(callbackRouteUrl);
|
|
2533
|
+
expect(postRedirectLocation).toBe("http://localhost:3000/dashboard");
|
|
2534
|
+
|
|
2535
|
+
const cookieHeader = postResponse.headers.get("set-cookie");
|
|
2536
|
+
const getResponse = await auth.api.callbackSSOSAML({
|
|
2537
|
+
method: "GET",
|
|
2538
|
+
params: {
|
|
2539
|
+
providerId: "issue-6615-provider",
|
|
2540
|
+
},
|
|
2541
|
+
headers: cookieHeader ? { cookie: cookieHeader } : undefined,
|
|
2542
|
+
asResponse: true,
|
|
2543
|
+
});
|
|
2544
|
+
|
|
2545
|
+
expect(getResponse).toBeInstanceOf(Response);
|
|
2546
|
+
expect(getResponse.status).toBe(302);
|
|
2547
|
+
const getRedirectLocation = getResponse.headers.get("location");
|
|
2548
|
+
expect(getRedirectLocation).toBe("http://localhost:3000");
|
|
2549
|
+
});
|
|
2550
|
+
|
|
2551
|
+
it("should prevent open redirect with malicious RelayState URL", async () => {
|
|
2552
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
2553
|
+
plugins: [sso()],
|
|
2554
|
+
});
|
|
2555
|
+
|
|
2556
|
+
const { headers } = await signInWithTestUser();
|
|
2557
|
+
|
|
2558
|
+
await auth.api.registerSSOProvider({
|
|
2559
|
+
body: {
|
|
2560
|
+
providerId: "open-redirect-test-provider",
|
|
2561
|
+
issuer: "http://localhost:8081",
|
|
2562
|
+
domain: "http://localhost:8081",
|
|
2563
|
+
samlConfig: {
|
|
2564
|
+
entryPoint: sharedMockIdP.metadataUrl.replace(
|
|
2565
|
+
"/idp/metadata",
|
|
2566
|
+
"/idp/post",
|
|
2567
|
+
),
|
|
2568
|
+
cert: certificate,
|
|
2569
|
+
callbackUrl: "http://localhost:3000/dashboard",
|
|
2570
|
+
wantAssertionsSigned: false,
|
|
2571
|
+
signatureAlgorithm: "sha256",
|
|
2572
|
+
digestAlgorithm: "sha256",
|
|
2573
|
+
idpMetadata: {
|
|
2574
|
+
metadata: idpMetadata,
|
|
2575
|
+
},
|
|
2576
|
+
spMetadata: {
|
|
2577
|
+
metadata: spMetadata,
|
|
2578
|
+
},
|
|
2579
|
+
identifierFormat:
|
|
2580
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
2581
|
+
},
|
|
2582
|
+
},
|
|
2583
|
+
headers,
|
|
2584
|
+
});
|
|
2585
|
+
|
|
2586
|
+
let samlResponse:
|
|
2587
|
+
| { samlResponse: string; entityEndpoint?: string }
|
|
2588
|
+
| undefined;
|
|
2589
|
+
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
|
|
2590
|
+
onSuccess: async (context) => {
|
|
2591
|
+
samlResponse = context.data as {
|
|
2592
|
+
samlResponse: string;
|
|
2593
|
+
entityEndpoint?: string;
|
|
2594
|
+
};
|
|
2595
|
+
},
|
|
2596
|
+
});
|
|
2597
|
+
|
|
2598
|
+
if (!samlResponse?.samlResponse) {
|
|
2599
|
+
throw new Error("Failed to get SAML response from mock IdP");
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
// Test POST with malicious RelayState - raw RelayState is not trusted
|
|
2603
|
+
// Falls back to parsedSamlConfig.callbackUrl
|
|
2604
|
+
const postResponse = await auth.api.callbackSSOSAML({
|
|
2605
|
+
method: "POST",
|
|
2606
|
+
body: {
|
|
2607
|
+
SAMLResponse: samlResponse.samlResponse,
|
|
2608
|
+
RelayState: "https://evil.com/phishing",
|
|
2609
|
+
},
|
|
2610
|
+
params: {
|
|
2611
|
+
providerId: "open-redirect-test-provider",
|
|
2612
|
+
},
|
|
2613
|
+
asResponse: true,
|
|
2614
|
+
});
|
|
2615
|
+
|
|
2616
|
+
expect(postResponse).toBeInstanceOf(Response);
|
|
2617
|
+
expect(postResponse.status).toBe(302);
|
|
2618
|
+
const postRedirectLocation = postResponse.headers.get("location");
|
|
2619
|
+
// Should NOT redirect to evil.com - raw RelayState is ignored
|
|
2620
|
+
expect(postRedirectLocation).not.toContain("evil.com");
|
|
2621
|
+
// Falls back to samlConfig.callbackUrl
|
|
2622
|
+
expect(postRedirectLocation).toBe("http://localhost:3000/dashboard");
|
|
2623
|
+
});
|
|
2624
|
+
|
|
2625
|
+
it("should prevent open redirect via GET with malicious RelayState", async () => {
|
|
2626
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
2627
|
+
plugins: [sso()],
|
|
2628
|
+
});
|
|
2629
|
+
|
|
2630
|
+
const { headers } = await signInWithTestUser();
|
|
2631
|
+
|
|
2632
|
+
await auth.api.registerSSOProvider({
|
|
2633
|
+
body: {
|
|
2634
|
+
providerId: "open-redirect-get-provider",
|
|
2635
|
+
issuer: "http://localhost:8081",
|
|
2636
|
+
domain: "http://localhost:8081",
|
|
2637
|
+
samlConfig: {
|
|
2638
|
+
entryPoint: sharedMockIdP.metadataUrl.replace(
|
|
2639
|
+
"/idp/metadata",
|
|
2640
|
+
"/idp/post",
|
|
2641
|
+
),
|
|
2642
|
+
cert: certificate,
|
|
2643
|
+
callbackUrl: "http://localhost:3000/dashboard",
|
|
2644
|
+
wantAssertionsSigned: false,
|
|
2645
|
+
signatureAlgorithm: "sha256",
|
|
2646
|
+
digestAlgorithm: "sha256",
|
|
2647
|
+
idpMetadata: {
|
|
2648
|
+
metadata: idpMetadata,
|
|
2649
|
+
},
|
|
2650
|
+
spMetadata: {
|
|
2651
|
+
metadata: spMetadata,
|
|
2652
|
+
},
|
|
2653
|
+
identifierFormat:
|
|
2654
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
2655
|
+
},
|
|
2656
|
+
},
|
|
2657
|
+
headers,
|
|
2658
|
+
});
|
|
2659
|
+
|
|
2660
|
+
let samlResponse:
|
|
2661
|
+
| { samlResponse: string; entityEndpoint?: string }
|
|
2662
|
+
| undefined;
|
|
2663
|
+
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
|
|
2664
|
+
onSuccess: async (context) => {
|
|
2665
|
+
samlResponse = context.data as {
|
|
2666
|
+
samlResponse: string;
|
|
2667
|
+
entityEndpoint?: string;
|
|
2668
|
+
};
|
|
2669
|
+
},
|
|
2670
|
+
});
|
|
2671
|
+
|
|
2672
|
+
if (!samlResponse?.samlResponse) {
|
|
2673
|
+
throw new Error("Failed to get SAML response from mock IdP");
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
// First do POST to establish session
|
|
2677
|
+
const postResponse = await auth.api.callbackSSOSAML({
|
|
2678
|
+
method: "POST",
|
|
2679
|
+
body: {
|
|
2680
|
+
SAMLResponse: samlResponse.samlResponse,
|
|
2681
|
+
},
|
|
2682
|
+
params: {
|
|
2683
|
+
providerId: "open-redirect-get-provider",
|
|
2684
|
+
},
|
|
2685
|
+
asResponse: true,
|
|
2686
|
+
});
|
|
2687
|
+
|
|
2688
|
+
const cookieHeader = postResponse.headers.get("set-cookie");
|
|
2689
|
+
|
|
2690
|
+
// Test GET with malicious RelayState in query params
|
|
2691
|
+
const getResponse = await auth.api.callbackSSOSAML({
|
|
2692
|
+
method: "GET",
|
|
2693
|
+
query: {
|
|
2694
|
+
RelayState: "https://evil.com/steal-cookies",
|
|
2695
|
+
},
|
|
2696
|
+
params: {
|
|
2697
|
+
providerId: "open-redirect-get-provider",
|
|
2698
|
+
},
|
|
2699
|
+
headers: cookieHeader ? { cookie: cookieHeader } : undefined,
|
|
2700
|
+
asResponse: true,
|
|
2701
|
+
});
|
|
2702
|
+
|
|
2703
|
+
expect(getResponse).toBeInstanceOf(Response);
|
|
2704
|
+
expect(getResponse.status).toBe(302);
|
|
2705
|
+
const getRedirectLocation = getResponse.headers.get("location");
|
|
2706
|
+
// Should NOT redirect to evil.com
|
|
2707
|
+
expect(getRedirectLocation).not.toContain("evil.com");
|
|
2708
|
+
expect(getRedirectLocation).toBe("http://localhost:3000");
|
|
2709
|
+
});
|
|
2710
|
+
|
|
2711
|
+
it("should allow relative path redirects", async () => {
|
|
2712
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
2713
|
+
plugins: [sso()],
|
|
2714
|
+
});
|
|
2715
|
+
|
|
2716
|
+
const { headers } = await signInWithTestUser();
|
|
2717
|
+
|
|
2718
|
+
await auth.api.registerSSOProvider({
|
|
2719
|
+
body: {
|
|
2720
|
+
providerId: "relative-path-provider",
|
|
2721
|
+
issuer: "http://localhost:8081",
|
|
2722
|
+
domain: "http://localhost:8081",
|
|
2723
|
+
samlConfig: {
|
|
2724
|
+
entryPoint: sharedMockIdP.metadataUrl.replace(
|
|
2725
|
+
"/idp/metadata",
|
|
2726
|
+
"/idp/post",
|
|
2727
|
+
),
|
|
2728
|
+
cert: certificate,
|
|
2729
|
+
callbackUrl: "http://localhost:3000/dashboard",
|
|
2730
|
+
wantAssertionsSigned: false,
|
|
2731
|
+
signatureAlgorithm: "sha256",
|
|
2732
|
+
digestAlgorithm: "sha256",
|
|
2733
|
+
idpMetadata: {
|
|
2734
|
+
metadata: idpMetadata,
|
|
2735
|
+
},
|
|
2736
|
+
spMetadata: {
|
|
2737
|
+
metadata: spMetadata,
|
|
2738
|
+
},
|
|
2739
|
+
identifierFormat:
|
|
2740
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
2741
|
+
},
|
|
2742
|
+
},
|
|
2743
|
+
headers,
|
|
2744
|
+
});
|
|
2745
|
+
|
|
2746
|
+
let samlResponse:
|
|
2747
|
+
| { samlResponse: string; entityEndpoint?: string }
|
|
2748
|
+
| undefined;
|
|
2749
|
+
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
|
|
2750
|
+
onSuccess: async (context) => {
|
|
2751
|
+
samlResponse = context.data as {
|
|
2752
|
+
samlResponse: string;
|
|
2753
|
+
entityEndpoint?: string;
|
|
2754
|
+
};
|
|
2755
|
+
},
|
|
2756
|
+
});
|
|
2757
|
+
|
|
2758
|
+
if (!samlResponse?.samlResponse) {
|
|
2759
|
+
throw new Error("Failed to get SAML response from mock IdP");
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
const postResponse = await auth.api.callbackSSOSAML({
|
|
2763
|
+
method: "POST",
|
|
2764
|
+
body: {
|
|
2765
|
+
SAMLResponse: samlResponse.samlResponse,
|
|
2766
|
+
RelayState: "/dashboard/settings",
|
|
2767
|
+
},
|
|
2768
|
+
params: {
|
|
2769
|
+
providerId: "relative-path-provider",
|
|
2770
|
+
},
|
|
2771
|
+
asResponse: true,
|
|
2772
|
+
});
|
|
2773
|
+
|
|
2774
|
+
expect(postResponse).toBeInstanceOf(Response);
|
|
2775
|
+
expect(postResponse.status).toBe(302);
|
|
2776
|
+
const redirectLocation = postResponse.headers.get("location");
|
|
2777
|
+
expect(redirectLocation).toBe("http://localhost:3000/dashboard");
|
|
2778
|
+
});
|
|
2779
|
+
|
|
2780
|
+
it("should block protocol-relative URL attacks (//evil.com)", async () => {
|
|
2781
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
2782
|
+
plugins: [sso()],
|
|
2783
|
+
});
|
|
2784
|
+
|
|
2785
|
+
const { headers } = await signInWithTestUser();
|
|
2786
|
+
|
|
2787
|
+
await auth.api.registerSSOProvider({
|
|
2788
|
+
body: {
|
|
2789
|
+
providerId: "protocol-relative-provider",
|
|
2790
|
+
issuer: "http://localhost:8081",
|
|
2791
|
+
domain: "http://localhost:8081",
|
|
2792
|
+
samlConfig: {
|
|
2793
|
+
entryPoint: sharedMockIdP.metadataUrl.replace(
|
|
2794
|
+
"/idp/metadata",
|
|
2795
|
+
"/idp/post",
|
|
2796
|
+
),
|
|
2797
|
+
cert: certificate,
|
|
2798
|
+
callbackUrl: "http://localhost:3000/dashboard",
|
|
2799
|
+
wantAssertionsSigned: false,
|
|
2800
|
+
signatureAlgorithm: "sha256",
|
|
2801
|
+
digestAlgorithm: "sha256",
|
|
2802
|
+
idpMetadata: {
|
|
2803
|
+
metadata: idpMetadata,
|
|
2804
|
+
},
|
|
2805
|
+
spMetadata: {
|
|
2806
|
+
metadata: spMetadata,
|
|
2807
|
+
},
|
|
2808
|
+
identifierFormat:
|
|
2809
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
2810
|
+
},
|
|
2811
|
+
},
|
|
2812
|
+
headers,
|
|
2813
|
+
});
|
|
2814
|
+
|
|
2815
|
+
let samlResponse:
|
|
2816
|
+
| { samlResponse: string; entityEndpoint?: string }
|
|
2817
|
+
| undefined;
|
|
2818
|
+
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
|
|
2819
|
+
onSuccess: async (context) => {
|
|
2820
|
+
samlResponse = context.data as {
|
|
2821
|
+
samlResponse: string;
|
|
2822
|
+
entityEndpoint?: string;
|
|
2823
|
+
};
|
|
2824
|
+
},
|
|
2825
|
+
});
|
|
2826
|
+
|
|
2827
|
+
if (!samlResponse?.samlResponse) {
|
|
2828
|
+
throw new Error("Failed to get SAML response from mock IdP");
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
// Test POST with protocol-relative URL - raw RelayState is not trusted
|
|
2832
|
+
// Falls back to parsedSamlConfig.callbackUrl
|
|
2833
|
+
const postResponse = await auth.api.callbackSSOSAML({
|
|
2834
|
+
method: "POST",
|
|
2835
|
+
body: {
|
|
2836
|
+
SAMLResponse: samlResponse.samlResponse,
|
|
2837
|
+
RelayState: "//evil.com/phishing",
|
|
2838
|
+
},
|
|
2839
|
+
params: {
|
|
2840
|
+
providerId: "protocol-relative-provider",
|
|
2841
|
+
},
|
|
2842
|
+
asResponse: true,
|
|
2843
|
+
});
|
|
2844
|
+
|
|
2845
|
+
expect(postResponse).toBeInstanceOf(Response);
|
|
2846
|
+
expect(postResponse.status).toBe(302);
|
|
2847
|
+
const redirectLocation = postResponse.headers.get("location");
|
|
2848
|
+
// Should NOT redirect to evil.com - raw RelayState is ignored
|
|
2849
|
+
expect(redirectLocation).not.toContain("evil.com");
|
|
2850
|
+
// Falls back to samlConfig.callbackUrl
|
|
2851
|
+
expect(redirectLocation).toBe("http://localhost:3000/dashboard");
|
|
2852
|
+
});
|
|
2853
|
+
});
|
|
2131
2854
|
|
|
2132
2855
|
describe("SAML SSO - Timestamp Validation", () => {
|
|
2133
2856
|
describe("Valid assertions within time window", () => {
|
|
@@ -2358,6 +3081,328 @@ describe("SAML SSO - Timestamp Validation", () => {
|
|
|
2358
3081
|
});
|
|
2359
3082
|
});
|
|
2360
3083
|
|
|
3084
|
+
describe("SAML ACS Origin Check Bypass", () => {
|
|
3085
|
+
describe("Positive: SAML endpoints allow external IdP origins", () => {
|
|
3086
|
+
it("should allow SAML callback POST from external IdP origin", async () => {
|
|
3087
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
3088
|
+
plugins: [sso()],
|
|
3089
|
+
});
|
|
3090
|
+
const { headers } = await signInWithTestUser();
|
|
3091
|
+
|
|
3092
|
+
// Register SAML provider with full config
|
|
3093
|
+
await auth.api.registerSSOProvider({
|
|
3094
|
+
body: {
|
|
3095
|
+
providerId: "origin-bypass-callback",
|
|
3096
|
+
issuer: "http://localhost:8081",
|
|
3097
|
+
domain: "origin-bypass.com",
|
|
3098
|
+
samlConfig: {
|
|
3099
|
+
entryPoint: sharedMockIdP.metadataUrl,
|
|
3100
|
+
cert: certificate,
|
|
3101
|
+
callbackUrl: "http://localhost:8081/api/auth/sso/saml2/callback",
|
|
3102
|
+
wantAssertionsSigned: false,
|
|
3103
|
+
signatureAlgorithm: "sha256",
|
|
3104
|
+
digestAlgorithm: "sha256",
|
|
3105
|
+
spMetadata: {
|
|
3106
|
+
metadata: spMetadata,
|
|
3107
|
+
},
|
|
3108
|
+
},
|
|
3109
|
+
},
|
|
3110
|
+
headers,
|
|
3111
|
+
});
|
|
3112
|
+
|
|
3113
|
+
// POST to callback with external Origin header (simulating IdP POST)
|
|
3114
|
+
// Origin check should be bypassed for SAML callback endpoints
|
|
3115
|
+
const callbackRes = await auth.handler(
|
|
3116
|
+
new Request(
|
|
3117
|
+
"http://localhost:8081/api/auth/sso/saml2/callback/origin-bypass-callback",
|
|
3118
|
+
{
|
|
3119
|
+
method: "POST",
|
|
3120
|
+
headers: {
|
|
3121
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
3122
|
+
Origin: "http://external-idp.example.com", // External IdP origin - would normally be blocked
|
|
3123
|
+
Cookie: headers.get("cookie") || "",
|
|
3124
|
+
},
|
|
3125
|
+
body: new URLSearchParams({
|
|
3126
|
+
SAMLResponse: Buffer.from("<fake-saml-response/>").toString(
|
|
3127
|
+
"base64",
|
|
3128
|
+
),
|
|
3129
|
+
RelayState: "",
|
|
3130
|
+
}).toString(),
|
|
3131
|
+
},
|
|
3132
|
+
),
|
|
3133
|
+
);
|
|
3134
|
+
|
|
3135
|
+
// Should NOT return 403 Forbidden (origin check bypassed)
|
|
3136
|
+
// May return other errors (400, 500) due to invalid SAML response, but NOT origin rejection
|
|
3137
|
+
expect(callbackRes.status).not.toBe(403);
|
|
3138
|
+
});
|
|
3139
|
+
|
|
3140
|
+
it("should allow ACS endpoint POST from external IdP origin", async () => {
|
|
3141
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
3142
|
+
plugins: [sso()],
|
|
3143
|
+
});
|
|
3144
|
+
const { headers } = await signInWithTestUser();
|
|
3145
|
+
|
|
3146
|
+
// Register SAML provider with full config
|
|
3147
|
+
await auth.api.registerSSOProvider({
|
|
3148
|
+
body: {
|
|
3149
|
+
providerId: "origin-bypass-acs",
|
|
3150
|
+
issuer: "http://localhost:8081",
|
|
3151
|
+
domain: "origin-bypass-acs.com",
|
|
3152
|
+
samlConfig: {
|
|
3153
|
+
entryPoint: sharedMockIdP.metadataUrl,
|
|
3154
|
+
cert: certificate,
|
|
3155
|
+
callbackUrl: "http://localhost:8081/api/auth/sso/saml2/sp/acs",
|
|
3156
|
+
wantAssertionsSigned: false,
|
|
3157
|
+
signatureAlgorithm: "sha256",
|
|
3158
|
+
digestAlgorithm: "sha256",
|
|
3159
|
+
spMetadata: {
|
|
3160
|
+
metadata: spMetadata,
|
|
3161
|
+
},
|
|
3162
|
+
},
|
|
3163
|
+
},
|
|
3164
|
+
headers,
|
|
3165
|
+
});
|
|
3166
|
+
|
|
3167
|
+
// POST to ACS with external Origin header
|
|
3168
|
+
const acsRes = await auth.handler(
|
|
3169
|
+
new Request(
|
|
3170
|
+
"http://localhost:8081/api/auth/sso/saml2/sp/acs/origin-bypass-acs",
|
|
3171
|
+
{
|
|
3172
|
+
method: "POST",
|
|
3173
|
+
headers: {
|
|
3174
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
3175
|
+
Origin: "http://idp.external.com", // External IdP origin
|
|
3176
|
+
Cookie: headers.get("cookie") || "",
|
|
3177
|
+
},
|
|
3178
|
+
body: new URLSearchParams({
|
|
3179
|
+
SAMLResponse: Buffer.from("<fake-saml-response/>").toString(
|
|
3180
|
+
"base64",
|
|
3181
|
+
),
|
|
3182
|
+
}).toString(),
|
|
3183
|
+
},
|
|
3184
|
+
),
|
|
3185
|
+
);
|
|
3186
|
+
|
|
3187
|
+
// Should NOT return 403 Forbidden
|
|
3188
|
+
expect(acsRes.status).not.toBe(403);
|
|
3189
|
+
});
|
|
3190
|
+
});
|
|
3191
|
+
|
|
3192
|
+
describe("Negative: Non-SAML endpoints remain protected", () => {
|
|
3193
|
+
it("should block POST to sign-up with untrusted origin when origin check is enabled", async () => {
|
|
3194
|
+
const { auth } = await getTestInstance({
|
|
3195
|
+
plugins: [sso()],
|
|
3196
|
+
advanced: {
|
|
3197
|
+
disableCSRFCheck: false,
|
|
3198
|
+
disableOriginCheck: false,
|
|
3199
|
+
},
|
|
3200
|
+
});
|
|
3201
|
+
|
|
3202
|
+
// Origin check applies when cookies are present and check is enabled
|
|
3203
|
+
const signUpRes = await auth.handler(
|
|
3204
|
+
new Request("http://localhost:8081/api/auth/sign-up/email", {
|
|
3205
|
+
method: "POST",
|
|
3206
|
+
headers: {
|
|
3207
|
+
"Content-Type": "application/json",
|
|
3208
|
+
Origin: "http://attacker.com",
|
|
3209
|
+
Cookie: "better-auth.session_token=fake-session",
|
|
3210
|
+
},
|
|
3211
|
+
body: JSON.stringify({
|
|
3212
|
+
email: "victim@example.com",
|
|
3213
|
+
password: "password123",
|
|
3214
|
+
name: "Victim",
|
|
3215
|
+
}),
|
|
3216
|
+
}),
|
|
3217
|
+
);
|
|
3218
|
+
|
|
3219
|
+
expect(signUpRes.status).toBe(403);
|
|
3220
|
+
});
|
|
3221
|
+
});
|
|
3222
|
+
|
|
3223
|
+
describe("Edge cases", () => {
|
|
3224
|
+
it("should allow GET requests to SAML metadata regardless of origin", async () => {
|
|
3225
|
+
const { auth } = await getTestInstance({
|
|
3226
|
+
plugins: [sso()],
|
|
3227
|
+
});
|
|
3228
|
+
|
|
3229
|
+
// GET requests always bypass origin check
|
|
3230
|
+
const metadataRes = await auth.handler(
|
|
3231
|
+
new Request("http://localhost:8081/api/auth/sso/saml2/sp/metadata", {
|
|
3232
|
+
method: "GET",
|
|
3233
|
+
headers: {
|
|
3234
|
+
Origin: "http://any-origin.com",
|
|
3235
|
+
},
|
|
3236
|
+
}),
|
|
3237
|
+
);
|
|
3238
|
+
|
|
3239
|
+
expect(metadataRes.status).not.toBe(403);
|
|
3240
|
+
});
|
|
3241
|
+
|
|
3242
|
+
it("should not redirect to malicious RelayState URLs", async () => {
|
|
3243
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
3244
|
+
plugins: [sso()],
|
|
3245
|
+
});
|
|
3246
|
+
const { headers } = await signInWithTestUser();
|
|
3247
|
+
|
|
3248
|
+
await auth.api.registerSSOProvider({
|
|
3249
|
+
body: {
|
|
3250
|
+
providerId: "relay-security-test",
|
|
3251
|
+
issuer: "http://localhost:8081",
|
|
3252
|
+
domain: "relay-security.com",
|
|
3253
|
+
samlConfig: {
|
|
3254
|
+
entryPoint: sharedMockIdP.metadataUrl,
|
|
3255
|
+
cert: certificate,
|
|
3256
|
+
callbackUrl: "http://localhost:8081/api/auth/sso/saml2/callback",
|
|
3257
|
+
wantAssertionsSigned: false,
|
|
3258
|
+
signatureAlgorithm: "sha256",
|
|
3259
|
+
digestAlgorithm: "sha256",
|
|
3260
|
+
spMetadata: {
|
|
3261
|
+
metadata: spMetadata,
|
|
3262
|
+
},
|
|
3263
|
+
},
|
|
3264
|
+
},
|
|
3265
|
+
headers,
|
|
3266
|
+
});
|
|
3267
|
+
|
|
3268
|
+
// Even with origin bypass, malicious RelayState should be rejected
|
|
3269
|
+
const callbackRes = await auth.handler(
|
|
3270
|
+
new Request(
|
|
3271
|
+
"http://localhost:8081/api/auth/sso/saml2/callback/relay-security-test",
|
|
3272
|
+
{
|
|
3273
|
+
method: "POST",
|
|
3274
|
+
headers: {
|
|
3275
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
3276
|
+
Origin: "http://idp.example.com",
|
|
3277
|
+
},
|
|
3278
|
+
body: new URLSearchParams({
|
|
3279
|
+
SAMLResponse: Buffer.from("<fake-saml-response/>").toString(
|
|
3280
|
+
"base64",
|
|
3281
|
+
),
|
|
3282
|
+
RelayState: "http://malicious-site.com/steal-token",
|
|
3283
|
+
}).toString(),
|
|
3284
|
+
},
|
|
3285
|
+
),
|
|
3286
|
+
);
|
|
3287
|
+
|
|
3288
|
+
// Should NOT redirect to malicious URL
|
|
3289
|
+
if (callbackRes.status === 302) {
|
|
3290
|
+
const location = callbackRes.headers.get("Location");
|
|
3291
|
+
expect(location).not.toContain("malicious-site.com");
|
|
3292
|
+
}
|
|
3293
|
+
});
|
|
3294
|
+
});
|
|
3295
|
+
});
|
|
3296
|
+
|
|
3297
|
+
describe("SAML Response Security", () => {
|
|
3298
|
+
it("should reject forged/unsigned SAML responses", async () => {
|
|
3299
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
3300
|
+
plugins: [sso()],
|
|
3301
|
+
});
|
|
3302
|
+
const { headers } = await signInWithTestUser();
|
|
3303
|
+
|
|
3304
|
+
await auth.api.registerSSOProvider({
|
|
3305
|
+
body: {
|
|
3306
|
+
providerId: "security-test-provider",
|
|
3307
|
+
issuer: "http://localhost:8081",
|
|
3308
|
+
domain: "security-test.com",
|
|
3309
|
+
samlConfig: {
|
|
3310
|
+
entryPoint: sharedMockIdP.metadataUrl,
|
|
3311
|
+
cert: certificate,
|
|
3312
|
+
callbackUrl: "http://localhost:8081/api/auth/sso/saml2/callback",
|
|
3313
|
+
wantAssertionsSigned: false,
|
|
3314
|
+
signatureAlgorithm: "sha256",
|
|
3315
|
+
digestAlgorithm: "sha256",
|
|
3316
|
+
spMetadata: {
|
|
3317
|
+
metadata: spMetadata,
|
|
3318
|
+
},
|
|
3319
|
+
},
|
|
3320
|
+
},
|
|
3321
|
+
headers,
|
|
3322
|
+
});
|
|
3323
|
+
|
|
3324
|
+
const forgedSAMLResponse = `
|
|
3325
|
+
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
|
|
3326
|
+
<saml:Assertion>
|
|
3327
|
+
<saml:Subject>
|
|
3328
|
+
<saml2:NameID xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">attacker@evil.com</saml2:NameID>
|
|
3329
|
+
</saml:Subject>
|
|
3330
|
+
</saml:Assertion>
|
|
3331
|
+
</samlp:Response>
|
|
3332
|
+
`;
|
|
3333
|
+
|
|
3334
|
+
const callbackRes = await auth.handler(
|
|
3335
|
+
new Request(
|
|
3336
|
+
"http://localhost:8081/api/auth/sso/saml2/callback/security-test-provider",
|
|
3337
|
+
{
|
|
3338
|
+
method: "POST",
|
|
3339
|
+
headers: {
|
|
3340
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
3341
|
+
},
|
|
3342
|
+
body: new URLSearchParams({
|
|
3343
|
+
SAMLResponse: Buffer.from(forgedSAMLResponse).toString("base64"),
|
|
3344
|
+
RelayState: "",
|
|
3345
|
+
}).toString(),
|
|
3346
|
+
},
|
|
3347
|
+
),
|
|
3348
|
+
);
|
|
3349
|
+
|
|
3350
|
+
expect(callbackRes.status).toBe(400);
|
|
3351
|
+
const body = await callbackRes.json();
|
|
3352
|
+
expect(body.message).toBe("Invalid SAML response");
|
|
3353
|
+
});
|
|
3354
|
+
|
|
3355
|
+
it("should reject SAML response with tampered nameID", async () => {
|
|
3356
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
3357
|
+
plugins: [sso()],
|
|
3358
|
+
});
|
|
3359
|
+
const { headers } = await signInWithTestUser();
|
|
3360
|
+
|
|
3361
|
+
await auth.api.registerSSOProvider({
|
|
3362
|
+
body: {
|
|
3363
|
+
providerId: "tamper-test-provider",
|
|
3364
|
+
issuer: "http://localhost:8081",
|
|
3365
|
+
domain: "tamper-test.com",
|
|
3366
|
+
samlConfig: {
|
|
3367
|
+
entryPoint: sharedMockIdP.metadataUrl,
|
|
3368
|
+
cert: certificate,
|
|
3369
|
+
callbackUrl: "http://localhost:8081/api/auth/sso/saml2/callback",
|
|
3370
|
+
wantAssertionsSigned: false,
|
|
3371
|
+
signatureAlgorithm: "sha256",
|
|
3372
|
+
digestAlgorithm: "sha256",
|
|
3373
|
+
spMetadata: {
|
|
3374
|
+
metadata: spMetadata,
|
|
3375
|
+
},
|
|
3376
|
+
},
|
|
3377
|
+
},
|
|
3378
|
+
headers,
|
|
3379
|
+
});
|
|
3380
|
+
|
|
3381
|
+
const tamperedResponse = `<?xml version="1.0"?>
|
|
3382
|
+
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">
|
|
3383
|
+
<saml2:NameID xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">admin@victim.com</saml2:NameID>
|
|
3384
|
+
</samlp:Response>`;
|
|
3385
|
+
|
|
3386
|
+
const callbackRes = await auth.handler(
|
|
3387
|
+
new Request(
|
|
3388
|
+
"http://localhost:8081/api/auth/sso/saml2/callback/tamper-test-provider",
|
|
3389
|
+
{
|
|
3390
|
+
method: "POST",
|
|
3391
|
+
headers: {
|
|
3392
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
3393
|
+
},
|
|
3394
|
+
body: new URLSearchParams({
|
|
3395
|
+
SAMLResponse: Buffer.from(tamperedResponse).toString("base64"),
|
|
3396
|
+
RelayState: "",
|
|
3397
|
+
}).toString(),
|
|
3398
|
+
},
|
|
3399
|
+
),
|
|
3400
|
+
);
|
|
3401
|
+
|
|
3402
|
+
expect(callbackRes.status).toBe(400);
|
|
3403
|
+
});
|
|
3404
|
+
});
|
|
3405
|
+
|
|
2361
3406
|
describe("SAML SSO - Size Limit Validation", () => {
|
|
2362
3407
|
it("should export default size limit constants", async () => {
|
|
2363
3408
|
const { DEFAULT_MAX_SAML_RESPONSE_SIZE, DEFAULT_MAX_SAML_METADATA_SIZE } =
|
|
@@ -2408,7 +3453,6 @@ describe("SAML SSO - Assertion Replay Protection", () => {
|
|
|
2408
3453
|
},
|
|
2409
3454
|
});
|
|
2410
3455
|
|
|
2411
|
-
// First submission should succeed
|
|
2412
3456
|
const firstResponse = await auth.handler(
|
|
2413
3457
|
new Request(
|
|
2414
3458
|
"http://localhost:3000/api/auth/sso/saml2/callback/replay-test-provider",
|
|
@@ -2429,7 +3473,6 @@ describe("SAML SSO - Assertion Replay Protection", () => {
|
|
|
2429
3473
|
const firstLocation = firstResponse.headers.get("location") || "";
|
|
2430
3474
|
expect(firstLocation).not.toContain("error");
|
|
2431
3475
|
|
|
2432
|
-
// Second submission (replay) should be rejected
|
|
2433
3476
|
const replayResponse = await auth.handler(
|
|
2434
3477
|
new Request(
|
|
2435
3478
|
"http://localhost:3000/api/auth/sso/saml2/callback/replay-test-provider",
|
|
@@ -2490,7 +3533,6 @@ describe("SAML SSO - Assertion Replay Protection", () => {
|
|
|
2490
3533
|
},
|
|
2491
3534
|
});
|
|
2492
3535
|
|
|
2493
|
-
// First submission to ACS endpoint should succeed
|
|
2494
3536
|
const firstResponse = await auth.handler(
|
|
2495
3537
|
new Request(
|
|
2496
3538
|
"http://localhost:3000/api/auth/sso/saml2/sp/acs/acs-replay-test-provider",
|
|
@@ -2511,7 +3553,6 @@ describe("SAML SSO - Assertion Replay Protection", () => {
|
|
|
2511
3553
|
const firstLocation = firstResponse.headers.get("location") || "";
|
|
2512
3554
|
expect(firstLocation).not.toContain("error");
|
|
2513
3555
|
|
|
2514
|
-
// Second submission (replay) to ACS endpoint should be rejected
|
|
2515
3556
|
const replayResponse = await auth.handler(
|
|
2516
3557
|
new Request(
|
|
2517
3558
|
"http://localhost:3000/api/auth/sso/saml2/sp/acs/acs-replay-test-provider",
|
|
@@ -2572,7 +3613,6 @@ describe("SAML SSO - Assertion Replay Protection", () => {
|
|
|
2572
3613
|
},
|
|
2573
3614
|
});
|
|
2574
3615
|
|
|
2575
|
-
// First: Submit to callback endpoint (should succeed)
|
|
2576
3616
|
const callbackResponse = await auth.handler(
|
|
2577
3617
|
new Request(
|
|
2578
3618
|
"http://localhost:3000/api/auth/sso/saml2/callback/cross-endpoint-provider",
|
|
@@ -2592,7 +3632,6 @@ describe("SAML SSO - Assertion Replay Protection", () => {
|
|
|
2592
3632
|
expect(callbackResponse.status).toBe(302);
|
|
2593
3633
|
expect(callbackResponse.headers.get("location")).not.toContain("error");
|
|
2594
3634
|
|
|
2595
|
-
// Second: Replay same assertion to ACS endpoint (should be rejected)
|
|
2596
3635
|
const acsReplayResponse = await auth.handler(
|
|
2597
3636
|
new Request(
|
|
2598
3637
|
"http://localhost:3000/api/auth/sso/saml2/sp/acs/cross-endpoint-provider",
|