@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/.turbo/turbo-build.log +7 -7
- package/dist/client.d.mts +1 -1
- package/dist/{index-BWvN4yrs.d.mts → index-m7FISidt.d.mts} +101 -33
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +159 -48
- 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 +19 -7
- package/src/routes/sso.ts +224 -73
- package/src/saml.test.ts +490 -1
- package/src/types.ts +49 -0
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
|
}
|