@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/.turbo/turbo-build.log +7 -7
- package/dist/client.d.mts +1 -1
- package/dist/{index-BWvN4yrs.d.mts → index-GoyGoP_a.d.mts} +390 -21
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +559 -63
- package/package.json +4 -4
- package/src/authn-request-store.ts +76 -0
- package/src/authn-request.test.ts +99 -0
- package/src/index.ts +46 -7
- package/src/oidc/discovery.test.ts +823 -0
- package/src/oidc/discovery.ts +355 -0
- package/src/oidc/errors.ts +86 -0
- package/src/oidc/index.ts +31 -0
- package/src/oidc/types.ts +210 -0
- package/src/oidc.test.ts +0 -164
- package/src/routes/sso.ts +415 -96
- package/src/saml.test.ts +781 -48
- package/src/types.ts +81 -0
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 {
|
|
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
|
|
1585
|
-
|
|
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
|
-
|
|
1928
|
+
await oidcServer.issuer.keys.generate("RS256");
|
|
1929
|
+
await oidcServer.start(8082, "localhost");
|
|
1593
1930
|
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
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
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
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
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
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
|
-
|
|
1617
|
-
{ email: "test@example.com", password: "password123" },
|
|
1618
|
-
{ onSuccess: setCookieToHeader(headers) },
|
|
1619
|
-
);
|
|
2077
|
+
});
|
|
1620
2078
|
|
|
1621
|
-
|
|
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: "
|
|
1624
|
-
issuer: "http://localhost:
|
|
1625
|
-
domain: "
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
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
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
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
|
-
|
|
1647
|
-
|
|
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
|
-
|
|
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
|
});
|