@better-auth/sso 1.5.0-beta.1 → 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-CvpS40sl.d.mts → index-CBBJTszO.d.mts} +429 -19
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +1107 -489
- package/dist/index.mjs.map +1 -0
- package/package.json +17 -14
- package/src/client.ts +5 -1
- package/src/constants.ts +16 -0
- package/src/index.ts +55 -6
- package/src/linking/org-assignment.test.ts +1 -1
- package/src/linking/org-assignment.ts +20 -13
- package/src/oidc.test.ts +113 -1
- 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 +285 -65
- package/src/saml/algorithms.ts +1 -31
- package/src/saml/assertions.test.ts +239 -0
- package/src/saml/assertions.ts +62 -0
- package/src/saml/index.ts +2 -0
- package/src/saml/parser.ts +56 -0
- package/src/saml-state.ts +78 -0
- package/src/saml.test.ts +2133 -422
- package/src/types.ts +20 -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";
|
|
7
8
|
import { base64 } from "@better-auth/utils/base64";
|
|
8
9
|
import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
|
|
9
|
-
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";
|
|
10
11
|
import { setSessionCookie } from "better-auth/cookies";
|
|
11
12
|
import { handleOAuthUserInfo } from "better-auth/oauth2";
|
|
12
13
|
import { decodeJwt } from "jose";
|
|
14
|
+
import { APIError as APIError$1 } from "better-call";
|
|
13
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
|
+
}
|
|
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",
|
|
@@ -306,118 +366,761 @@ const DEFAULT_ASSERTION_TTL_MS = 900 * 1e3;
|
|
|
306
366
|
* - Distributed systems across timezones
|
|
307
367
|
*/
|
|
308
368
|
const DEFAULT_CLOCK_SKEW_MS = 300 * 1e3;
|
|
309
|
-
|
|
310
|
-
//#endregion
|
|
311
|
-
//#region src/oidc/types.ts
|
|
312
369
|
/**
|
|
313
|
-
*
|
|
314
|
-
*
|
|
370
|
+
* Default maximum size for SAML responses (256 KB).
|
|
371
|
+
* Protects against memory exhaustion from oversized SAML payloads.
|
|
315
372
|
*/
|
|
316
|
-
|
|
317
|
-
code;
|
|
318
|
-
details;
|
|
319
|
-
constructor(code, message, details, options) {
|
|
320
|
-
super(message, options);
|
|
321
|
-
this.name = "DiscoveryError";
|
|
322
|
-
this.code = code;
|
|
323
|
-
this.details = details;
|
|
324
|
-
if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
|
|
325
|
-
}
|
|
326
|
-
};
|
|
373
|
+
const DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024;
|
|
327
374
|
/**
|
|
328
|
-
*
|
|
375
|
+
* Default maximum size for IdP metadata (100 KB).
|
|
376
|
+
* Protects against oversized metadata documents.
|
|
329
377
|
*/
|
|
330
|
-
const
|
|
331
|
-
"issuer",
|
|
332
|
-
"authorization_endpoint",
|
|
333
|
-
"token_endpoint",
|
|
334
|
-
"jwks_uri"
|
|
335
|
-
];
|
|
378
|
+
const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024;
|
|
336
379
|
|
|
337
380
|
//#endregion
|
|
338
|
-
//#region src/
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
* 5. Normalizes URLs
|
|
359
|
-
* 6. Selects token endpoint auth method
|
|
360
|
-
* 7. Merges with existing config (existing values take precedence)
|
|
361
|
-
*
|
|
362
|
-
* @param params - Discovery parameters
|
|
363
|
-
* @param isTrustedOrigin - Origin verification tester function
|
|
364
|
-
* @returns Hydrated OIDC configuration ready for persistence
|
|
365
|
-
* @throws DiscoveryError on any failure
|
|
366
|
-
*/
|
|
367
|
-
async function discoverOIDCConfig(params) {
|
|
368
|
-
const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
|
|
369
|
-
const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
|
|
370
|
-
validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
|
|
371
|
-
const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
|
|
372
|
-
validateDiscoveryDocument(discoveryDoc, issuer);
|
|
373
|
-
const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
|
|
374
|
-
const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
|
|
375
|
-
return {
|
|
376
|
-
issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
|
|
377
|
-
discoveryEndpoint: existingConfig?.discoveryEndpoint ?? discoveryUrl,
|
|
378
|
-
authorizationEndpoint: existingConfig?.authorizationEndpoint ?? normalizedDoc.authorization_endpoint,
|
|
379
|
-
tokenEndpoint: existingConfig?.tokenEndpoint ?? normalizedDoc.token_endpoint,
|
|
380
|
-
jwksEndpoint: existingConfig?.jwksEndpoint ?? normalizedDoc.jwks_uri,
|
|
381
|
-
userInfoEndpoint: existingConfig?.userInfoEndpoint ?? normalizedDoc.userinfo_endpoint,
|
|
382
|
-
tokenEndpointAuthentication: existingConfig?.tokenEndpointAuthentication ?? tokenEndpointAuth,
|
|
383
|
-
scopesSupported: existingConfig?.scopesSupported ?? normalizedDoc.scopes_supported
|
|
384
|
-
};
|
|
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;
|
|
395
|
+
}
|
|
396
|
+
else if (typeof value === "object" && value !== null) {
|
|
397
|
+
const found = findNode(value, nodeName);
|
|
398
|
+
if (found) return found;
|
|
399
|
+
}
|
|
400
|
+
return null;
|
|
385
401
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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;
|
|
396
413
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
414
|
+
|
|
415
|
+
//#endregion
|
|
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;
|
|
407
484
|
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
* @param timeout - Request timeout in milliseconds
|
|
413
|
-
* @returns The parsed discovery document
|
|
414
|
-
* @throws DiscoveryError on network errors, timeouts, or invalid responses
|
|
415
|
-
*/
|
|
416
|
-
async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
|
|
485
|
+
function normalizeDigestAlgorithm(alg) {
|
|
486
|
+
return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
|
|
487
|
+
}
|
|
488
|
+
function extractEncryptionAlgorithms(xml) {
|
|
417
489
|
try {
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
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
|
+
}
|
|
503
|
+
}
|
|
504
|
+
function hasEncryptedAssertion(xml) {
|
|
505
|
+
try {
|
|
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
|
|
516
|
+
});
|
|
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"
|
|
550
|
+
});
|
|
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"
|
|
558
|
+
});
|
|
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"
|
|
574
|
+
});
|
|
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"
|
|
579
|
+
});
|
|
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"
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
}
|
|
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"
|
|
636
|
+
});
|
|
637
|
+
}
|
|
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()));
|
|
734
|
+
}
|
|
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
|
+
}]
|
|
747
|
+
});
|
|
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;
|
|
751
|
+
}
|
|
752
|
+
function sanitizeProvider(provider, baseURL) {
|
|
753
|
+
let oidcConfig = null;
|
|
754
|
+
let samlConfig = null;
|
|
755
|
+
try {
|
|
756
|
+
oidcConfig = safeJsonParse(provider.oidcConfig);
|
|
757
|
+
} catch {
|
|
758
|
+
oidcConfig = null;
|
|
759
|
+
}
|
|
760
|
+
try {
|
|
761
|
+
samlConfig = safeJsonParse(provider.samlConfig);
|
|
762
|
+
} catch {
|
|
763
|
+
samlConfig = null;
|
|
764
|
+
}
|
|
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));
|
|
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
|
+
};
|
|
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
|
|
1015
|
+
/**
|
|
1016
|
+
* Custom error class for OIDC discovery failures.
|
|
1017
|
+
* Can be caught and mapped to APIError at the edge.
|
|
1018
|
+
*/
|
|
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
|
+
};
|
|
1030
|
+
/**
|
|
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
|
|
1044
|
+
*
|
|
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).
|
|
1048
|
+
*
|
|
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.
|
|
1055
|
+
*
|
|
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
|
|
1069
|
+
*/
|
|
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
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* Compute the discovery URL from an issuer URL.
|
|
1091
|
+
*
|
|
1092
|
+
* Per OIDC Discovery spec, the discovery document is located at:
|
|
1093
|
+
* <issuer>/.well-known/openid-configuration
|
|
1094
|
+
*
|
|
1095
|
+
* Handles trailing slashes correctly.
|
|
1096
|
+
*/
|
|
1097
|
+
function computeDiscoveryUrl(issuer) {
|
|
1098
|
+
return `${issuer.endsWith("/") ? issuer.slice(0, -1) : issuer}/.well-known/openid-configuration`;
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Validate a discovery URL before fetching.
|
|
1102
|
+
*
|
|
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.
|
|
1113
|
+
*
|
|
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
|
|
1118
|
+
*/
|
|
1119
|
+
async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
|
|
1120
|
+
try {
|
|
1121
|
+
const response = await betterFetch(url, {
|
|
1122
|
+
method: "GET",
|
|
1123
|
+
timeout
|
|
421
1124
|
});
|
|
422
1125
|
if (response.error) {
|
|
423
1126
|
const { status } = response.error;
|
|
@@ -523,363 +1226,178 @@ function normalizeUrl(name, endpoint, issuer) {
|
|
|
523
1226
|
} catch {
|
|
524
1227
|
const issuerURL = parseURL(name, issuer);
|
|
525
1228
|
const basePath = issuerURL.pathname.replace(/\/+$/, "");
|
|
526
|
-
const endpointPath = endpoint.replace(/^\/+/, "");
|
|
527
|
-
return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
/**
|
|
531
|
-
* Parses the given URL or throws in case of invalid or unsupported protocols
|
|
532
|
-
*
|
|
533
|
-
* @param name the url name
|
|
534
|
-
* @param endpoint the endpoint url
|
|
535
|
-
* @param [base] optional base path
|
|
536
|
-
* @returns
|
|
537
|
-
*/
|
|
538
|
-
function parseURL(name, endpoint, base) {
|
|
539
|
-
let endpointURL;
|
|
540
|
-
try {
|
|
541
|
-
endpointURL = new URL(endpoint, base);
|
|
542
|
-
if (endpointURL.protocol === "http:" || endpointURL.protocol === "https:") return endpointURL;
|
|
543
|
-
} catch (error) {
|
|
544
|
-
throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
|
|
545
|
-
}
|
|
546
|
-
throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must use the http or https supported protocols: ${endpoint}`, {
|
|
547
|
-
url: endpoint,
|
|
548
|
-
protocol: endpointURL.protocol
|
|
549
|
-
});
|
|
550
|
-
}
|
|
551
|
-
/**
|
|
552
|
-
* Select the token endpoint authentication method.
|
|
553
|
-
*
|
|
554
|
-
* @param doc - The discovery document
|
|
555
|
-
* @param existing - Existing authentication method from config
|
|
556
|
-
* @returns The selected authentication method
|
|
557
|
-
*/
|
|
558
|
-
function selectTokenEndpointAuthMethod(doc, existing) {
|
|
559
|
-
if (existing) return existing;
|
|
560
|
-
const supported = doc.token_endpoint_auth_methods_supported;
|
|
561
|
-
if (!supported || supported.length === 0) return "client_secret_basic";
|
|
562
|
-
if (supported.includes("client_secret_basic")) return "client_secret_basic";
|
|
563
|
-
if (supported.includes("client_secret_post")) return "client_secret_post";
|
|
564
|
-
return "client_secret_basic";
|
|
565
|
-
}
|
|
566
|
-
/**
|
|
567
|
-
* Check if a provider configuration needs runtime discovery.
|
|
568
|
-
*
|
|
569
|
-
* Returns true if we need discovery at runtime to complete the token exchange
|
|
570
|
-
* and validation. Specifically checks for:
|
|
571
|
-
* - `tokenEndpoint` - required for exchanging authorization code for tokens
|
|
572
|
-
* - `jwksEndpoint` - required for validating ID token signatures
|
|
573
|
-
*
|
|
574
|
-
* Note: `authorizationEndpoint` is handled separately in the sign-in flow,
|
|
575
|
-
* so it's not checked here.
|
|
576
|
-
*
|
|
577
|
-
* @param config - Partial OIDC config from the provider
|
|
578
|
-
* @returns true if runtime discovery should be performed
|
|
579
|
-
*/
|
|
580
|
-
function needsRuntimeDiscovery(config) {
|
|
581
|
-
if (!config) return true;
|
|
582
|
-
return !config.tokenEndpoint || !config.jwksEndpoint;
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
//#endregion
|
|
586
|
-
//#region src/oidc/errors.ts
|
|
587
|
-
/**
|
|
588
|
-
* OIDC Discovery Error Mapping
|
|
589
|
-
*
|
|
590
|
-
* Maps DiscoveryError codes to appropriate APIError responses.
|
|
591
|
-
* Used at the boundary between the discovery pipeline and HTTP handlers.
|
|
592
|
-
*/
|
|
593
|
-
/**
|
|
594
|
-
* Maps a DiscoveryError to an appropriate APIError for HTTP responses.
|
|
595
|
-
*
|
|
596
|
-
* Error code mapping:
|
|
597
|
-
* - discovery_invalid_url → 400 BAD_REQUEST
|
|
598
|
-
* - discovery_not_found → 400 BAD_REQUEST
|
|
599
|
-
* - discovery_invalid_json → 400 BAD_REQUEST
|
|
600
|
-
* - discovery_incomplete → 400 BAD_REQUEST
|
|
601
|
-
* - issuer_mismatch → 400 BAD_REQUEST
|
|
602
|
-
* - unsupported_token_auth_method → 400 BAD_REQUEST
|
|
603
|
-
* - discovery_timeout → 502 BAD_GATEWAY
|
|
604
|
-
* - discovery_unexpected_error → 502 BAD_GATEWAY
|
|
605
|
-
*
|
|
606
|
-
* @param error - The DiscoveryError to map
|
|
607
|
-
* @returns An APIError with appropriate status and message
|
|
608
|
-
*/
|
|
609
|
-
function mapDiscoveryErrorToAPIError(error) {
|
|
610
|
-
switch (error.code) {
|
|
611
|
-
case "discovery_timeout": return new APIError("BAD_GATEWAY", {
|
|
612
|
-
message: `OIDC discovery timed out: ${error.message}`,
|
|
613
|
-
code: error.code
|
|
614
|
-
});
|
|
615
|
-
case "discovery_unexpected_error": return new APIError("BAD_GATEWAY", {
|
|
616
|
-
message: `OIDC discovery failed: ${error.message}`,
|
|
617
|
-
code: error.code
|
|
618
|
-
});
|
|
619
|
-
case "discovery_not_found": return new APIError("BAD_REQUEST", {
|
|
620
|
-
message: `OIDC discovery endpoint not found. The issuer may not support OIDC discovery, or the URL is incorrect. ${error.message}`,
|
|
621
|
-
code: error.code
|
|
622
|
-
});
|
|
623
|
-
case "discovery_invalid_url": return new APIError("BAD_REQUEST", {
|
|
624
|
-
message: `Invalid OIDC discovery URL: ${error.message}`,
|
|
625
|
-
code: error.code
|
|
626
|
-
});
|
|
627
|
-
case "discovery_untrusted_origin": return new APIError("BAD_REQUEST", {
|
|
628
|
-
message: `Untrusted OIDC discovery URL: ${error.message}`,
|
|
629
|
-
code: error.code
|
|
630
|
-
});
|
|
631
|
-
case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
|
|
632
|
-
message: `OIDC discovery returned invalid data: ${error.message}`,
|
|
633
|
-
code: error.code
|
|
634
|
-
});
|
|
635
|
-
case "discovery_incomplete": return new APIError("BAD_REQUEST", {
|
|
636
|
-
message: `OIDC discovery document is missing required fields: ${error.message}`,
|
|
637
|
-
code: error.code
|
|
638
|
-
});
|
|
639
|
-
case "issuer_mismatch": return new APIError("BAD_REQUEST", {
|
|
640
|
-
message: `OIDC issuer mismatch: ${error.message}`,
|
|
641
|
-
code: error.code
|
|
642
|
-
});
|
|
643
|
-
case "unsupported_token_auth_method": return new APIError("BAD_REQUEST", {
|
|
644
|
-
message: `Incompatible OIDC provider: ${error.message}`,
|
|
645
|
-
code: error.code
|
|
646
|
-
});
|
|
647
|
-
default:
|
|
648
|
-
error.code;
|
|
649
|
-
return new APIError("INTERNAL_SERVER_ERROR", {
|
|
650
|
-
message: `Unexpected discovery error: ${error.message}`,
|
|
651
|
-
code: "discovery_unexpected_error"
|
|
652
|
-
});
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
//#endregion
|
|
657
|
-
//#region src/saml/algorithms.ts
|
|
658
|
-
const SignatureAlgorithm = {
|
|
659
|
-
RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
|
|
660
|
-
RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
|
|
661
|
-
RSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
|
|
662
|
-
RSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
|
|
663
|
-
ECDSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
|
|
664
|
-
ECDSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
|
|
665
|
-
ECDSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"
|
|
666
|
-
};
|
|
667
|
-
const DigestAlgorithm = {
|
|
668
|
-
SHA1: "http://www.w3.org/2000/09/xmldsig#sha1",
|
|
669
|
-
SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
|
|
670
|
-
SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
|
|
671
|
-
SHA512: "http://www.w3.org/2001/04/xmlenc#sha512"
|
|
672
|
-
};
|
|
673
|
-
const KeyEncryptionAlgorithm = {
|
|
674
|
-
RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
|
|
675
|
-
RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
|
|
676
|
-
RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep"
|
|
677
|
-
};
|
|
678
|
-
const DataEncryptionAlgorithm = {
|
|
679
|
-
TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
|
|
680
|
-
AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
|
|
681
|
-
AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
|
|
682
|
-
AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
|
|
683
|
-
AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
|
|
684
|
-
AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
|
|
685
|
-
AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
|
|
686
|
-
};
|
|
687
|
-
const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
|
|
688
|
-
const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS = [KeyEncryptionAlgorithm.RSA_1_5];
|
|
689
|
-
const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS = [DataEncryptionAlgorithm.TRIPLEDES_CBC];
|
|
690
|
-
const DEPRECATED_DIGEST_ALGORITHMS = [DigestAlgorithm.SHA1];
|
|
691
|
-
const SECURE_SIGNATURE_ALGORITHMS = [
|
|
692
|
-
SignatureAlgorithm.RSA_SHA256,
|
|
693
|
-
SignatureAlgorithm.RSA_SHA384,
|
|
694
|
-
SignatureAlgorithm.RSA_SHA512,
|
|
695
|
-
SignatureAlgorithm.ECDSA_SHA256,
|
|
696
|
-
SignatureAlgorithm.ECDSA_SHA384,
|
|
697
|
-
SignatureAlgorithm.ECDSA_SHA512
|
|
698
|
-
];
|
|
699
|
-
const SECURE_DIGEST_ALGORITHMS = [
|
|
700
|
-
DigestAlgorithm.SHA256,
|
|
701
|
-
DigestAlgorithm.SHA384,
|
|
702
|
-
DigestAlgorithm.SHA512
|
|
703
|
-
];
|
|
704
|
-
const SHORT_FORM_SIGNATURE_TO_URI = {
|
|
705
|
-
sha1: SignatureAlgorithm.RSA_SHA1,
|
|
706
|
-
sha256: SignatureAlgorithm.RSA_SHA256,
|
|
707
|
-
sha384: SignatureAlgorithm.RSA_SHA384,
|
|
708
|
-
sha512: SignatureAlgorithm.RSA_SHA512,
|
|
709
|
-
"rsa-sha1": SignatureAlgorithm.RSA_SHA1,
|
|
710
|
-
"rsa-sha256": SignatureAlgorithm.RSA_SHA256,
|
|
711
|
-
"rsa-sha384": SignatureAlgorithm.RSA_SHA384,
|
|
712
|
-
"rsa-sha512": SignatureAlgorithm.RSA_SHA512,
|
|
713
|
-
"ecdsa-sha256": SignatureAlgorithm.ECDSA_SHA256,
|
|
714
|
-
"ecdsa-sha384": SignatureAlgorithm.ECDSA_SHA384,
|
|
715
|
-
"ecdsa-sha512": SignatureAlgorithm.ECDSA_SHA512
|
|
716
|
-
};
|
|
717
|
-
const SHORT_FORM_DIGEST_TO_URI = {
|
|
718
|
-
sha1: DigestAlgorithm.SHA1,
|
|
719
|
-
sha256: DigestAlgorithm.SHA256,
|
|
720
|
-
sha384: DigestAlgorithm.SHA384,
|
|
721
|
-
sha512: DigestAlgorithm.SHA512
|
|
722
|
-
};
|
|
723
|
-
function normalizeSignatureAlgorithm(alg) {
|
|
724
|
-
return SHORT_FORM_SIGNATURE_TO_URI[alg.toLowerCase()] ?? alg;
|
|
725
|
-
}
|
|
726
|
-
function normalizeDigestAlgorithm(alg) {
|
|
727
|
-
return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
|
|
728
|
-
}
|
|
729
|
-
const xmlParser = new XMLParser({
|
|
730
|
-
ignoreAttributes: false,
|
|
731
|
-
attributeNamePrefix: "@_",
|
|
732
|
-
removeNSPrefix: true
|
|
733
|
-
});
|
|
734
|
-
function findNode(obj, nodeName) {
|
|
735
|
-
if (!obj || typeof obj !== "object") return null;
|
|
736
|
-
const record = obj;
|
|
737
|
-
if (nodeName in record) return record[nodeName];
|
|
738
|
-
for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
|
|
739
|
-
const found = findNode(item, nodeName);
|
|
740
|
-
if (found) return found;
|
|
741
|
-
}
|
|
742
|
-
else if (typeof value === "object" && value !== null) {
|
|
743
|
-
const found = findNode(value, nodeName);
|
|
744
|
-
if (found) return found;
|
|
745
|
-
}
|
|
746
|
-
return null;
|
|
747
|
-
}
|
|
748
|
-
function extractEncryptionAlgorithms(xml) {
|
|
749
|
-
try {
|
|
750
|
-
const parsed = xmlParser.parse(xml);
|
|
751
|
-
const keyAlg = (findNode(parsed, "EncryptedKey")?.EncryptionMethod)?.["@_Algorithm"];
|
|
752
|
-
const dataAlg = (findNode(parsed, "EncryptedData")?.EncryptionMethod)?.["@_Algorithm"];
|
|
753
|
-
return {
|
|
754
|
-
keyEncryption: keyAlg || null,
|
|
755
|
-
dataEncryption: dataAlg || null
|
|
756
|
-
};
|
|
757
|
-
} catch {
|
|
758
|
-
return {
|
|
759
|
-
keyEncryption: null,
|
|
760
|
-
dataEncryption: null
|
|
761
|
-
};
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
function hasEncryptedAssertion(xml) {
|
|
765
|
-
try {
|
|
766
|
-
return findNode(xmlParser.parse(xml), "EncryptedAssertion") !== null;
|
|
767
|
-
} catch {
|
|
768
|
-
return false;
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
function handleDeprecatedAlgorithm(message, behavior, errorCode) {
|
|
772
|
-
switch (behavior) {
|
|
773
|
-
case "reject": throw new APIError("BAD_REQUEST", {
|
|
774
|
-
message,
|
|
775
|
-
code: errorCode
|
|
776
|
-
});
|
|
777
|
-
case "warn":
|
|
778
|
-
console.warn(`[SAML Security Warning] ${message}`);
|
|
779
|
-
break;
|
|
780
|
-
case "allow": break;
|
|
1229
|
+
const endpointPath = endpoint.replace(/^\/+/, "");
|
|
1230
|
+
return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
|
|
781
1231
|
}
|
|
782
1232
|
}
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
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 });
|
|
796
1248
|
}
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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
|
|
800
1252
|
});
|
|
801
1253
|
}
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
if (
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
|
|
817
|
-
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
818
|
-
});
|
|
819
|
-
} 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");
|
|
820
|
-
}
|
|
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";
|
|
821
1268
|
}
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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;
|
|
825
1286
|
}
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|
|
839
1317
|
});
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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
|
|
852
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
|
+
});
|
|
853
1356
|
}
|
|
854
1357
|
}
|
|
855
1358
|
|
|
856
1359
|
//#endregion
|
|
857
|
-
//#region src/
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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
|
|
1374
|
+
};
|
|
1375
|
+
try {
|
|
1376
|
+
return generateGenericState(c, stateData, { cookieName: "relay_state" });
|
|
872
1377
|
} catch (error) {
|
|
873
|
-
|
|
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
|
|
1382
|
+
});
|
|
874
1383
|
}
|
|
875
|
-
return null;
|
|
876
1384
|
}
|
|
877
|
-
|
|
878
|
-
const
|
|
879
|
-
const
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
};
|
|
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" });
|
|
1391
|
+
} catch (error) {
|
|
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
|
+
});
|
|
1397
|
+
}
|
|
1398
|
+
if (!parsedData.errorURL) parsedData.errorURL = errorURL;
|
|
1399
|
+
return parsedData;
|
|
1400
|
+
}
|
|
883
1401
|
|
|
884
1402
|
//#endregion
|
|
885
1403
|
//#region src/routes/sso.ts
|
|
@@ -975,6 +1493,7 @@ const spMetadata = () => {
|
|
|
975
1493
|
Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.id}`
|
|
976
1494
|
}],
|
|
977
1495
|
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1496
|
+
authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
|
|
978
1497
|
nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
|
|
979
1498
|
});
|
|
980
1499
|
return new Response(sp.getMetadata(), { headers: { "Content-Type": "application/xml" } });
|
|
@@ -983,7 +1502,7 @@ const spMetadata = () => {
|
|
|
983
1502
|
const ssoProviderBodySchema = z.object({
|
|
984
1503
|
providerId: z.string({}).meta({ description: "The ID of the provider. This is used to identify the provider during login and callback" }),
|
|
985
1504
|
issuer: z.string({}).meta({ description: "The issuer of the provider" }),
|
|
986
|
-
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')" }),
|
|
987
1506
|
oidcConfig: z.object({
|
|
988
1507
|
clientId: z.string({}).meta({ description: "The client ID" }),
|
|
989
1508
|
clientSecret: z.string({}).meta({ description: "The client secret" }),
|
|
@@ -1035,6 +1554,7 @@ const ssoProviderBodySchema = z.object({
|
|
|
1035
1554
|
encPrivateKeyPass: z.string().optional()
|
|
1036
1555
|
}),
|
|
1037
1556
|
wantAssertionsSigned: z.boolean().optional(),
|
|
1557
|
+
authnRequestsSigned: z.boolean().optional(),
|
|
1038
1558
|
signatureAlgorithm: z.string().optional(),
|
|
1039
1559
|
digestAlgorithm: z.string().optional(),
|
|
1040
1560
|
identifierFormat: z.string().optional(),
|
|
@@ -1239,6 +1759,10 @@ const registerSSOProvider = (options) => {
|
|
|
1239
1759
|
})).length >= limit) throw new APIError("FORBIDDEN", { message: "You have reached the maximum number of SSO providers" });
|
|
1240
1760
|
const body = ctx.body;
|
|
1241
1761
|
if (z.string().url().safeParse(body.issuer).error) throw new APIError("BAD_REQUEST", { message: "Invalid issuer. Must be a valid URL" });
|
|
1762
|
+
if (body.samlConfig?.idpMetadata?.metadata) {
|
|
1763
|
+
const maxMetadataSize = options?.saml?.maxMetadataSize ?? DEFAULT_MAX_SAML_METADATA_SIZE;
|
|
1764
|
+
if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
|
|
1765
|
+
}
|
|
1242
1766
|
if (ctx.body.organizationId) {
|
|
1243
1767
|
if (!await ctx.context.adapter.findOne({
|
|
1244
1768
|
model: "member",
|
|
@@ -1273,7 +1797,7 @@ const registerSSOProvider = (options) => {
|
|
|
1273
1797
|
userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
|
|
1274
1798
|
tokenEndpointAuthentication: body.oidcConfig.tokenEndpointAuthentication
|
|
1275
1799
|
},
|
|
1276
|
-
isTrustedOrigin: ctx.context.isTrustedOrigin
|
|
1800
|
+
isTrustedOrigin: (url) => ctx.context.isTrustedOrigin(url)
|
|
1277
1801
|
});
|
|
1278
1802
|
} catch (error) {
|
|
1279
1803
|
if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
|
|
@@ -1333,6 +1857,7 @@ const registerSSOProvider = (options) => {
|
|
|
1333
1857
|
idpMetadata: body.samlConfig.idpMetadata,
|
|
1334
1858
|
spMetadata: body.samlConfig.spMetadata,
|
|
1335
1859
|
wantAssertionsSigned: body.samlConfig.wantAssertionsSigned,
|
|
1860
|
+
authnRequestsSigned: body.samlConfig.authnRequestsSigned,
|
|
1336
1861
|
signatureAlgorithm: body.samlConfig.signatureAlgorithm,
|
|
1337
1862
|
digestAlgorithm: body.samlConfig.digestAlgorithm,
|
|
1338
1863
|
identifierFormat: body.samlConfig.identifierFormat,
|
|
@@ -1478,20 +2003,33 @@ const signInSSO = (options) => {
|
|
|
1478
2003
|
};
|
|
1479
2004
|
}
|
|
1480
2005
|
if (!providerId && !orgId && !domain) throw new APIError("BAD_REQUEST", { message: "providerId, orgId or domain is required" });
|
|
1481
|
-
if (!provider)
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
return {
|
|
1490
|
-
...res,
|
|
1491
|
-
oidcConfig: res.oidcConfig ? safeJsonParse(res.oidcConfig) || void 0 : void 0,
|
|
1492
|
-
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
|
+
};
|
|
1493
2014
|
};
|
|
1494
|
-
|
|
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
|
+
}
|
|
1495
2033
|
if (!provider) throw new APIError("NOT_FOUND", { message: "No provider found for the issuer" });
|
|
1496
2034
|
if (body.providerType) {
|
|
1497
2035
|
if (body.providerType === "oidc" && !provider.oidcConfig) throw new APIError("BAD_REQUEST", { message: "OIDC provider is not configured" });
|
|
@@ -1533,6 +2071,7 @@ const signInSSO = (options) => {
|
|
|
1533
2071
|
if (provider.samlConfig) {
|
|
1534
2072
|
const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
|
|
1535
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 });
|
|
1536
2075
|
let metadata = parsedSamlConfig.spMetadata.metadata;
|
|
1537
2076
|
if (!metadata) metadata = saml.SPMetadata({
|
|
1538
2077
|
entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
|
@@ -1541,11 +2080,14 @@ const signInSSO = (options) => {
|
|
|
1541
2080
|
Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.providerId}`
|
|
1542
2081
|
}],
|
|
1543
2082
|
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
2083
|
+
authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
|
|
1544
2084
|
nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
|
|
1545
2085
|
}).getMetadata() || "";
|
|
1546
2086
|
const sp = saml.ServiceProvider({
|
|
1547
2087
|
metadata,
|
|
1548
|
-
allowCreate: true
|
|
2088
|
+
allowCreate: true,
|
|
2089
|
+
privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
|
|
2090
|
+
privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass
|
|
1549
2091
|
});
|
|
1550
2092
|
const idp = saml.IdentityProvider({
|
|
1551
2093
|
metadata: parsedSamlConfig.idpMetadata?.metadata,
|
|
@@ -1555,6 +2097,7 @@ const signInSSO = (options) => {
|
|
|
1555
2097
|
});
|
|
1556
2098
|
const loginRequest = sp.createLoginRequest(idp, "redirect");
|
|
1557
2099
|
if (!loginRequest) throw new APIError("BAD_REQUEST", { message: "Invalid SAML request" });
|
|
2100
|
+
const { state: relayState } = await generateRelayState(ctx, void 0, false);
|
|
1558
2101
|
if (loginRequest.id && options?.saml?.enableInResponseToValidation) {
|
|
1559
2102
|
const ttl = options?.saml?.requestTTL ?? DEFAULT_AUTHN_REQUEST_TTL_MS;
|
|
1560
2103
|
const record = {
|
|
@@ -1570,7 +2113,7 @@ const signInSSO = (options) => {
|
|
|
1570
2113
|
});
|
|
1571
2114
|
}
|
|
1572
2115
|
return ctx.json({
|
|
1573
|
-
url: `${loginRequest.context}&RelayState=${encodeURIComponent(
|
|
2116
|
+
url: `${loginRequest.context}&RelayState=${encodeURIComponent(relayState)}`,
|
|
1574
2117
|
redirect: true
|
|
1575
2118
|
});
|
|
1576
2119
|
}
|
|
@@ -1601,8 +2144,8 @@ const callbackSSO = (options) => {
|
|
|
1601
2144
|
const { code, error, error_description } = ctx.query;
|
|
1602
2145
|
const stateData = await parseState(ctx);
|
|
1603
2146
|
if (!stateData) {
|
|
1604
|
-
const errorURL
|
|
1605
|
-
throw ctx.redirect(`${errorURL
|
|
2147
|
+
const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
|
|
2148
|
+
throw ctx.redirect(`${errorURL}?error=invalid_state`);
|
|
1606
2149
|
}
|
|
1607
2150
|
const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
|
|
1608
2151
|
if (!code || error) throw ctx.redirect(`${errorURL || callbackURL}?error=${error}&error_description=${error_description}`);
|
|
@@ -1752,17 +2295,46 @@ const callbackSSOSAMLBodySchema = z.object({
|
|
|
1752
2295
|
SAMLResponse: z.string(),
|
|
1753
2296
|
RelayState: z.string().optional()
|
|
1754
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
|
+
};
|
|
1755
2326
|
const callbackSSOSAML = (options) => {
|
|
1756
2327
|
return createAuthEndpoint("/sso/saml2/callback/:providerId", {
|
|
1757
|
-
method: "POST",
|
|
1758
|
-
body: callbackSSOSAMLBodySchema,
|
|
2328
|
+
method: ["GET", "POST"],
|
|
2329
|
+
body: callbackSSOSAMLBodySchema.optional(),
|
|
2330
|
+
query: z.object({ RelayState: z.string().optional() }).optional(),
|
|
1759
2331
|
metadata: {
|
|
1760
2332
|
...HIDE_METADATA,
|
|
1761
2333
|
allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
|
|
1762
2334
|
openapi: {
|
|
1763
2335
|
operationId: "handleSAMLCallback",
|
|
1764
2336
|
summary: "Callback URL for SAML provider",
|
|
1765
|
-
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.",
|
|
1766
2338
|
responses: {
|
|
1767
2339
|
"302": { description: "Redirects to the callback URL" },
|
|
1768
2340
|
"400": { description: "Invalid SAML response" },
|
|
@@ -1771,8 +2343,26 @@ const callbackSSOSAML = (options) => {
|
|
|
1771
2343
|
}
|
|
1772
2344
|
}
|
|
1773
2345
|
}, async (ctx) => {
|
|
1774
|
-
const { SAMLResponse, RelayState } = ctx.body;
|
|
1775
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;
|
|
2358
|
+
const maxResponseSize = options?.saml?.maxResponseSize ?? DEFAULT_MAX_SAML_RESPONSE_SIZE;
|
|
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
|
+
}
|
|
1776
2366
|
let provider = null;
|
|
1777
2367
|
if (options?.defaultSSO?.length) {
|
|
1778
2368
|
const matchingDefault = options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId);
|
|
@@ -1838,17 +2428,18 @@ const callbackSSOSAML = (options) => {
|
|
|
1838
2428
|
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1839
2429
|
nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
|
|
1840
2430
|
});
|
|
2431
|
+
validateSingleAssertion(SAMLResponse);
|
|
1841
2432
|
let parsedResponse;
|
|
1842
2433
|
try {
|
|
1843
2434
|
parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
|
|
1844
2435
|
SAMLResponse,
|
|
1845
|
-
RelayState: RelayState || void 0
|
|
2436
|
+
RelayState: ctx.body.RelayState || void 0
|
|
1846
2437
|
} });
|
|
1847
2438
|
if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
|
|
1848
2439
|
} catch (error) {
|
|
1849
2440
|
ctx.context.logger.error("SAML response validation failed", {
|
|
1850
2441
|
error,
|
|
1851
|
-
decodedResponse:
|
|
2442
|
+
decodedResponse: Buffer.from(SAMLResponse, "base64").toString("utf-8")
|
|
1852
2443
|
});
|
|
1853
2444
|
throw new APIError("BAD_REQUEST", {
|
|
1854
2445
|
message: "Invalid SAML response",
|
|
@@ -1879,7 +2470,7 @@ const callbackSSOSAML = (options) => {
|
|
|
1879
2470
|
inResponseTo,
|
|
1880
2471
|
providerId: provider.providerId
|
|
1881
2472
|
});
|
|
1882
|
-
const redirectUrl =
|
|
2473
|
+
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1883
2474
|
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
|
|
1884
2475
|
}
|
|
1885
2476
|
if (storedRequest.providerId !== provider.providerId) {
|
|
@@ -1889,13 +2480,13 @@ const callbackSSOSAML = (options) => {
|
|
|
1889
2480
|
actualProvider: provider.providerId
|
|
1890
2481
|
});
|
|
1891
2482
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
1892
|
-
const redirectUrl =
|
|
2483
|
+
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1893
2484
|
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
|
|
1894
2485
|
}
|
|
1895
2486
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
1896
2487
|
} else if (!allowIdpInitiated) {
|
|
1897
2488
|
ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: provider.providerId });
|
|
1898
|
-
const redirectUrl =
|
|
2489
|
+
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1899
2490
|
throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
|
|
1900
2491
|
}
|
|
1901
2492
|
}
|
|
@@ -1922,7 +2513,7 @@ const callbackSSOSAML = (options) => {
|
|
|
1922
2513
|
issuer,
|
|
1923
2514
|
providerId: provider.providerId
|
|
1924
2515
|
});
|
|
1925
|
-
const redirectUrl =
|
|
2516
|
+
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1926
2517
|
throw ctx.redirect(`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
|
|
1927
2518
|
}
|
|
1928
2519
|
await ctx.context.internalAdapter.createVerificationValue({
|
|
@@ -1942,7 +2533,7 @@ const callbackSSOSAML = (options) => {
|
|
|
1942
2533
|
const userInfo = {
|
|
1943
2534
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
|
|
1944
2535
|
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
1945
|
-
email: attributes[mapping.email || "email"] || extract.nameID,
|
|
2536
|
+
email: (attributes[mapping.email || "email"] || extract.nameID).toLowerCase(),
|
|
1946
2537
|
name: [attributes[mapping.firstName || "givenName"], attributes[mapping.lastName || "surname"]].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
|
|
1947
2538
|
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
|
|
1948
2539
|
};
|
|
@@ -1956,7 +2547,7 @@ const callbackSSOSAML = (options) => {
|
|
|
1956
2547
|
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
1957
2548
|
}
|
|
1958
2549
|
const isTrustedProvider = !!ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
1959
|
-
const callbackUrl =
|
|
2550
|
+
const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1960
2551
|
const result = await handleOAuthUserInfo(ctx, {
|
|
1961
2552
|
userInfo: {
|
|
1962
2553
|
email: userInfo.email,
|
|
@@ -1998,10 +2589,10 @@ const callbackSSOSAML = (options) => {
|
|
|
1998
2589
|
session,
|
|
1999
2590
|
user
|
|
2000
2591
|
});
|
|
2001
|
-
|
|
2592
|
+
const safeRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
2593
|
+
throw ctx.redirect(safeRedirectUrl);
|
|
2002
2594
|
});
|
|
2003
2595
|
};
|
|
2004
|
-
const acsEndpointParamsSchema = z.object({ providerId: z.string().optional() });
|
|
2005
2596
|
const acsEndpointBodySchema = z.object({
|
|
2006
2597
|
SAMLResponse: z.string(),
|
|
2007
2598
|
RelayState: z.string().optional()
|
|
@@ -2009,7 +2600,6 @@ const acsEndpointBodySchema = z.object({
|
|
|
2009
2600
|
const acsEndpoint = (options) => {
|
|
2010
2601
|
return createAuthEndpoint("/sso/saml2/sp/acs/:providerId", {
|
|
2011
2602
|
method: "POST",
|
|
2012
|
-
params: acsEndpointParamsSchema,
|
|
2013
2603
|
body: acsEndpointBodySchema,
|
|
2014
2604
|
metadata: {
|
|
2015
2605
|
...HIDE_METADATA,
|
|
@@ -2024,6 +2614,8 @@ const acsEndpoint = (options) => {
|
|
|
2024
2614
|
}, async (ctx) => {
|
|
2025
2615
|
const { SAMLResponse, RelayState = "" } = ctx.body;
|
|
2026
2616
|
const { providerId } = ctx.params;
|
|
2617
|
+
const maxResponseSize = options?.saml?.maxResponseSize ?? DEFAULT_MAX_SAML_RESPONSE_SIZE;
|
|
2618
|
+
if (new TextEncoder().encode(SAMLResponse).length > maxResponseSize) throw new APIError("BAD_REQUEST", { message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)` });
|
|
2027
2619
|
let provider = null;
|
|
2028
2620
|
if (options?.defaultSSO?.length) {
|
|
2029
2621
|
const matchingDefault = providerId ? options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId) : options.defaultSSO[0];
|
|
@@ -2039,7 +2631,7 @@ const acsEndpoint = (options) => {
|
|
|
2039
2631
|
model: "ssoProvider",
|
|
2040
2632
|
where: [{
|
|
2041
2633
|
field: "providerId",
|
|
2042
|
-
value: providerId
|
|
2634
|
+
value: providerId
|
|
2043
2635
|
}]
|
|
2044
2636
|
}).then((res) => {
|
|
2045
2637
|
if (!res) return null;
|
|
@@ -2072,6 +2664,16 @@ const acsEndpoint = (options) => {
|
|
|
2072
2664
|
}],
|
|
2073
2665
|
signingCert: idpData?.cert || parsedSamlConfig.cert
|
|
2074
2666
|
}) : saml.IdentityProvider({ metadata: idpData.metadata });
|
|
2667
|
+
try {
|
|
2668
|
+
validateSingleAssertion(SAMLResponse);
|
|
2669
|
+
} catch (error) {
|
|
2670
|
+
if (error instanceof APIError) {
|
|
2671
|
+
const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2672
|
+
const errorCode = error.body?.code === "SAML_MULTIPLE_ASSERTIONS" ? "multiple_assertions" : "no_assertion";
|
|
2673
|
+
throw ctx.redirect(`${redirectUrl}?error=${errorCode}&error_description=${encodeURIComponent(error.message)}`);
|
|
2674
|
+
}
|
|
2675
|
+
throw error;
|
|
2676
|
+
}
|
|
2075
2677
|
let parsedResponse;
|
|
2076
2678
|
try {
|
|
2077
2679
|
parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
|
|
@@ -2082,7 +2684,7 @@ const acsEndpoint = (options) => {
|
|
|
2082
2684
|
} catch (error) {
|
|
2083
2685
|
ctx.context.logger.error("SAML response validation failed", {
|
|
2084
2686
|
error,
|
|
2085
|
-
decodedResponse:
|
|
2687
|
+
decodedResponse: Buffer.from(SAMLResponse, "base64").toString("utf-8")
|
|
2086
2688
|
});
|
|
2087
2689
|
throw new APIError("BAD_REQUEST", {
|
|
2088
2690
|
message: "Invalid SAML response",
|
|
@@ -2133,7 +2735,7 @@ const acsEndpoint = (options) => {
|
|
|
2133
2735
|
throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
|
|
2134
2736
|
}
|
|
2135
2737
|
}
|
|
2136
|
-
const assertionIdAcs = extractAssertionId(
|
|
2738
|
+
const assertionIdAcs = extractAssertionId(Buffer.from(SAMLResponse, "base64").toString("utf-8"));
|
|
2137
2739
|
if (assertionIdAcs) {
|
|
2138
2740
|
const issuer = idp.entityMeta.getEntityID();
|
|
2139
2741
|
const conditions = extract.conditions;
|
|
@@ -2175,7 +2777,7 @@ const acsEndpoint = (options) => {
|
|
|
2175
2777
|
const userInfo = {
|
|
2176
2778
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
|
|
2177
2779
|
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
2178
|
-
email: attributes[mapping.email || "email"] || extract.nameID,
|
|
2780
|
+
email: (attributes[mapping.email || "email"] || extract.nameID).toLowerCase(),
|
|
2179
2781
|
name: [attributes[mapping.firstName || "givenName"], attributes[mapping.lastName || "surname"]].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
|
|
2180
2782
|
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
|
|
2181
2783
|
};
|
|
@@ -2241,6 +2843,12 @@ saml.setSchemaValidator({ async validate(xml) {
|
|
|
2241
2843
|
if (XMLValidator.validate(xml, { allowBooleanAttributes: true }) === true) return "SUCCESS_VALIDATE_XML";
|
|
2242
2844
|
throw "ERR_INVALID_XML";
|
|
2243
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"];
|
|
2244
2852
|
function sso(options) {
|
|
2245
2853
|
const optionsWithStore = options;
|
|
2246
2854
|
let endpoints = {
|
|
@@ -2249,7 +2857,11 @@ function sso(options) {
|
|
|
2249
2857
|
signInSSO: signInSSO(optionsWithStore),
|
|
2250
2858
|
callbackSSO: callbackSSO(optionsWithStore),
|
|
2251
2859
|
callbackSSOSAML: callbackSSOSAML(optionsWithStore),
|
|
2252
|
-
acsEndpoint: acsEndpoint(optionsWithStore)
|
|
2860
|
+
acsEndpoint: acsEndpoint(optionsWithStore),
|
|
2861
|
+
listSSOProviders: listSSOProviders(),
|
|
2862
|
+
getSSOProvider: getSSOProvider(),
|
|
2863
|
+
updateSSOProvider: updateSSOProvider(optionsWithStore),
|
|
2864
|
+
deleteSSOProvider: deleteSSOProvider()
|
|
2253
2865
|
};
|
|
2254
2866
|
if (options?.domainVerification?.enabled) {
|
|
2255
2867
|
const domainVerificationEndpoints = {
|
|
@@ -2263,6 +2875,11 @@ function sso(options) {
|
|
|
2263
2875
|
}
|
|
2264
2876
|
return {
|
|
2265
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
|
+
},
|
|
2266
2883
|
endpoints,
|
|
2267
2884
|
hooks: { after: [{
|
|
2268
2885
|
matcher(context) {
|
|
@@ -2271,7 +2888,7 @@ function sso(options) {
|
|
|
2271
2888
|
handler: createAuthMiddleware(async (ctx) => {
|
|
2272
2889
|
const newSession = ctx.context.newSession;
|
|
2273
2890
|
if (!newSession?.user) return;
|
|
2274
|
-
if (!ctx.context.
|
|
2891
|
+
if (!ctx.context.hasPlugin("organization")) return;
|
|
2275
2892
|
await assignOrganizationByDomain(ctx, {
|
|
2276
2893
|
user: newSession.user,
|
|
2277
2894
|
provisioningOptions: options?.organizationProvisioning,
|
|
@@ -2332,4 +2949,5 @@ function sso(options) {
|
|
|
2332
2949
|
}
|
|
2333
2950
|
|
|
2334
2951
|
//#endregion
|
|
2335
|
-
export { 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
|