@better-auth/sso 1.4.7-beta.2 → 1.4.7-beta.4

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
@@ -17,6 +17,7 @@ import express from "express";
17
17
  import * as saml from "samlify";
18
18
  import {
19
19
  afterAll,
20
+ afterEach,
20
21
  beforeAll,
21
22
  beforeEach,
22
23
  describe,
@@ -24,7 +25,12 @@ import {
24
25
  it,
25
26
  vi,
26
27
  } from "vitest";
27
- import { sso } from ".";
28
+ import {
29
+ createInMemoryAuthnRequestStore,
30
+ DEFAULT_CLOCK_SKEW_MS,
31
+ sso,
32
+ validateSAMLTimestamp,
33
+ } from ".";
28
34
  import { ssoClient } from "./client";
29
35
 
30
36
  const spMetadata = `
@@ -1325,6 +1331,341 @@ describe("SAML SSO", async () => {
1325
1331
  expect(redirectLocation).not.toContain("error");
1326
1332
  expect(redirectLocation).toContain("dashboard");
1327
1333
  });
1334
+
1335
+ it("should reject unsolicited SAML response when allowIdpInitiated is false", async () => {
1336
+ const { auth, signInWithTestUser } = await getTestInstance({
1337
+ plugins: [
1338
+ sso({
1339
+ saml: {
1340
+ enableInResponseToValidation: true,
1341
+ allowIdpInitiated: false,
1342
+ },
1343
+ }),
1344
+ ],
1345
+ });
1346
+
1347
+ const { headers } = await signInWithTestUser();
1348
+
1349
+ await auth.api.registerSSOProvider({
1350
+ body: {
1351
+ providerId: "strict-saml-provider",
1352
+ issuer: "http://localhost:8081",
1353
+ domain: "http://localhost:8081",
1354
+ samlConfig: {
1355
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1356
+ cert: certificate,
1357
+ callbackUrl: "http://localhost:3000/dashboard",
1358
+ wantAssertionsSigned: false,
1359
+ signatureAlgorithm: "sha256",
1360
+ digestAlgorithm: "sha256",
1361
+ idpMetadata: {
1362
+ metadata: idpMetadata,
1363
+ },
1364
+ spMetadata: {
1365
+ metadata: spMetadata,
1366
+ },
1367
+ identifierFormat:
1368
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1369
+ },
1370
+ },
1371
+ headers,
1372
+ });
1373
+
1374
+ let samlResponse: any;
1375
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
1376
+ onSuccess: async (context) => {
1377
+ samlResponse = await context.data;
1378
+ },
1379
+ });
1380
+
1381
+ const response = await auth.handler(
1382
+ new Request(
1383
+ "http://localhost:3000/api/auth/sso/saml2/callback/strict-saml-provider",
1384
+ {
1385
+ method: "POST",
1386
+ headers: {
1387
+ "Content-Type": "application/x-www-form-urlencoded",
1388
+ },
1389
+ body: new URLSearchParams({
1390
+ SAMLResponse: samlResponse.samlResponse,
1391
+ RelayState: "http://localhost:3000/dashboard",
1392
+ }),
1393
+ },
1394
+ ),
1395
+ );
1396
+
1397
+ expect(response.status).toBe(302);
1398
+ const redirectLocation = response.headers.get("location") || "";
1399
+ expect(redirectLocation).toContain("error=unsolicited_response");
1400
+ });
1401
+
1402
+ it("should allow unsolicited SAML response when allowIdpInitiated is true (default)", async () => {
1403
+ const { auth, signInWithTestUser } = await getTestInstance({
1404
+ plugins: [
1405
+ sso({
1406
+ saml: {
1407
+ enableInResponseToValidation: true,
1408
+ allowIdpInitiated: true,
1409
+ },
1410
+ }),
1411
+ ],
1412
+ });
1413
+
1414
+ const { headers } = await signInWithTestUser();
1415
+
1416
+ await auth.api.registerSSOProvider({
1417
+ body: {
1418
+ providerId: "permissive-saml-provider",
1419
+ issuer: "http://localhost:8081",
1420
+ domain: "http://localhost:8081",
1421
+ samlConfig: {
1422
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1423
+ cert: certificate,
1424
+ callbackUrl: "http://localhost:3000/dashboard",
1425
+ wantAssertionsSigned: false,
1426
+ signatureAlgorithm: "sha256",
1427
+ digestAlgorithm: "sha256",
1428
+ idpMetadata: {
1429
+ metadata: idpMetadata,
1430
+ },
1431
+ spMetadata: {
1432
+ metadata: spMetadata,
1433
+ },
1434
+ identifierFormat:
1435
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1436
+ },
1437
+ },
1438
+ headers,
1439
+ });
1440
+
1441
+ let samlResponse: any;
1442
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
1443
+ onSuccess: async (context) => {
1444
+ samlResponse = await context.data;
1445
+ },
1446
+ });
1447
+
1448
+ const response = await auth.handler(
1449
+ new Request(
1450
+ "http://localhost:3000/api/auth/sso/saml2/callback/permissive-saml-provider",
1451
+ {
1452
+ method: "POST",
1453
+ headers: {
1454
+ "Content-Type": "application/x-www-form-urlencoded",
1455
+ },
1456
+ body: new URLSearchParams({
1457
+ SAMLResponse: samlResponse.samlResponse,
1458
+ RelayState: "http://localhost:3000/dashboard",
1459
+ }),
1460
+ },
1461
+ ),
1462
+ );
1463
+
1464
+ expect(response.status).toBe(302);
1465
+ const redirectLocation = response.headers.get("location") || "";
1466
+ expect(redirectLocation).not.toContain("error=unsolicited_response");
1467
+ });
1468
+
1469
+ it("should skip InResponseTo validation when not explicitly enabled (backward compatibility)", async () => {
1470
+ const { auth, signInWithTestUser } = await getTestInstance({
1471
+ plugins: [sso()],
1472
+ });
1473
+
1474
+ const { headers } = await signInWithTestUser();
1475
+
1476
+ await auth.api.registerSSOProvider({
1477
+ body: {
1478
+ providerId: "legacy-saml-provider",
1479
+ issuer: "http://localhost:8081",
1480
+ domain: "http://localhost:8081",
1481
+ samlConfig: {
1482
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1483
+ cert: certificate,
1484
+ callbackUrl: "http://localhost:3000/dashboard",
1485
+ wantAssertionsSigned: false,
1486
+ signatureAlgorithm: "sha256",
1487
+ digestAlgorithm: "sha256",
1488
+ idpMetadata: {
1489
+ metadata: idpMetadata,
1490
+ },
1491
+ spMetadata: {
1492
+ metadata: spMetadata,
1493
+ },
1494
+ identifierFormat:
1495
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1496
+ },
1497
+ },
1498
+ headers,
1499
+ });
1500
+
1501
+ let samlResponse: any;
1502
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
1503
+ onSuccess: async (context) => {
1504
+ samlResponse = await context.data;
1505
+ },
1506
+ });
1507
+
1508
+ const response = await auth.handler(
1509
+ new Request(
1510
+ "http://localhost:3000/api/auth/sso/saml2/callback/legacy-saml-provider",
1511
+ {
1512
+ method: "POST",
1513
+ headers: {
1514
+ "Content-Type": "application/x-www-form-urlencoded",
1515
+ },
1516
+ body: new URLSearchParams({
1517
+ SAMLResponse: samlResponse.samlResponse,
1518
+ RelayState: "http://localhost:3000/dashboard",
1519
+ }),
1520
+ },
1521
+ ),
1522
+ );
1523
+
1524
+ expect(response.status).toBe(302);
1525
+ const redirectLocation = response.headers.get("location") || "";
1526
+ expect(redirectLocation).not.toContain("error=");
1527
+ });
1528
+
1529
+ it("should enable validation automatically when custom authnRequestStore is provided", async () => {
1530
+ const customStore = createInMemoryAuthnRequestStore();
1531
+
1532
+ const { auth, signInWithTestUser } = await getTestInstance({
1533
+ plugins: [
1534
+ sso({
1535
+ saml: {
1536
+ authnRequestStore: customStore,
1537
+ allowIdpInitiated: false,
1538
+ },
1539
+ }),
1540
+ ],
1541
+ });
1542
+
1543
+ const { headers } = await signInWithTestUser();
1544
+
1545
+ await auth.api.registerSSOProvider({
1546
+ body: {
1547
+ providerId: "custom-store-provider",
1548
+ issuer: "http://localhost:8081",
1549
+ domain: "http://localhost:8081",
1550
+ samlConfig: {
1551
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1552
+ cert: certificate,
1553
+ callbackUrl: "http://localhost:3000/dashboard",
1554
+ wantAssertionsSigned: false,
1555
+ signatureAlgorithm: "sha256",
1556
+ digestAlgorithm: "sha256",
1557
+ idpMetadata: {
1558
+ metadata: idpMetadata,
1559
+ },
1560
+ spMetadata: {
1561
+ metadata: spMetadata,
1562
+ },
1563
+ identifierFormat:
1564
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1565
+ },
1566
+ },
1567
+ headers,
1568
+ });
1569
+
1570
+ let samlResponse: any;
1571
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
1572
+ onSuccess: async (context) => {
1573
+ samlResponse = await context.data;
1574
+ },
1575
+ });
1576
+
1577
+ const response = await auth.handler(
1578
+ new Request(
1579
+ "http://localhost:3000/api/auth/sso/saml2/callback/custom-store-provider",
1580
+ {
1581
+ method: "POST",
1582
+ headers: {
1583
+ "Content-Type": "application/x-www-form-urlencoded",
1584
+ },
1585
+ body: new URLSearchParams({
1586
+ SAMLResponse: samlResponse.samlResponse,
1587
+ RelayState: "http://localhost:3000/dashboard",
1588
+ }),
1589
+ },
1590
+ ),
1591
+ );
1592
+
1593
+ expect(response.status).toBe(302);
1594
+ const redirectLocation = response.headers.get("location") || "";
1595
+ expect(redirectLocation).toContain("error=unsolicited_response");
1596
+ });
1597
+
1598
+ it("should use verification table for InResponseTo validation when no custom store is provided", async () => {
1599
+ // When enableInResponseToValidation is true and no custom authnRequestStore is provided,
1600
+ // the plugin uses the verification table (database) for storing AuthnRequest IDs
1601
+ const { auth, signInWithTestUser } = await getTestInstance({
1602
+ plugins: [
1603
+ sso({
1604
+ saml: {
1605
+ enableInResponseToValidation: true,
1606
+ allowIdpInitiated: false,
1607
+ },
1608
+ }),
1609
+ ],
1610
+ });
1611
+
1612
+ const { headers } = await signInWithTestUser();
1613
+
1614
+ await auth.api.registerSSOProvider({
1615
+ body: {
1616
+ providerId: "db-fallback-provider",
1617
+ issuer: "http://localhost:8081",
1618
+ domain: "http://localhost:8081",
1619
+ samlConfig: {
1620
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1621
+ cert: certificate,
1622
+ callbackUrl: "http://localhost:3000/dashboard",
1623
+ wantAssertionsSigned: false,
1624
+ signatureAlgorithm: "sha256",
1625
+ digestAlgorithm: "sha256",
1626
+ idpMetadata: {
1627
+ metadata: idpMetadata,
1628
+ },
1629
+ spMetadata: {
1630
+ metadata: spMetadata,
1631
+ },
1632
+ identifierFormat:
1633
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1634
+ },
1635
+ },
1636
+ headers,
1637
+ });
1638
+
1639
+ // Try to use an unsolicited response - should be rejected since allowIdpInitiated is false
1640
+ // This proves the validation is working via the verification table fallback
1641
+ let samlResponse: any;
1642
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
1643
+ onSuccess: async (context) => {
1644
+ samlResponse = await context.data;
1645
+ },
1646
+ });
1647
+
1648
+ const response = await auth.handler(
1649
+ new Request(
1650
+ "http://localhost:3000/api/auth/sso/saml2/callback/db-fallback-provider",
1651
+ {
1652
+ method: "POST",
1653
+ headers: {
1654
+ "Content-Type": "application/x-www-form-urlencoded",
1655
+ },
1656
+ body: new URLSearchParams({
1657
+ SAMLResponse: samlResponse.samlResponse,
1658
+ RelayState: "http://localhost:3000/dashboard",
1659
+ }),
1660
+ },
1661
+ ),
1662
+ );
1663
+
1664
+ // Should reject unsolicited response, proving validation is active
1665
+ expect(response.status).toBe(302);
1666
+ const redirectLocation = response.headers.get("location") || "";
1667
+ expect(redirectLocation).toContain("error=unsolicited_response");
1668
+ });
1328
1669
  });
1329
1670
 
1330
1671
  describe("SAML SSO with custom fields", () => {
@@ -1581,71 +1922,463 @@ describe("SSO Provider Config Parsing", () => {
1581
1922
  });
1582
1923
 
1583
1924
  it("returns parsed OIDC config and avoids [object Object] in response", async () => {
1584
- const data = {
1585
- user: [] as any[],
1586
- session: [] as any[],
1587
- verification: [] as any[],
1588
- account: [] as any[],
1589
- ssoProvider: [] as any[],
1590
- };
1925
+ const { OAuth2Server } = await import("oauth2-mock-server");
1926
+ const oidcServer = new OAuth2Server();
1591
1927
 
1592
- const memory = memoryAdapter(data);
1928
+ await oidcServer.issuer.keys.generate("RS256");
1929
+ await oidcServer.start(8082, "localhost");
1593
1930
 
1594
- const auth = betterAuth({
1595
- database: memory,
1596
- baseURL: "http://localhost:3000",
1597
- emailAndPassword: { enabled: true },
1931
+ try {
1932
+ const data = {
1933
+ user: [] as any[],
1934
+ session: [] as any[],
1935
+ verification: [] as any[],
1936
+ account: [] as any[],
1937
+ ssoProvider: [] as any[],
1938
+ };
1939
+
1940
+ const memory = memoryAdapter(data);
1941
+
1942
+ const auth = betterAuth({
1943
+ database: memory,
1944
+ baseURL: "http://localhost:3000",
1945
+ emailAndPassword: { enabled: true },
1946
+ plugins: [sso()],
1947
+ });
1948
+
1949
+ const authClient = createAuthClient({
1950
+ baseURL: "http://localhost:3000",
1951
+ plugins: [bearer(), ssoClient()],
1952
+ fetchOptions: {
1953
+ customFetchImpl: async (url, init) =>
1954
+ auth.handler(new Request(url, init)),
1955
+ },
1956
+ });
1957
+
1958
+ const headers = new Headers();
1959
+ await authClient.signUp.email({
1960
+ email: "test@example.com",
1961
+ password: "password123",
1962
+ name: "Test User",
1963
+ });
1964
+ await authClient.signIn.email(
1965
+ { email: "test@example.com", password: "password123" },
1966
+ { onSuccess: setCookieToHeader(headers) },
1967
+ );
1968
+
1969
+ const provider = await auth.api.registerSSOProvider({
1970
+ body: {
1971
+ providerId: "oidc-config-provider",
1972
+ issuer: oidcServer.issuer.url!,
1973
+ domain: "example.com",
1974
+ oidcConfig: {
1975
+ clientId: "test-client",
1976
+ clientSecret: "test-secret",
1977
+ tokenEndpointAuthentication: "client_secret_basic",
1978
+ mapping: {
1979
+ id: "sub",
1980
+ email: "email",
1981
+ name: "name",
1982
+ },
1983
+ },
1984
+ },
1985
+ headers,
1986
+ });
1987
+
1988
+ expect(provider.oidcConfig).toBeDefined();
1989
+ expect(typeof provider.oidcConfig).toBe("object");
1990
+ expect(provider.oidcConfig?.clientId).toBe("test-client");
1991
+ expect(provider.oidcConfig?.clientSecret).toBe("test-secret");
1992
+
1993
+ const serialized = JSON.stringify(provider.oidcConfig);
1994
+ expect(serialized).not.toContain("[object Object]");
1995
+
1996
+ expect(provider.oidcConfig?.mapping?.id).toBe("sub");
1997
+ } finally {
1998
+ await oidcServer.stop().catch(() => {});
1999
+ }
2000
+ });
2001
+ });
2002
+
2003
+ describe("SAML SSO - Signature Validation Security", () => {
2004
+ it("should reject unsigned SAML response with forged NameID", async () => {
2005
+ const { auth, signInWithTestUser } = await getTestInstance({
1598
2006
  plugins: [sso()],
1599
2007
  });
1600
2008
 
1601
- const authClient = createAuthClient({
1602
- baseURL: "http://localhost:3000",
1603
- plugins: [bearer(), ssoClient()],
1604
- fetchOptions: {
1605
- customFetchImpl: async (url, init) =>
1606
- auth.handler(new Request(url, init)),
2009
+ const { headers } = await signInWithTestUser();
2010
+
2011
+ await auth.api.registerSSOProvider({
2012
+ body: {
2013
+ providerId: "security-test-provider",
2014
+ issuer: "http://localhost:8081",
2015
+ domain: "http://localhost:8081",
2016
+ samlConfig: {
2017
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
2018
+ cert: certificate,
2019
+ callbackUrl: "http://localhost:3000/dashboard",
2020
+ wantAssertionsSigned: false,
2021
+ signatureAlgorithm: "sha256",
2022
+ digestAlgorithm: "sha256",
2023
+ idpMetadata: {
2024
+ metadata: idpMetadata,
2025
+ },
2026
+ spMetadata: {
2027
+ metadata: spMetadata,
2028
+ },
2029
+ identifierFormat:
2030
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2031
+ },
1607
2032
  },
2033
+ headers,
1608
2034
  });
1609
2035
 
1610
- const headers = new Headers();
1611
- await authClient.signUp.email({
1612
- email: "test@example.com",
1613
- password: "password123",
1614
- name: "Test User",
2036
+ const forgedSamlResponse = `
2037
+ <saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
2038
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2039
+ <saml2p:Status>
2040
+ <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
2041
+ </saml2p:Status>
2042
+ <saml2:Assertion>
2043
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2044
+ <saml2:Subject>
2045
+ <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">attacker-forged@evil.com</saml2:NameID>
2046
+ </saml2:Subject>
2047
+ <saml2:Conditions>
2048
+ <saml2:AudienceRestriction>
2049
+ <saml2:Audience>http://localhost:3001</saml2:Audience>
2050
+ </saml2:AudienceRestriction>
2051
+ </saml2:Conditions>
2052
+ <saml2:AuthnStatement>
2053
+ <saml2:AuthnContext>
2054
+ <saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml2:AuthnContextClassRef>
2055
+ </saml2:AuthnContext>
2056
+ </saml2:AuthnStatement>
2057
+ </saml2:Assertion>
2058
+ </saml2p:Response>
2059
+ `;
2060
+
2061
+ const encodedForgedResponse =
2062
+ Buffer.from(forgedSamlResponse).toString("base64");
2063
+
2064
+ await expect(
2065
+ auth.api.callbackSSOSAML({
2066
+ body: {
2067
+ SAMLResponse: encodedForgedResponse,
2068
+ RelayState: "http://localhost:3000/dashboard",
2069
+ },
2070
+ params: {
2071
+ providerId: "security-test-provider",
2072
+ },
2073
+ }),
2074
+ ).rejects.toMatchObject({
2075
+ status: "BAD_REQUEST",
1615
2076
  });
1616
- await authClient.signIn.email(
1617
- { email: "test@example.com", password: "password123" },
1618
- { onSuccess: setCookieToHeader(headers) },
1619
- );
2077
+ });
1620
2078
 
1621
- const provider = await auth.api.registerSSOProvider({
2079
+ it("should reject SAML response with invalid signature", async () => {
2080
+ const { auth, signInWithTestUser } = await getTestInstance({
2081
+ plugins: [sso()],
2082
+ });
2083
+
2084
+ const { headers } = await signInWithTestUser();
2085
+
2086
+ await auth.api.registerSSOProvider({
1622
2087
  body: {
1623
- providerId: "oidc-config-provider",
1624
- issuer: "http://localhost:8080",
1625
- domain: "example.com",
1626
- oidcConfig: {
1627
- clientId: "test-client",
1628
- clientSecret: "test-secret",
1629
- discoveryEndpoint:
1630
- "http://localhost:8080/.well-known/openid-configuration",
1631
- mapping: {
1632
- id: "sub",
1633
- email: "email",
1634
- name: "name",
2088
+ providerId: "invalid-sig-provider",
2089
+ issuer: "http://localhost:8081",
2090
+ domain: "http://localhost:8081",
2091
+ samlConfig: {
2092
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
2093
+ cert: certificate,
2094
+ callbackUrl: "http://localhost:3000/dashboard",
2095
+ wantAssertionsSigned: false,
2096
+ signatureAlgorithm: "sha256",
2097
+ digestAlgorithm: "sha256",
2098
+ idpMetadata: {
2099
+ metadata: idpMetadata,
1635
2100
  },
2101
+ spMetadata: {
2102
+ metadata: spMetadata,
2103
+ },
2104
+ identifierFormat:
2105
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1636
2106
  },
1637
2107
  },
1638
2108
  headers,
1639
2109
  });
1640
2110
 
1641
- expect(provider.oidcConfig).toBeDefined();
1642
- expect(typeof provider.oidcConfig).toBe("object");
1643
- expect(provider.oidcConfig?.clientId).toBe("test-client");
1644
- expect(provider.oidcConfig?.clientSecret).toBe("test-secret");
2111
+ const responseWithBadSignature = `
2112
+ <saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
2113
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2114
+ <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
2115
+ <ds:SignedInfo>
2116
+ <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
2117
+ <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
2118
+ <ds:Reference>
2119
+ <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
2120
+ <ds:DigestValue>FAKE_DIGEST_VALUE</ds:DigestValue>
2121
+ </ds:Reference>
2122
+ </ds:SignedInfo>
2123
+ <ds:SignatureValue>INVALID_SIGNATURE_VALUE_THAT_SHOULD_FAIL_VERIFICATION</ds:SignatureValue>
2124
+ </ds:Signature>
2125
+ <saml2p:Status>
2126
+ <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
2127
+ </saml2p:Status>
2128
+ <saml2:Assertion>
2129
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2130
+ <saml2:Subject>
2131
+ <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">forged-admin@company.com</saml2:NameID>
2132
+ </saml2:Subject>
2133
+ </saml2:Assertion>
2134
+ </saml2p:Response>
2135
+ `;
2136
+
2137
+ const encodedBadSigResponse = Buffer.from(
2138
+ responseWithBadSignature,
2139
+ ).toString("base64");
1645
2140
 
1646
- const serialized = JSON.stringify(provider.oidcConfig);
1647
- expect(serialized).not.toContain("[object Object]");
2141
+ await expect(
2142
+ auth.api.callbackSSOSAML({
2143
+ body: {
2144
+ SAMLResponse: encodedBadSigResponse,
2145
+ RelayState: "http://localhost:3000/dashboard",
2146
+ },
2147
+ params: {
2148
+ providerId: "invalid-sig-provider",
2149
+ },
2150
+ }),
2151
+ ).rejects.toMatchObject({
2152
+ status: "BAD_REQUEST",
2153
+ });
2154
+ });
2155
+ });
2156
+
2157
+ describe("SAML SSO - Timestamp Validation", () => {
2158
+ describe("Valid assertions within time window", () => {
2159
+ it("should accept assertion with current NotBefore and future NotOnOrAfter", () => {
2160
+ const now = new Date();
2161
+ const fiveMinutesFromNow = new Date(Date.now() + 5 * 60 * 1000);
2162
+ expect(() =>
2163
+ validateSAMLTimestamp({
2164
+ notBefore: now.toISOString(),
2165
+ notOnOrAfter: fiveMinutesFromNow.toISOString(),
2166
+ }),
2167
+ ).not.toThrow();
2168
+ });
1648
2169
 
1649
- expect(provider.oidcConfig?.mapping?.id).toBe("sub");
2170
+ it("should accept assertion within clock skew tolerance (expired 2 min ago with 5 min skew)", () => {
2171
+ const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000).toISOString();
2172
+ expect(() =>
2173
+ validateSAMLTimestamp({ notOnOrAfter: twoMinutesAgo }),
2174
+ ).not.toThrow();
2175
+ });
2176
+
2177
+ it("should accept assertion with NotBefore slightly in future (within clock skew)", () => {
2178
+ const twoMinutesFromNow = new Date(
2179
+ Date.now() + 2 * 60 * 1000,
2180
+ ).toISOString();
2181
+ expect(() =>
2182
+ validateSAMLTimestamp({ notBefore: twoMinutesFromNow }),
2183
+ ).not.toThrow();
2184
+ });
2185
+ });
2186
+
2187
+ describe("NotBefore validation (future-dated assertions)", () => {
2188
+ it("should reject assertion with NotBefore too far in future (beyond clock skew)", () => {
2189
+ const tenMinutesFromNow = new Date(
2190
+ Date.now() + 10 * 60 * 1000,
2191
+ ).toISOString();
2192
+ expect(() =>
2193
+ validateSAMLTimestamp({ notBefore: tenMinutesFromNow }),
2194
+ ).toThrow("SAML assertion is not yet valid");
2195
+ });
2196
+
2197
+ it("should reject with custom strict clock skew (1 second)", () => {
2198
+ const threeSecondsFromNow = new Date(Date.now() + 3 * 1000).toISOString();
2199
+ expect(() =>
2200
+ validateSAMLTimestamp(
2201
+ { notBefore: threeSecondsFromNow },
2202
+ { clockSkew: 1000 },
2203
+ ),
2204
+ ).toThrow("SAML assertion is not yet valid");
2205
+ });
2206
+ });
2207
+
2208
+ describe("NotOnOrAfter validation (expired assertions)", () => {
2209
+ it("should reject expired assertion (NotOnOrAfter in past beyond clock skew)", () => {
2210
+ const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString();
2211
+ expect(() =>
2212
+ validateSAMLTimestamp({ notOnOrAfter: tenMinutesAgo }),
2213
+ ).toThrow("SAML assertion has expired");
2214
+ });
2215
+
2216
+ it("should reject with custom strict clock skew (1 second)", () => {
2217
+ const threeSecondsAgo = new Date(Date.now() - 3 * 1000).toISOString();
2218
+ expect(() =>
2219
+ validateSAMLTimestamp(
2220
+ { notOnOrAfter: threeSecondsAgo },
2221
+ { clockSkew: 1000 },
2222
+ ),
2223
+ ).toThrow("SAML assertion has expired");
2224
+ });
2225
+ });
2226
+
2227
+ describe("Boundary conditions (exactly at window edges)", () => {
2228
+ const FIXED_TIME = new Date("2024-01-15T12:00:00.000Z").getTime();
2229
+
2230
+ beforeEach(() => {
2231
+ vi.useFakeTimers();
2232
+ vi.setSystemTime(FIXED_TIME);
2233
+ });
2234
+
2235
+ afterEach(() => {
2236
+ vi.useRealTimers();
2237
+ });
2238
+
2239
+ it("should accept assertion expiring exactly at clock skew boundary", () => {
2240
+ const exactlyAtBoundary = new Date(
2241
+ FIXED_TIME - DEFAULT_CLOCK_SKEW_MS,
2242
+ ).toISOString();
2243
+ expect(() =>
2244
+ validateSAMLTimestamp({ notOnOrAfter: exactlyAtBoundary }),
2245
+ ).not.toThrow();
2246
+ });
2247
+
2248
+ it("should reject assertion expiring 1ms beyond clock skew boundary", () => {
2249
+ const justPastBoundary = new Date(
2250
+ FIXED_TIME - DEFAULT_CLOCK_SKEW_MS - 1,
2251
+ ).toISOString();
2252
+ expect(() =>
2253
+ validateSAMLTimestamp({ notOnOrAfter: justPastBoundary }),
2254
+ ).toThrow("SAML assertion has expired");
2255
+ });
2256
+
2257
+ it("should accept assertion with NotBefore exactly at clock skew boundary", () => {
2258
+ const exactlyAtBoundary = new Date(
2259
+ FIXED_TIME + DEFAULT_CLOCK_SKEW_MS,
2260
+ ).toISOString();
2261
+ expect(() =>
2262
+ validateSAMLTimestamp({ notBefore: exactlyAtBoundary }),
2263
+ ).not.toThrow();
2264
+ });
2265
+
2266
+ it("should reject assertion with NotBefore 1ms beyond clock skew boundary", () => {
2267
+ const justPastBoundary = new Date(
2268
+ FIXED_TIME + DEFAULT_CLOCK_SKEW_MS + 1,
2269
+ ).toISOString();
2270
+ expect(() =>
2271
+ validateSAMLTimestamp({ notBefore: justPastBoundary }),
2272
+ ).toThrow("SAML assertion is not yet valid");
2273
+ });
2274
+ });
2275
+
2276
+ describe("Missing timestamps behavior", () => {
2277
+ it("should accept missing timestamps when requireTimestamps is false (default)", () => {
2278
+ expect(() =>
2279
+ validateSAMLTimestamp(undefined, { requireTimestamps: false }),
2280
+ ).not.toThrow();
2281
+ });
2282
+
2283
+ it("should accept empty conditions when requireTimestamps is false", () => {
2284
+ expect(() =>
2285
+ validateSAMLTimestamp({}, { requireTimestamps: false }),
2286
+ ).not.toThrow();
2287
+ });
2288
+
2289
+ it("should reject missing timestamps when requireTimestamps is true", () => {
2290
+ expect(() =>
2291
+ validateSAMLTimestamp(undefined, { requireTimestamps: true }),
2292
+ ).toThrow("SAML assertion missing required timestamp conditions");
2293
+ });
2294
+
2295
+ it("should reject empty conditions when requireTimestamps is true", () => {
2296
+ expect(() =>
2297
+ validateSAMLTimestamp({}, { requireTimestamps: true }),
2298
+ ).toThrow("SAML assertion missing required timestamp conditions");
2299
+ });
2300
+
2301
+ it("should accept assertions with only NotBefore (valid)", () => {
2302
+ const now = new Date().toISOString();
2303
+ expect(() => validateSAMLTimestamp({ notBefore: now })).not.toThrow();
2304
+ });
2305
+
2306
+ it("should accept assertions with only NotOnOrAfter (valid, in future)", () => {
2307
+ const future = new Date(Date.now() + 10 * 60 * 1000).toISOString();
2308
+ expect(() =>
2309
+ validateSAMLTimestamp({ notOnOrAfter: future }),
2310
+ ).not.toThrow();
2311
+ });
2312
+ });
2313
+
2314
+ describe("Custom clock skew configuration", () => {
2315
+ it("should use custom clockSkew when provided", () => {
2316
+ const twoSecondsAgo = new Date(Date.now() - 2 * 1000).toISOString();
2317
+
2318
+ expect(() =>
2319
+ validateSAMLTimestamp(
2320
+ { notOnOrAfter: twoSecondsAgo },
2321
+ { clockSkew: 1000 },
2322
+ ),
2323
+ ).toThrow("SAML assertion has expired");
2324
+
2325
+ expect(() =>
2326
+ validateSAMLTimestamp(
2327
+ { notOnOrAfter: twoSecondsAgo },
2328
+ { clockSkew: 5 * 60 * 1000 },
2329
+ ),
2330
+ ).not.toThrow();
2331
+ });
2332
+
2333
+ it("should use default 5 minute clock skew when not specified", () => {
2334
+ const fourMinutesAgo = new Date(Date.now() - 4 * 60 * 1000).toISOString();
2335
+ expect(() =>
2336
+ validateSAMLTimestamp({ notOnOrAfter: fourMinutesAgo }),
2337
+ ).not.toThrow();
2338
+
2339
+ const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000).toISOString();
2340
+ expect(() =>
2341
+ validateSAMLTimestamp({ notOnOrAfter: sixMinutesAgo }),
2342
+ ).toThrow("SAML assertion has expired");
2343
+ });
2344
+ });
2345
+
2346
+ describe("Malformed timestamp handling", () => {
2347
+ it("should reject malformed NotBefore timestamp", () => {
2348
+ expect(() =>
2349
+ validateSAMLTimestamp({ notBefore: "not-a-valid-date" }),
2350
+ ).toThrow("SAML assertion has invalid NotBefore timestamp");
2351
+ });
2352
+
2353
+ it("should reject malformed NotOnOrAfter timestamp", () => {
2354
+ expect(() =>
2355
+ validateSAMLTimestamp({ notOnOrAfter: "invalid-timestamp" }),
2356
+ ).toThrow("SAML assertion has invalid NotOnOrAfter timestamp");
2357
+ });
2358
+
2359
+ it("should treat empty string timestamps as missing (falsy values)", () => {
2360
+ expect(() => validateSAMLTimestamp({ notBefore: "" })).not.toThrow();
2361
+ expect(() => validateSAMLTimestamp({ notOnOrAfter: "" })).not.toThrow();
2362
+ });
2363
+
2364
+ it("should reject garbage data in timestamps", () => {
2365
+ expect(() =>
2366
+ validateSAMLTimestamp({
2367
+ notBefore: "abc123xyz",
2368
+ notOnOrAfter: "!@#$%^&*()",
2369
+ }),
2370
+ ).toThrow("SAML assertion has invalid NotBefore timestamp");
2371
+ });
2372
+
2373
+ it("should accept valid ISO 8601 timestamps", () => {
2374
+ const now = new Date();
2375
+ const future = new Date(Date.now() + 10 * 60 * 1000);
2376
+ expect(() =>
2377
+ validateSAMLTimestamp({
2378
+ notBefore: now.toISOString(),
2379
+ notOnOrAfter: future.toISOString(),
2380
+ }),
2381
+ ).not.toThrow();
2382
+ });
1650
2383
  });
1651
2384
  });