@better-auth/sso 1.4.7-beta.2 → 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 = `
@@ -1325,6 +1325,341 @@ describe("SAML SSO", async () => {
1325
1325
  expect(redirectLocation).not.toContain("error");
1326
1326
  expect(redirectLocation).toContain("dashboard");
1327
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
+ });
1328
1663
  });
1329
1664
 
1330
1665
  describe("SAML SSO with custom fields", () => {
@@ -1649,3 +1984,157 @@ describe("SSO Provider Config Parsing", () => {
1649
1984
  expect(provider.oidcConfig?.mapping?.id).toBe("sub");
1650
1985
  });
1651
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;
@@ -259,4 +260,52 @@ export interface SSOOptions {
259
260
  */
260
261
  tokenPrefix?: string;
261
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
+ };
262
311
  }