@better-auth/sso 1.4.6 → 1.4.7-beta.3

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
@@ -24,7 +24,7 @@ import {
24
24
  it,
25
25
  vi,
26
26
  } from "vitest";
27
- import { sso } from ".";
27
+ import { createInMemoryAuthnRequestStore, sso } from ".";
28
28
  import { ssoClient } from "./client";
29
29
 
30
30
  const spMetadata = `
@@ -448,13 +448,9 @@ const createMockSAMLIdP = (port: number) => {
448
448
  async (req: ExpressRequest, res: ExpressResponse) => {
449
449
  const { SAMLResponse, RelayState } = req.body;
450
450
  try {
451
- const parseResult = await sp.parseLoginResponse(
452
- idp,
453
- saml.Constants.wording.binding.post,
454
- { body: { SAMLResponse } },
455
- );
456
-
457
- const { attributes, nameID } = parseResult.extract;
451
+ await sp.parseLoginResponse(idp, saml.Constants.wording.binding.post, {
452
+ body: { SAMLResponse },
453
+ });
458
454
 
459
455
  res.redirect(302, RelayState || "http://localhost:3000/dashboard");
460
456
  } catch (error) {
@@ -550,18 +546,6 @@ describe("SAML SSO with defaultSSO array", async () => {
550
546
  plugins: [sso(ssoOptions)],
551
547
  });
552
548
 
553
- const ctx = await auth.$context;
554
-
555
- const authClient = createAuthClient({
556
- baseURL: "http://localhost:3000",
557
- plugins: [bearer(), ssoClient()],
558
- fetchOptions: {
559
- customFetchImpl: async (url, init) => {
560
- return auth.handler(new Request(url, init));
561
- },
562
- },
563
- });
564
-
565
549
  beforeAll(async () => {
566
550
  await mockIdP.start();
567
551
  });
@@ -619,8 +603,6 @@ describe("SAML SSO", async () => {
619
603
  plugins: [sso(ssoOptions)],
620
604
  });
621
605
 
622
- const ctx = await auth.$context;
623
-
624
606
  const authClient = createAuthClient({
625
607
  baseURL: "http://localhost:3000",
626
608
  plugins: [bearer(), ssoClient()],
@@ -639,7 +621,7 @@ describe("SAML SSO", async () => {
639
621
 
640
622
  beforeAll(async () => {
641
623
  await mockIdP.start();
642
- const res = await authClient.signUp.email({
624
+ await authClient.signUp.email({
643
625
  email: testUser.email,
644
626
  password: testUser.password,
645
627
  name: testUser.name,
@@ -667,7 +649,7 @@ describe("SAML SSO", async () => {
667
649
  password: testUser.password,
668
650
  name: testUser.name,
669
651
  });
670
- const res = await authClient.signIn.email(testUser, {
652
+ await authClient.signIn.email(testUser, {
671
653
  throw: true,
672
654
  onSuccess: setCookieToHeader(headers),
673
655
  });
@@ -676,7 +658,7 @@ describe("SAML SSO", async () => {
676
658
 
677
659
  it("should register a new SAML provider", async () => {
678
660
  const headers = await getAuthHeaders();
679
- const res = await authClient.signIn.email(testUser, {
661
+ await authClient.signIn.email(testUser, {
680
662
  throw: true,
681
663
  onSuccess: setCookieToHeader(headers),
682
664
  });
@@ -847,11 +829,11 @@ describe("SAML SSO", async () => {
847
829
  });
848
830
  it("should initiate SAML login and handle response", async () => {
849
831
  const headers = await getAuthHeaders();
850
- const res = await authClient.signIn.email(testUser, {
832
+ await authClient.signIn.email(testUser, {
851
833
  throw: true,
852
834
  onSuccess: setCookieToHeader(headers),
853
835
  });
854
- const provider = await auth.api.registerSSOProvider({
836
+ await auth.api.registerSSOProvider({
855
837
  body: {
856
838
  providerId: "saml-provider-1",
857
839
  issuer: "http://localhost:8081",
@@ -1184,11 +1166,7 @@ describe("SAML SSO", async () => {
1184
1166
  });
1185
1167
 
1186
1168
  it("should deny account linking when provider is not trusted and domain is not verified", async () => {
1187
- const {
1188
- auth: authUntrusted,
1189
- signInWithTestUser,
1190
- client,
1191
- } = await getTestInstance({
1169
+ const { auth: authUntrusted, signInWithTestUser } = await getTestInstance({
1192
1170
  account: {
1193
1171
  accountLinking: {
1194
1172
  enabled: true,
@@ -1347,6 +1325,341 @@ describe("SAML SSO", async () => {
1347
1325
  expect(redirectLocation).not.toContain("error");
1348
1326
  expect(redirectLocation).toContain("dashboard");
1349
1327
  });
1328
+
1329
+ it("should reject unsolicited SAML response when allowIdpInitiated is false", async () => {
1330
+ const { auth, signInWithTestUser } = await getTestInstance({
1331
+ plugins: [
1332
+ sso({
1333
+ saml: {
1334
+ enableInResponseToValidation: true,
1335
+ allowIdpInitiated: false,
1336
+ },
1337
+ }),
1338
+ ],
1339
+ });
1340
+
1341
+ const { headers } = await signInWithTestUser();
1342
+
1343
+ await auth.api.registerSSOProvider({
1344
+ body: {
1345
+ providerId: "strict-saml-provider",
1346
+ issuer: "http://localhost:8081",
1347
+ domain: "http://localhost:8081",
1348
+ samlConfig: {
1349
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1350
+ cert: certificate,
1351
+ callbackUrl: "http://localhost:3000/dashboard",
1352
+ wantAssertionsSigned: false,
1353
+ signatureAlgorithm: "sha256",
1354
+ digestAlgorithm: "sha256",
1355
+ idpMetadata: {
1356
+ metadata: idpMetadata,
1357
+ },
1358
+ spMetadata: {
1359
+ metadata: spMetadata,
1360
+ },
1361
+ identifierFormat:
1362
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1363
+ },
1364
+ },
1365
+ headers,
1366
+ });
1367
+
1368
+ let samlResponse: any;
1369
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
1370
+ onSuccess: async (context) => {
1371
+ samlResponse = await context.data;
1372
+ },
1373
+ });
1374
+
1375
+ const response = await auth.handler(
1376
+ new Request(
1377
+ "http://localhost:3000/api/auth/sso/saml2/callback/strict-saml-provider",
1378
+ {
1379
+ method: "POST",
1380
+ headers: {
1381
+ "Content-Type": "application/x-www-form-urlencoded",
1382
+ },
1383
+ body: new URLSearchParams({
1384
+ SAMLResponse: samlResponse.samlResponse,
1385
+ RelayState: "http://localhost:3000/dashboard",
1386
+ }),
1387
+ },
1388
+ ),
1389
+ );
1390
+
1391
+ expect(response.status).toBe(302);
1392
+ const redirectLocation = response.headers.get("location") || "";
1393
+ expect(redirectLocation).toContain("error=unsolicited_response");
1394
+ });
1395
+
1396
+ it("should allow unsolicited SAML response when allowIdpInitiated is true (default)", async () => {
1397
+ const { auth, signInWithTestUser } = await getTestInstance({
1398
+ plugins: [
1399
+ sso({
1400
+ saml: {
1401
+ enableInResponseToValidation: true,
1402
+ allowIdpInitiated: true,
1403
+ },
1404
+ }),
1405
+ ],
1406
+ });
1407
+
1408
+ const { headers } = await signInWithTestUser();
1409
+
1410
+ await auth.api.registerSSOProvider({
1411
+ body: {
1412
+ providerId: "permissive-saml-provider",
1413
+ issuer: "http://localhost:8081",
1414
+ domain: "http://localhost:8081",
1415
+ samlConfig: {
1416
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1417
+ cert: certificate,
1418
+ callbackUrl: "http://localhost:3000/dashboard",
1419
+ wantAssertionsSigned: false,
1420
+ signatureAlgorithm: "sha256",
1421
+ digestAlgorithm: "sha256",
1422
+ idpMetadata: {
1423
+ metadata: idpMetadata,
1424
+ },
1425
+ spMetadata: {
1426
+ metadata: spMetadata,
1427
+ },
1428
+ identifierFormat:
1429
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1430
+ },
1431
+ },
1432
+ headers,
1433
+ });
1434
+
1435
+ let samlResponse: any;
1436
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
1437
+ onSuccess: async (context) => {
1438
+ samlResponse = await context.data;
1439
+ },
1440
+ });
1441
+
1442
+ const response = await auth.handler(
1443
+ new Request(
1444
+ "http://localhost:3000/api/auth/sso/saml2/callback/permissive-saml-provider",
1445
+ {
1446
+ method: "POST",
1447
+ headers: {
1448
+ "Content-Type": "application/x-www-form-urlencoded",
1449
+ },
1450
+ body: new URLSearchParams({
1451
+ SAMLResponse: samlResponse.samlResponse,
1452
+ RelayState: "http://localhost:3000/dashboard",
1453
+ }),
1454
+ },
1455
+ ),
1456
+ );
1457
+
1458
+ expect(response.status).toBe(302);
1459
+ const redirectLocation = response.headers.get("location") || "";
1460
+ expect(redirectLocation).not.toContain("error=unsolicited_response");
1461
+ });
1462
+
1463
+ it("should skip InResponseTo validation when not explicitly enabled (backward compatibility)", async () => {
1464
+ const { auth, signInWithTestUser } = await getTestInstance({
1465
+ plugins: [sso()],
1466
+ });
1467
+
1468
+ const { headers } = await signInWithTestUser();
1469
+
1470
+ await auth.api.registerSSOProvider({
1471
+ body: {
1472
+ providerId: "legacy-saml-provider",
1473
+ issuer: "http://localhost:8081",
1474
+ domain: "http://localhost:8081",
1475
+ samlConfig: {
1476
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1477
+ cert: certificate,
1478
+ callbackUrl: "http://localhost:3000/dashboard",
1479
+ wantAssertionsSigned: false,
1480
+ signatureAlgorithm: "sha256",
1481
+ digestAlgorithm: "sha256",
1482
+ idpMetadata: {
1483
+ metadata: idpMetadata,
1484
+ },
1485
+ spMetadata: {
1486
+ metadata: spMetadata,
1487
+ },
1488
+ identifierFormat:
1489
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1490
+ },
1491
+ },
1492
+ headers,
1493
+ });
1494
+
1495
+ let samlResponse: any;
1496
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
1497
+ onSuccess: async (context) => {
1498
+ samlResponse = await context.data;
1499
+ },
1500
+ });
1501
+
1502
+ const response = await auth.handler(
1503
+ new Request(
1504
+ "http://localhost:3000/api/auth/sso/saml2/callback/legacy-saml-provider",
1505
+ {
1506
+ method: "POST",
1507
+ headers: {
1508
+ "Content-Type": "application/x-www-form-urlencoded",
1509
+ },
1510
+ body: new URLSearchParams({
1511
+ SAMLResponse: samlResponse.samlResponse,
1512
+ RelayState: "http://localhost:3000/dashboard",
1513
+ }),
1514
+ },
1515
+ ),
1516
+ );
1517
+
1518
+ expect(response.status).toBe(302);
1519
+ const redirectLocation = response.headers.get("location") || "";
1520
+ expect(redirectLocation).not.toContain("error=");
1521
+ });
1522
+
1523
+ it("should enable validation automatically when custom authnRequestStore is provided", async () => {
1524
+ const customStore = createInMemoryAuthnRequestStore();
1525
+
1526
+ const { auth, signInWithTestUser } = await getTestInstance({
1527
+ plugins: [
1528
+ sso({
1529
+ saml: {
1530
+ authnRequestStore: customStore,
1531
+ allowIdpInitiated: false,
1532
+ },
1533
+ }),
1534
+ ],
1535
+ });
1536
+
1537
+ const { headers } = await signInWithTestUser();
1538
+
1539
+ await auth.api.registerSSOProvider({
1540
+ body: {
1541
+ providerId: "custom-store-provider",
1542
+ issuer: "http://localhost:8081",
1543
+ domain: "http://localhost:8081",
1544
+ samlConfig: {
1545
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1546
+ cert: certificate,
1547
+ callbackUrl: "http://localhost:3000/dashboard",
1548
+ wantAssertionsSigned: false,
1549
+ signatureAlgorithm: "sha256",
1550
+ digestAlgorithm: "sha256",
1551
+ idpMetadata: {
1552
+ metadata: idpMetadata,
1553
+ },
1554
+ spMetadata: {
1555
+ metadata: spMetadata,
1556
+ },
1557
+ identifierFormat:
1558
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1559
+ },
1560
+ },
1561
+ headers,
1562
+ });
1563
+
1564
+ let samlResponse: any;
1565
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
1566
+ onSuccess: async (context) => {
1567
+ samlResponse = await context.data;
1568
+ },
1569
+ });
1570
+
1571
+ const response = await auth.handler(
1572
+ new Request(
1573
+ "http://localhost:3000/api/auth/sso/saml2/callback/custom-store-provider",
1574
+ {
1575
+ method: "POST",
1576
+ headers: {
1577
+ "Content-Type": "application/x-www-form-urlencoded",
1578
+ },
1579
+ body: new URLSearchParams({
1580
+ SAMLResponse: samlResponse.samlResponse,
1581
+ RelayState: "http://localhost:3000/dashboard",
1582
+ }),
1583
+ },
1584
+ ),
1585
+ );
1586
+
1587
+ expect(response.status).toBe(302);
1588
+ const redirectLocation = response.headers.get("location") || "";
1589
+ expect(redirectLocation).toContain("error=unsolicited_response");
1590
+ });
1591
+
1592
+ it("should use verification table for InResponseTo validation when no custom store is provided", async () => {
1593
+ // When enableInResponseToValidation is true and no custom authnRequestStore is provided,
1594
+ // the plugin uses the verification table (database) for storing AuthnRequest IDs
1595
+ const { auth, signInWithTestUser } = await getTestInstance({
1596
+ plugins: [
1597
+ sso({
1598
+ saml: {
1599
+ enableInResponseToValidation: true,
1600
+ allowIdpInitiated: false,
1601
+ },
1602
+ }),
1603
+ ],
1604
+ });
1605
+
1606
+ const { headers } = await signInWithTestUser();
1607
+
1608
+ await auth.api.registerSSOProvider({
1609
+ body: {
1610
+ providerId: "db-fallback-provider",
1611
+ issuer: "http://localhost:8081",
1612
+ domain: "http://localhost:8081",
1613
+ samlConfig: {
1614
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1615
+ cert: certificate,
1616
+ callbackUrl: "http://localhost:3000/dashboard",
1617
+ wantAssertionsSigned: false,
1618
+ signatureAlgorithm: "sha256",
1619
+ digestAlgorithm: "sha256",
1620
+ idpMetadata: {
1621
+ metadata: idpMetadata,
1622
+ },
1623
+ spMetadata: {
1624
+ metadata: spMetadata,
1625
+ },
1626
+ identifierFormat:
1627
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1628
+ },
1629
+ },
1630
+ headers,
1631
+ });
1632
+
1633
+ // Try to use an unsolicited response - should be rejected since allowIdpInitiated is false
1634
+ // This proves the validation is working via the verification table fallback
1635
+ let samlResponse: any;
1636
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
1637
+ onSuccess: async (context) => {
1638
+ samlResponse = await context.data;
1639
+ },
1640
+ });
1641
+
1642
+ const response = await auth.handler(
1643
+ new Request(
1644
+ "http://localhost:3000/api/auth/sso/saml2/callback/db-fallback-provider",
1645
+ {
1646
+ method: "POST",
1647
+ headers: {
1648
+ "Content-Type": "application/x-www-form-urlencoded",
1649
+ },
1650
+ body: new URLSearchParams({
1651
+ SAMLResponse: samlResponse.samlResponse,
1652
+ RelayState: "http://localhost:3000/dashboard",
1653
+ }),
1654
+ },
1655
+ ),
1656
+ );
1657
+
1658
+ // Should reject unsolicited response, proving validation is active
1659
+ expect(response.status).toBe(302);
1660
+ const redirectLocation = response.headers.get("location") || "";
1661
+ expect(redirectLocation).toContain("error=unsolicited_response");
1662
+ });
1350
1663
  });
1351
1664
 
1352
1665
  describe("SAML SSO with custom fields", () => {
@@ -1401,7 +1714,7 @@ describe("SAML SSO with custom fields", () => {
1401
1714
 
1402
1715
  beforeAll(async () => {
1403
1716
  await mockIdP.start();
1404
- const res = await authClient.signUp.email({
1717
+ await authClient.signUp.email({
1405
1718
  email: testUser.email,
1406
1719
  password: testUser.password,
1407
1720
  name: testUser.name,
@@ -1671,3 +1984,157 @@ describe("SSO Provider Config Parsing", () => {
1671
1984
  expect(provider.oidcConfig?.mapping?.id).toBe("sub");
1672
1985
  });
1673
1986
  });
1987
+
1988
+ describe("SAML SSO - Signature Validation Security", () => {
1989
+ it("should reject unsigned SAML response with forged NameID", async () => {
1990
+ const { auth, signInWithTestUser } = await getTestInstance({
1991
+ plugins: [sso()],
1992
+ });
1993
+
1994
+ const { headers } = await signInWithTestUser();
1995
+
1996
+ await auth.api.registerSSOProvider({
1997
+ body: {
1998
+ providerId: "security-test-provider",
1999
+ issuer: "http://localhost:8081",
2000
+ domain: "http://localhost:8081",
2001
+ samlConfig: {
2002
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
2003
+ cert: certificate,
2004
+ callbackUrl: "http://localhost:3000/dashboard",
2005
+ wantAssertionsSigned: false,
2006
+ signatureAlgorithm: "sha256",
2007
+ digestAlgorithm: "sha256",
2008
+ idpMetadata: {
2009
+ metadata: idpMetadata,
2010
+ },
2011
+ spMetadata: {
2012
+ metadata: spMetadata,
2013
+ },
2014
+ identifierFormat:
2015
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2016
+ },
2017
+ },
2018
+ headers,
2019
+ });
2020
+
2021
+ const forgedSamlResponse = `
2022
+ <saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
2023
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2024
+ <saml2p:Status>
2025
+ <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
2026
+ </saml2p:Status>
2027
+ <saml2:Assertion>
2028
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2029
+ <saml2:Subject>
2030
+ <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">attacker-forged@evil.com</saml2:NameID>
2031
+ </saml2:Subject>
2032
+ <saml2:Conditions>
2033
+ <saml2:AudienceRestriction>
2034
+ <saml2:Audience>http://localhost:3001</saml2:Audience>
2035
+ </saml2:AudienceRestriction>
2036
+ </saml2:Conditions>
2037
+ <saml2:AuthnStatement>
2038
+ <saml2:AuthnContext>
2039
+ <saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml2:AuthnContextClassRef>
2040
+ </saml2:AuthnContext>
2041
+ </saml2:AuthnStatement>
2042
+ </saml2:Assertion>
2043
+ </saml2p:Response>
2044
+ `;
2045
+
2046
+ const encodedForgedResponse =
2047
+ Buffer.from(forgedSamlResponse).toString("base64");
2048
+
2049
+ await expect(
2050
+ auth.api.callbackSSOSAML({
2051
+ body: {
2052
+ SAMLResponse: encodedForgedResponse,
2053
+ RelayState: "http://localhost:3000/dashboard",
2054
+ },
2055
+ params: {
2056
+ providerId: "security-test-provider",
2057
+ },
2058
+ }),
2059
+ ).rejects.toMatchObject({
2060
+ status: "BAD_REQUEST",
2061
+ });
2062
+ });
2063
+
2064
+ it("should reject SAML response with invalid signature", async () => {
2065
+ const { auth, signInWithTestUser } = await getTestInstance({
2066
+ plugins: [sso()],
2067
+ });
2068
+
2069
+ const { headers } = await signInWithTestUser();
2070
+
2071
+ await auth.api.registerSSOProvider({
2072
+ body: {
2073
+ providerId: "invalid-sig-provider",
2074
+ issuer: "http://localhost:8081",
2075
+ domain: "http://localhost:8081",
2076
+ samlConfig: {
2077
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
2078
+ cert: certificate,
2079
+ callbackUrl: "http://localhost:3000/dashboard",
2080
+ wantAssertionsSigned: false,
2081
+ signatureAlgorithm: "sha256",
2082
+ digestAlgorithm: "sha256",
2083
+ idpMetadata: {
2084
+ metadata: idpMetadata,
2085
+ },
2086
+ spMetadata: {
2087
+ metadata: spMetadata,
2088
+ },
2089
+ identifierFormat:
2090
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2091
+ },
2092
+ },
2093
+ headers,
2094
+ });
2095
+
2096
+ const responseWithBadSignature = `
2097
+ <saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
2098
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2099
+ <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
2100
+ <ds:SignedInfo>
2101
+ <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
2102
+ <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
2103
+ <ds:Reference>
2104
+ <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
2105
+ <ds:DigestValue>FAKE_DIGEST_VALUE</ds:DigestValue>
2106
+ </ds:Reference>
2107
+ </ds:SignedInfo>
2108
+ <ds:SignatureValue>INVALID_SIGNATURE_VALUE_THAT_SHOULD_FAIL_VERIFICATION</ds:SignatureValue>
2109
+ </ds:Signature>
2110
+ <saml2p:Status>
2111
+ <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
2112
+ </saml2p:Status>
2113
+ <saml2:Assertion>
2114
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2115
+ <saml2:Subject>
2116
+ <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">forged-admin@company.com</saml2:NameID>
2117
+ </saml2:Subject>
2118
+ </saml2:Assertion>
2119
+ </saml2p:Response>
2120
+ `;
2121
+
2122
+ const encodedBadSigResponse = Buffer.from(
2123
+ responseWithBadSignature,
2124
+ ).toString("base64");
2125
+
2126
+ await expect(
2127
+ auth.api.callbackSSOSAML({
2128
+ body: {
2129
+ SAMLResponse: encodedBadSigResponse,
2130
+ RelayState: "http://localhost:3000/dashboard",
2131
+ },
2132
+ params: {
2133
+ providerId: "invalid-sig-provider",
2134
+ },
2135
+ }),
2136
+ ).rejects.toMatchObject({
2137
+ status: "BAD_REQUEST",
2138
+ });
2139
+ });
2140
+ });
package/src/types.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { OAuth2Tokens, User } from "better-auth";
2
+ import type { AuthnRequestStore } from "./authn-request-store";
2
3
 
3
4
  export interface OIDCMapping {
4
5
  id?: string | undefined;
@@ -232,7 +233,13 @@ export interface SSOOptions {
232
233
  *
233
234
  * If you want to allow account linking for specific trusted providers, enable the `accountLinking` option in your auth config and specify those
234
235
  * providers in the `trustedProviders` list.
236
+ *
235
237
  * @default false
238
+ *
239
+ * @deprecated This option is discouraged for new projects. Relying on provider-level `email_verified` is a weaker
240
+ * trust signal compared to using `trustedProviders` in `accountLinking` or enabling `domainVerification` for SSO.
241
+ * Existing configurations will continue to work, but new integrations should use explicit trust mechanisms.
242
+ * This option may be removed in a future major version.
236
243
  */
237
244
  trustEmailVerified?: boolean | undefined;
238
245
  /**
@@ -253,4 +260,52 @@ export interface SSOOptions {
253
260
  */
254
261
  tokenPrefix?: string;
255
262
  };
263
+ /**
264
+ * SAML security options for AuthnRequest/InResponseTo validation.
265
+ * This prevents unsolicited responses, replay attacks, and cross-provider injection.
266
+ */
267
+ saml?: {
268
+ /**
269
+ * Enable InResponseTo validation for SP-initiated SAML flows.
270
+ * When enabled, AuthnRequest IDs are tracked and validated against SAML responses.
271
+ *
272
+ * Storage behavior:
273
+ * - Uses `secondaryStorage` (e.g., Redis) if configured in your auth options
274
+ * - Falls back to the verification table in the database otherwise
275
+ *
276
+ * This works correctly in serverless environments without any additional configuration.
277
+ *
278
+ * @default false
279
+ */
280
+ enableInResponseToValidation?: boolean;
281
+ /**
282
+ * Allow IdP-initiated SSO (unsolicited SAML responses).
283
+ * When true, responses without InResponseTo are accepted.
284
+ * When false, all responses must correlate to a stored AuthnRequest.
285
+ *
286
+ * Only applies when InResponseTo validation is enabled.
287
+ *
288
+ * @default true
289
+ */
290
+ allowIdpInitiated?: boolean;
291
+ /**
292
+ * TTL for AuthnRequest records in milliseconds.
293
+ * Requests older than this will be rejected.
294
+ *
295
+ * Only applies when InResponseTo validation is enabled.
296
+ *
297
+ * @default 300000 (5 minutes)
298
+ */
299
+ requestTTL?: number;
300
+ /**
301
+ * Custom AuthnRequest store implementation.
302
+ * Use this to provide a custom storage backend (e.g., Redis-backed store).
303
+ *
304
+ * Providing a custom store automatically enables InResponseTo validation.
305
+ *
306
+ * Note: When not provided, the default storage (secondaryStorage with
307
+ * verification table fallback) is used automatically.
308
+ */
309
+ authnRequestStore?: AuthnRequestStore;
310
+ };
256
311
  }