@better-auth/sso 1.4.17 → 1.5.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/saml.test.ts CHANGED
@@ -1,11 +1,11 @@
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
- import { setCookieToHeader } from "better-auth/cookies";
8
+ import { parseSetCookieHeader, setCookieToHeader } from "better-auth/cookies";
9
9
  import { bearer } from "better-auth/plugins";
10
10
  import { getTestInstance } from "better-auth/test";
11
11
  import bodyParser from "body-parser";
@@ -399,7 +399,14 @@ const createMockSAMLIdP = (port: number) => {
399
399
  app.get(
400
400
  "/api/sso/saml2/idp/post",
401
401
  async (req: ExpressRequest, res: ExpressResponse) => {
402
- const user = { emailAddress: "test@email.com", famName: "hello world" };
402
+ const emailCase = req.query.emailCase as string;
403
+ const emailValue =
404
+ emailCase === "mixed" ? "TestUser@Example.com" : "test@email.com";
405
+ const user = {
406
+ email: emailValue,
407
+ emailAddress: emailValue,
408
+ famName: "hello world",
409
+ };
403
410
  const { context, entityEndpoint } = await idp.createLoginResponse(
404
411
  sp,
405
412
  {} as any,
@@ -413,7 +420,14 @@ const createMockSAMLIdP = (port: number) => {
413
420
  app.get(
414
421
  "/api/sso/saml2/idp/redirect",
415
422
  async (req: ExpressRequest, res: ExpressResponse) => {
416
- const user = { emailAddress: "test@email.com", famName: "hello world" };
423
+ const emailCase = req.query.emailCase as string;
424
+ const emailValue =
425
+ emailCase === "mixed" ? "TestUser@Example.com" : "test@email.com";
426
+ const user = {
427
+ email: emailValue,
428
+ emailAddress: emailValue,
429
+ famName: "hello world",
430
+ };
417
431
  const { context, entityEndpoint } = await idp.createLoginResponse(
418
432
  sp,
419
433
  {} as any,
@@ -574,6 +588,155 @@ describe("SAML SSO with defaultSSO array", async () => {
574
588
  });
575
589
  });
576
590
 
591
+ describe("SAML SSO with signed AuthnRequests", async () => {
592
+ // IdP metadata with WantAuthnRequestsSigned="true" for testing signed requests
593
+ const idpMetadataWithSignedRequests = `
594
+ <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="http://localhost:8081/api/sso/saml2/idp/metadata">
595
+ <md:IDPSSODescriptor WantAuthnRequestsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
596
+ <md:KeyDescriptor use="signing">
597
+ <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
598
+ <ds:X509Data>
599
+ <ds:X509Certificate>MIIFOjCCAyICCQCqP5DN+xQZDjANBgkqhkiG9w0BAQsFADBfMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzENMAsGA1UECgwEVGVzdDEdMBsGCSqGSIb3DQEJARYOdGVzdEBnbWFpbC5jb20wHhcNMjMxMTE5MTIzNzE3WhcNMzMxMTE2MTIzNzE3WjBfMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzENMAsGA1UECgwEVGVzdDEdMBsGCSqGSIb3DQEJARYOdGVzdEBnbWFpbC5jb20wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQD5giLoLyED41IHt0RxB/k6x4K0vzAKiGecPyedRNR1oyiv3OYkuG5jgTE2wcPZc7kD1Eg5d6th0BWHy/ovaNS5mkgnOV6jKkMaWW4sCMSnLnaWy0seftPK3O4mNeZpM5e9amj2gXnZvKrK8cqnJ/bsUUQvXxttXNVVmOHWg/t3c2vJ4XuUfph6wIKbrj297ILzuAFRNvAVxeS0tElwepvZ5Wbf7Hc1MORAqTpw/mp8cRjHRzYCA9y6OM4hgVs1gvTJS8WGoMmsdAZHaOnv9vLJvW3jDLQQecOheYIJncWgcESzJFIkmXadorYCEfWhwwBdVphknmeLr4BMpJBclAYaFjYDLIKpMcXYO5k/2r3BgSPlw4oqbxbR5geD05myKYtZ/wNUtku118NjhIfJFulU/kfDcp1rYYkvzgBfqr80wgNps4oQzVr1mnpgHsSTAhXMuZbaTByJRmPqecyvyQqRQcRIN0oTLJNGyzoUf0RkH6DKJ4+7qDhlq4Zhlfso9OFMv9xeONfIrJo5HtTfFZfidkXZqir2ZqwqNlNOMfK5DsYq37x2Gkgqig4nqLpITXyxfnQpL2HsaoFrlctt/OL+Zqba7NT4heYk9GX8qlAS+Ipsv6T2HSANbah55oSS3uvcrDOug2Zq7+GYMLKS1IKUKhwX+wLMxmMwSJQ9ZgFwfQIDAQABMA0GCSqGSIb3DQEBCwUAA4ICAQCkGPZdflocTSXIe5bbehsBn/IPdyb38eH2HaAvWqO2XNcDcq+6/uLc8BVK4JMa3AFS9xtBza7MOXN/lw/Ccb8uJGVNUE31+rTvsJaDtMCQkp+9aG04I1BonEHfSB0ANcTy/Gp+4hKyFCd6x35uyPO7CWX5Z8I87q9LF6Dte3/v1j7VZgDjAi9yHpBJv9Xje33AK1vF+WmEfDUOi8y2B8htVeoyS3owln3ZUbnmJdCmMp2BMRq63ymINwklEaYaNrp1L201bSqNdKZF2sNwROWyDX+WFYgufrnzPYb6HS8gYb4oEZmaG5cBM7Hs730/3BlbHKhxNTy1Io2TVCYcMQD+ieiVg5e5eGTwaPYGuVvY3NVhO8FaYBG7K2NT2hqutdCMaQpGyHEzbbbTY1afhbeMmWWqivRnVJNDv4kgBc2SE8JO82qHikIW9Om0cghC5xwTT+1JTtxxD1KeC1M1IwLzzuuMmwJSKAsv4duDqN+YRIP78J2SlrssqlsmoF8+48e7Vzr7JRT/Ya274P8RpUPNtxTR7WDmZ4tunqXjiBpz6l0uTtVXnj5UBo4HCyRjWJOGf15OCuQX03qz8tKn1IbZUf723qrmSF+cxBwHqpAywqhTSsaLjIXKnQ0UlMov7QWb0a5N07JZMdMSerbHvbXd/z9S1Ssea2+EGuTYuQur3A==</ds:X509Certificate>
600
+ </ds:X509Data>
601
+ </ds:KeyInfo>
602
+ </md:KeyDescriptor>
603
+ <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:8081/api/sso/saml2/idp/redirect"/>
604
+ <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost:8081/api/sso/saml2/idp/post"/>
605
+ </md:IDPSSODescriptor>
606
+ </md:EntityDescriptor>
607
+ `;
608
+
609
+ const data = {
610
+ user: [],
611
+ session: [],
612
+ verification: [],
613
+ account: [],
614
+ ssoProvider: [],
615
+ };
616
+
617
+ const memory = memoryAdapter(data);
618
+
619
+ const ssoOptions = {
620
+ defaultSSO: [
621
+ {
622
+ domain: "localhost:8081",
623
+ providerId: "signed-saml",
624
+ samlConfig: {
625
+ issuer: "http://localhost:8081",
626
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
627
+ cert: certificate,
628
+ callbackUrl: "http://localhost:8081/dashboard",
629
+ wantAssertionsSigned: false,
630
+ authnRequestsSigned: true,
631
+ signatureAlgorithm: "sha256",
632
+ digestAlgorithm: "sha256",
633
+ privateKey: idPk,
634
+ spMetadata: {
635
+ privateKey: idPk,
636
+ },
637
+ idpMetadata: {
638
+ metadata: idpMetadataWithSignedRequests,
639
+ },
640
+ identifierFormat:
641
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
642
+ },
643
+ },
644
+ ],
645
+ };
646
+
647
+ const auth = betterAuth({
648
+ database: memory,
649
+ baseURL: "http://localhost:3000",
650
+ emailAndPassword: {
651
+ enabled: true,
652
+ },
653
+ plugins: [sso(ssoOptions)],
654
+ });
655
+
656
+ it("should generate signed AuthnRequest when authnRequestsSigned is true", async () => {
657
+ const signInResponse = await auth.api.signInSSO({
658
+ body: {
659
+ providerId: "signed-saml",
660
+ callbackURL: "http://localhost:3000/dashboard",
661
+ },
662
+ });
663
+
664
+ expect(signInResponse).toEqual({
665
+ url: expect.stringContaining("http://localhost:8081"),
666
+ redirect: true,
667
+ });
668
+ // When authnRequestsSigned is true and privateKey is provided,
669
+ // samlify adds Signature and SigAlg parameters to the redirect URL
670
+ expect(signInResponse.url).toContain("Signature=");
671
+ expect(signInResponse.url).toContain("SigAlg=");
672
+ });
673
+ });
674
+
675
+ describe("SAML SSO without signed AuthnRequests", async () => {
676
+ const data = {
677
+ user: [],
678
+ session: [],
679
+ verification: [],
680
+ account: [],
681
+ ssoProvider: [],
682
+ };
683
+
684
+ const memory = memoryAdapter(data);
685
+
686
+ const ssoOptions = {
687
+ defaultSSO: [
688
+ {
689
+ domain: "localhost:8082",
690
+ providerId: "unsigned-saml",
691
+ samlConfig: {
692
+ issuer: "http://localhost:8082",
693
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
694
+ cert: certificate,
695
+ callbackUrl: "http://localhost:8082/dashboard",
696
+ wantAssertionsSigned: false,
697
+ authnRequestsSigned: false,
698
+ signatureAlgorithm: "sha256",
699
+ digestAlgorithm: "sha256",
700
+ idpMetadata: {
701
+ metadata: idpMetadata,
702
+ },
703
+ spMetadata: {
704
+ metadata: spMetadata,
705
+ },
706
+ identifierFormat:
707
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
708
+ },
709
+ },
710
+ ],
711
+ };
712
+
713
+ const auth = betterAuth({
714
+ database: memory,
715
+ baseURL: "http://localhost:3000",
716
+ emailAndPassword: {
717
+ enabled: true,
718
+ },
719
+ plugins: [sso(ssoOptions)],
720
+ });
721
+
722
+ it("should NOT include Signature in URL when authnRequestsSigned is false", async () => {
723
+ const signInResponse = await auth.api.signInSSO({
724
+ body: {
725
+ providerId: "unsigned-saml",
726
+ callbackURL: "http://localhost:3000/dashboard",
727
+ },
728
+ });
729
+
730
+ expect(signInResponse).toEqual({
731
+ url: expect.stringContaining("http://localhost:8081"),
732
+ redirect: true,
733
+ });
734
+ // When authnRequestsSigned is false (default), no Signature should be in the URL
735
+ expect(signInResponse.url).not.toContain("Signature=");
736
+ expect(signInResponse.url).not.toContain("SigAlg=");
737
+ });
738
+ });
739
+
577
740
  describe("SAML SSO", async () => {
578
741
  const data = {
579
742
  user: [],
@@ -1100,18 +1263,15 @@ describe("SAML SSO", async () => {
1100
1263
  });
1101
1264
  });
1102
1265
 
1103
- it("should reject SAML sign-in when disableImplicitSignUp is true and user doesn't exist", async () => {
1104
- const { auth: authWithDisabledSignUp, signInWithTestUser } =
1105
- await getTestInstance({
1106
- plugins: [sso({ disableImplicitSignUp: true })],
1107
- });
1266
+ it("should initiate SAML login and validate RelayState", async () => {
1267
+ const { auth, signInWithTestUser } = await getTestInstance({
1268
+ plugins: [sso()],
1269
+ });
1108
1270
 
1109
1271
  const { headers } = await signInWithTestUser();
1110
-
1111
- // Register SAML provider
1112
- await authWithDisabledSignUp.api.registerSSOProvider({
1272
+ await auth.api.registerSSOProvider({
1113
1273
  body: {
1114
- providerId: "saml-test-provider",
1274
+ providerId: "saml-provider-1",
1115
1275
  issuer: "http://localhost:8081",
1116
1276
  domain: "http://localhost:8081",
1117
1277
  samlConfig: {
@@ -1131,50 +1291,58 @@ describe("SAML SSO", async () => {
1131
1291
  "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1132
1292
  },
1133
1293
  },
1134
- headers: headers,
1294
+ headers,
1295
+ });
1296
+
1297
+ const response = await auth.api.signInSSO({
1298
+ body: {
1299
+ providerId: "saml-provider-1",
1300
+ callbackURL: "http://localhost:3000/dashboard",
1301
+ },
1302
+ returnHeaders: true,
1303
+ });
1304
+
1305
+ const signInResponse = response.response;
1306
+ expect(signInResponse).toEqual({
1307
+ url: expect.stringContaining("http://localhost:8081"),
1308
+ redirect: true,
1135
1309
  });
1136
1310
 
1137
- // Identity Provider-initiated: Get SAML response directly from IdP
1138
- // The mock IdP will return test@email.com, which doesn't exist in the DB
1139
1311
  let samlResponse: any;
1140
- await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
1312
+ await betterFetch(signInResponse?.url, {
1141
1313
  onSuccess: async (context) => {
1142
1314
  samlResponse = await context.data;
1143
1315
  },
1144
1316
  });
1145
1317
 
1146
- const response = await authWithDisabledSignUp.handler(
1147
- new Request(
1148
- "http://localhost:3000/api/auth/sso/saml2/callback/saml-test-provider",
1149
- {
1150
- method: "POST",
1151
- headers: {
1152
- "Content-Type": "application/x-www-form-urlencoded",
1153
- },
1154
- body: new URLSearchParams({
1155
- SAMLResponse: samlResponse.samlResponse,
1156
- RelayState: "http://localhost:3000/dashboard",
1157
- }),
1158
- },
1159
- ),
1160
- );
1318
+ const samlRedirectUrl = new URL(signInResponse?.url);
1319
+ const callbackResponse = await auth.api.callbackSSOSAML({
1320
+ method: "POST",
1321
+ body: {
1322
+ SAMLResponse: samlResponse.samlResponse,
1323
+ RelayState: samlRedirectUrl.searchParams.get("RelayState") ?? "",
1324
+ },
1325
+ headers: {
1326
+ Cookie: response.headers.get("set-cookie") ?? "",
1327
+ },
1328
+ params: {
1329
+ providerId: "saml-provider-1",
1330
+ },
1331
+ asResponse: true,
1332
+ });
1161
1333
 
1162
- expect(response.status).toBe(302);
1163
- const redirectLocation = response.headers.get("location") || "";
1164
- expect(redirectLocation).toContain("error=signup_disabled");
1334
+ expect(callbackResponse.headers.get("location")).toContain("dashboard");
1165
1335
  });
1166
1336
 
1167
- it("should reject SAML ACS (IdP-initiated) when disableImplicitSignUp is true and user doesn't exist", async () => {
1168
- const { auth: authWithDisabledSignUp, signInWithTestUser } =
1169
- await getTestInstance({
1170
- plugins: [sso({ disableImplicitSignUp: true })],
1171
- });
1337
+ it("should initiate SAML login and fallback to callbackUrl on invalid RelayState", async () => {
1338
+ const { auth, signInWithTestUser } = await getTestInstance({
1339
+ plugins: [sso()],
1340
+ });
1172
1341
 
1173
1342
  const { headers } = await signInWithTestUser();
1174
-
1175
- await authWithDisabledSignUp.api.registerSSOProvider({
1343
+ await auth.api.registerSSOProvider({
1176
1344
  body: {
1177
- providerId: "saml-acs-test-provider",
1345
+ providerId: "saml-provider-1",
1178
1346
  issuer: "http://localhost:8081",
1179
1347
  domain: "http://localhost:8081",
1180
1348
  samlConfig: {
@@ -1194,53 +1362,60 @@ describe("SAML SSO", async () => {
1194
1362
  "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1195
1363
  },
1196
1364
  },
1197
- headers: headers,
1365
+ headers,
1366
+ });
1367
+
1368
+ const response = await auth.api.signInSSO({
1369
+ body: {
1370
+ providerId: "saml-provider-1",
1371
+ callbackURL: "http://localhost:3000/dashboard",
1372
+ },
1373
+ returnHeaders: true,
1374
+ });
1375
+
1376
+ const signInResponse = response.response;
1377
+ expect(signInResponse).toEqual({
1378
+ url: expect.stringContaining("http://localhost:8081"),
1379
+ redirect: true,
1198
1380
  });
1199
1381
 
1200
1382
  let samlResponse: any;
1201
- await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
1383
+ await betterFetch(signInResponse?.url, {
1202
1384
  onSuccess: async (context) => {
1203
1385
  samlResponse = await context.data;
1204
1386
  },
1205
1387
  });
1206
1388
 
1207
- const response = await authWithDisabledSignUp.handler(
1208
- new Request(
1209
- "http://localhost:3000/api/auth/sso/saml2/sp/acs/saml-acs-test-provider",
1210
- {
1211
- method: "POST",
1212
- headers: {
1213
- "Content-Type": "application/x-www-form-urlencoded",
1214
- },
1215
- body: new URLSearchParams({
1216
- SAMLResponse: samlResponse.samlResponse,
1217
- RelayState: "http://localhost:3000/dashboard",
1218
- }),
1219
- },
1220
- ),
1221
- );
1389
+ const callbackResponse = await auth.api.callbackSSOSAML({
1390
+ method: "POST",
1391
+ body: {
1392
+ SAMLResponse: samlResponse.samlResponse,
1393
+ RelayState: "not-the-right-relay-state",
1394
+ },
1395
+ headers: {
1396
+ Cookie: response.headers.get("set-cookie") ?? "",
1397
+ },
1398
+ params: {
1399
+ providerId: "saml-provider-1",
1400
+ },
1401
+ asResponse: true,
1402
+ });
1222
1403
 
1223
- expect(response.status).toBe(302);
1224
- const redirectLocation = response.headers.get("location") || "";
1225
- expect(redirectLocation).toContain("error=signup_disabled");
1404
+ expect(callbackResponse.status).toBe(302);
1405
+ expect(callbackResponse.headers.get("location")).toBe(
1406
+ "http://localhost:3000/dashboard",
1407
+ );
1226
1408
  });
1227
1409
 
1228
- it("should deny account linking when provider is not trusted and domain is not verified", async () => {
1229
- const { auth: authUntrusted, signInWithTestUser } = await getTestInstance({
1230
- account: {
1231
- accountLinking: {
1232
- enabled: true,
1233
- trustedProviders: [],
1234
- },
1235
- },
1236
- plugins: [sso()],
1410
+ it("should initiate SAML login and signup user when disableImplicitSignUp is true but requestSignup is explicitly enabled", async () => {
1411
+ const { auth, signInWithTestUser } = await getTestInstance({
1412
+ plugins: [sso({ disableImplicitSignUp: true })],
1237
1413
  });
1238
1414
 
1239
1415
  const { headers } = await signInWithTestUser();
1240
-
1241
- await authUntrusted.api.registerSSOProvider({
1416
+ await auth.api.registerSSOProvider({
1242
1417
  body: {
1243
- providerId: "untrusted-saml-provider",
1418
+ providerId: "saml-provider-1",
1244
1419
  issuer: "http://localhost:8081",
1245
1420
  domain: "http://localhost:8081",
1246
1421
  samlConfig: {
@@ -1263,14 +1438,218 @@ describe("SAML SSO", async () => {
1263
1438
  headers,
1264
1439
  });
1265
1440
 
1266
- const ctx = await authUntrusted.$context;
1267
- await ctx.adapter.create({
1268
- model: "user",
1269
- data: {
1270
- id: "existing-user-id",
1271
- email: "test@email.com",
1272
- name: "Existing User",
1273
- emailVerified: true,
1441
+ const response = await auth.api.signInSSO({
1442
+ body: {
1443
+ providerId: "saml-provider-1",
1444
+ callbackURL: "http://localhost:3000/dashboard",
1445
+ requestSignUp: true,
1446
+ },
1447
+ returnHeaders: true,
1448
+ });
1449
+
1450
+ const signInResponse = response.response;
1451
+ expect(signInResponse).toEqual({
1452
+ url: expect.stringContaining("http://localhost:8081"),
1453
+ redirect: true,
1454
+ });
1455
+
1456
+ let samlResponse: any;
1457
+ await betterFetch(signInResponse?.url, {
1458
+ onSuccess: async (context) => {
1459
+ samlResponse = await context.data;
1460
+ },
1461
+ });
1462
+
1463
+ const samlRedirectUrl = new URL(signInResponse?.url);
1464
+ const callbackResponse = await auth.api.callbackSSOSAML({
1465
+ method: "POST",
1466
+ body: {
1467
+ SAMLResponse: samlResponse.samlResponse,
1468
+ RelayState: samlRedirectUrl.searchParams.get("RelayState") ?? "",
1469
+ },
1470
+ headers: {
1471
+ Cookie: response.headers.get("set-cookie") ?? "",
1472
+ },
1473
+ params: {
1474
+ providerId: "saml-provider-1",
1475
+ },
1476
+ asResponse: true,
1477
+ });
1478
+
1479
+ expect(callbackResponse.headers.get("location")).toContain("dashboard");
1480
+ });
1481
+
1482
+ it("should reject SAML sign-in when disableImplicitSignUp is true and user doesn't exist", async () => {
1483
+ const { auth: authWithDisabledSignUp, signInWithTestUser } =
1484
+ await getTestInstance({
1485
+ plugins: [sso({ disableImplicitSignUp: true })],
1486
+ });
1487
+
1488
+ const { headers } = await signInWithTestUser();
1489
+
1490
+ // Register SAML provider
1491
+ await authWithDisabledSignUp.api.registerSSOProvider({
1492
+ body: {
1493
+ providerId: "saml-test-provider",
1494
+ issuer: "http://localhost:8081",
1495
+ domain: "http://localhost:8081",
1496
+ samlConfig: {
1497
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1498
+ cert: certificate,
1499
+ callbackUrl: "http://localhost:3000/dashboard",
1500
+ wantAssertionsSigned: false,
1501
+ signatureAlgorithm: "sha256",
1502
+ digestAlgorithm: "sha256",
1503
+ idpMetadata: {
1504
+ metadata: idpMetadata,
1505
+ },
1506
+ spMetadata: {
1507
+ metadata: spMetadata,
1508
+ },
1509
+ identifierFormat:
1510
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1511
+ },
1512
+ },
1513
+ headers: headers,
1514
+ });
1515
+
1516
+ // Identity Provider-initiated: Get SAML response directly from IdP
1517
+ // The mock IdP will return test@email.com, which doesn't exist in the DB
1518
+ let samlResponse: any;
1519
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
1520
+ onSuccess: async (context) => {
1521
+ samlResponse = await context.data;
1522
+ },
1523
+ });
1524
+
1525
+ const response = await authWithDisabledSignUp.handler(
1526
+ new Request(
1527
+ "http://localhost:3000/api/auth/sso/saml2/callback/saml-test-provider",
1528
+ {
1529
+ method: "POST",
1530
+ headers: {
1531
+ "Content-Type": "application/x-www-form-urlencoded",
1532
+ },
1533
+ body: new URLSearchParams({
1534
+ SAMLResponse: samlResponse.samlResponse,
1535
+ RelayState: "http://localhost:3000/dashboard",
1536
+ }),
1537
+ },
1538
+ ),
1539
+ );
1540
+
1541
+ expect(response.status).toBe(302);
1542
+ const redirectLocation = response.headers.get("location") || "";
1543
+ expect(redirectLocation).toContain("error=signup_disabled");
1544
+ });
1545
+
1546
+ it("should reject SAML ACS (IdP-initiated) when disableImplicitSignUp is true and user doesn't exist", async () => {
1547
+ const { auth: authWithDisabledSignUp, signInWithTestUser } =
1548
+ await getTestInstance({
1549
+ plugins: [sso({ disableImplicitSignUp: true })],
1550
+ });
1551
+
1552
+ const { headers } = await signInWithTestUser();
1553
+
1554
+ await authWithDisabledSignUp.api.registerSSOProvider({
1555
+ body: {
1556
+ providerId: "saml-acs-test-provider",
1557
+ issuer: "http://localhost:8081",
1558
+ domain: "http://localhost:8081",
1559
+ samlConfig: {
1560
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1561
+ cert: certificate,
1562
+ callbackUrl: "http://localhost:3000/dashboard",
1563
+ wantAssertionsSigned: false,
1564
+ signatureAlgorithm: "sha256",
1565
+ digestAlgorithm: "sha256",
1566
+ idpMetadata: {
1567
+ metadata: idpMetadata,
1568
+ },
1569
+ spMetadata: {
1570
+ metadata: spMetadata,
1571
+ },
1572
+ identifierFormat:
1573
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1574
+ },
1575
+ },
1576
+ headers: headers,
1577
+ });
1578
+
1579
+ let samlResponse: any;
1580
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
1581
+ onSuccess: async (context) => {
1582
+ samlResponse = await context.data;
1583
+ },
1584
+ });
1585
+
1586
+ const response = await authWithDisabledSignUp.handler(
1587
+ new Request(
1588
+ "http://localhost:3000/api/auth/sso/saml2/sp/acs/saml-acs-test-provider",
1589
+ {
1590
+ method: "POST",
1591
+ headers: {
1592
+ "Content-Type": "application/x-www-form-urlencoded",
1593
+ },
1594
+ body: new URLSearchParams({
1595
+ SAMLResponse: samlResponse.samlResponse,
1596
+ RelayState: "http://localhost:3000/dashboard",
1597
+ }),
1598
+ },
1599
+ ),
1600
+ );
1601
+
1602
+ expect(response.status).toBe(302);
1603
+ const redirectLocation = response.headers.get("location") || "";
1604
+ expect(redirectLocation).toContain("error=signup_disabled");
1605
+ });
1606
+
1607
+ it("should deny account linking when provider is not trusted and domain is not verified", async () => {
1608
+ const { auth: authUntrusted, signInWithTestUser } = await getTestInstance({
1609
+ account: {
1610
+ accountLinking: {
1611
+ enabled: true,
1612
+ trustedProviders: [],
1613
+ },
1614
+ },
1615
+ plugins: [sso()],
1616
+ });
1617
+
1618
+ const { headers } = await signInWithTestUser();
1619
+
1620
+ await authUntrusted.api.registerSSOProvider({
1621
+ body: {
1622
+ providerId: "untrusted-saml-provider",
1623
+ issuer: "http://localhost:8081",
1624
+ domain: "http://localhost:8081",
1625
+ samlConfig: {
1626
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1627
+ cert: certificate,
1628
+ callbackUrl: "http://localhost:3000/dashboard",
1629
+ wantAssertionsSigned: false,
1630
+ signatureAlgorithm: "sha256",
1631
+ digestAlgorithm: "sha256",
1632
+ idpMetadata: {
1633
+ metadata: idpMetadata,
1634
+ },
1635
+ spMetadata: {
1636
+ metadata: spMetadata,
1637
+ },
1638
+ identifierFormat:
1639
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1640
+ },
1641
+ },
1642
+ headers,
1643
+ });
1644
+
1645
+ const ctx = await authUntrusted.$context;
1646
+ await ctx.adapter.create({
1647
+ model: "user",
1648
+ data: {
1649
+ id: "existing-user-id",
1650
+ email: "test@email.com",
1651
+ name: "Existing User",
1652
+ emailVerified: true,
1274
1653
  createdAt: new Date(),
1275
1654
  updatedAt: new Date(),
1276
1655
  },
@@ -1293,7 +1672,6 @@ describe("SAML SSO", async () => {
1293
1672
  },
1294
1673
  body: new URLSearchParams({
1295
1674
  SAMLResponse: samlResponse.samlResponse,
1296
- RelayState: "http://localhost:3000/dashboard",
1297
1675
  }),
1298
1676
  },
1299
1677
  ),
@@ -1374,7 +1752,6 @@ describe("SAML SSO", async () => {
1374
1752
  },
1375
1753
  body: new URLSearchParams({
1376
1754
  SAMLResponse: samlResponse.samlResponse,
1377
- RelayState: "http://localhost:3000/dashboard",
1378
1755
  }),
1379
1756
  },
1380
1757
  ),
@@ -1442,7 +1819,6 @@ describe("SAML SSO", async () => {
1442
1819
  },
1443
1820
  body: new URLSearchParams({
1444
1821
  SAMLResponse: samlResponse.samlResponse,
1445
- RelayState: "http://localhost:3000/dashboard",
1446
1822
  }),
1447
1823
  },
1448
1824
  ),
@@ -1509,7 +1885,6 @@ describe("SAML SSO", async () => {
1509
1885
  },
1510
1886
  body: new URLSearchParams({
1511
1887
  SAMLResponse: samlResponse.samlResponse,
1512
- RelayState: "http://localhost:3000/dashboard",
1513
1888
  }),
1514
1889
  },
1515
1890
  ),
@@ -1569,7 +1944,6 @@ describe("SAML SSO", async () => {
1569
1944
  },
1570
1945
  body: new URLSearchParams({
1571
1946
  SAMLResponse: samlResponse.samlResponse,
1572
- RelayState: "http://localhost:3000/dashboard",
1573
1947
  }),
1574
1948
  },
1575
1949
  ),
@@ -1638,7 +2012,6 @@ describe("SAML SSO", async () => {
1638
2012
  },
1639
2013
  body: new URLSearchParams({
1640
2014
  SAMLResponse: samlResponse.samlResponse,
1641
- RelayState: "http://localhost:3000/dashboard",
1642
2015
  }),
1643
2016
  },
1644
2017
  ),
@@ -1978,8 +2351,8 @@ describe("SSO Provider Config Parsing", () => {
1978
2351
  });
1979
2352
  });
1980
2353
 
1981
- describe("SAML SSO - Signature Validation Security", () => {
1982
- it("should reject unsigned SAML response with forged NameID", async () => {
2354
+ describe("SAML SSO - IdP Initiated Flow", () => {
2355
+ it("should handle IdP-initiated flow with GET after POST redirect", async () => {
1983
2356
  const { auth, signInWithTestUser } = await getTestInstance({
1984
2357
  plugins: [sso()],
1985
2358
  });
@@ -1988,11 +2361,14 @@ describe("SAML SSO - Signature Validation Security", () => {
1988
2361
 
1989
2362
  await auth.api.registerSSOProvider({
1990
2363
  body: {
1991
- providerId: "security-test-provider",
2364
+ providerId: "idp-initiated-provider",
1992
2365
  issuer: "http://localhost:8081",
1993
2366
  domain: "http://localhost:8081",
1994
2367
  samlConfig: {
1995
- entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
2368
+ entryPoint: sharedMockIdP.metadataUrl.replace(
2369
+ "/idp/metadata",
2370
+ "/idp/post",
2371
+ ),
1996
2372
  cert: certificate,
1997
2373
  callbackUrl: "http://localhost:3000/dashboard",
1998
2374
  wantAssertionsSigned: false,
@@ -2011,64 +2387,110 @@ describe("SAML SSO - Signature Validation Security", () => {
2011
2387
  headers,
2012
2388
  });
2013
2389
 
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
- `;
2390
+ let samlResponse:
2391
+ | { samlResponse: string; entityEndpoint?: string }
2392
+ | undefined;
2393
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
2394
+ onSuccess: async (context) => {
2395
+ samlResponse = context.data as {
2396
+ samlResponse: string;
2397
+ entityEndpoint?: string;
2398
+ };
2399
+ },
2400
+ });
2038
2401
 
2039
- const encodedForgedResponse = base64.encode(forgedSamlResponse);
2402
+ if (!samlResponse?.samlResponse) {
2403
+ throw new Error("Failed to get SAML response from mock IdP");
2404
+ }
2040
2405
 
2041
- await expect(
2042
- auth.api.callbackSSOSAML({
2043
- body: {
2044
- SAMLResponse: encodedForgedResponse,
2045
- RelayState: "http://localhost:3000/dashboard",
2046
- },
2406
+ const postResponse = await auth.api.callbackSSOSAML({
2407
+ method: "POST",
2408
+ body: {
2409
+ SAMLResponse: samlResponse.samlResponse,
2410
+ RelayState: "http://localhost:3000/dashboard",
2411
+ },
2412
+ params: {
2413
+ providerId: "idp-initiated-provider",
2414
+ },
2415
+ asResponse: true,
2416
+ });
2417
+
2418
+ expect(postResponse).toBeInstanceOf(Response);
2419
+ expect(postResponse.status).toBe(302);
2420
+ const redirectLocation = postResponse.headers.get("location");
2421
+ expect(redirectLocation).toBe("http://localhost:3000/dashboard");
2422
+
2423
+ const cookieHeader = postResponse.headers.get("set-cookie");
2424
+ const getResponse = await auth.api.callbackSSOSAML({
2425
+ method: "GET",
2426
+ query: {
2427
+ RelayState: "http://localhost:3000/dashboard",
2428
+ },
2429
+ params: {
2430
+ providerId: "idp-initiated-provider",
2431
+ },
2432
+ headers: cookieHeader ? { cookie: cookieHeader } : undefined,
2433
+ asResponse: true,
2434
+ });
2435
+
2436
+ expect(getResponse).toBeInstanceOf(Response);
2437
+ expect(getResponse.status).toBe(302);
2438
+ const getRedirectLocation = getResponse.headers.get("location");
2439
+ expect(getRedirectLocation).toBe("http://localhost:3000/dashboard");
2440
+ });
2441
+
2442
+ it("should reject direct GET request without session", async () => {
2443
+ const { auth } = await getTestInstance({
2444
+ plugins: [sso()],
2445
+ });
2446
+
2447
+ const getResponse = await auth.api
2448
+ .callbackSSOSAML({
2449
+ method: "GET",
2047
2450
  params: {
2048
- providerId: "security-test-provider",
2451
+ providerId: "test-provider",
2049
2452
  },
2050
- }),
2051
- ).rejects.toMatchObject({
2052
- status: "BAD_REQUEST",
2053
- });
2453
+ asResponse: true,
2454
+ })
2455
+ .catch((e) => {
2456
+ if (e instanceof APIError && e.status === "FOUND") {
2457
+ return new Response(null, {
2458
+ status: e.statusCode,
2459
+ headers: e.headers || new Headers(),
2460
+ });
2461
+ }
2462
+ throw e;
2463
+ });
2464
+
2465
+ expect(getResponse).toBeInstanceOf(Response);
2466
+ expect(getResponse.status).toBe(302);
2467
+ const redirectLocation = getResponse.headers.get("location");
2468
+ expect(redirectLocation).toContain("/error");
2469
+ expect(redirectLocation).toContain("error=invalid_request");
2054
2470
  });
2055
2471
 
2056
- it("should reject SAML response with invalid signature", async () => {
2472
+ it("should prevent redirect loop when callbackUrl points to callback route", async () => {
2057
2473
  const { auth, signInWithTestUser } = await getTestInstance({
2058
2474
  plugins: [sso()],
2059
2475
  });
2060
2476
 
2061
2477
  const { headers } = await signInWithTestUser();
2062
2478
 
2479
+ const callbackRouteUrl =
2480
+ "http://localhost:3000/api/auth/sso/saml2/callback/loop-test-provider";
2481
+
2063
2482
  await auth.api.registerSSOProvider({
2064
2483
  body: {
2065
- providerId: "invalid-sig-provider",
2484
+ providerId: "loop-test-provider",
2066
2485
  issuer: "http://localhost:8081",
2067
2486
  domain: "http://localhost:8081",
2068
2487
  samlConfig: {
2069
- entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
2488
+ entryPoint: sharedMockIdP.metadataUrl.replace(
2489
+ "/idp/metadata",
2490
+ "/idp/post",
2491
+ ),
2070
2492
  cert: certificate,
2071
- callbackUrl: "http://localhost:3000/dashboard",
2493
+ callbackUrl: callbackRouteUrl,
2072
2494
  wantAssertionsSigned: false,
2073
2495
  signatureAlgorithm: "sha256",
2074
2496
  digestAlgorithm: "sha256",
@@ -2085,47 +2507,511 @@ describe("SAML SSO - Signature Validation Security", () => {
2085
2507
  headers,
2086
2508
  });
2087
2509
 
2088
- const responseWithBadSignature = `
2089
- <saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
2090
- <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2091
- <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
2092
- <ds:SignedInfo>
2093
- <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
2094
- <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
2095
- <ds:Reference>
2096
- <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
2097
- <ds:DigestValue>FAKE_DIGEST_VALUE</ds:DigestValue>
2098
- </ds:Reference>
2099
- </ds:SignedInfo>
2100
- <ds:SignatureValue>INVALID_SIGNATURE_VALUE_THAT_SHOULD_FAIL_VERIFICATION</ds:SignatureValue>
2101
- </ds:Signature>
2102
- <saml2p:Status>
2103
- <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
2104
- </saml2p:Status>
2105
- <saml2:Assertion>
2106
- <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2107
- <saml2:Subject>
2108
- <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">forged-admin@company.com</saml2:NameID>
2109
- </saml2:Subject>
2110
- </saml2:Assertion>
2111
- </saml2p:Response>
2112
- `;
2510
+ let samlResponse:
2511
+ | { samlResponse: string; entityEndpoint?: string }
2512
+ | undefined;
2513
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
2514
+ onSuccess: async (context) => {
2515
+ samlResponse = context.data as {
2516
+ samlResponse: string;
2517
+ entityEndpoint?: string;
2518
+ };
2519
+ },
2520
+ });
2113
2521
 
2114
- const encodedBadSigResponse = base64.encode(responseWithBadSignature);
2522
+ if (!samlResponse?.samlResponse) {
2523
+ throw new Error("Failed to get SAML response from mock IdP");
2524
+ }
2115
2525
 
2116
- await expect(
2117
- auth.api.callbackSSOSAML({
2118
- body: {
2119
- SAMLResponse: encodedBadSigResponse,
2120
- RelayState: "http://localhost:3000/dashboard",
2526
+ const postResponse = await auth.api.callbackSSOSAML({
2527
+ method: "POST",
2528
+ body: {
2529
+ SAMLResponse: samlResponse.samlResponse,
2530
+ },
2531
+ params: {
2532
+ providerId: "loop-test-provider",
2533
+ },
2534
+ asResponse: true,
2535
+ });
2536
+
2537
+ expect(postResponse).toBeInstanceOf(Response);
2538
+ expect(postResponse.status).toBe(302);
2539
+ const redirectLocation = postResponse.headers.get("location");
2540
+ expect(redirectLocation).not.toBe(callbackRouteUrl);
2541
+ expect(redirectLocation).toBe("http://localhost:3000");
2542
+ });
2543
+
2544
+ it("should handle GET request with RelayState in query", async () => {
2545
+ const { auth, signInWithTestUser } = await getTestInstance({
2546
+ plugins: [sso()],
2547
+ });
2548
+
2549
+ const { headers } = await signInWithTestUser();
2550
+
2551
+ await auth.api.registerSSOProvider({
2552
+ body: {
2553
+ providerId: "relaystate-provider",
2554
+ issuer: "http://localhost:8081",
2555
+ domain: "http://localhost:8081",
2556
+ samlConfig: {
2557
+ entryPoint: sharedMockIdP.metadataUrl.replace(
2558
+ "/idp/metadata",
2559
+ "/idp/post",
2560
+ ),
2561
+ cert: certificate,
2562
+ callbackUrl: "http://localhost:3000/dashboard",
2563
+ wantAssertionsSigned: false,
2564
+ signatureAlgorithm: "sha256",
2565
+ digestAlgorithm: "sha256",
2566
+ idpMetadata: {
2567
+ metadata: idpMetadata,
2568
+ },
2569
+ spMetadata: {
2570
+ metadata: spMetadata,
2571
+ },
2572
+ identifierFormat:
2573
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2121
2574
  },
2122
- params: {
2123
- providerId: "invalid-sig-provider",
2575
+ },
2576
+ headers,
2577
+ });
2578
+
2579
+ let samlResponse:
2580
+ | { samlResponse: string; entityEndpoint?: string }
2581
+ | undefined;
2582
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
2583
+ onSuccess: async (context) => {
2584
+ samlResponse = context.data as {
2585
+ samlResponse: string;
2586
+ entityEndpoint?: string;
2587
+ };
2588
+ },
2589
+ });
2590
+
2591
+ if (!samlResponse?.samlResponse) {
2592
+ throw new Error("Failed to get SAML response from mock IdP");
2593
+ }
2594
+
2595
+ const postResponse = await auth.api.callbackSSOSAML({
2596
+ method: "POST",
2597
+ body: {
2598
+ SAMLResponse: samlResponse.samlResponse,
2599
+ RelayState: "http://localhost:3000/custom-path",
2600
+ },
2601
+ params: {
2602
+ providerId: "relaystate-provider",
2603
+ },
2604
+ asResponse: true,
2605
+ });
2606
+
2607
+ const cookieHeader = postResponse.headers.get("set-cookie");
2608
+ const getResponse = await auth.api.callbackSSOSAML({
2609
+ method: "GET",
2610
+ query: {
2611
+ RelayState: "http://localhost:3000/custom-path",
2612
+ },
2613
+ params: {
2614
+ providerId: "relaystate-provider",
2615
+ },
2616
+ headers: cookieHeader ? { cookie: cookieHeader } : undefined,
2617
+ asResponse: true,
2618
+ });
2619
+
2620
+ expect(getResponse).toBeInstanceOf(Response);
2621
+ expect(getResponse.status).toBe(302);
2622
+ const redirectLocation = getResponse.headers.get("location");
2623
+ expect(redirectLocation).toBe("http://localhost:3000/custom-path");
2624
+ });
2625
+
2626
+ it("should handle GET request when POST redirects to callback URL (original issue scenario)", async () => {
2627
+ const { auth, signInWithTestUser } = await getTestInstance({
2628
+ plugins: [sso()],
2629
+ });
2630
+
2631
+ const { headers } = await signInWithTestUser();
2632
+
2633
+ const callbackRouteUrl =
2634
+ "http://localhost:3000/api/auth/sso/saml2/callback/issue-6615-provider";
2635
+
2636
+ await auth.api.registerSSOProvider({
2637
+ body: {
2638
+ providerId: "issue-6615-provider",
2639
+ issuer: "http://localhost:8081",
2640
+ domain: "http://localhost:8081",
2641
+ samlConfig: {
2642
+ entryPoint: sharedMockIdP.metadataUrl.replace(
2643
+ "/idp/metadata",
2644
+ "/idp/post",
2645
+ ),
2646
+ cert: certificate,
2647
+ callbackUrl: "http://localhost:3000/dashboard",
2648
+ wantAssertionsSigned: false,
2649
+ signatureAlgorithm: "sha256",
2650
+ digestAlgorithm: "sha256",
2651
+ idpMetadata: {
2652
+ metadata: idpMetadata,
2653
+ },
2654
+ spMetadata: {
2655
+ metadata: spMetadata,
2656
+ },
2657
+ identifierFormat:
2658
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2124
2659
  },
2125
- }),
2126
- ).rejects.toMatchObject({
2127
- status: "BAD_REQUEST",
2660
+ },
2661
+ headers,
2662
+ });
2663
+
2664
+ let samlResponse:
2665
+ | { samlResponse: string; entityEndpoint?: string }
2666
+ | undefined;
2667
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
2668
+ onSuccess: async (context) => {
2669
+ samlResponse = context.data as {
2670
+ samlResponse: string;
2671
+ entityEndpoint?: string;
2672
+ };
2673
+ },
2674
+ });
2675
+
2676
+ if (!samlResponse?.samlResponse) {
2677
+ throw new Error("Failed to get SAML response from mock IdP");
2678
+ }
2679
+
2680
+ const postResponse = await auth.api.callbackSSOSAML({
2681
+ method: "POST",
2682
+ body: {
2683
+ SAMLResponse: samlResponse.samlResponse,
2684
+ RelayState: callbackRouteUrl,
2685
+ },
2686
+ params: {
2687
+ providerId: "issue-6615-provider",
2688
+ },
2689
+ asResponse: true,
2690
+ });
2691
+
2692
+ expect(postResponse).toBeInstanceOf(Response);
2693
+ expect(postResponse.status).toBe(302);
2694
+ const postRedirectLocation = postResponse.headers.get("location");
2695
+ expect(postRedirectLocation).not.toBe(callbackRouteUrl);
2696
+ expect(postRedirectLocation).toBe("http://localhost:3000/dashboard");
2697
+
2698
+ const cookieHeader = postResponse.headers.get("set-cookie");
2699
+ const getResponse = await auth.api.callbackSSOSAML({
2700
+ method: "GET",
2701
+ params: {
2702
+ providerId: "issue-6615-provider",
2703
+ },
2704
+ headers: cookieHeader ? { cookie: cookieHeader } : undefined,
2705
+ asResponse: true,
2706
+ });
2707
+
2708
+ expect(getResponse).toBeInstanceOf(Response);
2709
+ expect(getResponse.status).toBe(302);
2710
+ const getRedirectLocation = getResponse.headers.get("location");
2711
+ expect(getRedirectLocation).toBe("http://localhost:3000");
2712
+ });
2713
+
2714
+ it("should prevent open redirect with malicious RelayState URL", async () => {
2715
+ const { auth, signInWithTestUser } = await getTestInstance({
2716
+ plugins: [sso()],
2717
+ });
2718
+
2719
+ const { headers } = await signInWithTestUser();
2720
+
2721
+ await auth.api.registerSSOProvider({
2722
+ body: {
2723
+ providerId: "open-redirect-test-provider",
2724
+ issuer: "http://localhost:8081",
2725
+ domain: "http://localhost:8081",
2726
+ samlConfig: {
2727
+ entryPoint: sharedMockIdP.metadataUrl.replace(
2728
+ "/idp/metadata",
2729
+ "/idp/post",
2730
+ ),
2731
+ cert: certificate,
2732
+ callbackUrl: "http://localhost:3000/dashboard",
2733
+ wantAssertionsSigned: false,
2734
+ signatureAlgorithm: "sha256",
2735
+ digestAlgorithm: "sha256",
2736
+ idpMetadata: {
2737
+ metadata: idpMetadata,
2738
+ },
2739
+ spMetadata: {
2740
+ metadata: spMetadata,
2741
+ },
2742
+ identifierFormat:
2743
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2744
+ },
2745
+ },
2746
+ headers,
2747
+ });
2748
+
2749
+ let samlResponse:
2750
+ | { samlResponse: string; entityEndpoint?: string }
2751
+ | undefined;
2752
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
2753
+ onSuccess: async (context) => {
2754
+ samlResponse = context.data as {
2755
+ samlResponse: string;
2756
+ entityEndpoint?: string;
2757
+ };
2758
+ },
2759
+ });
2760
+
2761
+ if (!samlResponse?.samlResponse) {
2762
+ throw new Error("Failed to get SAML response from mock IdP");
2763
+ }
2764
+
2765
+ // Test POST with malicious RelayState - raw RelayState is not trusted
2766
+ // Falls back to parsedSamlConfig.callbackUrl
2767
+ const postResponse = await auth.api.callbackSSOSAML({
2768
+ method: "POST",
2769
+ body: {
2770
+ SAMLResponse: samlResponse.samlResponse,
2771
+ RelayState: "https://evil.com/phishing",
2772
+ },
2773
+ params: {
2774
+ providerId: "open-redirect-test-provider",
2775
+ },
2776
+ asResponse: true,
2777
+ });
2778
+
2779
+ expect(postResponse).toBeInstanceOf(Response);
2780
+ expect(postResponse.status).toBe(302);
2781
+ const postRedirectLocation = postResponse.headers.get("location");
2782
+ // Should NOT redirect to evil.com - raw RelayState is ignored
2783
+ expect(postRedirectLocation).not.toContain("evil.com");
2784
+ // Falls back to samlConfig.callbackUrl
2785
+ expect(postRedirectLocation).toBe("http://localhost:3000/dashboard");
2786
+ });
2787
+
2788
+ it("should prevent open redirect via GET with malicious RelayState", async () => {
2789
+ const { auth, signInWithTestUser } = await getTestInstance({
2790
+ plugins: [sso()],
2791
+ });
2792
+
2793
+ const { headers } = await signInWithTestUser();
2794
+
2795
+ await auth.api.registerSSOProvider({
2796
+ body: {
2797
+ providerId: "open-redirect-get-provider",
2798
+ issuer: "http://localhost:8081",
2799
+ domain: "http://localhost:8081",
2800
+ samlConfig: {
2801
+ entryPoint: sharedMockIdP.metadataUrl.replace(
2802
+ "/idp/metadata",
2803
+ "/idp/post",
2804
+ ),
2805
+ cert: certificate,
2806
+ callbackUrl: "http://localhost:3000/dashboard",
2807
+ wantAssertionsSigned: false,
2808
+ signatureAlgorithm: "sha256",
2809
+ digestAlgorithm: "sha256",
2810
+ idpMetadata: {
2811
+ metadata: idpMetadata,
2812
+ },
2813
+ spMetadata: {
2814
+ metadata: spMetadata,
2815
+ },
2816
+ identifierFormat:
2817
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2818
+ },
2819
+ },
2820
+ headers,
2128
2821
  });
2822
+
2823
+ let samlResponse:
2824
+ | { samlResponse: string; entityEndpoint?: string }
2825
+ | undefined;
2826
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
2827
+ onSuccess: async (context) => {
2828
+ samlResponse = context.data as {
2829
+ samlResponse: string;
2830
+ entityEndpoint?: string;
2831
+ };
2832
+ },
2833
+ });
2834
+
2835
+ if (!samlResponse?.samlResponse) {
2836
+ throw new Error("Failed to get SAML response from mock IdP");
2837
+ }
2838
+
2839
+ // First do POST to establish session
2840
+ const postResponse = await auth.api.callbackSSOSAML({
2841
+ method: "POST",
2842
+ body: {
2843
+ SAMLResponse: samlResponse.samlResponse,
2844
+ },
2845
+ params: {
2846
+ providerId: "open-redirect-get-provider",
2847
+ },
2848
+ asResponse: true,
2849
+ });
2850
+
2851
+ const cookieHeader = postResponse.headers.get("set-cookie");
2852
+
2853
+ // Test GET with malicious RelayState in query params
2854
+ const getResponse = await auth.api.callbackSSOSAML({
2855
+ method: "GET",
2856
+ query: {
2857
+ RelayState: "https://evil.com/steal-cookies",
2858
+ },
2859
+ params: {
2860
+ providerId: "open-redirect-get-provider",
2861
+ },
2862
+ headers: cookieHeader ? { cookie: cookieHeader } : undefined,
2863
+ asResponse: true,
2864
+ });
2865
+
2866
+ expect(getResponse).toBeInstanceOf(Response);
2867
+ expect(getResponse.status).toBe(302);
2868
+ const getRedirectLocation = getResponse.headers.get("location");
2869
+ // Should NOT redirect to evil.com
2870
+ expect(getRedirectLocation).not.toContain("evil.com");
2871
+ expect(getRedirectLocation).toBe("http://localhost:3000");
2872
+ });
2873
+
2874
+ it("should allow relative path redirects", async () => {
2875
+ const { auth, signInWithTestUser } = await getTestInstance({
2876
+ plugins: [sso()],
2877
+ });
2878
+
2879
+ const { headers } = await signInWithTestUser();
2880
+
2881
+ await auth.api.registerSSOProvider({
2882
+ body: {
2883
+ providerId: "relative-path-provider",
2884
+ issuer: "http://localhost:8081",
2885
+ domain: "http://localhost:8081",
2886
+ samlConfig: {
2887
+ entryPoint: sharedMockIdP.metadataUrl.replace(
2888
+ "/idp/metadata",
2889
+ "/idp/post",
2890
+ ),
2891
+ cert: certificate,
2892
+ callbackUrl: "http://localhost:3000/dashboard",
2893
+ wantAssertionsSigned: false,
2894
+ signatureAlgorithm: "sha256",
2895
+ digestAlgorithm: "sha256",
2896
+ idpMetadata: {
2897
+ metadata: idpMetadata,
2898
+ },
2899
+ spMetadata: {
2900
+ metadata: spMetadata,
2901
+ },
2902
+ identifierFormat:
2903
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2904
+ },
2905
+ },
2906
+ headers,
2907
+ });
2908
+
2909
+ let samlResponse:
2910
+ | { samlResponse: string; entityEndpoint?: string }
2911
+ | undefined;
2912
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
2913
+ onSuccess: async (context) => {
2914
+ samlResponse = context.data as {
2915
+ samlResponse: string;
2916
+ entityEndpoint?: string;
2917
+ };
2918
+ },
2919
+ });
2920
+
2921
+ if (!samlResponse?.samlResponse) {
2922
+ throw new Error("Failed to get SAML response from mock IdP");
2923
+ }
2924
+
2925
+ const postResponse = await auth.api.callbackSSOSAML({
2926
+ method: "POST",
2927
+ body: {
2928
+ SAMLResponse: samlResponse.samlResponse,
2929
+ RelayState: "/dashboard/settings",
2930
+ },
2931
+ params: {
2932
+ providerId: "relative-path-provider",
2933
+ },
2934
+ asResponse: true,
2935
+ });
2936
+
2937
+ expect(postResponse).toBeInstanceOf(Response);
2938
+ expect(postResponse.status).toBe(302);
2939
+ const redirectLocation = postResponse.headers.get("location");
2940
+ expect(redirectLocation).toBe("http://localhost:3000/dashboard");
2941
+ });
2942
+
2943
+ it("should block protocol-relative URL attacks (//evil.com)", async () => {
2944
+ const { auth, signInWithTestUser } = await getTestInstance({
2945
+ plugins: [sso()],
2946
+ });
2947
+
2948
+ const { headers } = await signInWithTestUser();
2949
+
2950
+ await auth.api.registerSSOProvider({
2951
+ body: {
2952
+ providerId: "protocol-relative-provider",
2953
+ issuer: "http://localhost:8081",
2954
+ domain: "http://localhost:8081",
2955
+ samlConfig: {
2956
+ entryPoint: sharedMockIdP.metadataUrl.replace(
2957
+ "/idp/metadata",
2958
+ "/idp/post",
2959
+ ),
2960
+ cert: certificate,
2961
+ callbackUrl: "http://localhost:3000/dashboard",
2962
+ wantAssertionsSigned: false,
2963
+ signatureAlgorithm: "sha256",
2964
+ digestAlgorithm: "sha256",
2965
+ idpMetadata: {
2966
+ metadata: idpMetadata,
2967
+ },
2968
+ spMetadata: {
2969
+ metadata: spMetadata,
2970
+ },
2971
+ identifierFormat:
2972
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2973
+ },
2974
+ },
2975
+ headers,
2976
+ });
2977
+
2978
+ let samlResponse:
2979
+ | { samlResponse: string; entityEndpoint?: string }
2980
+ | undefined;
2981
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
2982
+ onSuccess: async (context) => {
2983
+ samlResponse = context.data as {
2984
+ samlResponse: string;
2985
+ entityEndpoint?: string;
2986
+ };
2987
+ },
2988
+ });
2989
+
2990
+ if (!samlResponse?.samlResponse) {
2991
+ throw new Error("Failed to get SAML response from mock IdP");
2992
+ }
2993
+
2994
+ // Test POST with protocol-relative URL - raw RelayState is not trusted
2995
+ // Falls back to parsedSamlConfig.callbackUrl
2996
+ const postResponse = await auth.api.callbackSSOSAML({
2997
+ method: "POST",
2998
+ body: {
2999
+ SAMLResponse: samlResponse.samlResponse,
3000
+ RelayState: "//evil.com/phishing",
3001
+ },
3002
+ params: {
3003
+ providerId: "protocol-relative-provider",
3004
+ },
3005
+ asResponse: true,
3006
+ });
3007
+
3008
+ expect(postResponse).toBeInstanceOf(Response);
3009
+ expect(postResponse.status).toBe(302);
3010
+ const redirectLocation = postResponse.headers.get("location");
3011
+ // Should NOT redirect to evil.com - raw RelayState is ignored
3012
+ expect(redirectLocation).not.toContain("evil.com");
3013
+ // Falls back to samlConfig.callbackUrl
3014
+ expect(redirectLocation).toBe("http://localhost:3000/dashboard");
2129
3015
  });
2130
3016
  });
2131
3017
 
@@ -2278,83 +3164,405 @@ describe("SAML SSO - Timestamp Validation", () => {
2278
3164
  expect(() => validateSAMLTimestamp({ notBefore: now })).not.toThrow();
2279
3165
  });
2280
3166
 
2281
- it("should accept assertions with only NotOnOrAfter (valid, in future)", () => {
2282
- const future = new Date(Date.now() + 10 * 60 * 1000).toISOString();
2283
- expect(() =>
2284
- validateSAMLTimestamp({ notOnOrAfter: future }),
2285
- ).not.toThrow();
3167
+ it("should accept assertions with only NotOnOrAfter (valid, in future)", () => {
3168
+ const future = new Date(Date.now() + 10 * 60 * 1000).toISOString();
3169
+ expect(() =>
3170
+ validateSAMLTimestamp({ notOnOrAfter: future }),
3171
+ ).not.toThrow();
3172
+ });
3173
+ });
3174
+
3175
+ describe("Custom clock skew configuration", () => {
3176
+ it("should use custom clockSkew when provided", () => {
3177
+ const twoSecondsAgo = new Date(Date.now() - 2 * 1000).toISOString();
3178
+
3179
+ expect(() =>
3180
+ validateSAMLTimestamp(
3181
+ { notOnOrAfter: twoSecondsAgo },
3182
+ { clockSkew: 1000 },
3183
+ ),
3184
+ ).toThrow("SAML assertion has expired");
3185
+
3186
+ expect(() =>
3187
+ validateSAMLTimestamp(
3188
+ { notOnOrAfter: twoSecondsAgo },
3189
+ { clockSkew: 5 * 60 * 1000 },
3190
+ ),
3191
+ ).not.toThrow();
3192
+ });
3193
+
3194
+ it("should use default 5 minute clock skew when not specified", () => {
3195
+ const fourMinutesAgo = new Date(Date.now() - 4 * 60 * 1000).toISOString();
3196
+ expect(() =>
3197
+ validateSAMLTimestamp({ notOnOrAfter: fourMinutesAgo }),
3198
+ ).not.toThrow();
3199
+
3200
+ const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000).toISOString();
3201
+ expect(() =>
3202
+ validateSAMLTimestamp({ notOnOrAfter: sixMinutesAgo }),
3203
+ ).toThrow("SAML assertion has expired");
3204
+ });
3205
+ });
3206
+
3207
+ describe("Malformed timestamp handling", () => {
3208
+ it("should reject malformed NotBefore timestamp", () => {
3209
+ expect(() =>
3210
+ validateSAMLTimestamp({ notBefore: "not-a-valid-date" }),
3211
+ ).toThrow("SAML assertion has invalid NotBefore timestamp");
3212
+ });
3213
+
3214
+ it("should reject malformed NotOnOrAfter timestamp", () => {
3215
+ expect(() =>
3216
+ validateSAMLTimestamp({ notOnOrAfter: "invalid-timestamp" }),
3217
+ ).toThrow("SAML assertion has invalid NotOnOrAfter timestamp");
3218
+ });
3219
+
3220
+ it("should treat empty string timestamps as missing (falsy values)", () => {
3221
+ expect(() => validateSAMLTimestamp({ notBefore: "" })).not.toThrow();
3222
+ expect(() => validateSAMLTimestamp({ notOnOrAfter: "" })).not.toThrow();
3223
+ });
3224
+
3225
+ it("should reject garbage data in timestamps", () => {
3226
+ expect(() =>
3227
+ validateSAMLTimestamp({
3228
+ notBefore: "abc123xyz",
3229
+ notOnOrAfter: "!@#$%^&*()",
3230
+ }),
3231
+ ).toThrow("SAML assertion has invalid NotBefore timestamp");
3232
+ });
3233
+
3234
+ it("should accept valid ISO 8601 timestamps", () => {
3235
+ const now = new Date();
3236
+ const future = new Date(Date.now() + 10 * 60 * 1000);
3237
+ expect(() =>
3238
+ validateSAMLTimestamp({
3239
+ notBefore: now.toISOString(),
3240
+ notOnOrAfter: future.toISOString(),
3241
+ }),
3242
+ ).not.toThrow();
3243
+ });
3244
+ });
3245
+ });
3246
+
3247
+ describe("SAML ACS Origin Check Bypass", () => {
3248
+ describe("Positive: SAML endpoints allow external IdP origins", () => {
3249
+ it("should allow SAML callback POST from external IdP origin", async () => {
3250
+ const { auth, signInWithTestUser } = await getTestInstance({
3251
+ plugins: [sso()],
3252
+ });
3253
+ const { headers } = await signInWithTestUser();
3254
+
3255
+ // Register SAML provider with full config
3256
+ await auth.api.registerSSOProvider({
3257
+ body: {
3258
+ providerId: "origin-bypass-callback",
3259
+ issuer: "http://localhost:8081",
3260
+ domain: "origin-bypass.com",
3261
+ samlConfig: {
3262
+ entryPoint: sharedMockIdP.metadataUrl,
3263
+ cert: certificate,
3264
+ callbackUrl: "http://localhost:8081/api/auth/sso/saml2/callback",
3265
+ wantAssertionsSigned: false,
3266
+ signatureAlgorithm: "sha256",
3267
+ digestAlgorithm: "sha256",
3268
+ spMetadata: {
3269
+ metadata: spMetadata,
3270
+ },
3271
+ },
3272
+ },
3273
+ headers,
3274
+ });
3275
+
3276
+ // POST to callback with external Origin header (simulating IdP POST)
3277
+ // Origin check should be bypassed for SAML callback endpoints
3278
+ const callbackRes = await auth.handler(
3279
+ new Request(
3280
+ "http://localhost:8081/api/auth/sso/saml2/callback/origin-bypass-callback",
3281
+ {
3282
+ method: "POST",
3283
+ headers: {
3284
+ "Content-Type": "application/x-www-form-urlencoded",
3285
+ Origin: "http://external-idp.example.com", // External IdP origin - would normally be blocked
3286
+ Cookie: headers.get("cookie") || "",
3287
+ },
3288
+ body: new URLSearchParams({
3289
+ SAMLResponse: Buffer.from("<fake-saml-response/>").toString(
3290
+ "base64",
3291
+ ),
3292
+ RelayState: "",
3293
+ }).toString(),
3294
+ },
3295
+ ),
3296
+ );
3297
+
3298
+ // Should NOT return 403 Forbidden (origin check bypassed)
3299
+ // May return other errors (400, 500) due to invalid SAML response, but NOT origin rejection
3300
+ expect(callbackRes.status).not.toBe(403);
3301
+ });
3302
+
3303
+ it("should allow ACS endpoint POST from external IdP origin", async () => {
3304
+ const { auth, signInWithTestUser } = await getTestInstance({
3305
+ plugins: [sso()],
3306
+ });
3307
+ const { headers } = await signInWithTestUser();
3308
+
3309
+ // Register SAML provider with full config
3310
+ await auth.api.registerSSOProvider({
3311
+ body: {
3312
+ providerId: "origin-bypass-acs",
3313
+ issuer: "http://localhost:8081",
3314
+ domain: "origin-bypass-acs.com",
3315
+ samlConfig: {
3316
+ entryPoint: sharedMockIdP.metadataUrl,
3317
+ cert: certificate,
3318
+ callbackUrl: "http://localhost:8081/api/auth/sso/saml2/sp/acs",
3319
+ wantAssertionsSigned: false,
3320
+ signatureAlgorithm: "sha256",
3321
+ digestAlgorithm: "sha256",
3322
+ spMetadata: {
3323
+ metadata: spMetadata,
3324
+ },
3325
+ },
3326
+ },
3327
+ headers,
3328
+ });
3329
+
3330
+ // POST to ACS with external Origin header
3331
+ const acsRes = await auth.handler(
3332
+ new Request(
3333
+ "http://localhost:8081/api/auth/sso/saml2/sp/acs/origin-bypass-acs",
3334
+ {
3335
+ method: "POST",
3336
+ headers: {
3337
+ "Content-Type": "application/x-www-form-urlencoded",
3338
+ Origin: "http://idp.external.com", // External IdP origin
3339
+ Cookie: headers.get("cookie") || "",
3340
+ },
3341
+ body: new URLSearchParams({
3342
+ SAMLResponse: Buffer.from("<fake-saml-response/>").toString(
3343
+ "base64",
3344
+ ),
3345
+ }).toString(),
3346
+ },
3347
+ ),
3348
+ );
3349
+
3350
+ // Should NOT return 403 Forbidden
3351
+ expect(acsRes.status).not.toBe(403);
3352
+ });
3353
+ });
3354
+
3355
+ describe("Negative: Non-SAML endpoints remain protected", () => {
3356
+ it("should block POST to sign-up with untrusted origin when origin check is enabled", async () => {
3357
+ const { auth } = await getTestInstance({
3358
+ plugins: [sso()],
3359
+ advanced: {
3360
+ disableCSRFCheck: false,
3361
+ disableOriginCheck: false,
3362
+ },
3363
+ });
3364
+
3365
+ // Origin check applies when cookies are present and check is enabled
3366
+ const signUpRes = await auth.handler(
3367
+ new Request("http://localhost:8081/api/auth/sign-up/email", {
3368
+ method: "POST",
3369
+ headers: {
3370
+ "Content-Type": "application/json",
3371
+ Origin: "http://attacker.com",
3372
+ Cookie: "better-auth.session_token=fake-session",
3373
+ },
3374
+ body: JSON.stringify({
3375
+ email: "victim@example.com",
3376
+ password: "password123",
3377
+ name: "Victim",
3378
+ }),
3379
+ }),
3380
+ );
3381
+
3382
+ expect(signUpRes.status).toBe(403);
2286
3383
  });
2287
3384
  });
2288
3385
 
2289
- describe("Custom clock skew configuration", () => {
2290
- it("should use custom clockSkew when provided", () => {
2291
- const twoSecondsAgo = new Date(Date.now() - 2 * 1000).toISOString();
3386
+ describe("Edge cases", () => {
3387
+ it("should allow GET requests to SAML metadata regardless of origin", async () => {
3388
+ const { auth } = await getTestInstance({
3389
+ plugins: [sso()],
3390
+ });
2292
3391
 
2293
- expect(() =>
2294
- validateSAMLTimestamp(
2295
- { notOnOrAfter: twoSecondsAgo },
2296
- { clockSkew: 1000 },
2297
- ),
2298
- ).toThrow("SAML assertion has expired");
3392
+ // GET requests always bypass origin check
3393
+ const metadataRes = await auth.handler(
3394
+ new Request("http://localhost:8081/api/auth/sso/saml2/sp/metadata", {
3395
+ method: "GET",
3396
+ headers: {
3397
+ Origin: "http://any-origin.com",
3398
+ },
3399
+ }),
3400
+ );
2299
3401
 
2300
- expect(() =>
2301
- validateSAMLTimestamp(
2302
- { notOnOrAfter: twoSecondsAgo },
2303
- { clockSkew: 5 * 60 * 1000 },
2304
- ),
2305
- ).not.toThrow();
3402
+ expect(metadataRes.status).not.toBe(403);
2306
3403
  });
2307
3404
 
2308
- it("should use default 5 minute clock skew when not specified", () => {
2309
- const fourMinutesAgo = new Date(Date.now() - 4 * 60 * 1000).toISOString();
2310
- expect(() =>
2311
- validateSAMLTimestamp({ notOnOrAfter: fourMinutesAgo }),
2312
- ).not.toThrow();
3405
+ it("should not redirect to malicious RelayState URLs", async () => {
3406
+ const { auth, signInWithTestUser } = await getTestInstance({
3407
+ plugins: [sso()],
3408
+ });
3409
+ const { headers } = await signInWithTestUser();
2313
3410
 
2314
- const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000).toISOString();
2315
- expect(() =>
2316
- validateSAMLTimestamp({ notOnOrAfter: sixMinutesAgo }),
2317
- ).toThrow("SAML assertion has expired");
3411
+ await auth.api.registerSSOProvider({
3412
+ body: {
3413
+ providerId: "relay-security-test",
3414
+ issuer: "http://localhost:8081",
3415
+ domain: "relay-security.com",
3416
+ samlConfig: {
3417
+ entryPoint: sharedMockIdP.metadataUrl,
3418
+ cert: certificate,
3419
+ callbackUrl: "http://localhost:8081/api/auth/sso/saml2/callback",
3420
+ wantAssertionsSigned: false,
3421
+ signatureAlgorithm: "sha256",
3422
+ digestAlgorithm: "sha256",
3423
+ spMetadata: {
3424
+ metadata: spMetadata,
3425
+ },
3426
+ },
3427
+ },
3428
+ headers,
3429
+ });
3430
+
3431
+ // Even with origin bypass, malicious RelayState should be rejected
3432
+ const callbackRes = await auth.handler(
3433
+ new Request(
3434
+ "http://localhost:8081/api/auth/sso/saml2/callback/relay-security-test",
3435
+ {
3436
+ method: "POST",
3437
+ headers: {
3438
+ "Content-Type": "application/x-www-form-urlencoded",
3439
+ Origin: "http://idp.example.com",
3440
+ },
3441
+ body: new URLSearchParams({
3442
+ SAMLResponse: Buffer.from("<fake-saml-response/>").toString(
3443
+ "base64",
3444
+ ),
3445
+ RelayState: "http://malicious-site.com/steal-token",
3446
+ }).toString(),
3447
+ },
3448
+ ),
3449
+ );
3450
+
3451
+ // Should NOT redirect to malicious URL
3452
+ if (callbackRes.status === 302) {
3453
+ const location = callbackRes.headers.get("Location");
3454
+ expect(location).not.toContain("malicious-site.com");
3455
+ }
2318
3456
  });
2319
3457
  });
3458
+ });
2320
3459
 
2321
- describe("Malformed timestamp handling", () => {
2322
- it("should reject malformed NotBefore timestamp", () => {
2323
- expect(() =>
2324
- validateSAMLTimestamp({ notBefore: "not-a-valid-date" }),
2325
- ).toThrow("SAML assertion has invalid NotBefore timestamp");
3460
+ describe("SAML Response Security", () => {
3461
+ it("should reject forged/unsigned SAML responses", async () => {
3462
+ const { auth, signInWithTestUser } = await getTestInstance({
3463
+ plugins: [sso()],
2326
3464
  });
3465
+ const { headers } = await signInWithTestUser();
2327
3466
 
2328
- it("should reject malformed NotOnOrAfter timestamp", () => {
2329
- expect(() =>
2330
- validateSAMLTimestamp({ notOnOrAfter: "invalid-timestamp" }),
2331
- ).toThrow("SAML assertion has invalid NotOnOrAfter timestamp");
3467
+ await auth.api.registerSSOProvider({
3468
+ body: {
3469
+ providerId: "security-test-provider",
3470
+ issuer: "http://localhost:8081",
3471
+ domain: "security-test.com",
3472
+ samlConfig: {
3473
+ entryPoint: sharedMockIdP.metadataUrl,
3474
+ cert: certificate,
3475
+ callbackUrl: "http://localhost:8081/api/auth/sso/saml2/callback",
3476
+ wantAssertionsSigned: false,
3477
+ signatureAlgorithm: "sha256",
3478
+ digestAlgorithm: "sha256",
3479
+ spMetadata: {
3480
+ metadata: spMetadata,
3481
+ },
3482
+ },
3483
+ },
3484
+ headers,
2332
3485
  });
2333
3486
 
2334
- it("should treat empty string timestamps as missing (falsy values)", () => {
2335
- expect(() => validateSAMLTimestamp({ notBefore: "" })).not.toThrow();
2336
- expect(() => validateSAMLTimestamp({ notOnOrAfter: "" })).not.toThrow();
2337
- });
3487
+ const forgedSAMLResponse = `
3488
+ <samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
3489
+ <saml:Assertion>
3490
+ <saml:Subject>
3491
+ <saml2:NameID xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">attacker@evil.com</saml2:NameID>
3492
+ </saml:Subject>
3493
+ </saml:Assertion>
3494
+ </samlp:Response>
3495
+ `;
2338
3496
 
2339
- it("should reject garbage data in timestamps", () => {
2340
- expect(() =>
2341
- validateSAMLTimestamp({
2342
- notBefore: "abc123xyz",
2343
- notOnOrAfter: "!@#$%^&*()",
2344
- }),
2345
- ).toThrow("SAML assertion has invalid NotBefore timestamp");
3497
+ const callbackRes = await auth.handler(
3498
+ new Request(
3499
+ "http://localhost:8081/api/auth/sso/saml2/callback/security-test-provider",
3500
+ {
3501
+ method: "POST",
3502
+ headers: {
3503
+ "Content-Type": "application/x-www-form-urlencoded",
3504
+ },
3505
+ body: new URLSearchParams({
3506
+ SAMLResponse: Buffer.from(forgedSAMLResponse).toString("base64"),
3507
+ RelayState: "",
3508
+ }).toString(),
3509
+ },
3510
+ ),
3511
+ );
3512
+
3513
+ expect(callbackRes.status).toBe(400);
3514
+ const body = await callbackRes.json();
3515
+ expect(body.message).toBe("Invalid SAML response");
3516
+ });
3517
+
3518
+ it("should reject SAML response with tampered nameID", async () => {
3519
+ const { auth, signInWithTestUser } = await getTestInstance({
3520
+ plugins: [sso()],
2346
3521
  });
3522
+ const { headers } = await signInWithTestUser();
2347
3523
 
2348
- it("should accept valid ISO 8601 timestamps", () => {
2349
- const now = new Date();
2350
- const future = new Date(Date.now() + 10 * 60 * 1000);
2351
- expect(() =>
2352
- validateSAMLTimestamp({
2353
- notBefore: now.toISOString(),
2354
- notOnOrAfter: future.toISOString(),
2355
- }),
2356
- ).not.toThrow();
3524
+ await auth.api.registerSSOProvider({
3525
+ body: {
3526
+ providerId: "tamper-test-provider",
3527
+ issuer: "http://localhost:8081",
3528
+ domain: "tamper-test.com",
3529
+ samlConfig: {
3530
+ entryPoint: sharedMockIdP.metadataUrl,
3531
+ cert: certificate,
3532
+ callbackUrl: "http://localhost:8081/api/auth/sso/saml2/callback",
3533
+ wantAssertionsSigned: false,
3534
+ signatureAlgorithm: "sha256",
3535
+ digestAlgorithm: "sha256",
3536
+ spMetadata: {
3537
+ metadata: spMetadata,
3538
+ },
3539
+ },
3540
+ },
3541
+ headers,
2357
3542
  });
3543
+
3544
+ const tamperedResponse = `<?xml version="1.0"?>
3545
+ <samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">
3546
+ <saml2:NameID xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">admin@victim.com</saml2:NameID>
3547
+ </samlp:Response>`;
3548
+
3549
+ const callbackRes = await auth.handler(
3550
+ new Request(
3551
+ "http://localhost:8081/api/auth/sso/saml2/callback/tamper-test-provider",
3552
+ {
3553
+ method: "POST",
3554
+ headers: {
3555
+ "Content-Type": "application/x-www-form-urlencoded",
3556
+ },
3557
+ body: new URLSearchParams({
3558
+ SAMLResponse: Buffer.from(tamperedResponse).toString("base64"),
3559
+ RelayState: "",
3560
+ }).toString(),
3561
+ },
3562
+ ),
3563
+ );
3564
+
3565
+ expect(callbackRes.status).toBe(400);
2358
3566
  });
2359
3567
  });
2360
3568
 
@@ -2408,7 +3616,6 @@ describe("SAML SSO - Assertion Replay Protection", () => {
2408
3616
  },
2409
3617
  });
2410
3618
 
2411
- // First submission should succeed
2412
3619
  const firstResponse = await auth.handler(
2413
3620
  new Request(
2414
3621
  "http://localhost:3000/api/auth/sso/saml2/callback/replay-test-provider",
@@ -2429,7 +3636,6 @@ describe("SAML SSO - Assertion Replay Protection", () => {
2429
3636
  const firstLocation = firstResponse.headers.get("location") || "";
2430
3637
  expect(firstLocation).not.toContain("error");
2431
3638
 
2432
- // Second submission (replay) should be rejected
2433
3639
  const replayResponse = await auth.handler(
2434
3640
  new Request(
2435
3641
  "http://localhost:3000/api/auth/sso/saml2/callback/replay-test-provider",
@@ -2490,7 +3696,6 @@ describe("SAML SSO - Assertion Replay Protection", () => {
2490
3696
  },
2491
3697
  });
2492
3698
 
2493
- // First submission to ACS endpoint should succeed
2494
3699
  const firstResponse = await auth.handler(
2495
3700
  new Request(
2496
3701
  "http://localhost:3000/api/auth/sso/saml2/sp/acs/acs-replay-test-provider",
@@ -2511,7 +3716,6 @@ describe("SAML SSO - Assertion Replay Protection", () => {
2511
3716
  const firstLocation = firstResponse.headers.get("location") || "";
2512
3717
  expect(firstLocation).not.toContain("error");
2513
3718
 
2514
- // Second submission (replay) to ACS endpoint should be rejected
2515
3719
  const replayResponse = await auth.handler(
2516
3720
  new Request(
2517
3721
  "http://localhost:3000/api/auth/sso/saml2/sp/acs/acs-replay-test-provider",
@@ -2572,7 +3776,6 @@ describe("SAML SSO - Assertion Replay Protection", () => {
2572
3776
  },
2573
3777
  });
2574
3778
 
2575
- // First: Submit to callback endpoint (should succeed)
2576
3779
  const callbackResponse = await auth.handler(
2577
3780
  new Request(
2578
3781
  "http://localhost:3000/api/auth/sso/saml2/callback/cross-endpoint-provider",
@@ -2592,7 +3795,6 @@ describe("SAML SSO - Assertion Replay Protection", () => {
2592
3795
  expect(callbackResponse.status).toBe(302);
2593
3796
  expect(callbackResponse.headers.get("location")).not.toContain("error");
2594
3797
 
2595
- // Second: Replay same assertion to ACS endpoint (should be rejected)
2596
3798
  const acsReplayResponse = await auth.handler(
2597
3799
  new Request(
2598
3800
  "http://localhost:3000/api/auth/sso/saml2/sp/acs/cross-endpoint-provider",
@@ -2961,4 +4163,157 @@ describe("SAML SSO - Single Assertion Validation", () => {
2961
4163
  expect(response.status).toBe(302);
2962
4164
  expect(response.headers.get("location")).not.toContain("error");
2963
4165
  });
4166
+
4167
+ it("should normalize email to lowercase in SAML authentication to prevent duplicate creation", async () => {
4168
+ const { auth, client, signInWithTestUser, db } = await getTestInstance({
4169
+ plugins: [sso()],
4170
+ });
4171
+
4172
+ const { headers } = await signInWithTestUser();
4173
+
4174
+ await auth.api.registerSSOProvider({
4175
+ body: {
4176
+ providerId: "email-case-provider",
4177
+ issuer: "http://localhost:8081",
4178
+ domain: "example.com",
4179
+ samlConfig: {
4180
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
4181
+ cert: certificate,
4182
+ callbackUrl: "http://localhost:3000/dashboard",
4183
+ wantAssertionsSigned: false,
4184
+ signatureAlgorithm: "sha256",
4185
+ digestAlgorithm: "sha256",
4186
+ idpMetadata: {
4187
+ metadata: idpMetadata,
4188
+ },
4189
+ spMetadata: {
4190
+ metadata: spMetadata,
4191
+ },
4192
+ identifierFormat:
4193
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
4194
+ mapping: {
4195
+ id: "nameID",
4196
+ email: "nameID",
4197
+ name: "displayName",
4198
+ },
4199
+ },
4200
+ },
4201
+ headers,
4202
+ });
4203
+
4204
+ let samlResponse1: { samlResponse: string } | undefined;
4205
+ await betterFetch(
4206
+ "http://localhost:8081/api/sso/saml2/idp/post?emailCase=mixed",
4207
+ {
4208
+ onSuccess: async (context) => {
4209
+ samlResponse1 = context.data as { samlResponse: string };
4210
+ },
4211
+ },
4212
+ );
4213
+
4214
+ expect(samlResponse1?.samlResponse).toBeDefined();
4215
+
4216
+ const firstCallbackResponse = await auth.handler(
4217
+ new Request(
4218
+ "http://localhost:3000/api/auth/sso/saml2/callback/email-case-provider",
4219
+ {
4220
+ method: "POST",
4221
+ headers: {
4222
+ "Content-Type": "application/x-www-form-urlencoded",
4223
+ },
4224
+ body: new URLSearchParams({
4225
+ SAMLResponse: samlResponse1!.samlResponse,
4226
+ RelayState: "http://localhost:3000/dashboard",
4227
+ }),
4228
+ },
4229
+ ),
4230
+ );
4231
+
4232
+ expect(firstCallbackResponse.status).toBe(302);
4233
+ expect(firstCallbackResponse.headers.get("location")).toContain(
4234
+ "dashboard",
4235
+ );
4236
+ expect(firstCallbackResponse.headers.get("location")).not.toContain(
4237
+ "error",
4238
+ );
4239
+
4240
+ const firstCookies = parseSetCookieHeader(
4241
+ firstCallbackResponse.headers.get("set-cookie") ?? "",
4242
+ );
4243
+ const firstSessionToken = firstCookies.get(
4244
+ "better-auth.session_token",
4245
+ )?.value;
4246
+ expect(firstSessionToken).toBeDefined();
4247
+
4248
+ const firstSession = await client.getSession({
4249
+ fetchOptions: {
4250
+ headers: {
4251
+ Cookie: `better-auth.session_token=${firstSessionToken}`,
4252
+ },
4253
+ },
4254
+ });
4255
+
4256
+ expect(firstSession.data?.user.email).toBe("testuser@example.com");
4257
+ const firstUserId = firstSession.data?.user.id;
4258
+ expect(firstUserId).toBeDefined();
4259
+
4260
+ let samlResponse2: { samlResponse: string } | undefined;
4261
+ await betterFetch(
4262
+ "http://localhost:8081/api/sso/saml2/idp/post?emailCase=mixed",
4263
+ {
4264
+ onSuccess: async (context) => {
4265
+ samlResponse2 = context.data as { samlResponse: string };
4266
+ },
4267
+ },
4268
+ );
4269
+
4270
+ const secondCallbackResponse = await auth.handler(
4271
+ new Request(
4272
+ "http://localhost:3000/api/auth/sso/saml2/callback/email-case-provider",
4273
+ {
4274
+ method: "POST",
4275
+ headers: {
4276
+ "Content-Type": "application/x-www-form-urlencoded",
4277
+ },
4278
+ body: new URLSearchParams({
4279
+ SAMLResponse: samlResponse2!.samlResponse,
4280
+ RelayState: "http://localhost:3000/dashboard",
4281
+ }),
4282
+ },
4283
+ ),
4284
+ );
4285
+
4286
+ expect(secondCallbackResponse.status).toBe(302);
4287
+ expect(secondCallbackResponse.headers.get("location")).toContain(
4288
+ "dashboard",
4289
+ );
4290
+ expect(secondCallbackResponse.headers.get("location")).not.toContain(
4291
+ "error",
4292
+ );
4293
+
4294
+ const secondCookies = parseSetCookieHeader(
4295
+ secondCallbackResponse.headers.get("set-cookie") ?? "",
4296
+ );
4297
+ const secondSessionToken = secondCookies.get(
4298
+ "better-auth.session_token",
4299
+ )?.value;
4300
+ expect(secondSessionToken).toBeDefined();
4301
+
4302
+ const secondSession = await client.getSession({
4303
+ fetchOptions: {
4304
+ headers: {
4305
+ Cookie: `better-auth.session_token=${secondSessionToken}`,
4306
+ },
4307
+ },
4308
+ });
4309
+
4310
+ expect(secondSession.data?.user.id).toBe(firstUserId);
4311
+ expect(secondSession.data?.user.email).toBe("testuser@example.com");
4312
+
4313
+ const users = (await db.findMany({ model: "user" })) as {
4314
+ email: string;
4315
+ }[];
4316
+ const samlUsers = users.filter((u) => u.email === "testuser@example.com");
4317
+ expect(samlUsers).toHaveLength(1);
4318
+ });
2964
4319
  });