@better-auth/sso 1.4.17 → 1.5.0-beta.10
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 +13 -9
- package/LICENSE.md +15 -12
- package/dist/client.d.mts +7 -2
- package/dist/client.mjs +7 -2
- package/dist/client.mjs.map +1 -0
- package/dist/{index-XUgmj4eH.d.mts → index-CBBJTszO.d.mts} +395 -16
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1130 -598
- package/dist/index.mjs.map +1 -0
- package/package.json +16 -14
- package/src/client.ts +5 -1
- package/src/index.ts +49 -6
- package/src/linking/org-assignment.test.ts +1 -1
- package/src/linking/org-assignment.ts +20 -13
- package/src/oidc.test.ts +112 -0
- package/src/providers.test.ts +1326 -0
- package/src/routes/providers.ts +565 -0
- package/src/routes/schemas.ts +96 -0
- package/src/routes/sso.ts +220 -50
- package/src/saml-state.ts +78 -0
- package/src/saml.test.ts +1588 -233
- package/src/types.ts +8 -0
- package/src/utils.test.ts +103 -0
- package/src/utils.ts +45 -5
- package/tsconfig.json +3 -0
- package/tsdown.config.ts +1 -0
package/dist/index.mjs
CHANGED
|
@@ -1,16 +1,69 @@
|
|
|
1
|
-
import { APIError, createAuthEndpoint, createAuthMiddleware, sessionMiddleware } from "better-auth/api";
|
|
1
|
+
import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
|
|
2
2
|
import { XMLParser, XMLValidator } from "fast-xml-parser";
|
|
3
3
|
import * as saml from "samlify";
|
|
4
|
+
import { X509Certificate } from "node:crypto";
|
|
4
5
|
import { generateRandomString } from "better-auth/crypto";
|
|
5
6
|
import * as z$1 from "zod/v4";
|
|
6
7
|
import z from "zod/v4";
|
|
8
|
+
import { base64 } from "@better-auth/utils/base64";
|
|
7
9
|
import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
|
|
8
|
-
import { HIDE_METADATA, createAuthorizationURL, generateState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
|
|
10
|
+
import { HIDE_METADATA, createAuthorizationURL, generateGenericState, generateState, parseGenericState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
|
|
9
11
|
import { setSessionCookie } from "better-auth/cookies";
|
|
10
12
|
import { handleOAuthUserInfo } from "better-auth/oauth2";
|
|
11
13
|
import { decodeJwt } from "jose";
|
|
12
|
-
import {
|
|
14
|
+
import { APIError as APIError$1 } from "better-call";
|
|
15
|
+
|
|
16
|
+
//#region src/utils.ts
|
|
17
|
+
/**
|
|
18
|
+
* Safely parses a value that might be a JSON string or already a parsed object.
|
|
19
|
+
* This handles cases where ORMs like Drizzle might return already parsed objects
|
|
20
|
+
* instead of JSON strings from TEXT/JSON columns.
|
|
21
|
+
*
|
|
22
|
+
* @param value - The value to parse (string, object, null, or undefined)
|
|
23
|
+
* @returns The parsed object or null
|
|
24
|
+
* @throws Error if string parsing fails
|
|
25
|
+
*/
|
|
26
|
+
function safeJsonParse(value) {
|
|
27
|
+
if (!value) return null;
|
|
28
|
+
if (typeof value === "object") return value;
|
|
29
|
+
if (typeof value === "string") try {
|
|
30
|
+
return JSON.parse(value);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
throw new Error(`Failed to parse JSON: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Checks if a domain matches any domain in a comma-separated list.
|
|
38
|
+
*/
|
|
39
|
+
const domainMatches = (searchDomain, domainList) => {
|
|
40
|
+
const search = searchDomain.toLowerCase();
|
|
41
|
+
return domainList.split(",").map((d) => d.trim().toLowerCase()).filter(Boolean).some((d) => search === d || search.endsWith(`.${d}`));
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Validates email domain against allowed domain(s).
|
|
45
|
+
* Supports comma-separated domains for multi-domain SSO.
|
|
46
|
+
*/
|
|
47
|
+
const validateEmailDomain = (email, domain) => {
|
|
48
|
+
const emailDomain = email.split("@")[1]?.toLowerCase();
|
|
49
|
+
if (!emailDomain || !domain) return false;
|
|
50
|
+
return domainMatches(emailDomain, domain);
|
|
51
|
+
};
|
|
52
|
+
function parseCertificate(certPem) {
|
|
53
|
+
const cert = new X509Certificate(certPem.includes("-----BEGIN") ? certPem : `-----BEGIN CERTIFICATE-----\n${certPem}\n-----END CERTIFICATE-----`);
|
|
54
|
+
return {
|
|
55
|
+
fingerprintSha256: cert.fingerprint256,
|
|
56
|
+
notBefore: cert.validFrom,
|
|
57
|
+
notAfter: cert.validTo,
|
|
58
|
+
publicKeyAlgorithm: cert.publicKey.asymmetricKeyType?.toUpperCase() || "UNKNOWN"
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function maskClientId(clientId) {
|
|
62
|
+
if (clientId.length <= 4) return "****";
|
|
63
|
+
return `****${clientId.slice(-4)}`;
|
|
64
|
+
}
|
|
13
65
|
|
|
66
|
+
//#endregion
|
|
14
67
|
//#region src/linking/org-assignment.ts
|
|
15
68
|
/**
|
|
16
69
|
* Assigns a user to an organization based on the SSO provider's organizationId.
|
|
@@ -20,7 +73,7 @@ async function assignOrganizationFromProvider(ctx, options) {
|
|
|
20
73
|
const { user, profile, provider, token, provisioningOptions } = options;
|
|
21
74
|
if (!provider.organizationId) return;
|
|
22
75
|
if (provisioningOptions?.disabled) return;
|
|
23
|
-
if (!ctx.context.
|
|
76
|
+
if (!ctx.context.hasPlugin("organization")) return;
|
|
24
77
|
if (await ctx.context.adapter.findOne({
|
|
25
78
|
model: "member",
|
|
26
79
|
where: [{
|
|
@@ -58,7 +111,7 @@ async function assignOrganizationFromProvider(ctx, options) {
|
|
|
58
111
|
async function assignOrganizationByDomain(ctx, options) {
|
|
59
112
|
const { user, provisioningOptions, domainVerification } = options;
|
|
60
113
|
if (provisioningOptions?.disabled) return;
|
|
61
|
-
if (!ctx.context.
|
|
114
|
+
if (!ctx.context.hasPlugin("organization")) return;
|
|
62
115
|
const domain = user.email.split("@")[1];
|
|
63
116
|
if (!domain) return;
|
|
64
117
|
const whereClause = [{
|
|
@@ -69,10 +122,17 @@ async function assignOrganizationByDomain(ctx, options) {
|
|
|
69
122
|
field: "domainVerified",
|
|
70
123
|
value: true
|
|
71
124
|
});
|
|
72
|
-
|
|
125
|
+
let ssoProvider = await ctx.context.adapter.findOne({
|
|
73
126
|
model: "ssoProvider",
|
|
74
127
|
where: whereClause
|
|
75
128
|
});
|
|
129
|
+
if (!ssoProvider) ssoProvider = (await ctx.context.adapter.findMany({
|
|
130
|
+
model: "ssoProvider",
|
|
131
|
+
where: domainVerification?.enabled ? [{
|
|
132
|
+
field: "domainVerified",
|
|
133
|
+
value: true
|
|
134
|
+
}] : []
|
|
135
|
+
})).find((p) => domainMatches(domain, p.domain)) ?? null;
|
|
76
136
|
if (!ssoProvider || !ssoProvider.organizationId) return;
|
|
77
137
|
if (await ctx.context.adapter.findOne({
|
|
78
138
|
model: "member",
|
|
@@ -318,637 +378,1026 @@ const DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024;
|
|
|
318
378
|
const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024;
|
|
319
379
|
|
|
320
380
|
//#endregion
|
|
321
|
-
//#region src/
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
if (
|
|
381
|
+
//#region src/saml/parser.ts
|
|
382
|
+
const xmlParser = new XMLParser({
|
|
383
|
+
ignoreAttributes: false,
|
|
384
|
+
attributeNamePrefix: "@_",
|
|
385
|
+
removeNSPrefix: true,
|
|
386
|
+
processEntities: false
|
|
387
|
+
});
|
|
388
|
+
function findNode(obj, nodeName) {
|
|
389
|
+
if (!obj || typeof obj !== "object") return null;
|
|
390
|
+
const record = obj;
|
|
391
|
+
if (nodeName in record) return record[nodeName];
|
|
392
|
+
for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
|
|
393
|
+
const found = findNode(item, nodeName);
|
|
394
|
+
if (found) return found;
|
|
335
395
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
"
|
|
344
|
-
|
|
345
|
-
|
|
396
|
+
else if (typeof value === "object" && value !== null) {
|
|
397
|
+
const found = findNode(value, nodeName);
|
|
398
|
+
if (found) return found;
|
|
399
|
+
}
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
function countAllNodes(obj, nodeName) {
|
|
403
|
+
if (!obj || typeof obj !== "object") return 0;
|
|
404
|
+
let count = 0;
|
|
405
|
+
const record = obj;
|
|
406
|
+
if (nodeName in record) {
|
|
407
|
+
const node = record[nodeName];
|
|
408
|
+
count += Array.isArray(node) ? node.length : 1;
|
|
409
|
+
}
|
|
410
|
+
for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) count += countAllNodes(item, nodeName);
|
|
411
|
+
else if (typeof value === "object" && value !== null) count += countAllNodes(value, nodeName);
|
|
412
|
+
return count;
|
|
413
|
+
}
|
|
346
414
|
|
|
347
415
|
//#endregion
|
|
348
|
-
//#region src/
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
416
|
+
//#region src/saml/algorithms.ts
|
|
417
|
+
const SignatureAlgorithm = {
|
|
418
|
+
RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
|
|
419
|
+
RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
|
|
420
|
+
RSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
|
|
421
|
+
RSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
|
|
422
|
+
ECDSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
|
|
423
|
+
ECDSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
|
|
424
|
+
ECDSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"
|
|
425
|
+
};
|
|
426
|
+
const DigestAlgorithm = {
|
|
427
|
+
SHA1: "http://www.w3.org/2000/09/xmldsig#sha1",
|
|
428
|
+
SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
|
|
429
|
+
SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
|
|
430
|
+
SHA512: "http://www.w3.org/2001/04/xmlenc#sha512"
|
|
431
|
+
};
|
|
432
|
+
const KeyEncryptionAlgorithm = {
|
|
433
|
+
RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
|
|
434
|
+
RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
|
|
435
|
+
RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep"
|
|
436
|
+
};
|
|
437
|
+
const DataEncryptionAlgorithm = {
|
|
438
|
+
TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
|
|
439
|
+
AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
|
|
440
|
+
AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
|
|
441
|
+
AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
|
|
442
|
+
AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
|
|
443
|
+
AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
|
|
444
|
+
AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
|
|
445
|
+
};
|
|
446
|
+
const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
|
|
447
|
+
const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS = [KeyEncryptionAlgorithm.RSA_1_5];
|
|
448
|
+
const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS = [DataEncryptionAlgorithm.TRIPLEDES_CBC];
|
|
449
|
+
const DEPRECATED_DIGEST_ALGORITHMS = [DigestAlgorithm.SHA1];
|
|
450
|
+
const SECURE_SIGNATURE_ALGORITHMS = [
|
|
451
|
+
SignatureAlgorithm.RSA_SHA256,
|
|
452
|
+
SignatureAlgorithm.RSA_SHA384,
|
|
453
|
+
SignatureAlgorithm.RSA_SHA512,
|
|
454
|
+
SignatureAlgorithm.ECDSA_SHA256,
|
|
455
|
+
SignatureAlgorithm.ECDSA_SHA384,
|
|
456
|
+
SignatureAlgorithm.ECDSA_SHA512
|
|
457
|
+
];
|
|
458
|
+
const SECURE_DIGEST_ALGORITHMS = [
|
|
459
|
+
DigestAlgorithm.SHA256,
|
|
460
|
+
DigestAlgorithm.SHA384,
|
|
461
|
+
DigestAlgorithm.SHA512
|
|
462
|
+
];
|
|
463
|
+
const SHORT_FORM_SIGNATURE_TO_URI = {
|
|
464
|
+
sha1: SignatureAlgorithm.RSA_SHA1,
|
|
465
|
+
sha256: SignatureAlgorithm.RSA_SHA256,
|
|
466
|
+
sha384: SignatureAlgorithm.RSA_SHA384,
|
|
467
|
+
sha512: SignatureAlgorithm.RSA_SHA512,
|
|
468
|
+
"rsa-sha1": SignatureAlgorithm.RSA_SHA1,
|
|
469
|
+
"rsa-sha256": SignatureAlgorithm.RSA_SHA256,
|
|
470
|
+
"rsa-sha384": SignatureAlgorithm.RSA_SHA384,
|
|
471
|
+
"rsa-sha512": SignatureAlgorithm.RSA_SHA512,
|
|
472
|
+
"ecdsa-sha256": SignatureAlgorithm.ECDSA_SHA256,
|
|
473
|
+
"ecdsa-sha384": SignatureAlgorithm.ECDSA_SHA384,
|
|
474
|
+
"ecdsa-sha512": SignatureAlgorithm.ECDSA_SHA512
|
|
475
|
+
};
|
|
476
|
+
const SHORT_FORM_DIGEST_TO_URI = {
|
|
477
|
+
sha1: DigestAlgorithm.SHA1,
|
|
478
|
+
sha256: DigestAlgorithm.SHA256,
|
|
479
|
+
sha384: DigestAlgorithm.SHA384,
|
|
480
|
+
sha512: DigestAlgorithm.SHA512
|
|
481
|
+
};
|
|
482
|
+
function normalizeSignatureAlgorithm(alg) {
|
|
483
|
+
return SHORT_FORM_SIGNATURE_TO_URI[alg.toLowerCase()] ?? alg;
|
|
395
484
|
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
*
|
|
399
|
-
* Per OIDC Discovery spec, the discovery document is located at:
|
|
400
|
-
* <issuer>/.well-known/openid-configuration
|
|
401
|
-
*
|
|
402
|
-
* Handles trailing slashes correctly.
|
|
403
|
-
*/
|
|
404
|
-
function computeDiscoveryUrl(issuer) {
|
|
405
|
-
return `${issuer.endsWith("/") ? issuer.slice(0, -1) : issuer}/.well-known/openid-configuration`;
|
|
485
|
+
function normalizeDigestAlgorithm(alg) {
|
|
486
|
+
return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
|
|
406
487
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
488
|
+
function extractEncryptionAlgorithms(xml) {
|
|
489
|
+
try {
|
|
490
|
+
const parsed = xmlParser.parse(xml);
|
|
491
|
+
const keyAlg = (findNode(parsed, "EncryptedKey")?.EncryptionMethod)?.["@_Algorithm"];
|
|
492
|
+
const dataAlg = (findNode(parsed, "EncryptedData")?.EncryptionMethod)?.["@_Algorithm"];
|
|
493
|
+
return {
|
|
494
|
+
keyEncryption: keyAlg || null,
|
|
495
|
+
dataEncryption: dataAlg || null
|
|
496
|
+
};
|
|
497
|
+
} catch {
|
|
498
|
+
return {
|
|
499
|
+
keyEncryption: null,
|
|
500
|
+
dataEncryption: null
|
|
501
|
+
};
|
|
502
|
+
}
|
|
417
503
|
}
|
|
418
|
-
|
|
419
|
-
* Fetch the OIDC discovery document from the IdP.
|
|
420
|
-
*
|
|
421
|
-
* @param url - The discovery endpoint URL
|
|
422
|
-
* @param timeout - Request timeout in milliseconds
|
|
423
|
-
* @returns The parsed discovery document
|
|
424
|
-
* @throws DiscoveryError on network errors, timeouts, or invalid responses
|
|
425
|
-
*/
|
|
426
|
-
async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
|
|
504
|
+
function hasEncryptedAssertion(xml) {
|
|
427
505
|
try {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
506
|
+
return findNode(xmlParser.parse(xml), "EncryptedAssertion") !== null;
|
|
507
|
+
} catch {
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
function handleDeprecatedAlgorithm(message, behavior, errorCode) {
|
|
512
|
+
switch (behavior) {
|
|
513
|
+
case "reject": throw new APIError("BAD_REQUEST", {
|
|
514
|
+
message,
|
|
515
|
+
code: errorCode
|
|
431
516
|
});
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
517
|
+
case "warn":
|
|
518
|
+
console.warn(`[SAML Security Warning] ${message}`);
|
|
519
|
+
break;
|
|
520
|
+
case "allow": break;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
function validateSignatureAlgorithm(algorithm, options = {}) {
|
|
524
|
+
if (!algorithm) return;
|
|
525
|
+
const { onDeprecated = "warn", allowedSignatureAlgorithms } = options;
|
|
526
|
+
if (allowedSignatureAlgorithms) {
|
|
527
|
+
if (!allowedSignatureAlgorithms.includes(algorithm)) throw new APIError("BAD_REQUEST", {
|
|
528
|
+
message: `SAML signature algorithm not in allow-list: ${algorithm}`,
|
|
529
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
530
|
+
});
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(algorithm)) {
|
|
534
|
+
handleDeprecatedAlgorithm(`SAML response uses deprecated signature algorithm: ${algorithm}. Please configure your IdP to use SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (!SECURE_SIGNATURE_ALGORITHMS.includes(algorithm)) throw new APIError("BAD_REQUEST", {
|
|
538
|
+
message: `SAML signature algorithm not recognized: ${algorithm}`,
|
|
539
|
+
code: "SAML_UNKNOWN_ALGORITHM"
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
function validateEncryptionAlgorithms(algorithms, options = {}) {
|
|
543
|
+
const { onDeprecated = "warn", allowedKeyEncryptionAlgorithms, allowedDataEncryptionAlgorithms } = options;
|
|
544
|
+
const { keyEncryption, dataEncryption } = algorithms;
|
|
545
|
+
if (keyEncryption) {
|
|
546
|
+
if (allowedKeyEncryptionAlgorithms) {
|
|
547
|
+
if (!allowedKeyEncryptionAlgorithms.includes(keyEncryption)) throw new APIError("BAD_REQUEST", {
|
|
548
|
+
message: `SAML key encryption algorithm not in allow-list: ${keyEncryption}`,
|
|
549
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
437
550
|
});
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
551
|
+
} else if (DEPRECATED_KEY_ENCRYPTION_ALGORITHMS.includes(keyEncryption)) handleDeprecatedAlgorithm(`SAML response uses deprecated key encryption algorithm: ${keyEncryption}. Please configure your IdP to use RSA-OAEP.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
|
|
552
|
+
}
|
|
553
|
+
if (dataEncryption) {
|
|
554
|
+
if (allowedDataEncryptionAlgorithms) {
|
|
555
|
+
if (!allowedDataEncryptionAlgorithms.includes(dataEncryption)) throw new APIError("BAD_REQUEST", {
|
|
556
|
+
message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
|
|
557
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
441
558
|
});
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
559
|
+
} else if (DEPRECATED_DATA_ENCRYPTION_ALGORITHMS.includes(dataEncryption)) handleDeprecatedAlgorithm(`SAML response uses deprecated data encryption algorithm: ${dataEncryption}. Please configure your IdP to use AES-GCM.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
function validateSAMLAlgorithms(response, options) {
|
|
563
|
+
validateSignatureAlgorithm(response.sigAlg, options);
|
|
564
|
+
if (hasEncryptedAssertion(response.samlContent)) validateEncryptionAlgorithms(extractEncryptionAlgorithms(response.samlContent), options);
|
|
565
|
+
}
|
|
566
|
+
function validateConfigAlgorithms(config, options = {}) {
|
|
567
|
+
const { onDeprecated = "warn", allowedSignatureAlgorithms, allowedDigestAlgorithms } = options;
|
|
568
|
+
if (config.signatureAlgorithm) {
|
|
569
|
+
const normalized = normalizeSignatureAlgorithm(config.signatureAlgorithm);
|
|
570
|
+
if (allowedSignatureAlgorithms) {
|
|
571
|
+
if (!allowedSignatureAlgorithms.map(normalizeSignatureAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
572
|
+
message: `SAML signature algorithm not in allow-list: ${config.signatureAlgorithm}`,
|
|
573
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
445
574
|
});
|
|
446
|
-
}
|
|
447
|
-
if (!
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
url,
|
|
451
|
-
bodyPreview: data.slice(0, 200)
|
|
575
|
+
} else if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(normalized)) handleDeprecatedAlgorithm(`SAML config uses deprecated signature algorithm: ${config.signatureAlgorithm}. Consider using SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_CONFIG_ALGORITHM");
|
|
576
|
+
else if (!SECURE_SIGNATURE_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
577
|
+
message: `SAML signature algorithm not recognized: ${config.signatureAlgorithm}`,
|
|
578
|
+
code: "SAML_UNKNOWN_ALGORITHM"
|
|
452
579
|
});
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
if (
|
|
457
|
-
|
|
458
|
-
|
|
580
|
+
}
|
|
581
|
+
if (config.digestAlgorithm) {
|
|
582
|
+
const normalized = normalizeDigestAlgorithm(config.digestAlgorithm);
|
|
583
|
+
if (allowedDigestAlgorithms) {
|
|
584
|
+
if (!allowedDigestAlgorithms.map(normalizeDigestAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
585
|
+
message: `SAML digest algorithm not in allow-list: ${config.digestAlgorithm}`,
|
|
586
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
587
|
+
});
|
|
588
|
+
} else if (DEPRECATED_DIGEST_ALGORITHMS.includes(normalized)) handleDeprecatedAlgorithm(`SAML config uses deprecated digest algorithm: ${config.digestAlgorithm}. Consider using SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_CONFIG_ALGORITHM");
|
|
589
|
+
else if (!SECURE_DIGEST_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
590
|
+
message: `SAML digest algorithm not recognized: ${config.digestAlgorithm}`,
|
|
591
|
+
code: "SAML_UNKNOWN_ALGORITHM"
|
|
459
592
|
});
|
|
460
|
-
throw new DiscoveryError("discovery_unexpected_error", `Unexpected error during discovery: ${error instanceof Error ? error.message : String(error)}`, { url }, { cause: error });
|
|
461
593
|
}
|
|
462
594
|
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
595
|
+
|
|
596
|
+
//#endregion
|
|
597
|
+
//#region src/saml/assertions.ts
|
|
598
|
+
/** @lintignore used in tests */
|
|
599
|
+
function countAssertions(xml) {
|
|
600
|
+
let parsed;
|
|
601
|
+
try {
|
|
602
|
+
parsed = xmlParser.parse(xml);
|
|
603
|
+
} catch {
|
|
604
|
+
throw new APIError("BAD_REQUEST", {
|
|
605
|
+
message: "Failed to parse SAML response XML",
|
|
606
|
+
code: "SAML_INVALID_XML"
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
const assertions = countAllNodes(parsed, "Assertion");
|
|
610
|
+
const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
|
|
611
|
+
return {
|
|
612
|
+
assertions,
|
|
613
|
+
encryptedAssertions,
|
|
614
|
+
total: assertions + encryptedAssertions
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
function validateSingleAssertion(samlResponse) {
|
|
618
|
+
let xml;
|
|
619
|
+
try {
|
|
620
|
+
xml = new TextDecoder().decode(base64.decode(samlResponse));
|
|
621
|
+
if (!xml.includes("<")) throw new Error("Not XML");
|
|
622
|
+
} catch {
|
|
623
|
+
throw new APIError("BAD_REQUEST", {
|
|
624
|
+
message: "Invalid base64-encoded SAML response",
|
|
625
|
+
code: "SAML_INVALID_ENCODING"
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
const counts = countAssertions(xml);
|
|
629
|
+
if (counts.total === 0) throw new APIError("BAD_REQUEST", {
|
|
630
|
+
message: "SAML response contains no assertions",
|
|
631
|
+
code: "SAML_NO_ASSERTION"
|
|
632
|
+
});
|
|
633
|
+
if (counts.total > 1) throw new APIError("BAD_REQUEST", {
|
|
634
|
+
message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
|
|
635
|
+
code: "SAML_MULTIPLE_ASSERTIONS"
|
|
485
636
|
});
|
|
486
637
|
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
638
|
+
|
|
639
|
+
//#endregion
|
|
640
|
+
//#region src/routes/schemas.ts
|
|
641
|
+
const oidcMappingSchema = z.object({
|
|
642
|
+
id: z.string().optional(),
|
|
643
|
+
email: z.string().optional(),
|
|
644
|
+
emailVerified: z.string().optional(),
|
|
645
|
+
name: z.string().optional(),
|
|
646
|
+
image: z.string().optional(),
|
|
647
|
+
extraFields: z.record(z.string(), z.any()).optional()
|
|
648
|
+
}).optional();
|
|
649
|
+
const samlMappingSchema = z.object({
|
|
650
|
+
id: z.string().optional(),
|
|
651
|
+
email: z.string().optional(),
|
|
652
|
+
emailVerified: z.string().optional(),
|
|
653
|
+
name: z.string().optional(),
|
|
654
|
+
firstName: z.string().optional(),
|
|
655
|
+
lastName: z.string().optional(),
|
|
656
|
+
extraFields: z.record(z.string(), z.any()).optional()
|
|
657
|
+
}).optional();
|
|
658
|
+
const oidcConfigSchema = z.object({
|
|
659
|
+
clientId: z.string().optional(),
|
|
660
|
+
clientSecret: z.string().optional(),
|
|
661
|
+
authorizationEndpoint: z.string().url().optional(),
|
|
662
|
+
tokenEndpoint: z.string().url().optional(),
|
|
663
|
+
userInfoEndpoint: z.string().url().optional(),
|
|
664
|
+
tokenEndpointAuthentication: z.enum(["client_secret_post", "client_secret_basic"]).optional(),
|
|
665
|
+
jwksEndpoint: z.string().url().optional(),
|
|
666
|
+
discoveryEndpoint: z.string().url().optional(),
|
|
667
|
+
scopes: z.array(z.string()).optional(),
|
|
668
|
+
pkce: z.boolean().optional(),
|
|
669
|
+
overrideUserInfo: z.boolean().optional(),
|
|
670
|
+
mapping: oidcMappingSchema
|
|
671
|
+
});
|
|
672
|
+
const samlConfigSchema = z.object({
|
|
673
|
+
entryPoint: z.string().url().optional(),
|
|
674
|
+
cert: z.string().optional(),
|
|
675
|
+
callbackUrl: z.string().url().optional(),
|
|
676
|
+
audience: z.string().optional(),
|
|
677
|
+
idpMetadata: z.object({
|
|
678
|
+
metadata: z.string().optional(),
|
|
679
|
+
entityID: z.string().optional(),
|
|
680
|
+
cert: z.string().optional(),
|
|
681
|
+
privateKey: z.string().optional(),
|
|
682
|
+
privateKeyPass: z.string().optional(),
|
|
683
|
+
isAssertionEncrypted: z.boolean().optional(),
|
|
684
|
+
encPrivateKey: z.string().optional(),
|
|
685
|
+
encPrivateKeyPass: z.string().optional(),
|
|
686
|
+
singleSignOnService: z.array(z.object({
|
|
687
|
+
Binding: z.string(),
|
|
688
|
+
Location: z.string().url()
|
|
689
|
+
})).optional()
|
|
690
|
+
}).optional(),
|
|
691
|
+
spMetadata: z.object({
|
|
692
|
+
metadata: z.string().optional(),
|
|
693
|
+
entityID: z.string().optional(),
|
|
694
|
+
binding: z.string().optional(),
|
|
695
|
+
privateKey: z.string().optional(),
|
|
696
|
+
privateKeyPass: z.string().optional(),
|
|
697
|
+
isAssertionEncrypted: z.boolean().optional(),
|
|
698
|
+
encPrivateKey: z.string().optional(),
|
|
699
|
+
encPrivateKeyPass: z.string().optional()
|
|
700
|
+
}).optional(),
|
|
701
|
+
wantAssertionsSigned: z.boolean().optional(),
|
|
702
|
+
authnRequestsSigned: z.boolean().optional(),
|
|
703
|
+
signatureAlgorithm: z.string().optional(),
|
|
704
|
+
digestAlgorithm: z.string().optional(),
|
|
705
|
+
identifierFormat: z.string().optional(),
|
|
706
|
+
privateKey: z.string().optional(),
|
|
707
|
+
decryptionPvk: z.string().optional(),
|
|
708
|
+
additionalParams: z.record(z.string(), z.any()).optional(),
|
|
709
|
+
mapping: samlMappingSchema
|
|
710
|
+
});
|
|
711
|
+
const updateSSOProviderBodySchema = z.object({
|
|
712
|
+
issuer: z.string().url().optional(),
|
|
713
|
+
domain: z.string().optional(),
|
|
714
|
+
oidcConfig: oidcConfigSchema.optional(),
|
|
715
|
+
samlConfig: samlConfigSchema.optional()
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
//#endregion
|
|
719
|
+
//#region src/routes/providers.ts
|
|
720
|
+
const ADMIN_ROLES = ["owner", "admin"];
|
|
721
|
+
async function isOrgAdmin(ctx, userId, organizationId) {
|
|
722
|
+
const member = await ctx.context.adapter.findOne({
|
|
723
|
+
model: "member",
|
|
724
|
+
where: [{
|
|
725
|
+
field: "userId",
|
|
726
|
+
value: userId
|
|
727
|
+
}, {
|
|
728
|
+
field: "organizationId",
|
|
729
|
+
value: organizationId
|
|
730
|
+
}]
|
|
731
|
+
});
|
|
732
|
+
if (!member) return false;
|
|
733
|
+
return member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()));
|
|
505
734
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
url
|
|
735
|
+
async function batchCheckOrgAdmin(ctx, userId, organizationIds) {
|
|
736
|
+
if (organizationIds.length === 0) return /* @__PURE__ */ new Set();
|
|
737
|
+
const members = await ctx.context.adapter.findMany({
|
|
738
|
+
model: "member",
|
|
739
|
+
where: [{
|
|
740
|
+
field: "userId",
|
|
741
|
+
value: userId
|
|
742
|
+
}, {
|
|
743
|
+
field: "organizationId",
|
|
744
|
+
value: organizationIds,
|
|
745
|
+
operator: "in"
|
|
746
|
+
}]
|
|
519
747
|
});
|
|
520
|
-
|
|
748
|
+
const adminOrgIds = /* @__PURE__ */ new Set();
|
|
749
|
+
for (const member of members) if (member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()))) adminOrgIds.add(member.organizationId);
|
|
750
|
+
return adminOrgIds;
|
|
521
751
|
}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
* @param name - The endpoint name (e.g token_endpoint)
|
|
526
|
-
* @param endpoint - The endpoint URL to normalize
|
|
527
|
-
* @param issuer - The base issuer URL
|
|
528
|
-
* @returns The normalized endpoint URL
|
|
529
|
-
*/
|
|
530
|
-
function normalizeUrl(name, endpoint, issuer) {
|
|
752
|
+
function sanitizeProvider(provider, baseURL) {
|
|
753
|
+
let oidcConfig = null;
|
|
754
|
+
let samlConfig = null;
|
|
531
755
|
try {
|
|
532
|
-
|
|
756
|
+
oidcConfig = safeJsonParse(provider.oidcConfig);
|
|
533
757
|
} catch {
|
|
534
|
-
|
|
535
|
-
const basePath = issuerURL.pathname.replace(/\/+$/, "");
|
|
536
|
-
const endpointPath = endpoint.replace(/^\/+/, "");
|
|
537
|
-
return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
|
|
758
|
+
oidcConfig = null;
|
|
538
759
|
}
|
|
539
|
-
}
|
|
540
|
-
/**
|
|
541
|
-
* Parses the given URL or throws in case of invalid or unsupported protocols
|
|
542
|
-
*
|
|
543
|
-
* @param name the url name
|
|
544
|
-
* @param endpoint the endpoint url
|
|
545
|
-
* @param [base] optional base path
|
|
546
|
-
* @returns
|
|
547
|
-
*/
|
|
548
|
-
function parseURL(name, endpoint, base) {
|
|
549
|
-
let endpointURL;
|
|
550
760
|
try {
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
|
|
761
|
+
samlConfig = safeJsonParse(provider.samlConfig);
|
|
762
|
+
} catch {
|
|
763
|
+
samlConfig = null;
|
|
555
764
|
}
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
765
|
+
const type = samlConfig ? "saml" : "oidc";
|
|
766
|
+
return {
|
|
767
|
+
providerId: provider.providerId,
|
|
768
|
+
type,
|
|
769
|
+
issuer: provider.issuer,
|
|
770
|
+
domain: provider.domain,
|
|
771
|
+
organizationId: provider.organizationId || null,
|
|
772
|
+
domainVerified: provider.domainVerified ?? false,
|
|
773
|
+
oidcConfig: oidcConfig ? {
|
|
774
|
+
discoveryEndpoint: oidcConfig.discoveryEndpoint,
|
|
775
|
+
clientIdLastFour: maskClientId(oidcConfig.clientId),
|
|
776
|
+
pkce: oidcConfig.pkce,
|
|
777
|
+
authorizationEndpoint: oidcConfig.authorizationEndpoint,
|
|
778
|
+
tokenEndpoint: oidcConfig.tokenEndpoint,
|
|
779
|
+
userInfoEndpoint: oidcConfig.userInfoEndpoint,
|
|
780
|
+
jwksEndpoint: oidcConfig.jwksEndpoint,
|
|
781
|
+
scopes: oidcConfig.scopes,
|
|
782
|
+
tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication
|
|
783
|
+
} : void 0,
|
|
784
|
+
samlConfig: samlConfig ? {
|
|
785
|
+
entryPoint: samlConfig.entryPoint,
|
|
786
|
+
callbackUrl: samlConfig.callbackUrl,
|
|
787
|
+
audience: samlConfig.audience,
|
|
788
|
+
wantAssertionsSigned: samlConfig.wantAssertionsSigned,
|
|
789
|
+
authnRequestsSigned: samlConfig.authnRequestsSigned,
|
|
790
|
+
identifierFormat: samlConfig.identifierFormat,
|
|
791
|
+
signatureAlgorithm: samlConfig.signatureAlgorithm,
|
|
792
|
+
digestAlgorithm: samlConfig.digestAlgorithm,
|
|
793
|
+
certificate: (() => {
|
|
794
|
+
try {
|
|
795
|
+
return parseCertificate(samlConfig.cert);
|
|
796
|
+
} catch {
|
|
797
|
+
return { error: "Failed to parse certificate" };
|
|
798
|
+
}
|
|
799
|
+
})()
|
|
800
|
+
} : void 0,
|
|
801
|
+
spMetadataUrl: `${baseURL}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(provider.providerId)}`
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
const listSSOProviders = () => {
|
|
805
|
+
return createAuthEndpoint("/sso/providers", {
|
|
806
|
+
method: "GET",
|
|
807
|
+
use: [sessionMiddleware],
|
|
808
|
+
metadata: { openapi: {
|
|
809
|
+
operationId: "listSSOProviders",
|
|
810
|
+
summary: "List SSO providers",
|
|
811
|
+
description: "Returns a list of SSO providers the user has access to",
|
|
812
|
+
responses: { "200": { description: "List of SSO providers" } }
|
|
813
|
+
} }
|
|
814
|
+
}, async (ctx) => {
|
|
815
|
+
const userId = ctx.context.session.user.id;
|
|
816
|
+
const allProviders = await ctx.context.adapter.findMany({ model: "ssoProvider" });
|
|
817
|
+
const userOwnedProviders = allProviders.filter((p) => p.userId === userId && !p.organizationId);
|
|
818
|
+
const orgProviders = allProviders.filter((p) => p.organizationId !== null && p.organizationId !== void 0);
|
|
819
|
+
const orgPluginEnabled = ctx.context.hasPlugin("organization");
|
|
820
|
+
let accessibleProviders = [...userOwnedProviders];
|
|
821
|
+
if (orgPluginEnabled && orgProviders.length > 0) {
|
|
822
|
+
const adminOrgIds = await batchCheckOrgAdmin(ctx, userId, [...new Set(orgProviders.map((p) => p.organizationId).filter((id) => id !== null && id !== void 0))]);
|
|
823
|
+
const orgAccessibleProviders = orgProviders.filter((provider) => provider.organizationId && adminOrgIds.has(provider.organizationId));
|
|
824
|
+
accessibleProviders = [...accessibleProviders, ...orgAccessibleProviders];
|
|
825
|
+
} else if (!orgPluginEnabled) {
|
|
826
|
+
const userOwnedOrgProviders = orgProviders.filter((p) => p.userId === userId);
|
|
827
|
+
accessibleProviders = [...accessibleProviders, ...userOwnedOrgProviders];
|
|
828
|
+
}
|
|
829
|
+
const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL));
|
|
830
|
+
return ctx.json({ providers });
|
|
831
|
+
});
|
|
832
|
+
};
|
|
833
|
+
const getSSOProviderParamsSchema = z.object({ providerId: z.string() });
|
|
834
|
+
async function checkProviderAccess(ctx, providerId) {
|
|
835
|
+
const userId = ctx.context.session.user.id;
|
|
836
|
+
const provider = await ctx.context.adapter.findOne({
|
|
837
|
+
model: "ssoProvider",
|
|
838
|
+
where: [{
|
|
839
|
+
field: "providerId",
|
|
840
|
+
value: providerId
|
|
841
|
+
}]
|
|
842
|
+
});
|
|
843
|
+
if (!provider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
|
|
844
|
+
let hasAccess = false;
|
|
845
|
+
if (provider.organizationId) if (ctx.context.hasPlugin("organization")) hasAccess = await isOrgAdmin(ctx, userId, provider.organizationId);
|
|
846
|
+
else hasAccess = provider.userId === userId;
|
|
847
|
+
else hasAccess = provider.userId === userId;
|
|
848
|
+
if (!hasAccess) throw new APIError("FORBIDDEN", { message: "You don't have access to this provider" });
|
|
849
|
+
return provider;
|
|
850
|
+
}
|
|
851
|
+
const getSSOProvider = () => {
|
|
852
|
+
return createAuthEndpoint("/sso/providers/:providerId", {
|
|
853
|
+
method: "GET",
|
|
854
|
+
use: [sessionMiddleware],
|
|
855
|
+
params: getSSOProviderParamsSchema,
|
|
856
|
+
metadata: { openapi: {
|
|
857
|
+
operationId: "getSSOProvider",
|
|
858
|
+
summary: "Get SSO provider details",
|
|
859
|
+
description: "Returns sanitized details for a specific SSO provider",
|
|
860
|
+
responses: {
|
|
861
|
+
"200": { description: "SSO provider details" },
|
|
862
|
+
"404": { description: "Provider not found" },
|
|
863
|
+
"403": { description: "Access denied" }
|
|
864
|
+
}
|
|
865
|
+
} }
|
|
866
|
+
}, async (ctx) => {
|
|
867
|
+
const { providerId } = ctx.params;
|
|
868
|
+
const provider = await checkProviderAccess(ctx, providerId);
|
|
869
|
+
return ctx.json(sanitizeProvider(provider, ctx.context.baseURL));
|
|
559
870
|
});
|
|
871
|
+
};
|
|
872
|
+
function parseAndValidateConfig(configString, configType) {
|
|
873
|
+
let config = null;
|
|
874
|
+
try {
|
|
875
|
+
config = safeJsonParse(configString);
|
|
876
|
+
} catch {
|
|
877
|
+
config = null;
|
|
878
|
+
}
|
|
879
|
+
if (!config) throw new APIError("BAD_REQUEST", { message: `Cannot update ${configType} config for a provider that doesn't have ${configType} configured` });
|
|
880
|
+
return config;
|
|
881
|
+
}
|
|
882
|
+
function mergeSAMLConfig(current, updates, issuer) {
|
|
883
|
+
return {
|
|
884
|
+
...current,
|
|
885
|
+
...updates,
|
|
886
|
+
issuer,
|
|
887
|
+
entryPoint: updates.entryPoint ?? current.entryPoint,
|
|
888
|
+
cert: updates.cert ?? current.cert,
|
|
889
|
+
callbackUrl: updates.callbackUrl ?? current.callbackUrl,
|
|
890
|
+
spMetadata: updates.spMetadata ?? current.spMetadata,
|
|
891
|
+
idpMetadata: updates.idpMetadata ?? current.idpMetadata,
|
|
892
|
+
mapping: updates.mapping ?? current.mapping,
|
|
893
|
+
audience: updates.audience ?? current.audience,
|
|
894
|
+
wantAssertionsSigned: updates.wantAssertionsSigned ?? current.wantAssertionsSigned,
|
|
895
|
+
authnRequestsSigned: updates.authnRequestsSigned ?? current.authnRequestsSigned,
|
|
896
|
+
identifierFormat: updates.identifierFormat ?? current.identifierFormat,
|
|
897
|
+
signatureAlgorithm: updates.signatureAlgorithm ?? current.signatureAlgorithm,
|
|
898
|
+
digestAlgorithm: updates.digestAlgorithm ?? current.digestAlgorithm
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
function mergeOIDCConfig(current, updates, issuer) {
|
|
902
|
+
return {
|
|
903
|
+
...current,
|
|
904
|
+
...updates,
|
|
905
|
+
issuer,
|
|
906
|
+
pkce: updates.pkce ?? current.pkce ?? true,
|
|
907
|
+
clientId: updates.clientId ?? current.clientId,
|
|
908
|
+
clientSecret: updates.clientSecret ?? current.clientSecret,
|
|
909
|
+
discoveryEndpoint: updates.discoveryEndpoint ?? current.discoveryEndpoint,
|
|
910
|
+
mapping: updates.mapping ?? current.mapping,
|
|
911
|
+
scopes: updates.scopes ?? current.scopes,
|
|
912
|
+
authorizationEndpoint: updates.authorizationEndpoint ?? current.authorizationEndpoint,
|
|
913
|
+
tokenEndpoint: updates.tokenEndpoint ?? current.tokenEndpoint,
|
|
914
|
+
userInfoEndpoint: updates.userInfoEndpoint ?? current.userInfoEndpoint,
|
|
915
|
+
jwksEndpoint: updates.jwksEndpoint ?? current.jwksEndpoint,
|
|
916
|
+
tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication
|
|
917
|
+
};
|
|
560
918
|
}
|
|
919
|
+
const updateSSOProvider = (options) => {
|
|
920
|
+
return createAuthEndpoint("/sso/providers/:providerId", {
|
|
921
|
+
method: "PATCH",
|
|
922
|
+
use: [sessionMiddleware],
|
|
923
|
+
params: getSSOProviderParamsSchema,
|
|
924
|
+
body: updateSSOProviderBodySchema,
|
|
925
|
+
metadata: { openapi: {
|
|
926
|
+
operationId: "updateSSOProvider",
|
|
927
|
+
summary: "Update SSO provider",
|
|
928
|
+
description: "Partially update an SSO provider. Only provided fields are updated. If domain changes, domainVerified is reset to false.",
|
|
929
|
+
responses: {
|
|
930
|
+
"200": { description: "SSO provider updated successfully" },
|
|
931
|
+
"404": { description: "Provider not found" },
|
|
932
|
+
"403": { description: "Access denied" }
|
|
933
|
+
}
|
|
934
|
+
} }
|
|
935
|
+
}, async (ctx) => {
|
|
936
|
+
const { providerId } = ctx.params;
|
|
937
|
+
const body = ctx.body;
|
|
938
|
+
const { issuer, domain, samlConfig, oidcConfig } = body;
|
|
939
|
+
if (!issuer && !domain && !samlConfig && !oidcConfig) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
|
|
940
|
+
const existingProvider = await checkProviderAccess(ctx, providerId);
|
|
941
|
+
const updateData = {};
|
|
942
|
+
if (body.issuer !== void 0) updateData.issuer = body.issuer;
|
|
943
|
+
if (body.domain !== void 0) {
|
|
944
|
+
updateData.domain = body.domain;
|
|
945
|
+
if (body.domain !== existingProvider.domain) updateData.domainVerified = false;
|
|
946
|
+
}
|
|
947
|
+
if (body.samlConfig) {
|
|
948
|
+
if (body.samlConfig.idpMetadata?.metadata) {
|
|
949
|
+
const maxMetadataSize = options?.saml?.maxMetadataSize ?? DEFAULT_MAX_SAML_METADATA_SIZE;
|
|
950
|
+
if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
|
|
951
|
+
}
|
|
952
|
+
if (body.samlConfig.signatureAlgorithm !== void 0 || body.samlConfig.digestAlgorithm !== void 0) validateConfigAlgorithms({
|
|
953
|
+
signatureAlgorithm: body.samlConfig.signatureAlgorithm,
|
|
954
|
+
digestAlgorithm: body.samlConfig.digestAlgorithm
|
|
955
|
+
}, options?.saml?.algorithms);
|
|
956
|
+
const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
|
|
957
|
+
const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
|
|
958
|
+
updateData.samlConfig = JSON.stringify(updatedSamlConfig);
|
|
959
|
+
}
|
|
960
|
+
if (body.oidcConfig) {
|
|
961
|
+
const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
|
|
962
|
+
const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
|
|
963
|
+
updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
|
|
964
|
+
}
|
|
965
|
+
await ctx.context.adapter.update({
|
|
966
|
+
model: "ssoProvider",
|
|
967
|
+
where: [{
|
|
968
|
+
field: "providerId",
|
|
969
|
+
value: providerId
|
|
970
|
+
}],
|
|
971
|
+
update: updateData
|
|
972
|
+
});
|
|
973
|
+
const fullProvider = await ctx.context.adapter.findOne({
|
|
974
|
+
model: "ssoProvider",
|
|
975
|
+
where: [{
|
|
976
|
+
field: "providerId",
|
|
977
|
+
value: providerId
|
|
978
|
+
}]
|
|
979
|
+
});
|
|
980
|
+
if (!fullProvider) throw new APIError("NOT_FOUND", { message: "Provider not found after update" });
|
|
981
|
+
return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL));
|
|
982
|
+
});
|
|
983
|
+
};
|
|
984
|
+
const deleteSSOProvider = () => {
|
|
985
|
+
return createAuthEndpoint("/sso/providers/:providerId", {
|
|
986
|
+
method: "DELETE",
|
|
987
|
+
use: [sessionMiddleware],
|
|
988
|
+
params: getSSOProviderParamsSchema,
|
|
989
|
+
metadata: { openapi: {
|
|
990
|
+
operationId: "deleteSSOProvider",
|
|
991
|
+
summary: "Delete SSO provider",
|
|
992
|
+
description: "Deletes an SSO provider",
|
|
993
|
+
responses: {
|
|
994
|
+
"200": { description: "SSO provider deleted successfully" },
|
|
995
|
+
"404": { description: "Provider not found" },
|
|
996
|
+
"403": { description: "Access denied" }
|
|
997
|
+
}
|
|
998
|
+
} }
|
|
999
|
+
}, async (ctx) => {
|
|
1000
|
+
const { providerId } = ctx.params;
|
|
1001
|
+
await checkProviderAccess(ctx, providerId);
|
|
1002
|
+
await ctx.context.adapter.delete({
|
|
1003
|
+
model: "ssoProvider",
|
|
1004
|
+
where: [{
|
|
1005
|
+
field: "providerId",
|
|
1006
|
+
value: providerId
|
|
1007
|
+
}]
|
|
1008
|
+
});
|
|
1009
|
+
return ctx.json({ success: true });
|
|
1010
|
+
});
|
|
1011
|
+
};
|
|
1012
|
+
|
|
1013
|
+
//#endregion
|
|
1014
|
+
//#region src/oidc/types.ts
|
|
561
1015
|
/**
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
* @param doc - The discovery document
|
|
565
|
-
* @param existing - Existing authentication method from config
|
|
566
|
-
* @returns The selected authentication method
|
|
1016
|
+
* Custom error class for OIDC discovery failures.
|
|
1017
|
+
* Can be caught and mapped to APIError at the edge.
|
|
567
1018
|
*/
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
1019
|
+
var DiscoveryError = class DiscoveryError extends Error {
|
|
1020
|
+
code;
|
|
1021
|
+
details;
|
|
1022
|
+
constructor(code, message, details, options) {
|
|
1023
|
+
super(message, options);
|
|
1024
|
+
this.name = "DiscoveryError";
|
|
1025
|
+
this.code = code;
|
|
1026
|
+
this.details = details;
|
|
1027
|
+
if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
|
|
1028
|
+
}
|
|
1029
|
+
};
|
|
576
1030
|
/**
|
|
577
|
-
*
|
|
1031
|
+
* Required fields that must be present in a valid discovery document.
|
|
1032
|
+
*/
|
|
1033
|
+
const REQUIRED_DISCOVERY_FIELDS = [
|
|
1034
|
+
"issuer",
|
|
1035
|
+
"authorization_endpoint",
|
|
1036
|
+
"token_endpoint",
|
|
1037
|
+
"jwks_uri"
|
|
1038
|
+
];
|
|
1039
|
+
|
|
1040
|
+
//#endregion
|
|
1041
|
+
//#region src/oidc/discovery.ts
|
|
1042
|
+
/**
|
|
1043
|
+
* OIDC Discovery Pipeline
|
|
578
1044
|
*
|
|
579
|
-
*
|
|
580
|
-
*
|
|
581
|
-
*
|
|
582
|
-
* - `jwksEndpoint` - required for validating ID token signatures
|
|
1045
|
+
* Implements OIDC discovery document fetching, validation, and hydration.
|
|
1046
|
+
* This module is used both at provider registration time (to persist validated config)
|
|
1047
|
+
* and at runtime (to hydrate legacy providers that are missing metadata).
|
|
583
1048
|
*
|
|
584
|
-
*
|
|
585
|
-
|
|
1049
|
+
* @see https://openid.net/specs/openid-connect-discovery-1_0.html
|
|
1050
|
+
*/
|
|
1051
|
+
/** Default timeout for discovery requests (10 seconds) */
|
|
1052
|
+
const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
|
|
1053
|
+
/**
|
|
1054
|
+
* Main entry point: Discover and hydrate OIDC configuration from an issuer.
|
|
586
1055
|
*
|
|
587
|
-
*
|
|
588
|
-
*
|
|
1056
|
+
* This function:
|
|
1057
|
+
* 1. Computes the discovery URL from the issuer
|
|
1058
|
+
* 2. Validates the discovery URL
|
|
1059
|
+
* 3. Fetches the discovery document
|
|
1060
|
+
* 4. Validates the discovery document (issuer match + required fields)
|
|
1061
|
+
* 5. Normalizes URLs
|
|
1062
|
+
* 6. Selects token endpoint auth method
|
|
1063
|
+
* 7. Merges with existing config (existing values take precedence)
|
|
1064
|
+
*
|
|
1065
|
+
* @param params - Discovery parameters
|
|
1066
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
1067
|
+
* @returns Hydrated OIDC configuration ready for persistence
|
|
1068
|
+
* @throws DiscoveryError on any failure
|
|
589
1069
|
*/
|
|
590
|
-
function
|
|
591
|
-
|
|
592
|
-
|
|
1070
|
+
async function discoverOIDCConfig(params) {
|
|
1071
|
+
const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
|
|
1072
|
+
const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
|
|
1073
|
+
validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
|
|
1074
|
+
const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
|
|
1075
|
+
validateDiscoveryDocument(discoveryDoc, issuer);
|
|
1076
|
+
const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
|
|
1077
|
+
const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
|
|
1078
|
+
return {
|
|
1079
|
+
issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
|
|
1080
|
+
discoveryEndpoint: existingConfig?.discoveryEndpoint ?? discoveryUrl,
|
|
1081
|
+
authorizationEndpoint: existingConfig?.authorizationEndpoint ?? normalizedDoc.authorization_endpoint,
|
|
1082
|
+
tokenEndpoint: existingConfig?.tokenEndpoint ?? normalizedDoc.token_endpoint,
|
|
1083
|
+
jwksEndpoint: existingConfig?.jwksEndpoint ?? normalizedDoc.jwks_uri,
|
|
1084
|
+
userInfoEndpoint: existingConfig?.userInfoEndpoint ?? normalizedDoc.userinfo_endpoint,
|
|
1085
|
+
tokenEndpointAuthentication: existingConfig?.tokenEndpointAuthentication ?? tokenEndpointAuth,
|
|
1086
|
+
scopesSupported: existingConfig?.scopesSupported ?? normalizedDoc.scopes_supported
|
|
1087
|
+
};
|
|
593
1088
|
}
|
|
594
|
-
|
|
595
|
-
//#endregion
|
|
596
|
-
//#region src/oidc/errors.ts
|
|
597
1089
|
/**
|
|
598
|
-
*
|
|
1090
|
+
* Compute the discovery URL from an issuer URL.
|
|
599
1091
|
*
|
|
600
|
-
*
|
|
601
|
-
*
|
|
1092
|
+
* Per OIDC Discovery spec, the discovery document is located at:
|
|
1093
|
+
* <issuer>/.well-known/openid-configuration
|
|
1094
|
+
*
|
|
1095
|
+
* Handles trailing slashes correctly.
|
|
602
1096
|
*/
|
|
1097
|
+
function computeDiscoveryUrl(issuer) {
|
|
1098
|
+
return `${issuer.endsWith("/") ? issuer.slice(0, -1) : issuer}/.well-known/openid-configuration`;
|
|
1099
|
+
}
|
|
603
1100
|
/**
|
|
604
|
-
*
|
|
1101
|
+
* Validate a discovery URL before fetching.
|
|
605
1102
|
*
|
|
606
|
-
*
|
|
607
|
-
* -
|
|
608
|
-
*
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
1103
|
+
* @param url - The discovery URL to validate
|
|
1104
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
1105
|
+
* @throws DiscoveryError if URL is invalid
|
|
1106
|
+
*/
|
|
1107
|
+
function validateDiscoveryUrl(url, isTrustedOrigin) {
|
|
1108
|
+
const discoveryEndpoint = parseURL("discoveryEndpoint", url).toString();
|
|
1109
|
+
if (!isTrustedOrigin(discoveryEndpoint)) throw new DiscoveryError("discovery_untrusted_origin", `The main discovery endpoint "${discoveryEndpoint}" is not trusted by your trusted origins configuration.`, { url: discoveryEndpoint });
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Fetch the OIDC discovery document from the IdP.
|
|
615
1113
|
*
|
|
616
|
-
* @param
|
|
617
|
-
* @
|
|
1114
|
+
* @param url - The discovery endpoint URL
|
|
1115
|
+
* @param timeout - Request timeout in milliseconds
|
|
1116
|
+
* @returns The parsed discovery document
|
|
1117
|
+
* @throws DiscoveryError on network errors, timeouts, or invalid responses
|
|
618
1118
|
*/
|
|
619
|
-
function
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
1119
|
+
async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
|
|
1120
|
+
try {
|
|
1121
|
+
const response = await betterFetch(url, {
|
|
1122
|
+
method: "GET",
|
|
1123
|
+
timeout
|
|
624
1124
|
});
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
1125
|
+
if (response.error) {
|
|
1126
|
+
const { status } = response.error;
|
|
1127
|
+
if (status === 404) throw new DiscoveryError("discovery_not_found", "Discovery endpoint not found", {
|
|
1128
|
+
url,
|
|
1129
|
+
status
|
|
1130
|
+
});
|
|
1131
|
+
if (status === 408) throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
|
|
1132
|
+
url,
|
|
1133
|
+
timeout
|
|
1134
|
+
});
|
|
1135
|
+
throw new DiscoveryError("discovery_unexpected_error", `Unexpected discovery error: ${response.error.statusText}`, {
|
|
1136
|
+
url,
|
|
1137
|
+
...response.error
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
if (!response.data) throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned an empty response", { url });
|
|
1141
|
+
const data = response.data;
|
|
1142
|
+
if (typeof data === "string") throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned invalid JSON", {
|
|
1143
|
+
url,
|
|
1144
|
+
bodyPreview: data.slice(0, 200)
|
|
628
1145
|
});
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
code: error.code
|
|
636
|
-
});
|
|
637
|
-
case "discovery_untrusted_origin": return new APIError("BAD_REQUEST", {
|
|
638
|
-
message: `Untrusted OIDC discovery URL: ${error.message}`,
|
|
639
|
-
code: error.code
|
|
640
|
-
});
|
|
641
|
-
case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
|
|
642
|
-
message: `OIDC discovery returned invalid data: ${error.message}`,
|
|
643
|
-
code: error.code
|
|
644
|
-
});
|
|
645
|
-
case "discovery_incomplete": return new APIError("BAD_REQUEST", {
|
|
646
|
-
message: `OIDC discovery document is missing required fields: ${error.message}`,
|
|
647
|
-
code: error.code
|
|
648
|
-
});
|
|
649
|
-
case "issuer_mismatch": return new APIError("BAD_REQUEST", {
|
|
650
|
-
message: `OIDC issuer mismatch: ${error.message}`,
|
|
651
|
-
code: error.code
|
|
652
|
-
});
|
|
653
|
-
case "unsupported_token_auth_method": return new APIError("BAD_REQUEST", {
|
|
654
|
-
message: `Incompatible OIDC provider: ${error.message}`,
|
|
655
|
-
code: error.code
|
|
1146
|
+
return data;
|
|
1147
|
+
} catch (error) {
|
|
1148
|
+
if (error instanceof DiscoveryError) throw error;
|
|
1149
|
+
if (error instanceof Error && error.name === "AbortError") throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
|
|
1150
|
+
url,
|
|
1151
|
+
timeout
|
|
656
1152
|
});
|
|
657
|
-
|
|
658
|
-
error.code;
|
|
659
|
-
return new APIError("INTERNAL_SERVER_ERROR", {
|
|
660
|
-
message: `Unexpected discovery error: ${error.message}`,
|
|
661
|
-
code: "discovery_unexpected_error"
|
|
662
|
-
});
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
//#endregion
|
|
667
|
-
//#region src/saml/parser.ts
|
|
668
|
-
const xmlParser = new XMLParser({
|
|
669
|
-
ignoreAttributes: false,
|
|
670
|
-
attributeNamePrefix: "@_",
|
|
671
|
-
removeNSPrefix: true,
|
|
672
|
-
processEntities: false
|
|
673
|
-
});
|
|
674
|
-
function findNode(obj, nodeName) {
|
|
675
|
-
if (!obj || typeof obj !== "object") return null;
|
|
676
|
-
const record = obj;
|
|
677
|
-
if (nodeName in record) return record[nodeName];
|
|
678
|
-
for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
|
|
679
|
-
const found = findNode(item, nodeName);
|
|
680
|
-
if (found) return found;
|
|
681
|
-
}
|
|
682
|
-
else if (typeof value === "object" && value !== null) {
|
|
683
|
-
const found = findNode(value, nodeName);
|
|
684
|
-
if (found) return found;
|
|
685
|
-
}
|
|
686
|
-
return null;
|
|
687
|
-
}
|
|
688
|
-
function countAllNodes(obj, nodeName) {
|
|
689
|
-
if (!obj || typeof obj !== "object") return 0;
|
|
690
|
-
let count = 0;
|
|
691
|
-
const record = obj;
|
|
692
|
-
if (nodeName in record) {
|
|
693
|
-
const node = record[nodeName];
|
|
694
|
-
count += Array.isArray(node) ? node.length : 1;
|
|
1153
|
+
throw new DiscoveryError("discovery_unexpected_error", `Unexpected error during discovery: ${error instanceof Error ? error.message : String(error)}`, { url }, { cause: error });
|
|
695
1154
|
}
|
|
696
|
-
for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) count += countAllNodes(item, nodeName);
|
|
697
|
-
else if (typeof value === "object" && value !== null) count += countAllNodes(value, nodeName);
|
|
698
|
-
return count;
|
|
699
1155
|
}
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
};
|
|
723
|
-
const DataEncryptionAlgorithm = {
|
|
724
|
-
TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
|
|
725
|
-
AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
|
|
726
|
-
AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
|
|
727
|
-
AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
|
|
728
|
-
AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
|
|
729
|
-
AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
|
|
730
|
-
AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
|
|
731
|
-
};
|
|
732
|
-
const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
|
|
733
|
-
const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS = [KeyEncryptionAlgorithm.RSA_1_5];
|
|
734
|
-
const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS = [DataEncryptionAlgorithm.TRIPLEDES_CBC];
|
|
735
|
-
const DEPRECATED_DIGEST_ALGORITHMS = [DigestAlgorithm.SHA1];
|
|
736
|
-
const SECURE_SIGNATURE_ALGORITHMS = [
|
|
737
|
-
SignatureAlgorithm.RSA_SHA256,
|
|
738
|
-
SignatureAlgorithm.RSA_SHA384,
|
|
739
|
-
SignatureAlgorithm.RSA_SHA512,
|
|
740
|
-
SignatureAlgorithm.ECDSA_SHA256,
|
|
741
|
-
SignatureAlgorithm.ECDSA_SHA384,
|
|
742
|
-
SignatureAlgorithm.ECDSA_SHA512
|
|
743
|
-
];
|
|
744
|
-
const SECURE_DIGEST_ALGORITHMS = [
|
|
745
|
-
DigestAlgorithm.SHA256,
|
|
746
|
-
DigestAlgorithm.SHA384,
|
|
747
|
-
DigestAlgorithm.SHA512
|
|
748
|
-
];
|
|
749
|
-
const SHORT_FORM_SIGNATURE_TO_URI = {
|
|
750
|
-
sha1: SignatureAlgorithm.RSA_SHA1,
|
|
751
|
-
sha256: SignatureAlgorithm.RSA_SHA256,
|
|
752
|
-
sha384: SignatureAlgorithm.RSA_SHA384,
|
|
753
|
-
sha512: SignatureAlgorithm.RSA_SHA512,
|
|
754
|
-
"rsa-sha1": SignatureAlgorithm.RSA_SHA1,
|
|
755
|
-
"rsa-sha256": SignatureAlgorithm.RSA_SHA256,
|
|
756
|
-
"rsa-sha384": SignatureAlgorithm.RSA_SHA384,
|
|
757
|
-
"rsa-sha512": SignatureAlgorithm.RSA_SHA512,
|
|
758
|
-
"ecdsa-sha256": SignatureAlgorithm.ECDSA_SHA256,
|
|
759
|
-
"ecdsa-sha384": SignatureAlgorithm.ECDSA_SHA384,
|
|
760
|
-
"ecdsa-sha512": SignatureAlgorithm.ECDSA_SHA512
|
|
761
|
-
};
|
|
762
|
-
const SHORT_FORM_DIGEST_TO_URI = {
|
|
763
|
-
sha1: DigestAlgorithm.SHA1,
|
|
764
|
-
sha256: DigestAlgorithm.SHA256,
|
|
765
|
-
sha384: DigestAlgorithm.SHA384,
|
|
766
|
-
sha512: DigestAlgorithm.SHA512
|
|
767
|
-
};
|
|
768
|
-
function normalizeSignatureAlgorithm(alg) {
|
|
769
|
-
return SHORT_FORM_SIGNATURE_TO_URI[alg.toLowerCase()] ?? alg;
|
|
1156
|
+
/**
|
|
1157
|
+
* Validate a discovery document.
|
|
1158
|
+
*
|
|
1159
|
+
* Checks:
|
|
1160
|
+
* 1. All required fields are present
|
|
1161
|
+
* 2. Issuer matches the configured issuer (case-sensitive, exact match)
|
|
1162
|
+
*
|
|
1163
|
+
* Invariant: If this function returns without throwing, the document is safe
|
|
1164
|
+
* to use for hydrating OIDC config (required fields present, issuer matches
|
|
1165
|
+
* configured value, basic structural sanity verified).
|
|
1166
|
+
*
|
|
1167
|
+
* @param doc - The discovery document to validate
|
|
1168
|
+
* @param configuredIssuer - The expected issuer value
|
|
1169
|
+
* @throws DiscoveryError if validation fails
|
|
1170
|
+
*/
|
|
1171
|
+
function validateDiscoveryDocument(doc, configuredIssuer) {
|
|
1172
|
+
const missingFields = [];
|
|
1173
|
+
for (const field of REQUIRED_DISCOVERY_FIELDS) if (!doc[field]) missingFields.push(field);
|
|
1174
|
+
if (missingFields.length > 0) throw new DiscoveryError("discovery_incomplete", `Discovery document is missing required fields: ${missingFields.join(", ")}`, { missingFields });
|
|
1175
|
+
if ((doc.issuer.endsWith("/") ? doc.issuer.slice(0, -1) : doc.issuer) !== (configuredIssuer.endsWith("/") ? configuredIssuer.slice(0, -1) : configuredIssuer)) throw new DiscoveryError("issuer_mismatch", `Discovered issuer "${doc.issuer}" does not match configured issuer "${configuredIssuer}"`, {
|
|
1176
|
+
discovered: doc.issuer,
|
|
1177
|
+
configured: configuredIssuer
|
|
1178
|
+
});
|
|
770
1179
|
}
|
|
771
|
-
|
|
772
|
-
|
|
1180
|
+
/**
|
|
1181
|
+
* Normalize URLs in the discovery document.
|
|
1182
|
+
*
|
|
1183
|
+
* @param document - The discovery document
|
|
1184
|
+
* @param issuer - The base issuer URL
|
|
1185
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
1186
|
+
* @returns The normalized discovery document
|
|
1187
|
+
*/
|
|
1188
|
+
function normalizeDiscoveryUrls(document, issuer, isTrustedOrigin) {
|
|
1189
|
+
const doc = { ...document };
|
|
1190
|
+
doc.token_endpoint = normalizeAndValidateUrl("token_endpoint", doc.token_endpoint, issuer, isTrustedOrigin);
|
|
1191
|
+
doc.authorization_endpoint = normalizeAndValidateUrl("authorization_endpoint", doc.authorization_endpoint, issuer, isTrustedOrigin);
|
|
1192
|
+
doc.jwks_uri = normalizeAndValidateUrl("jwks_uri", doc.jwks_uri, issuer, isTrustedOrigin);
|
|
1193
|
+
if (doc.userinfo_endpoint) doc.userinfo_endpoint = normalizeAndValidateUrl("userinfo_endpoint", doc.userinfo_endpoint, issuer, isTrustedOrigin);
|
|
1194
|
+
if (doc.revocation_endpoint) doc.revocation_endpoint = normalizeAndValidateUrl("revocation_endpoint", doc.revocation_endpoint, issuer, isTrustedOrigin);
|
|
1195
|
+
if (doc.end_session_endpoint) doc.end_session_endpoint = normalizeAndValidateUrl("end_session_endpoint", doc.end_session_endpoint, issuer, isTrustedOrigin);
|
|
1196
|
+
if (doc.introspection_endpoint) doc.introspection_endpoint = normalizeAndValidateUrl("introspection_endpoint", doc.introspection_endpoint, issuer, isTrustedOrigin);
|
|
1197
|
+
return doc;
|
|
773
1198
|
}
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
1199
|
+
/**
|
|
1200
|
+
* Normalizes and validates a single URL endpoint
|
|
1201
|
+
* @param name The url name
|
|
1202
|
+
* @param endpoint The url to validate
|
|
1203
|
+
* @param issuer The issuer base url
|
|
1204
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
1205
|
+
* @returns
|
|
1206
|
+
*/
|
|
1207
|
+
function normalizeAndValidateUrl(name, endpoint, issuer, isTrustedOrigin) {
|
|
1208
|
+
const url = normalizeUrl(name, endpoint, issuer);
|
|
1209
|
+
if (!isTrustedOrigin(url)) throw new DiscoveryError("discovery_untrusted_origin", `The ${name} "${url}" is not trusted by your trusted origins configuration.`, {
|
|
1210
|
+
endpoint: name,
|
|
1211
|
+
url
|
|
1212
|
+
});
|
|
1213
|
+
return url;
|
|
789
1214
|
}
|
|
790
|
-
|
|
1215
|
+
/**
|
|
1216
|
+
* Normalize a single URL endpoint.
|
|
1217
|
+
*
|
|
1218
|
+
* @param name - The endpoint name (e.g token_endpoint)
|
|
1219
|
+
* @param endpoint - The endpoint URL to normalize
|
|
1220
|
+
* @param issuer - The base issuer URL
|
|
1221
|
+
* @returns The normalized endpoint URL
|
|
1222
|
+
*/
|
|
1223
|
+
function normalizeUrl(name, endpoint, issuer) {
|
|
791
1224
|
try {
|
|
792
|
-
return
|
|
1225
|
+
return parseURL(name, endpoint).toString();
|
|
793
1226
|
} catch {
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
switch (behavior) {
|
|
799
|
-
case "reject": throw new APIError("BAD_REQUEST", {
|
|
800
|
-
message,
|
|
801
|
-
code: errorCode
|
|
802
|
-
});
|
|
803
|
-
case "warn":
|
|
804
|
-
console.warn(`[SAML Security Warning] ${message}`);
|
|
805
|
-
break;
|
|
806
|
-
case "allow": break;
|
|
1227
|
+
const issuerURL = parseURL(name, issuer);
|
|
1228
|
+
const basePath = issuerURL.pathname.replace(/\/+$/, "");
|
|
1229
|
+
const endpointPath = endpoint.replace(/^\/+/, "");
|
|
1230
|
+
return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
|
|
807
1231
|
}
|
|
808
1232
|
}
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
return;
|
|
1233
|
+
/**
|
|
1234
|
+
* Parses the given URL or throws in case of invalid or unsupported protocols
|
|
1235
|
+
*
|
|
1236
|
+
* @param name the url name
|
|
1237
|
+
* @param endpoint the endpoint url
|
|
1238
|
+
* @param [base] optional base path
|
|
1239
|
+
* @returns
|
|
1240
|
+
*/
|
|
1241
|
+
function parseURL(name, endpoint, base) {
|
|
1242
|
+
let endpointURL;
|
|
1243
|
+
try {
|
|
1244
|
+
endpointURL = new URL(endpoint, base);
|
|
1245
|
+
if (endpointURL.protocol === "http:" || endpointURL.protocol === "https:") return endpointURL;
|
|
1246
|
+
} catch (error) {
|
|
1247
|
+
throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
|
|
822
1248
|
}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
1249
|
+
throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must use the http or https supported protocols: ${endpoint}`, {
|
|
1250
|
+
url: endpoint,
|
|
1251
|
+
protocol: endpointURL.protocol
|
|
826
1252
|
});
|
|
827
1253
|
}
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
if (
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
|
|
843
|
-
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
844
|
-
});
|
|
845
|
-
} else if (DEPRECATED_DATA_ENCRYPTION_ALGORITHMS.includes(dataEncryption)) handleDeprecatedAlgorithm(`SAML response uses deprecated data encryption algorithm: ${dataEncryption}. Please configure your IdP to use AES-GCM.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
|
|
846
|
-
}
|
|
1254
|
+
/**
|
|
1255
|
+
* Select the token endpoint authentication method.
|
|
1256
|
+
*
|
|
1257
|
+
* @param doc - The discovery document
|
|
1258
|
+
* @param existing - Existing authentication method from config
|
|
1259
|
+
* @returns The selected authentication method
|
|
1260
|
+
*/
|
|
1261
|
+
function selectTokenEndpointAuthMethod(doc, existing) {
|
|
1262
|
+
if (existing) return existing;
|
|
1263
|
+
const supported = doc.token_endpoint_auth_methods_supported;
|
|
1264
|
+
if (!supported || supported.length === 0) return "client_secret_basic";
|
|
1265
|
+
if (supported.includes("client_secret_basic")) return "client_secret_basic";
|
|
1266
|
+
if (supported.includes("client_secret_post")) return "client_secret_post";
|
|
1267
|
+
return "client_secret_basic";
|
|
847
1268
|
}
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
1269
|
+
/**
|
|
1270
|
+
* Check if a provider configuration needs runtime discovery.
|
|
1271
|
+
*
|
|
1272
|
+
* Returns true if we need discovery at runtime to complete the token exchange
|
|
1273
|
+
* and validation. Specifically checks for:
|
|
1274
|
+
* - `tokenEndpoint` - required for exchanging authorization code for tokens
|
|
1275
|
+
* - `jwksEndpoint` - required for validating ID token signatures
|
|
1276
|
+
*
|
|
1277
|
+
* Note: `authorizationEndpoint` is handled separately in the sign-in flow,
|
|
1278
|
+
* so it's not checked here.
|
|
1279
|
+
*
|
|
1280
|
+
* @param config - Partial OIDC config from the provider
|
|
1281
|
+
* @returns true if runtime discovery should be performed
|
|
1282
|
+
*/
|
|
1283
|
+
function needsRuntimeDiscovery(config) {
|
|
1284
|
+
if (!config) return true;
|
|
1285
|
+
return !config.tokenEndpoint || !config.jwksEndpoint;
|
|
851
1286
|
}
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
1287
|
+
|
|
1288
|
+
//#endregion
|
|
1289
|
+
//#region src/oidc/errors.ts
|
|
1290
|
+
/**
|
|
1291
|
+
* OIDC Discovery Error Mapping
|
|
1292
|
+
*
|
|
1293
|
+
* Maps DiscoveryError codes to appropriate APIError responses.
|
|
1294
|
+
* Used at the boundary between the discovery pipeline and HTTP handlers.
|
|
1295
|
+
*/
|
|
1296
|
+
/**
|
|
1297
|
+
* Maps a DiscoveryError to an appropriate APIError for HTTP responses.
|
|
1298
|
+
*
|
|
1299
|
+
* Error code mapping:
|
|
1300
|
+
* - discovery_invalid_url → 400 BAD_REQUEST
|
|
1301
|
+
* - discovery_not_found → 400 BAD_REQUEST
|
|
1302
|
+
* - discovery_invalid_json → 400 BAD_REQUEST
|
|
1303
|
+
* - discovery_incomplete → 400 BAD_REQUEST
|
|
1304
|
+
* - issuer_mismatch → 400 BAD_REQUEST
|
|
1305
|
+
* - unsupported_token_auth_method → 400 BAD_REQUEST
|
|
1306
|
+
* - discovery_timeout → 502 BAD_GATEWAY
|
|
1307
|
+
* - discovery_unexpected_error → 502 BAD_GATEWAY
|
|
1308
|
+
*
|
|
1309
|
+
* @param error - The DiscoveryError to map
|
|
1310
|
+
* @returns An APIError with appropriate status and message
|
|
1311
|
+
*/
|
|
1312
|
+
function mapDiscoveryErrorToAPIError(error) {
|
|
1313
|
+
switch (error.code) {
|
|
1314
|
+
case "discovery_timeout": return new APIError("BAD_GATEWAY", {
|
|
1315
|
+
message: `OIDC discovery timed out: ${error.message}`,
|
|
1316
|
+
code: error.code
|
|
865
1317
|
});
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
1318
|
+
case "discovery_unexpected_error": return new APIError("BAD_GATEWAY", {
|
|
1319
|
+
message: `OIDC discovery failed: ${error.message}`,
|
|
1320
|
+
code: error.code
|
|
1321
|
+
});
|
|
1322
|
+
case "discovery_not_found": return new APIError("BAD_REQUEST", {
|
|
1323
|
+
message: `OIDC discovery endpoint not found. The issuer may not support OIDC discovery, or the URL is incorrect. ${error.message}`,
|
|
1324
|
+
code: error.code
|
|
1325
|
+
});
|
|
1326
|
+
case "discovery_invalid_url": return new APIError("BAD_REQUEST", {
|
|
1327
|
+
message: `Invalid OIDC discovery URL: ${error.message}`,
|
|
1328
|
+
code: error.code
|
|
1329
|
+
});
|
|
1330
|
+
case "discovery_untrusted_origin": return new APIError("BAD_REQUEST", {
|
|
1331
|
+
message: `Untrusted OIDC discovery URL: ${error.message}`,
|
|
1332
|
+
code: error.code
|
|
1333
|
+
});
|
|
1334
|
+
case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
|
|
1335
|
+
message: `OIDC discovery returned invalid data: ${error.message}`,
|
|
1336
|
+
code: error.code
|
|
1337
|
+
});
|
|
1338
|
+
case "discovery_incomplete": return new APIError("BAD_REQUEST", {
|
|
1339
|
+
message: `OIDC discovery document is missing required fields: ${error.message}`,
|
|
1340
|
+
code: error.code
|
|
1341
|
+
});
|
|
1342
|
+
case "issuer_mismatch": return new APIError("BAD_REQUEST", {
|
|
1343
|
+
message: `OIDC issuer mismatch: ${error.message}`,
|
|
1344
|
+
code: error.code
|
|
1345
|
+
});
|
|
1346
|
+
case "unsupported_token_auth_method": return new APIError("BAD_REQUEST", {
|
|
1347
|
+
message: `Incompatible OIDC provider: ${error.message}`,
|
|
1348
|
+
code: error.code
|
|
878
1349
|
});
|
|
1350
|
+
default:
|
|
1351
|
+
error.code;
|
|
1352
|
+
return new APIError("INTERNAL_SERVER_ERROR", {
|
|
1353
|
+
message: `Unexpected discovery error: ${error.message}`,
|
|
1354
|
+
code: "discovery_unexpected_error"
|
|
1355
|
+
});
|
|
879
1356
|
}
|
|
880
1357
|
}
|
|
881
1358
|
|
|
882
1359
|
//#endregion
|
|
883
|
-
//#region src/saml
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
return {
|
|
898
|
-
assertions,
|
|
899
|
-
encryptedAssertions,
|
|
900
|
-
total: assertions + encryptedAssertions
|
|
1360
|
+
//#region src/saml-state.ts
|
|
1361
|
+
async function generateRelayState(c, link, additionalData) {
|
|
1362
|
+
const callbackURL = c.body.callbackURL;
|
|
1363
|
+
if (!callbackURL) throw new APIError$1("BAD_REQUEST", { message: "callbackURL is required" });
|
|
1364
|
+
const codeVerifier = generateRandomString(128);
|
|
1365
|
+
const stateData = {
|
|
1366
|
+
...additionalData ? additionalData : {},
|
|
1367
|
+
callbackURL,
|
|
1368
|
+
codeVerifier,
|
|
1369
|
+
errorURL: c.body.errorCallbackURL,
|
|
1370
|
+
newUserURL: c.body.newUserCallbackURL,
|
|
1371
|
+
link,
|
|
1372
|
+
expiresAt: Date.now() + 600 * 1e3,
|
|
1373
|
+
requestSignUp: c.body.requestSignUp
|
|
901
1374
|
};
|
|
902
|
-
}
|
|
903
|
-
function validateSingleAssertion(samlResponse) {
|
|
904
|
-
let xml;
|
|
905
1375
|
try {
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
throw new APIError("
|
|
910
|
-
message: "
|
|
911
|
-
|
|
1376
|
+
return generateGenericState(c, stateData, { cookieName: "relay_state" });
|
|
1377
|
+
} catch (error) {
|
|
1378
|
+
c.context.logger.error("Failed to create verification for relay state", error);
|
|
1379
|
+
throw new APIError$1("INTERNAL_SERVER_ERROR", {
|
|
1380
|
+
message: "State error: Unable to create verification for relay state",
|
|
1381
|
+
cause: error
|
|
912
1382
|
});
|
|
913
1383
|
}
|
|
914
|
-
const counts = countAssertions(xml);
|
|
915
|
-
if (counts.total === 0) throw new APIError("BAD_REQUEST", {
|
|
916
|
-
message: "SAML response contains no assertions",
|
|
917
|
-
code: "SAML_NO_ASSERTION"
|
|
918
|
-
});
|
|
919
|
-
if (counts.total > 1) throw new APIError("BAD_REQUEST", {
|
|
920
|
-
message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
|
|
921
|
-
code: "SAML_MULTIPLE_ASSERTIONS"
|
|
922
|
-
});
|
|
923
1384
|
}
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
* instead of JSON strings from TEXT/JSON columns.
|
|
931
|
-
*
|
|
932
|
-
* @param value - The value to parse (string, object, null, or undefined)
|
|
933
|
-
* @returns The parsed object or null
|
|
934
|
-
* @throws Error if string parsing fails
|
|
935
|
-
*/
|
|
936
|
-
function safeJsonParse(value) {
|
|
937
|
-
if (!value) return null;
|
|
938
|
-
if (typeof value === "object") return value;
|
|
939
|
-
if (typeof value === "string") try {
|
|
940
|
-
return JSON.parse(value);
|
|
1385
|
+
async function parseRelayState(c) {
|
|
1386
|
+
const state = c.body.RelayState;
|
|
1387
|
+
const errorURL = c.context.options.onAPIError?.errorURL || `${c.context.baseURL}/error`;
|
|
1388
|
+
let parsedData;
|
|
1389
|
+
try {
|
|
1390
|
+
parsedData = await parseGenericState(c, state, { cookieName: "relay_state" });
|
|
941
1391
|
} catch (error) {
|
|
942
|
-
|
|
1392
|
+
c.context.logger.error("Failed to parse relay state", error);
|
|
1393
|
+
throw new APIError$1("BAD_REQUEST", {
|
|
1394
|
+
message: "State error: failed to validate relay state",
|
|
1395
|
+
cause: error
|
|
1396
|
+
});
|
|
943
1397
|
}
|
|
944
|
-
|
|
1398
|
+
if (!parsedData.errorURL) parsedData.errorURL = errorURL;
|
|
1399
|
+
return parsedData;
|
|
945
1400
|
}
|
|
946
|
-
const validateEmailDomain = (email, domain) => {
|
|
947
|
-
const emailDomain = email.split("@")[1]?.toLowerCase();
|
|
948
|
-
const providerDomain = domain.toLowerCase();
|
|
949
|
-
if (!emailDomain || !providerDomain) return false;
|
|
950
|
-
return emailDomain === providerDomain || emailDomain.endsWith(`.${providerDomain}`);
|
|
951
|
-
};
|
|
952
1401
|
|
|
953
1402
|
//#endregion
|
|
954
1403
|
//#region src/routes/sso.ts
|
|
@@ -1044,6 +1493,7 @@ const spMetadata = () => {
|
|
|
1044
1493
|
Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.id}`
|
|
1045
1494
|
}],
|
|
1046
1495
|
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1496
|
+
authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
|
|
1047
1497
|
nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
|
|
1048
1498
|
});
|
|
1049
1499
|
return new Response(sp.getMetadata(), { headers: { "Content-Type": "application/xml" } });
|
|
@@ -1052,7 +1502,7 @@ const spMetadata = () => {
|
|
|
1052
1502
|
const ssoProviderBodySchema = z.object({
|
|
1053
1503
|
providerId: z.string({}).meta({ description: "The ID of the provider. This is used to identify the provider during login and callback" }),
|
|
1054
1504
|
issuer: z.string({}).meta({ description: "The issuer of the provider" }),
|
|
1055
|
-
domain: z.string({}).meta({ description: "The domain of the provider.
|
|
1505
|
+
domain: z.string({}).meta({ description: "The domain(s) of the provider. For enterprise multi-domain SSO where a single IdP serves multiple email domains, use comma-separated values (e.g., 'company.com,subsidiary.com,acquired-company.com')" }),
|
|
1056
1506
|
oidcConfig: z.object({
|
|
1057
1507
|
clientId: z.string({}).meta({ description: "The client ID" }),
|
|
1058
1508
|
clientSecret: z.string({}).meta({ description: "The client secret" }),
|
|
@@ -1104,6 +1554,7 @@ const ssoProviderBodySchema = z.object({
|
|
|
1104
1554
|
encPrivateKeyPass: z.string().optional()
|
|
1105
1555
|
}),
|
|
1106
1556
|
wantAssertionsSigned: z.boolean().optional(),
|
|
1557
|
+
authnRequestsSigned: z.boolean().optional(),
|
|
1107
1558
|
signatureAlgorithm: z.string().optional(),
|
|
1108
1559
|
digestAlgorithm: z.string().optional(),
|
|
1109
1560
|
identifierFormat: z.string().optional(),
|
|
@@ -1406,6 +1857,7 @@ const registerSSOProvider = (options) => {
|
|
|
1406
1857
|
idpMetadata: body.samlConfig.idpMetadata,
|
|
1407
1858
|
spMetadata: body.samlConfig.spMetadata,
|
|
1408
1859
|
wantAssertionsSigned: body.samlConfig.wantAssertionsSigned,
|
|
1860
|
+
authnRequestsSigned: body.samlConfig.authnRequestsSigned,
|
|
1409
1861
|
signatureAlgorithm: body.samlConfig.signatureAlgorithm,
|
|
1410
1862
|
digestAlgorithm: body.samlConfig.digestAlgorithm,
|
|
1411
1863
|
identifierFormat: body.samlConfig.identifierFormat,
|
|
@@ -1551,20 +2003,33 @@ const signInSSO = (options) => {
|
|
|
1551
2003
|
};
|
|
1552
2004
|
}
|
|
1553
2005
|
if (!providerId && !orgId && !domain) throw new APIError("BAD_REQUEST", { message: "providerId, orgId or domain is required" });
|
|
1554
|
-
if (!provider)
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
return {
|
|
1563
|
-
...res,
|
|
1564
|
-
oidcConfig: res.oidcConfig ? safeJsonParse(res.oidcConfig) || void 0 : void 0,
|
|
1565
|
-
samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
|
|
2006
|
+
if (!provider) {
|
|
2007
|
+
const parseProvider = (res) => {
|
|
2008
|
+
if (!res) return null;
|
|
2009
|
+
return {
|
|
2010
|
+
...res,
|
|
2011
|
+
oidcConfig: res.oidcConfig ? safeJsonParse(res.oidcConfig) || void 0 : void 0,
|
|
2012
|
+
samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
|
|
2013
|
+
};
|
|
1566
2014
|
};
|
|
1567
|
-
|
|
2015
|
+
if (providerId || orgId) provider = parseProvider(await ctx.context.adapter.findOne({
|
|
2016
|
+
model: "ssoProvider",
|
|
2017
|
+
where: [{
|
|
2018
|
+
field: providerId ? "providerId" : "organizationId",
|
|
2019
|
+
value: providerId || orgId
|
|
2020
|
+
}]
|
|
2021
|
+
}));
|
|
2022
|
+
else if (domain) {
|
|
2023
|
+
provider = parseProvider(await ctx.context.adapter.findOne({
|
|
2024
|
+
model: "ssoProvider",
|
|
2025
|
+
where: [{
|
|
2026
|
+
field: "domain",
|
|
2027
|
+
value: domain
|
|
2028
|
+
}]
|
|
2029
|
+
}));
|
|
2030
|
+
if (!provider) provider = parseProvider((await ctx.context.adapter.findMany({ model: "ssoProvider" })).find((p) => domainMatches(domain, p.domain)) ?? null);
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
1568
2033
|
if (!provider) throw new APIError("NOT_FOUND", { message: "No provider found for the issuer" });
|
|
1569
2034
|
if (body.providerType) {
|
|
1570
2035
|
if (body.providerType === "oidc" && !provider.oidcConfig) throw new APIError("BAD_REQUEST", { message: "OIDC provider is not configured" });
|
|
@@ -1606,6 +2071,7 @@ const signInSSO = (options) => {
|
|
|
1606
2071
|
if (provider.samlConfig) {
|
|
1607
2072
|
const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
|
|
1608
2073
|
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
2074
|
+
if (parsedSamlConfig.authnRequestsSigned && !parsedSamlConfig.spMetadata?.privateKey && !parsedSamlConfig.privateKey) ctx.context.logger.warn("authnRequestsSigned is enabled but no privateKey provided - AuthnRequests will not be signed", { providerId: provider.providerId });
|
|
1609
2075
|
let metadata = parsedSamlConfig.spMetadata.metadata;
|
|
1610
2076
|
if (!metadata) metadata = saml.SPMetadata({
|
|
1611
2077
|
entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
|
@@ -1614,11 +2080,14 @@ const signInSSO = (options) => {
|
|
|
1614
2080
|
Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.providerId}`
|
|
1615
2081
|
}],
|
|
1616
2082
|
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
2083
|
+
authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
|
|
1617
2084
|
nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
|
|
1618
2085
|
}).getMetadata() || "";
|
|
1619
2086
|
const sp = saml.ServiceProvider({
|
|
1620
2087
|
metadata,
|
|
1621
|
-
allowCreate: true
|
|
2088
|
+
allowCreate: true,
|
|
2089
|
+
privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
|
|
2090
|
+
privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass
|
|
1622
2091
|
});
|
|
1623
2092
|
const idp = saml.IdentityProvider({
|
|
1624
2093
|
metadata: parsedSamlConfig.idpMetadata?.metadata,
|
|
@@ -1628,6 +2097,7 @@ const signInSSO = (options) => {
|
|
|
1628
2097
|
});
|
|
1629
2098
|
const loginRequest = sp.createLoginRequest(idp, "redirect");
|
|
1630
2099
|
if (!loginRequest) throw new APIError("BAD_REQUEST", { message: "Invalid SAML request" });
|
|
2100
|
+
const { state: relayState } = await generateRelayState(ctx, void 0, false);
|
|
1631
2101
|
if (loginRequest.id && options?.saml?.enableInResponseToValidation) {
|
|
1632
2102
|
const ttl = options?.saml?.requestTTL ?? DEFAULT_AUTHN_REQUEST_TTL_MS;
|
|
1633
2103
|
const record = {
|
|
@@ -1643,7 +2113,7 @@ const signInSSO = (options) => {
|
|
|
1643
2113
|
});
|
|
1644
2114
|
}
|
|
1645
2115
|
return ctx.json({
|
|
1646
|
-
url: `${loginRequest.context}&RelayState=${encodeURIComponent(
|
|
2116
|
+
url: `${loginRequest.context}&RelayState=${encodeURIComponent(relayState)}`,
|
|
1647
2117
|
redirect: true
|
|
1648
2118
|
});
|
|
1649
2119
|
}
|
|
@@ -1674,8 +2144,8 @@ const callbackSSO = (options) => {
|
|
|
1674
2144
|
const { code, error, error_description } = ctx.query;
|
|
1675
2145
|
const stateData = await parseState(ctx);
|
|
1676
2146
|
if (!stateData) {
|
|
1677
|
-
const errorURL
|
|
1678
|
-
throw ctx.redirect(`${errorURL
|
|
2147
|
+
const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
|
|
2148
|
+
throw ctx.redirect(`${errorURL}?error=invalid_state`);
|
|
1679
2149
|
}
|
|
1680
2150
|
const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
|
|
1681
2151
|
if (!code || error) throw ctx.redirect(`${errorURL || callbackURL}?error=${error}&error_description=${error_description}`);
|
|
@@ -1825,17 +2295,46 @@ const callbackSSOSAMLBodySchema = z.object({
|
|
|
1825
2295
|
SAMLResponse: z.string(),
|
|
1826
2296
|
RelayState: z.string().optional()
|
|
1827
2297
|
});
|
|
2298
|
+
/**
|
|
2299
|
+
* Validates and returns a safe redirect URL.
|
|
2300
|
+
* - Prevents open redirect attacks by validating against trusted origins
|
|
2301
|
+
* - Prevents redirect loops by checking if URL points to callback route
|
|
2302
|
+
* - Falls back to appOrigin if URL is invalid or unsafe
|
|
2303
|
+
*/
|
|
2304
|
+
const getSafeRedirectUrl = (url, callbackPath, appOrigin, isTrustedOrigin) => {
|
|
2305
|
+
if (!url) return appOrigin;
|
|
2306
|
+
if (url.startsWith("/") && !url.startsWith("//")) {
|
|
2307
|
+
try {
|
|
2308
|
+
const absoluteUrl = new URL(url, appOrigin);
|
|
2309
|
+
if (absoluteUrl.origin !== appOrigin) return appOrigin;
|
|
2310
|
+
const callbackPathname = new URL(callbackPath).pathname;
|
|
2311
|
+
if (absoluteUrl.pathname === callbackPathname) return appOrigin;
|
|
2312
|
+
} catch {
|
|
2313
|
+
return appOrigin;
|
|
2314
|
+
}
|
|
2315
|
+
return url;
|
|
2316
|
+
}
|
|
2317
|
+
if (!isTrustedOrigin(url, { allowRelativePaths: false })) return appOrigin;
|
|
2318
|
+
try {
|
|
2319
|
+
const callbackPathname = new URL(callbackPath).pathname;
|
|
2320
|
+
if (new URL(url).pathname === callbackPathname) return appOrigin;
|
|
2321
|
+
} catch {
|
|
2322
|
+
if (url === callbackPath || url.startsWith(`${callbackPath}?`)) return appOrigin;
|
|
2323
|
+
}
|
|
2324
|
+
return url;
|
|
2325
|
+
};
|
|
1828
2326
|
const callbackSSOSAML = (options) => {
|
|
1829
2327
|
return createAuthEndpoint("/sso/saml2/callback/:providerId", {
|
|
1830
|
-
method: "POST",
|
|
1831
|
-
body: callbackSSOSAMLBodySchema,
|
|
2328
|
+
method: ["GET", "POST"],
|
|
2329
|
+
body: callbackSSOSAMLBodySchema.optional(),
|
|
2330
|
+
query: z.object({ RelayState: z.string().optional() }).optional(),
|
|
1832
2331
|
metadata: {
|
|
1833
2332
|
...HIDE_METADATA,
|
|
1834
2333
|
allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
|
|
1835
2334
|
openapi: {
|
|
1836
2335
|
operationId: "handleSAMLCallback",
|
|
1837
2336
|
summary: "Callback URL for SAML provider",
|
|
1838
|
-
description: "This endpoint is used as the callback URL for SAML providers.",
|
|
2337
|
+
description: "This endpoint is used as the callback URL for SAML providers. Supports both GET and POST methods for IdP-initiated and SP-initiated flows.",
|
|
1839
2338
|
responses: {
|
|
1840
2339
|
"302": { description: "Redirects to the callback URL" },
|
|
1841
2340
|
"400": { description: "Invalid SAML response" },
|
|
@@ -1844,10 +2343,26 @@ const callbackSSOSAML = (options) => {
|
|
|
1844
2343
|
}
|
|
1845
2344
|
}
|
|
1846
2345
|
}, async (ctx) => {
|
|
1847
|
-
const { SAMLResponse, RelayState } = ctx.body;
|
|
1848
2346
|
const { providerId } = ctx.params;
|
|
2347
|
+
const appOrigin = new URL(ctx.context.baseURL).origin;
|
|
2348
|
+
const errorURL = ctx.context.options.onAPIError?.errorURL || `${appOrigin}/error`;
|
|
2349
|
+
const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/callback/${providerId}`;
|
|
2350
|
+
if (ctx.method === "GET" && !ctx.body?.SAMLResponse) {
|
|
2351
|
+
if (!(await getSessionFromCtx(ctx))?.session) throw ctx.redirect(`${errorURL}?error=invalid_request`);
|
|
2352
|
+
const relayState = ctx.query?.RelayState;
|
|
2353
|
+
const safeRedirectUrl = getSafeRedirectUrl(relayState, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
2354
|
+
throw ctx.redirect(safeRedirectUrl);
|
|
2355
|
+
}
|
|
2356
|
+
if (!ctx.body?.SAMLResponse) throw new APIError("BAD_REQUEST", { message: "SAMLResponse is required for POST requests" });
|
|
2357
|
+
const { SAMLResponse } = ctx.body;
|
|
1849
2358
|
const maxResponseSize = options?.saml?.maxResponseSize ?? DEFAULT_MAX_SAML_RESPONSE_SIZE;
|
|
1850
2359
|
if (new TextEncoder().encode(SAMLResponse).length > maxResponseSize) throw new APIError("BAD_REQUEST", { message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)` });
|
|
2360
|
+
let relayState = null;
|
|
2361
|
+
if (ctx.body.RelayState) try {
|
|
2362
|
+
relayState = await parseRelayState(ctx);
|
|
2363
|
+
} catch {
|
|
2364
|
+
relayState = null;
|
|
2365
|
+
}
|
|
1851
2366
|
let provider = null;
|
|
1852
2367
|
if (options?.defaultSSO?.length) {
|
|
1853
2368
|
const matchingDefault = options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId);
|
|
@@ -1918,7 +2433,7 @@ const callbackSSOSAML = (options) => {
|
|
|
1918
2433
|
try {
|
|
1919
2434
|
parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
|
|
1920
2435
|
SAMLResponse,
|
|
1921
|
-
RelayState: RelayState || void 0
|
|
2436
|
+
RelayState: ctx.body.RelayState || void 0
|
|
1922
2437
|
} });
|
|
1923
2438
|
if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
|
|
1924
2439
|
} catch (error) {
|
|
@@ -1955,7 +2470,7 @@ const callbackSSOSAML = (options) => {
|
|
|
1955
2470
|
inResponseTo,
|
|
1956
2471
|
providerId: provider.providerId
|
|
1957
2472
|
});
|
|
1958
|
-
const redirectUrl =
|
|
2473
|
+
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1959
2474
|
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
|
|
1960
2475
|
}
|
|
1961
2476
|
if (storedRequest.providerId !== provider.providerId) {
|
|
@@ -1965,13 +2480,13 @@ const callbackSSOSAML = (options) => {
|
|
|
1965
2480
|
actualProvider: provider.providerId
|
|
1966
2481
|
});
|
|
1967
2482
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
1968
|
-
const redirectUrl =
|
|
2483
|
+
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1969
2484
|
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
|
|
1970
2485
|
}
|
|
1971
2486
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
1972
2487
|
} else if (!allowIdpInitiated) {
|
|
1973
2488
|
ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: provider.providerId });
|
|
1974
|
-
const redirectUrl =
|
|
2489
|
+
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1975
2490
|
throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
|
|
1976
2491
|
}
|
|
1977
2492
|
}
|
|
@@ -1998,7 +2513,7 @@ const callbackSSOSAML = (options) => {
|
|
|
1998
2513
|
issuer,
|
|
1999
2514
|
providerId: provider.providerId
|
|
2000
2515
|
});
|
|
2001
|
-
const redirectUrl =
|
|
2516
|
+
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2002
2517
|
throw ctx.redirect(`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
|
|
2003
2518
|
}
|
|
2004
2519
|
await ctx.context.internalAdapter.createVerificationValue({
|
|
@@ -2018,7 +2533,7 @@ const callbackSSOSAML = (options) => {
|
|
|
2018
2533
|
const userInfo = {
|
|
2019
2534
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
|
|
2020
2535
|
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
2021
|
-
email: attributes[mapping.email || "email"] || extract.nameID,
|
|
2536
|
+
email: (attributes[mapping.email || "email"] || extract.nameID).toLowerCase(),
|
|
2022
2537
|
name: [attributes[mapping.firstName || "givenName"], attributes[mapping.lastName || "surname"]].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
|
|
2023
2538
|
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
|
|
2024
2539
|
};
|
|
@@ -2032,7 +2547,7 @@ const callbackSSOSAML = (options) => {
|
|
|
2032
2547
|
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
2033
2548
|
}
|
|
2034
2549
|
const isTrustedProvider = !!ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
2035
|
-
const callbackUrl =
|
|
2550
|
+
const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2036
2551
|
const result = await handleOAuthUserInfo(ctx, {
|
|
2037
2552
|
userInfo: {
|
|
2038
2553
|
email: userInfo.email,
|
|
@@ -2074,7 +2589,8 @@ const callbackSSOSAML = (options) => {
|
|
|
2074
2589
|
session,
|
|
2075
2590
|
user
|
|
2076
2591
|
});
|
|
2077
|
-
|
|
2592
|
+
const safeRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
2593
|
+
throw ctx.redirect(safeRedirectUrl);
|
|
2078
2594
|
});
|
|
2079
2595
|
};
|
|
2080
2596
|
const acsEndpointBodySchema = z.object({
|
|
@@ -2261,7 +2777,7 @@ const acsEndpoint = (options) => {
|
|
|
2261
2777
|
const userInfo = {
|
|
2262
2778
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
|
|
2263
2779
|
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
2264
|
-
email: attributes[mapping.email || "email"] || extract.nameID,
|
|
2780
|
+
email: (attributes[mapping.email || "email"] || extract.nameID).toLowerCase(),
|
|
2265
2781
|
name: [attributes[mapping.firstName || "givenName"], attributes[mapping.lastName || "surname"]].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
|
|
2266
2782
|
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
|
|
2267
2783
|
};
|
|
@@ -2327,6 +2843,12 @@ saml.setSchemaValidator({ async validate(xml) {
|
|
|
2327
2843
|
if (XMLValidator.validate(xml, { allowBooleanAttributes: true }) === true) return "SUCCESS_VALIDATE_XML";
|
|
2328
2844
|
throw "ERR_INVALID_XML";
|
|
2329
2845
|
} });
|
|
2846
|
+
/**
|
|
2847
|
+
* SAML endpoint paths that should skip origin check validation.
|
|
2848
|
+
* These endpoints receive POST requests from external Identity Providers,
|
|
2849
|
+
* which won't have a matching Origin header.
|
|
2850
|
+
*/
|
|
2851
|
+
const SAML_SKIP_ORIGIN_CHECK_PATHS = ["/sso/saml2/callback", "/sso/saml2/sp/acs"];
|
|
2330
2852
|
function sso(options) {
|
|
2331
2853
|
const optionsWithStore = options;
|
|
2332
2854
|
let endpoints = {
|
|
@@ -2335,7 +2857,11 @@ function sso(options) {
|
|
|
2335
2857
|
signInSSO: signInSSO(optionsWithStore),
|
|
2336
2858
|
callbackSSO: callbackSSO(optionsWithStore),
|
|
2337
2859
|
callbackSSOSAML: callbackSSOSAML(optionsWithStore),
|
|
2338
|
-
acsEndpoint: acsEndpoint(optionsWithStore)
|
|
2860
|
+
acsEndpoint: acsEndpoint(optionsWithStore),
|
|
2861
|
+
listSSOProviders: listSSOProviders(),
|
|
2862
|
+
getSSOProvider: getSSOProvider(),
|
|
2863
|
+
updateSSOProvider: updateSSOProvider(optionsWithStore),
|
|
2864
|
+
deleteSSOProvider: deleteSSOProvider()
|
|
2339
2865
|
};
|
|
2340
2866
|
if (options?.domainVerification?.enabled) {
|
|
2341
2867
|
const domainVerificationEndpoints = {
|
|
@@ -2349,6 +2875,11 @@ function sso(options) {
|
|
|
2349
2875
|
}
|
|
2350
2876
|
return {
|
|
2351
2877
|
id: "sso",
|
|
2878
|
+
init(ctx) {
|
|
2879
|
+
const existing = ctx.skipOriginCheck;
|
|
2880
|
+
if (existing === true) return {};
|
|
2881
|
+
return { context: { skipOriginCheck: [...Array.isArray(existing) ? existing : [], ...SAML_SKIP_ORIGIN_CHECK_PATHS] } };
|
|
2882
|
+
},
|
|
2352
2883
|
endpoints,
|
|
2353
2884
|
hooks: { after: [{
|
|
2354
2885
|
matcher(context) {
|
|
@@ -2357,7 +2888,7 @@ function sso(options) {
|
|
|
2357
2888
|
handler: createAuthMiddleware(async (ctx) => {
|
|
2358
2889
|
const newSession = ctx.context.newSession;
|
|
2359
2890
|
if (!newSession?.user) return;
|
|
2360
|
-
if (!ctx.context.
|
|
2891
|
+
if (!ctx.context.hasPlugin("organization")) return;
|
|
2361
2892
|
await assignOrganizationByDomain(ctx, {
|
|
2362
2893
|
user: newSession.user,
|
|
2363
2894
|
provisioningOptions: options?.organizationProvisioning,
|
|
@@ -2418,4 +2949,5 @@ function sso(options) {
|
|
|
2418
2949
|
}
|
|
2419
2950
|
|
|
2420
2951
|
//#endregion
|
|
2421
|
-
export { DEFAULT_CLOCK_SKEW_MS, DEFAULT_MAX_SAML_METADATA_SIZE, DEFAULT_MAX_SAML_RESPONSE_SIZE, DataEncryptionAlgorithm, DigestAlgorithm, DiscoveryError, KeyEncryptionAlgorithm, REQUIRED_DISCOVERY_FIELDS, SignatureAlgorithm, computeDiscoveryUrl, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, selectTokenEndpointAuthMethod, sso, validateDiscoveryDocument, validateDiscoveryUrl, validateSAMLTimestamp };
|
|
2952
|
+
export { DEFAULT_CLOCK_SKEW_MS, DEFAULT_MAX_SAML_METADATA_SIZE, DEFAULT_MAX_SAML_RESPONSE_SIZE, DataEncryptionAlgorithm, DigestAlgorithm, DiscoveryError, KeyEncryptionAlgorithm, REQUIRED_DISCOVERY_FIELDS, SignatureAlgorithm, computeDiscoveryUrl, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, selectTokenEndpointAuthMethod, sso, validateDiscoveryDocument, validateDiscoveryUrl, validateSAMLTimestamp };
|
|
2953
|
+
//# sourceMappingURL=index.mjs.map
|