@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/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 - Signature Validation Security", () => {
1982
- it("should reject unsigned SAML response with forged NameID", async () => {
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: "security-test-provider",
2201
+ providerId: "idp-initiated-provider",
1992
2202
  issuer: "http://localhost:8081",
1993
2203
  domain: "http://localhost:8081",
1994
2204
  samlConfig: {
1995
- entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
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
- const forgedSamlResponse = `
2015
- <saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
2016
- <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2017
- <saml2p:Status>
2018
- <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
2019
- </saml2p:Status>
2020
- <saml2:Assertion>
2021
- <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2022
- <saml2:Subject>
2023
- <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">attacker-forged@evil.com</saml2:NameID>
2024
- </saml2:Subject>
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
- const encodedForgedResponse = base64.encode(forgedSamlResponse);
2239
+ if (!samlResponse?.samlResponse) {
2240
+ throw new Error("Failed to get SAML response from mock IdP");
2241
+ }
2040
2242
 
2041
- await expect(
2042
- auth.api.callbackSSOSAML({
2043
- body: {
2044
- SAMLResponse: encodedForgedResponse,
2045
- RelayState: "http://localhost:3000/dashboard",
2046
- },
2047
- params: {
2048
- providerId: "security-test-provider",
2049
- },
2050
- }),
2051
- ).rejects.toMatchObject({
2052
- status: "BAD_REQUEST",
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 reject SAML response with invalid signature", async () => {
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: "invalid-sig-provider",
2321
+ providerId: "loop-test-provider",
2066
2322
  issuer: "http://localhost:8081",
2067
2323
  domain: "http://localhost:8081",
2068
2324
  samlConfig: {
2069
- entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
2325
+ entryPoint: sharedMockIdP.metadataUrl.replace(
2326
+ "/idp/metadata",
2327
+ "/idp/post",
2328
+ ),
2070
2329
  cert: certificate,
2071
- callbackUrl: "http://localhost:3000/dashboard",
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
- const responseWithBadSignature = `
2089
- <saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
2090
- <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2091
- <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
2092
- <ds:SignedInfo>
2093
- <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
2094
- <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
2095
- <ds:Reference>
2096
- <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
2097
- <ds:DigestValue>FAKE_DIGEST_VALUE</ds:DigestValue>
2098
- </ds:Reference>
2099
- </ds:SignedInfo>
2100
- <ds:SignatureValue>INVALID_SIGNATURE_VALUE_THAT_SHOULD_FAIL_VERIFICATION</ds:SignatureValue>
2101
- </ds:Signature>
2102
- <saml2p:Status>
2103
- <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
2104
- </saml2p:Status>
2105
- <saml2:Assertion>
2106
- <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2107
- <saml2:Subject>
2108
- <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">forged-admin@company.com</saml2:NameID>
2109
- </saml2:Subject>
2110
- </saml2:Assertion>
2111
- </saml2p:Response>
2112
- `;
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
- const encodedBadSigResponse = base64.encode(responseWithBadSignature);
2359
+ if (!samlResponse?.samlResponse) {
2360
+ throw new Error("Failed to get SAML response from mock IdP");
2361
+ }
2115
2362
 
2116
- await expect(
2117
- auth.api.callbackSSOSAML({
2118
- body: {
2119
- SAMLResponse: encodedBadSigResponse,
2120
- RelayState: "http://localhost:3000/dashboard",
2121
- },
2122
- params: {
2123
- providerId: "invalid-sig-provider",
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",