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