@better-auth/sso 1.6.10 → 1.6.12
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/dist/index.mjs
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import { t as PACKAGE_VERSION } from "./version-
|
|
1
|
+
import { t as PACKAGE_VERSION } from "./version-dnGn0OgM.mjs";
|
|
2
2
|
import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
|
|
3
3
|
import { XMLParser, XMLValidator } from "fast-xml-parser";
|
|
4
4
|
import { X509Certificate } from "node:crypto";
|
|
5
5
|
import { getHostname } from "tldts";
|
|
6
6
|
import { generateRandomString } from "better-auth/crypto";
|
|
7
7
|
import * as z from "zod";
|
|
8
|
-
import {
|
|
8
|
+
import { isPublicRoutableHost } from "@better-auth/core/utils/host";
|
|
9
9
|
import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
|
|
10
|
+
import { base64 } from "@better-auth/utils/base64";
|
|
11
|
+
import { isAPIError } from "@better-auth/core/utils/is-api-error";
|
|
10
12
|
import { HIDE_METADATA, createAuthorizationURL, generateGenericState, generateState, parseGenericState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
|
|
11
13
|
import { deleteSessionCookie, setSessionCookie } from "better-auth/cookies";
|
|
12
14
|
import { handleOAuthUserInfo } from "better-auth/oauth2";
|
|
@@ -371,992 +373,1060 @@ const verifyDomain = (options) => {
|
|
|
371
373
|
});
|
|
372
374
|
};
|
|
373
375
|
//#endregion
|
|
374
|
-
//#region src/
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
if (
|
|
388
|
-
}
|
|
389
|
-
else if (typeof value === "object" && value !== null) {
|
|
390
|
-
const found = findNode(value, nodeName);
|
|
391
|
-
if (found) return found;
|
|
392
|
-
}
|
|
393
|
-
return null;
|
|
394
|
-
}
|
|
395
|
-
function countAllNodes(obj, nodeName) {
|
|
396
|
-
if (!obj || typeof obj !== "object") return 0;
|
|
397
|
-
let count = 0;
|
|
398
|
-
const record = obj;
|
|
399
|
-
if (nodeName in record) {
|
|
400
|
-
const node = record[nodeName];
|
|
401
|
-
count += Array.isArray(node) ? node.length : 1;
|
|
376
|
+
//#region src/oidc/types.ts
|
|
377
|
+
/**
|
|
378
|
+
* Custom error class for OIDC discovery failures.
|
|
379
|
+
* Can be caught and mapped to APIError at the edge.
|
|
380
|
+
*/
|
|
381
|
+
var DiscoveryError = class DiscoveryError extends Error {
|
|
382
|
+
code;
|
|
383
|
+
details;
|
|
384
|
+
constructor(code, message, details, options) {
|
|
385
|
+
super(message, options);
|
|
386
|
+
this.name = "DiscoveryError";
|
|
387
|
+
this.code = code;
|
|
388
|
+
this.details = details;
|
|
389
|
+
if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
|
|
402
390
|
}
|
|
403
|
-
for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) count += countAllNodes(item, nodeName);
|
|
404
|
-
else if (typeof value === "object" && value !== null) count += countAllNodes(value, nodeName);
|
|
405
|
-
return count;
|
|
406
|
-
}
|
|
407
|
-
//#endregion
|
|
408
|
-
//#region src/saml/algorithms.ts
|
|
409
|
-
const SignatureAlgorithm = {
|
|
410
|
-
RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
|
|
411
|
-
RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
|
|
412
|
-
RSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
|
|
413
|
-
RSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
|
|
414
|
-
ECDSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
|
|
415
|
-
ECDSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
|
|
416
|
-
ECDSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"
|
|
417
|
-
};
|
|
418
|
-
const DigestAlgorithm = {
|
|
419
|
-
SHA1: "http://www.w3.org/2000/09/xmldsig#sha1",
|
|
420
|
-
SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
|
|
421
|
-
SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
|
|
422
|
-
SHA512: "http://www.w3.org/2001/04/xmlenc#sha512"
|
|
423
391
|
};
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
|
|
433
|
-
AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
|
|
434
|
-
AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
|
|
435
|
-
AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
|
|
436
|
-
AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
|
|
437
|
-
};
|
|
438
|
-
const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
|
|
439
|
-
const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS = [KeyEncryptionAlgorithm.RSA_1_5];
|
|
440
|
-
const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS = [DataEncryptionAlgorithm.TRIPLEDES_CBC];
|
|
441
|
-
const DEPRECATED_DIGEST_ALGORITHMS = [DigestAlgorithm.SHA1];
|
|
442
|
-
const SECURE_SIGNATURE_ALGORITHMS = [
|
|
443
|
-
SignatureAlgorithm.RSA_SHA256,
|
|
444
|
-
SignatureAlgorithm.RSA_SHA384,
|
|
445
|
-
SignatureAlgorithm.RSA_SHA512,
|
|
446
|
-
SignatureAlgorithm.ECDSA_SHA256,
|
|
447
|
-
SignatureAlgorithm.ECDSA_SHA384,
|
|
448
|
-
SignatureAlgorithm.ECDSA_SHA512
|
|
449
|
-
];
|
|
450
|
-
const SECURE_DIGEST_ALGORITHMS = [
|
|
451
|
-
DigestAlgorithm.SHA256,
|
|
452
|
-
DigestAlgorithm.SHA384,
|
|
453
|
-
DigestAlgorithm.SHA512
|
|
392
|
+
/**
|
|
393
|
+
* Required fields that must be present in a valid discovery document.
|
|
394
|
+
*/
|
|
395
|
+
const REQUIRED_DISCOVERY_FIELDS = [
|
|
396
|
+
"issuer",
|
|
397
|
+
"authorization_endpoint",
|
|
398
|
+
"token_endpoint",
|
|
399
|
+
"jwks_uri"
|
|
454
400
|
];
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
401
|
+
//#endregion
|
|
402
|
+
//#region src/oidc/discovery.ts
|
|
403
|
+
/**
|
|
404
|
+
* OIDC Discovery Pipeline
|
|
405
|
+
*
|
|
406
|
+
* Implements OIDC discovery document fetching, validation, and hydration.
|
|
407
|
+
* This module is used both at provider registration time (to persist validated config)
|
|
408
|
+
* and at runtime (to hydrate legacy providers that are missing metadata).
|
|
409
|
+
*
|
|
410
|
+
* @see https://openid.net/specs/openid-connect-discovery-1_0.html
|
|
411
|
+
*/
|
|
412
|
+
/** Default timeout for discovery requests (10 seconds) */
|
|
413
|
+
const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
|
|
414
|
+
/**
|
|
415
|
+
* Main entry point: Discover and hydrate OIDC configuration from an issuer.
|
|
416
|
+
*
|
|
417
|
+
* This function:
|
|
418
|
+
* 1. Computes the discovery URL from the issuer
|
|
419
|
+
* 2. Validates the discovery URL
|
|
420
|
+
* 3. Fetches the discovery document
|
|
421
|
+
* 4. Validates the discovery document (issuer match + required fields)
|
|
422
|
+
* 5. Normalizes URLs
|
|
423
|
+
* 6. Selects token endpoint auth method
|
|
424
|
+
* 7. Merges with existing config (existing values take precedence)
|
|
425
|
+
*
|
|
426
|
+
* @param params - Discovery parameters
|
|
427
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
428
|
+
* @returns Hydrated OIDC configuration ready for persistence
|
|
429
|
+
* @throws DiscoveryError on any failure
|
|
430
|
+
*/
|
|
431
|
+
async function discoverOIDCConfig(params) {
|
|
432
|
+
const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
|
|
433
|
+
const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
|
|
434
|
+
validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
|
|
435
|
+
const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
|
|
436
|
+
validateDiscoveryDocument(discoveryDoc, issuer);
|
|
437
|
+
const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
|
|
438
|
+
const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
|
|
439
|
+
return {
|
|
440
|
+
issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
|
|
441
|
+
discoveryEndpoint: existingConfig?.discoveryEndpoint ?? discoveryUrl,
|
|
442
|
+
authorizationEndpoint: existingConfig?.authorizationEndpoint ?? normalizedDoc.authorization_endpoint,
|
|
443
|
+
tokenEndpoint: existingConfig?.tokenEndpoint ?? normalizedDoc.token_endpoint,
|
|
444
|
+
jwksEndpoint: existingConfig?.jwksEndpoint ?? normalizedDoc.jwks_uri,
|
|
445
|
+
userInfoEndpoint: existingConfig?.userInfoEndpoint ?? normalizedDoc.userinfo_endpoint,
|
|
446
|
+
tokenEndpointAuthentication: existingConfig?.tokenEndpointAuthentication ?? tokenEndpointAuth,
|
|
447
|
+
scopesSupported: existingConfig?.scopesSupported ?? normalizedDoc.scopes_supported
|
|
448
|
+
};
|
|
495
449
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
450
|
+
/**
|
|
451
|
+
* Compute the discovery URL from an issuer URL.
|
|
452
|
+
*
|
|
453
|
+
* Per OIDC Discovery spec, the discovery document is located at:
|
|
454
|
+
* <issuer>/.well-known/openid-configuration
|
|
455
|
+
*
|
|
456
|
+
* Handles trailing slashes correctly.
|
|
457
|
+
*/
|
|
458
|
+
function computeDiscoveryUrl(issuer) {
|
|
459
|
+
return `${issuer.endsWith("/") ? issuer.slice(0, -1) : issuer}/.well-known/openid-configuration`;
|
|
502
460
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
}
|
|
461
|
+
/**
|
|
462
|
+
* Validate a discovery URL before fetching.
|
|
463
|
+
*
|
|
464
|
+
* @param url - The discovery URL to validate
|
|
465
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
466
|
+
* @throws DiscoveryError if URL is invalid
|
|
467
|
+
*/
|
|
468
|
+
function validateDiscoveryUrl(url, isTrustedOrigin) {
|
|
469
|
+
const discoveryEndpoint = parseURL("discoveryEndpoint", url).toString();
|
|
470
|
+
if (!isTrustedOrigin(discoveryEndpoint)) throw new DiscoveryError("discovery_untrusted_origin", `The main discovery endpoint "${discoveryEndpoint}" is not trusted by your trusted origins configuration.`, { url: discoveryEndpoint });
|
|
514
471
|
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
472
|
+
/**
|
|
473
|
+
* Validate that a user-supplied OIDC endpoint URL is safe to fetch.
|
|
474
|
+
*
|
|
475
|
+
* Layered checks (in order):
|
|
476
|
+
* 1. URL parsing + http(s) scheme → discovery_invalid_url
|
|
477
|
+
* 2. Public-routable host (RFC 6890) → allowed
|
|
478
|
+
* 3. Operator-allowlisted via trustedOrigins → allowed (opt-in for internal IdPs)
|
|
479
|
+
* 4. Otherwise → discovery_private_host
|
|
480
|
+
*
|
|
481
|
+
* Step 2 rejects loopback, RFC 1918, link-local, ULA, shared-address,
|
|
482
|
+
* cloud-metadata FQDNs (e.g. `169.254.169.254`, `metadata.google.internal`),
|
|
483
|
+
* multicast, and reserved ranges. See `isPublicRoutableHost` in
|
|
484
|
+
* `@better-auth/core/utils/host`.
|
|
485
|
+
*
|
|
486
|
+
* Step 3 is the documented escape hatch for customers whose IdP runs on a
|
|
487
|
+
* private network or behind a corporate VPN: they add the IdP origin to their
|
|
488
|
+
* `trustedOrigins` configuration.
|
|
489
|
+
*
|
|
490
|
+
* @param name - The endpoint field name, used in error messages
|
|
491
|
+
* @param endpoint - The URL to validate
|
|
492
|
+
* @param isTrustedOrigin - Predicate matching the configured `trustedOrigins`
|
|
493
|
+
* @throws DiscoveryError(discovery_invalid_url) — malformed URL or non-http(s) scheme
|
|
494
|
+
* @throws DiscoveryError(discovery_private_host) — host is not publicly routable and not allowlisted
|
|
495
|
+
*/
|
|
496
|
+
function validateSkipDiscoveryEndpoint(name, endpoint, isTrustedOrigin) {
|
|
497
|
+
const parsed = parseURL(name, endpoint);
|
|
498
|
+
if (isPublicRoutableHost(parsed.hostname)) return;
|
|
499
|
+
if (isTrustedOrigin(parsed.toString())) return;
|
|
500
|
+
throw new DiscoveryError("discovery_private_host", `The ${name} URL (${parsed.toString()}) is not publicly routable: ${parsed.hostname}. If this is an internal IdP, add its origin to trustedOrigins.`, {
|
|
501
|
+
endpoint: name,
|
|
502
|
+
url: endpoint,
|
|
503
|
+
hostname: parsed.hostname
|
|
532
504
|
});
|
|
533
505
|
}
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
506
|
+
/**
|
|
507
|
+
* Validate every present OIDC endpoint URL in a registration or update body.
|
|
508
|
+
*
|
|
509
|
+
* Each provided URL is checked with {@link validateSkipDiscoveryEndpoint}.
|
|
510
|
+
* Omitted (undefined / null / empty) fields are skipped.
|
|
511
|
+
*
|
|
512
|
+
* @param config - OIDC endpoint URLs from the request body
|
|
513
|
+
* @param isTrustedOrigin - Predicate matching the configured `trustedOrigins`
|
|
514
|
+
* @throws DiscoveryError on the first invalid endpoint
|
|
515
|
+
*/
|
|
516
|
+
function validateSkipDiscoveryEndpoints(config, isTrustedOrigin) {
|
|
517
|
+
const fields = [
|
|
518
|
+
["authorizationEndpoint", config.authorizationEndpoint],
|
|
519
|
+
["tokenEndpoint", config.tokenEndpoint],
|
|
520
|
+
["userInfoEndpoint", config.userInfoEndpoint],
|
|
521
|
+
["jwksEndpoint", config.jwksEndpoint],
|
|
522
|
+
["discoveryEndpoint", config.discoveryEndpoint]
|
|
523
|
+
];
|
|
524
|
+
for (const [name, url] of fields) if (url) validateSkipDiscoveryEndpoint(name, url, isTrustedOrigin);
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Fetch the OIDC discovery document from the IdP.
|
|
528
|
+
*
|
|
529
|
+
* @param url - The discovery endpoint URL
|
|
530
|
+
* @param timeout - Request timeout in milliseconds
|
|
531
|
+
* @returns The parsed discovery document
|
|
532
|
+
* @throws DiscoveryError on network errors, timeouts, or invalid responses
|
|
533
|
+
*/
|
|
534
|
+
async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
|
|
535
|
+
try {
|
|
536
|
+
const response = await betterFetch(url, {
|
|
537
|
+
method: "GET",
|
|
538
|
+
timeout
|
|
539
|
+
});
|
|
540
|
+
if (response.error) {
|
|
541
|
+
const { status } = response.error;
|
|
542
|
+
if (status === 404) throw new DiscoveryError("discovery_not_found", "Discovery endpoint not found", {
|
|
543
|
+
url,
|
|
544
|
+
status
|
|
542
545
|
});
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
if (allowedDataEncryptionAlgorithms) {
|
|
547
|
-
if (!allowedDataEncryptionAlgorithms.includes(dataEncryption)) throw new APIError("BAD_REQUEST", {
|
|
548
|
-
message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
|
|
549
|
-
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
546
|
+
if (status === 408) throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
|
|
547
|
+
url,
|
|
548
|
+
timeout
|
|
550
549
|
});
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
function validateSAMLAlgorithms(response, options) {
|
|
555
|
-
validateSignatureAlgorithm(response.sigAlg, options);
|
|
556
|
-
if (hasEncryptedAssertion(response.samlContent)) validateEncryptionAlgorithms(extractEncryptionAlgorithms(response.samlContent), options);
|
|
557
|
-
}
|
|
558
|
-
function validateConfigAlgorithms(config, options = {}) {
|
|
559
|
-
const { onDeprecated = "warn", allowedSignatureAlgorithms, allowedDigestAlgorithms } = options;
|
|
560
|
-
if (config.signatureAlgorithm) {
|
|
561
|
-
const normalized = normalizeSignatureAlgorithm(config.signatureAlgorithm);
|
|
562
|
-
if (allowedSignatureAlgorithms) {
|
|
563
|
-
if (!allowedSignatureAlgorithms.map(normalizeSignatureAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
564
|
-
message: `SAML signature algorithm not in allow-list: ${config.signatureAlgorithm}`,
|
|
565
|
-
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
550
|
+
throw new DiscoveryError("discovery_unexpected_error", `Unexpected discovery error: ${response.error.statusText}`, {
|
|
551
|
+
url,
|
|
552
|
+
...response.error
|
|
566
553
|
});
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
554
|
+
}
|
|
555
|
+
if (!response.data) throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned an empty response", { url });
|
|
556
|
+
const data = response.data;
|
|
557
|
+
if (typeof data === "string") throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned invalid JSON", {
|
|
558
|
+
url,
|
|
559
|
+
bodyPreview: data.slice(0, 200)
|
|
571
560
|
});
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
if (
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
579
|
-
});
|
|
580
|
-
} 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");
|
|
581
|
-
else if (!SECURE_DIGEST_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
582
|
-
message: `SAML digest algorithm not recognized: ${config.digestAlgorithm}`,
|
|
583
|
-
code: "SAML_UNKNOWN_ALGORITHM"
|
|
561
|
+
return data;
|
|
562
|
+
} catch (error) {
|
|
563
|
+
if (error instanceof DiscoveryError) throw error;
|
|
564
|
+
if (error instanceof Error && error.name === "AbortError") throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
|
|
565
|
+
url,
|
|
566
|
+
timeout
|
|
584
567
|
});
|
|
568
|
+
throw new DiscoveryError("discovery_unexpected_error", `Unexpected error during discovery: ${error instanceof Error ? error.message : String(error)}`, { url }, { cause: error });
|
|
585
569
|
}
|
|
586
570
|
}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
571
|
+
/**
|
|
572
|
+
* Validate a discovery document.
|
|
573
|
+
*
|
|
574
|
+
* Checks:
|
|
575
|
+
* 1. All required fields are present
|
|
576
|
+
* 2. Issuer matches the configured issuer (case-sensitive, exact match)
|
|
577
|
+
*
|
|
578
|
+
* Invariant: If this function returns without throwing, the document is safe
|
|
579
|
+
* to use for hydrating OIDC config (required fields present, issuer matches
|
|
580
|
+
* configured value, basic structural sanity verified).
|
|
581
|
+
*
|
|
582
|
+
* @param doc - The discovery document to validate
|
|
583
|
+
* @param configuredIssuer - The expected issuer value
|
|
584
|
+
* @throws DiscoveryError if validation fails
|
|
585
|
+
*/
|
|
586
|
+
function validateDiscoveryDocument(doc, configuredIssuer) {
|
|
587
|
+
const missingFields = [];
|
|
588
|
+
for (const field of REQUIRED_DISCOVERY_FIELDS) if (!doc[field]) missingFields.push(field);
|
|
589
|
+
if (missingFields.length > 0) throw new DiscoveryError("discovery_incomplete", `Discovery document is missing required fields: ${missingFields.join(", ")}`, { missingFields });
|
|
590
|
+
if ((doc.issuer.endsWith("/") ? doc.issuer.slice(0, -1) : doc.issuer) !== (configuredIssuer.endsWith("/") ? configuredIssuer.slice(0, -1) : configuredIssuer)) throw new DiscoveryError("issuer_mismatch", `Discovered issuer "${doc.issuer}" does not match configured issuer "${configuredIssuer}"`, {
|
|
591
|
+
discovered: doc.issuer,
|
|
592
|
+
configured: configuredIssuer
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Normalize URLs in the discovery document.
|
|
597
|
+
*
|
|
598
|
+
* @param document - The discovery document
|
|
599
|
+
* @param issuer - The base issuer URL
|
|
600
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
601
|
+
* @returns The normalized discovery document
|
|
602
|
+
*/
|
|
603
|
+
function normalizeDiscoveryUrls(document, issuer, isTrustedOrigin) {
|
|
604
|
+
const doc = { ...document };
|
|
605
|
+
doc.token_endpoint = normalizeAndValidateUrl("token_endpoint", doc.token_endpoint, issuer, isTrustedOrigin);
|
|
606
|
+
doc.authorization_endpoint = normalizeAndValidateUrl("authorization_endpoint", doc.authorization_endpoint, issuer, isTrustedOrigin);
|
|
607
|
+
doc.jwks_uri = normalizeAndValidateUrl("jwks_uri", doc.jwks_uri, issuer, isTrustedOrigin);
|
|
608
|
+
if (doc.userinfo_endpoint) doc.userinfo_endpoint = normalizeAndValidateUrl("userinfo_endpoint", doc.userinfo_endpoint, issuer, isTrustedOrigin);
|
|
609
|
+
if (doc.revocation_endpoint) doc.revocation_endpoint = normalizeAndValidateUrl("revocation_endpoint", doc.revocation_endpoint, issuer, isTrustedOrigin);
|
|
610
|
+
if (doc.end_session_endpoint) doc.end_session_endpoint = normalizeAndValidateUrl("end_session_endpoint", doc.end_session_endpoint, issuer, isTrustedOrigin);
|
|
611
|
+
if (doc.introspection_endpoint) doc.introspection_endpoint = normalizeAndValidateUrl("introspection_endpoint", doc.introspection_endpoint, issuer, isTrustedOrigin);
|
|
612
|
+
return doc;
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Normalizes and validates a single URL endpoint
|
|
616
|
+
* @param name The url name
|
|
617
|
+
* @param endpoint The url to validate
|
|
618
|
+
* @param issuer The issuer base url
|
|
619
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
620
|
+
* @returns
|
|
621
|
+
*/
|
|
622
|
+
function normalizeAndValidateUrl(name, endpoint, issuer, isTrustedOrigin) {
|
|
623
|
+
const url = normalizeUrl(name, endpoint, issuer);
|
|
624
|
+
if (!isTrustedOrigin(url)) throw new DiscoveryError("discovery_untrusted_origin", `The ${name} "${url}" is not trusted by your trusted origins configuration.`, {
|
|
625
|
+
endpoint: name,
|
|
626
|
+
url
|
|
627
|
+
});
|
|
628
|
+
return url;
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Normalize a single URL endpoint.
|
|
632
|
+
*
|
|
633
|
+
* @param name - The endpoint name (e.g token_endpoint)
|
|
634
|
+
* @param endpoint - The endpoint URL to normalize
|
|
635
|
+
* @param issuer - The base issuer URL
|
|
636
|
+
* @returns The normalized endpoint URL
|
|
637
|
+
*/
|
|
638
|
+
function normalizeUrl(name, endpoint, issuer) {
|
|
591
639
|
try {
|
|
592
|
-
|
|
640
|
+
return parseURL(name, endpoint).toString();
|
|
593
641
|
} catch {
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
642
|
+
const issuerURL = parseURL(name, issuer);
|
|
643
|
+
const basePath = issuerURL.pathname.replace(/\/+$/, "");
|
|
644
|
+
const endpointPath = endpoint.replace(/^\/+/, "");
|
|
645
|
+
return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
|
|
598
646
|
}
|
|
599
|
-
const assertions = countAllNodes(parsed, "Assertion");
|
|
600
|
-
const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
|
|
601
|
-
return {
|
|
602
|
-
assertions,
|
|
603
|
-
encryptedAssertions,
|
|
604
|
-
total: assertions + encryptedAssertions
|
|
605
|
-
};
|
|
606
647
|
}
|
|
607
|
-
|
|
608
|
-
|
|
648
|
+
/**
|
|
649
|
+
* Parses the given URL or throws in case of invalid or unsupported protocols
|
|
650
|
+
*
|
|
651
|
+
* @param name the url name
|
|
652
|
+
* @param endpoint the endpoint url
|
|
653
|
+
* @param [base] optional base path
|
|
654
|
+
* @returns
|
|
655
|
+
*/
|
|
656
|
+
function parseURL(name, endpoint, base) {
|
|
657
|
+
let endpointURL;
|
|
609
658
|
try {
|
|
610
|
-
|
|
611
|
-
if (
|
|
612
|
-
} catch {
|
|
613
|
-
throw new
|
|
614
|
-
message: "Invalid base64-encoded SAML response",
|
|
615
|
-
code: "SAML_INVALID_ENCODING"
|
|
616
|
-
});
|
|
659
|
+
endpointURL = new URL(endpoint, base);
|
|
660
|
+
if (endpointURL.protocol === "http:" || endpointURL.protocol === "https:") return endpointURL;
|
|
661
|
+
} catch (error) {
|
|
662
|
+
throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
|
|
617
663
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
code: "SAML_NO_ASSERTION"
|
|
622
|
-
});
|
|
623
|
-
if (counts.total > 1) throw new APIError("BAD_REQUEST", {
|
|
624
|
-
message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
|
|
625
|
-
code: "SAML_MULTIPLE_ASSERTIONS"
|
|
664
|
+
throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must use the http or https supported protocols: ${endpoint}`, {
|
|
665
|
+
url: endpoint,
|
|
666
|
+
protocol: endpointURL.protocol
|
|
626
667
|
});
|
|
627
668
|
}
|
|
628
|
-
//#endregion
|
|
629
|
-
//#region src/routes/schemas.ts
|
|
630
|
-
const oidcMappingSchema = z.object({
|
|
631
|
-
id: z.string().optional(),
|
|
632
|
-
email: z.string().optional(),
|
|
633
|
-
emailVerified: z.string().optional(),
|
|
634
|
-
name: z.string().optional(),
|
|
635
|
-
image: z.string().optional(),
|
|
636
|
-
extraFields: z.record(z.string(), z.any()).optional()
|
|
637
|
-
}).optional();
|
|
638
|
-
const samlMappingSchema = z.object({
|
|
639
|
-
id: z.string().optional(),
|
|
640
|
-
email: z.string().optional(),
|
|
641
|
-
emailVerified: z.string().optional(),
|
|
642
|
-
name: z.string().optional(),
|
|
643
|
-
firstName: z.string().optional(),
|
|
644
|
-
lastName: z.string().optional(),
|
|
645
|
-
extraFields: z.record(z.string(), z.any()).optional()
|
|
646
|
-
}).optional();
|
|
647
|
-
const oidcConfigSchema = z.object({
|
|
648
|
-
clientId: z.string().optional(),
|
|
649
|
-
clientSecret: z.string().optional(),
|
|
650
|
-
authorizationEndpoint: z.string().url().optional(),
|
|
651
|
-
tokenEndpoint: z.string().url().optional(),
|
|
652
|
-
userInfoEndpoint: z.string().url().optional(),
|
|
653
|
-
tokenEndpointAuthentication: z.enum(["client_secret_post", "client_secret_basic"]).optional(),
|
|
654
|
-
jwksEndpoint: z.string().url().optional(),
|
|
655
|
-
discoveryEndpoint: z.string().url().optional(),
|
|
656
|
-
scopes: z.array(z.string()).optional(),
|
|
657
|
-
pkce: z.boolean().optional(),
|
|
658
|
-
overrideUserInfo: z.boolean().optional(),
|
|
659
|
-
mapping: oidcMappingSchema
|
|
660
|
-
});
|
|
661
|
-
const samlConfigSchema = z.object({
|
|
662
|
-
entryPoint: z.string().url().optional(),
|
|
663
|
-
cert: z.string().optional(),
|
|
664
|
-
callbackUrl: z.string().url().optional(),
|
|
665
|
-
audience: z.string().optional(),
|
|
666
|
-
idpMetadata: z.object({
|
|
667
|
-
metadata: z.string().optional(),
|
|
668
|
-
entityID: z.string().optional(),
|
|
669
|
-
cert: z.string().optional(),
|
|
670
|
-
privateKey: z.string().optional(),
|
|
671
|
-
privateKeyPass: z.string().optional(),
|
|
672
|
-
isAssertionEncrypted: z.boolean().optional(),
|
|
673
|
-
encPrivateKey: z.string().optional(),
|
|
674
|
-
encPrivateKeyPass: z.string().optional(),
|
|
675
|
-
singleSignOnService: z.array(z.object({
|
|
676
|
-
Binding: z.string(),
|
|
677
|
-
Location: z.string().url()
|
|
678
|
-
})).optional()
|
|
679
|
-
}).optional(),
|
|
680
|
-
spMetadata: z.object({
|
|
681
|
-
metadata: z.string().optional(),
|
|
682
|
-
entityID: z.string().optional(),
|
|
683
|
-
binding: z.string().optional(),
|
|
684
|
-
privateKey: z.string().optional(),
|
|
685
|
-
privateKeyPass: z.string().optional(),
|
|
686
|
-
isAssertionEncrypted: z.boolean().optional(),
|
|
687
|
-
encPrivateKey: z.string().optional(),
|
|
688
|
-
encPrivateKeyPass: z.string().optional()
|
|
689
|
-
}).optional(),
|
|
690
|
-
wantAssertionsSigned: z.boolean().optional(),
|
|
691
|
-
authnRequestsSigned: z.boolean().optional(),
|
|
692
|
-
signatureAlgorithm: z.string().optional(),
|
|
693
|
-
digestAlgorithm: z.string().optional(),
|
|
694
|
-
identifierFormat: z.string().optional(),
|
|
695
|
-
privateKey: z.string().optional(),
|
|
696
|
-
decryptionPvk: z.string().optional(),
|
|
697
|
-
additionalParams: z.record(z.string(), z.any()).optional(),
|
|
698
|
-
mapping: samlMappingSchema
|
|
699
|
-
});
|
|
700
|
-
const updateSSOProviderBodySchema = z.object({
|
|
701
|
-
issuer: z.string().url().optional(),
|
|
702
|
-
domain: z.string().optional(),
|
|
703
|
-
oidcConfig: oidcConfigSchema.optional(),
|
|
704
|
-
samlConfig: samlConfigSchema.optional()
|
|
705
|
-
});
|
|
706
|
-
//#endregion
|
|
707
|
-
//#region src/routes/providers.ts
|
|
708
|
-
const ADMIN_ROLES = ["owner", "admin"];
|
|
709
|
-
async function isOrgAdmin(ctx, userId, organizationId) {
|
|
710
|
-
const member = await ctx.context.adapter.findOne({
|
|
711
|
-
model: "member",
|
|
712
|
-
where: [{
|
|
713
|
-
field: "userId",
|
|
714
|
-
value: userId
|
|
715
|
-
}, {
|
|
716
|
-
field: "organizationId",
|
|
717
|
-
value: organizationId
|
|
718
|
-
}]
|
|
719
|
-
});
|
|
720
|
-
if (!member) return false;
|
|
721
|
-
return member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()));
|
|
722
|
-
}
|
|
723
|
-
async function batchCheckOrgAdmin(ctx, userId, organizationIds) {
|
|
724
|
-
if (organizationIds.length === 0) return /* @__PURE__ */ new Set();
|
|
725
|
-
const members = await ctx.context.adapter.findMany({
|
|
726
|
-
model: "member",
|
|
727
|
-
where: [{
|
|
728
|
-
field: "userId",
|
|
729
|
-
value: userId
|
|
730
|
-
}, {
|
|
731
|
-
field: "organizationId",
|
|
732
|
-
value: organizationIds,
|
|
733
|
-
operator: "in"
|
|
734
|
-
}]
|
|
735
|
-
});
|
|
736
|
-
const adminOrgIds = /* @__PURE__ */ new Set();
|
|
737
|
-
for (const member of members) if (member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()))) adminOrgIds.add(member.organizationId);
|
|
738
|
-
return adminOrgIds;
|
|
739
|
-
}
|
|
740
|
-
function sanitizeProvider(provider, baseURL) {
|
|
741
|
-
let oidcConfig = null;
|
|
742
|
-
let samlConfig = null;
|
|
743
|
-
try {
|
|
744
|
-
oidcConfig = safeJsonParse(provider.oidcConfig);
|
|
745
|
-
} catch {
|
|
746
|
-
oidcConfig = null;
|
|
747
|
-
}
|
|
748
|
-
try {
|
|
749
|
-
samlConfig = safeJsonParse(provider.samlConfig);
|
|
750
|
-
} catch {
|
|
751
|
-
samlConfig = null;
|
|
752
|
-
}
|
|
753
|
-
const type = samlConfig ? "saml" : "oidc";
|
|
754
|
-
return {
|
|
755
|
-
providerId: provider.providerId,
|
|
756
|
-
type,
|
|
757
|
-
issuer: provider.issuer,
|
|
758
|
-
domain: provider.domain,
|
|
759
|
-
organizationId: provider.organizationId || null,
|
|
760
|
-
domainVerified: provider.domainVerified ?? false,
|
|
761
|
-
oidcConfig: oidcConfig ? {
|
|
762
|
-
discoveryEndpoint: oidcConfig.discoveryEndpoint,
|
|
763
|
-
clientIdLastFour: maskClientId(oidcConfig.clientId),
|
|
764
|
-
pkce: oidcConfig.pkce,
|
|
765
|
-
authorizationEndpoint: oidcConfig.authorizationEndpoint,
|
|
766
|
-
tokenEndpoint: oidcConfig.tokenEndpoint,
|
|
767
|
-
userInfoEndpoint: oidcConfig.userInfoEndpoint,
|
|
768
|
-
jwksEndpoint: oidcConfig.jwksEndpoint,
|
|
769
|
-
scopes: oidcConfig.scopes,
|
|
770
|
-
tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication
|
|
771
|
-
} : void 0,
|
|
772
|
-
samlConfig: samlConfig ? {
|
|
773
|
-
entryPoint: samlConfig.entryPoint,
|
|
774
|
-
callbackUrl: samlConfig.callbackUrl,
|
|
775
|
-
audience: samlConfig.audience,
|
|
776
|
-
wantAssertionsSigned: samlConfig.wantAssertionsSigned,
|
|
777
|
-
authnRequestsSigned: samlConfig.authnRequestsSigned,
|
|
778
|
-
identifierFormat: samlConfig.identifierFormat,
|
|
779
|
-
signatureAlgorithm: samlConfig.signatureAlgorithm,
|
|
780
|
-
digestAlgorithm: samlConfig.digestAlgorithm,
|
|
781
|
-
certificate: (() => {
|
|
782
|
-
try {
|
|
783
|
-
return parseCertificate(samlConfig.cert);
|
|
784
|
-
} catch {
|
|
785
|
-
return { error: "Failed to parse certificate" };
|
|
786
|
-
}
|
|
787
|
-
})()
|
|
788
|
-
} : void 0,
|
|
789
|
-
spMetadataUrl: `${baseURL}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(provider.providerId)}`
|
|
790
|
-
};
|
|
791
|
-
}
|
|
792
|
-
const listSSOProviders = () => {
|
|
793
|
-
return createAuthEndpoint("/sso/providers", {
|
|
794
|
-
method: "GET",
|
|
795
|
-
use: [sessionMiddleware],
|
|
796
|
-
metadata: { openapi: {
|
|
797
|
-
operationId: "listSSOProviders",
|
|
798
|
-
summary: "List SSO providers",
|
|
799
|
-
description: "Returns a list of SSO providers the user has access to",
|
|
800
|
-
responses: { "200": { description: "List of SSO providers" } }
|
|
801
|
-
} }
|
|
802
|
-
}, async (ctx) => {
|
|
803
|
-
const userId = ctx.context.session.user.id;
|
|
804
|
-
const allProviders = await ctx.context.adapter.findMany({ model: "ssoProvider" });
|
|
805
|
-
const userOwnedProviders = allProviders.filter((p) => p.userId === userId && !p.organizationId);
|
|
806
|
-
const orgProviders = allProviders.filter((p) => p.organizationId !== null && p.organizationId !== void 0);
|
|
807
|
-
const orgPluginEnabled = ctx.context.hasPlugin("organization");
|
|
808
|
-
let accessibleProviders = [...userOwnedProviders];
|
|
809
|
-
if (orgPluginEnabled && orgProviders.length > 0) {
|
|
810
|
-
const adminOrgIds = await batchCheckOrgAdmin(ctx, userId, [...new Set(orgProviders.map((p) => p.organizationId).filter((id) => id !== null && id !== void 0))]);
|
|
811
|
-
const orgAccessibleProviders = orgProviders.filter((provider) => provider.organizationId && adminOrgIds.has(provider.organizationId));
|
|
812
|
-
accessibleProviders = [...accessibleProviders, ...orgAccessibleProviders];
|
|
813
|
-
} else if (!orgPluginEnabled) {
|
|
814
|
-
const userOwnedOrgProviders = orgProviders.filter((p) => p.userId === userId);
|
|
815
|
-
accessibleProviders = [...accessibleProviders, ...userOwnedOrgProviders];
|
|
816
|
-
}
|
|
817
|
-
const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL));
|
|
818
|
-
return ctx.json({ providers });
|
|
819
|
-
});
|
|
820
|
-
};
|
|
821
|
-
const getSSOProviderQuerySchema = z.object({ providerId: z.string() });
|
|
822
|
-
async function checkProviderAccess(ctx, providerId) {
|
|
823
|
-
const userId = ctx.context.session.user.id;
|
|
824
|
-
const provider = await ctx.context.adapter.findOne({
|
|
825
|
-
model: "ssoProvider",
|
|
826
|
-
where: [{
|
|
827
|
-
field: "providerId",
|
|
828
|
-
value: providerId
|
|
829
|
-
}]
|
|
830
|
-
});
|
|
831
|
-
if (!provider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
|
|
832
|
-
let hasAccess = false;
|
|
833
|
-
if (provider.organizationId) if (ctx.context.hasPlugin("organization")) hasAccess = await isOrgAdmin(ctx, userId, provider.organizationId);
|
|
834
|
-
else hasAccess = provider.userId === userId;
|
|
835
|
-
else hasAccess = provider.userId === userId;
|
|
836
|
-
if (!hasAccess) throw new APIError("FORBIDDEN", { message: "You don't have access to this provider" });
|
|
837
|
-
return provider;
|
|
838
|
-
}
|
|
839
|
-
const getSSOProvider = () => {
|
|
840
|
-
return createAuthEndpoint("/sso/get-provider", {
|
|
841
|
-
method: "GET",
|
|
842
|
-
use: [sessionMiddleware],
|
|
843
|
-
query: getSSOProviderQuerySchema,
|
|
844
|
-
metadata: { openapi: {
|
|
845
|
-
operationId: "getSSOProvider",
|
|
846
|
-
summary: "Get SSO provider details",
|
|
847
|
-
description: "Returns sanitized details for a specific SSO provider",
|
|
848
|
-
responses: {
|
|
849
|
-
"200": { description: "SSO provider details" },
|
|
850
|
-
"404": { description: "Provider not found" },
|
|
851
|
-
"403": { description: "Access denied" }
|
|
852
|
-
}
|
|
853
|
-
} }
|
|
854
|
-
}, async (ctx) => {
|
|
855
|
-
const { providerId } = ctx.query;
|
|
856
|
-
const provider = await checkProviderAccess(ctx, providerId);
|
|
857
|
-
return ctx.json(sanitizeProvider(provider, ctx.context.baseURL));
|
|
858
|
-
});
|
|
859
|
-
};
|
|
860
|
-
function parseAndValidateConfig(configString, configType) {
|
|
861
|
-
let config = null;
|
|
862
|
-
try {
|
|
863
|
-
config = safeJsonParse(configString);
|
|
864
|
-
} catch {
|
|
865
|
-
config = null;
|
|
866
|
-
}
|
|
867
|
-
if (!config) throw new APIError("BAD_REQUEST", { message: `Cannot update ${configType} config for a provider that doesn't have ${configType} configured` });
|
|
868
|
-
return config;
|
|
869
|
-
}
|
|
870
|
-
function mergeSAMLConfig(current, updates, issuer) {
|
|
871
|
-
return {
|
|
872
|
-
...current,
|
|
873
|
-
...updates,
|
|
874
|
-
issuer,
|
|
875
|
-
entryPoint: updates.entryPoint ?? current.entryPoint,
|
|
876
|
-
cert: updates.cert ?? current.cert,
|
|
877
|
-
callbackUrl: updates.callbackUrl ?? current.callbackUrl,
|
|
878
|
-
spMetadata: updates.spMetadata ?? current.spMetadata,
|
|
879
|
-
idpMetadata: updates.idpMetadata ?? current.idpMetadata,
|
|
880
|
-
mapping: updates.mapping ?? current.mapping,
|
|
881
|
-
audience: updates.audience ?? current.audience,
|
|
882
|
-
wantAssertionsSigned: updates.wantAssertionsSigned ?? current.wantAssertionsSigned,
|
|
883
|
-
authnRequestsSigned: updates.authnRequestsSigned ?? current.authnRequestsSigned,
|
|
884
|
-
identifierFormat: updates.identifierFormat ?? current.identifierFormat,
|
|
885
|
-
signatureAlgorithm: updates.signatureAlgorithm ?? current.signatureAlgorithm,
|
|
886
|
-
digestAlgorithm: updates.digestAlgorithm ?? current.digestAlgorithm
|
|
887
|
-
};
|
|
888
|
-
}
|
|
889
|
-
function mergeOIDCConfig(current, updates, issuer) {
|
|
890
|
-
return {
|
|
891
|
-
...current,
|
|
892
|
-
...updates,
|
|
893
|
-
issuer,
|
|
894
|
-
pkce: updates.pkce ?? current.pkce ?? true,
|
|
895
|
-
clientId: updates.clientId ?? current.clientId,
|
|
896
|
-
clientSecret: updates.clientSecret ?? current.clientSecret,
|
|
897
|
-
discoveryEndpoint: updates.discoveryEndpoint ?? current.discoveryEndpoint,
|
|
898
|
-
mapping: updates.mapping ?? current.mapping,
|
|
899
|
-
scopes: updates.scopes ?? current.scopes,
|
|
900
|
-
authorizationEndpoint: updates.authorizationEndpoint ?? current.authorizationEndpoint,
|
|
901
|
-
tokenEndpoint: updates.tokenEndpoint ?? current.tokenEndpoint,
|
|
902
|
-
userInfoEndpoint: updates.userInfoEndpoint ?? current.userInfoEndpoint,
|
|
903
|
-
jwksEndpoint: updates.jwksEndpoint ?? current.jwksEndpoint,
|
|
904
|
-
tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication
|
|
905
|
-
};
|
|
906
|
-
}
|
|
907
|
-
const updateSSOProvider = (options) => {
|
|
908
|
-
return createAuthEndpoint("/sso/update-provider", {
|
|
909
|
-
method: "POST",
|
|
910
|
-
use: [sessionMiddleware],
|
|
911
|
-
body: updateSSOProviderBodySchema.extend({ providerId: z.string() }),
|
|
912
|
-
metadata: { openapi: {
|
|
913
|
-
operationId: "updateSSOProvider",
|
|
914
|
-
summary: "Update SSO provider",
|
|
915
|
-
description: "Partially update an SSO provider. Only provided fields are updated. If domain changes, domainVerified is reset to false.",
|
|
916
|
-
responses: {
|
|
917
|
-
"200": { description: "SSO provider updated successfully" },
|
|
918
|
-
"404": { description: "Provider not found" },
|
|
919
|
-
"403": { description: "Access denied" }
|
|
920
|
-
}
|
|
921
|
-
} }
|
|
922
|
-
}, async (ctx) => {
|
|
923
|
-
const { providerId, ...body } = ctx.body;
|
|
924
|
-
const { issuer, domain, samlConfig, oidcConfig } = body;
|
|
925
|
-
if (!issuer && !domain && !samlConfig && !oidcConfig) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
|
|
926
|
-
const existingProvider = await checkProviderAccess(ctx, providerId);
|
|
927
|
-
const updateData = {};
|
|
928
|
-
if (body.issuer !== void 0) updateData.issuer = body.issuer;
|
|
929
|
-
if (body.domain !== void 0) {
|
|
930
|
-
updateData.domain = body.domain;
|
|
931
|
-
if (body.domain !== existingProvider.domain) updateData.domainVerified = false;
|
|
932
|
-
}
|
|
933
|
-
if (body.samlConfig) {
|
|
934
|
-
if (body.samlConfig.idpMetadata?.metadata) {
|
|
935
|
-
const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
|
|
936
|
-
if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
|
|
937
|
-
}
|
|
938
|
-
if (body.samlConfig.signatureAlgorithm !== void 0 || body.samlConfig.digestAlgorithm !== void 0) validateConfigAlgorithms({
|
|
939
|
-
signatureAlgorithm: body.samlConfig.signatureAlgorithm,
|
|
940
|
-
digestAlgorithm: body.samlConfig.digestAlgorithm
|
|
941
|
-
}, options?.saml?.algorithms);
|
|
942
|
-
const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
|
|
943
|
-
const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
|
|
944
|
-
updateData.samlConfig = JSON.stringify(updatedSamlConfig);
|
|
945
|
-
}
|
|
946
|
-
if (body.oidcConfig) {
|
|
947
|
-
const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
|
|
948
|
-
const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
|
|
949
|
-
updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
|
|
950
|
-
}
|
|
951
|
-
await ctx.context.adapter.update({
|
|
952
|
-
model: "ssoProvider",
|
|
953
|
-
where: [{
|
|
954
|
-
field: "providerId",
|
|
955
|
-
value: providerId
|
|
956
|
-
}],
|
|
957
|
-
update: updateData
|
|
958
|
-
});
|
|
959
|
-
const fullProvider = await ctx.context.adapter.findOne({
|
|
960
|
-
model: "ssoProvider",
|
|
961
|
-
where: [{
|
|
962
|
-
field: "providerId",
|
|
963
|
-
value: providerId
|
|
964
|
-
}]
|
|
965
|
-
});
|
|
966
|
-
if (!fullProvider) throw new APIError("NOT_FOUND", { message: "Provider not found after update" });
|
|
967
|
-
return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL));
|
|
968
|
-
});
|
|
969
|
-
};
|
|
970
|
-
const deleteSSOProvider = () => {
|
|
971
|
-
return createAuthEndpoint("/sso/delete-provider", {
|
|
972
|
-
method: "POST",
|
|
973
|
-
use: [sessionMiddleware],
|
|
974
|
-
body: z.object({ providerId: z.string() }),
|
|
975
|
-
metadata: { openapi: {
|
|
976
|
-
operationId: "deleteSSOProvider",
|
|
977
|
-
summary: "Delete SSO provider",
|
|
978
|
-
description: "Deletes an SSO provider",
|
|
979
|
-
responses: {
|
|
980
|
-
"200": { description: "SSO provider deleted successfully" },
|
|
981
|
-
"404": { description: "Provider not found" },
|
|
982
|
-
"403": { description: "Access denied" }
|
|
983
|
-
}
|
|
984
|
-
} }
|
|
985
|
-
}, async (ctx) => {
|
|
986
|
-
const { providerId } = ctx.body;
|
|
987
|
-
await checkProviderAccess(ctx, providerId);
|
|
988
|
-
await ctx.context.adapter.delete({
|
|
989
|
-
model: "ssoProvider",
|
|
990
|
-
where: [{
|
|
991
|
-
field: "providerId",
|
|
992
|
-
value: providerId
|
|
993
|
-
}]
|
|
994
|
-
});
|
|
995
|
-
return ctx.json({ success: true });
|
|
996
|
-
});
|
|
997
|
-
};
|
|
998
|
-
//#endregion
|
|
999
|
-
//#region src/oidc/types.ts
|
|
1000
669
|
/**
|
|
1001
|
-
*
|
|
1002
|
-
*
|
|
670
|
+
* Select the token endpoint authentication method.
|
|
671
|
+
*
|
|
672
|
+
* @param doc - The discovery document
|
|
673
|
+
* @param existing - Existing authentication method from config
|
|
674
|
+
* @returns The selected authentication method
|
|
675
|
+
*/
|
|
676
|
+
function selectTokenEndpointAuthMethod(doc, existing) {
|
|
677
|
+
if (existing) return existing;
|
|
678
|
+
const supported = doc.token_endpoint_auth_methods_supported;
|
|
679
|
+
if (!supported || supported.length === 0) return "client_secret_basic";
|
|
680
|
+
if (supported.includes("client_secret_basic")) return "client_secret_basic";
|
|
681
|
+
if (supported.includes("client_secret_post")) return "client_secret_post";
|
|
682
|
+
return "client_secret_basic";
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Check if a provider configuration needs runtime discovery.
|
|
686
|
+
*
|
|
687
|
+
* Returns true if we need discovery at runtime to complete the token exchange
|
|
688
|
+
* and validation. Specifically checks for:
|
|
689
|
+
* - `tokenEndpoint` - required for exchanging authorization code for tokens
|
|
690
|
+
* - `jwksEndpoint` - required for validating ID token signatures
|
|
691
|
+
* - `authorizationEndpoint` - required for redirecting users to the IdP for login
|
|
692
|
+
*
|
|
693
|
+
* @param config - Partial OIDC config from the provider
|
|
694
|
+
* @returns true if runtime discovery should be performed
|
|
1003
695
|
*/
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
super(message, options);
|
|
1009
|
-
this.name = "DiscoveryError";
|
|
1010
|
-
this.code = code;
|
|
1011
|
-
this.details = details;
|
|
1012
|
-
if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
|
|
1013
|
-
}
|
|
1014
|
-
};
|
|
696
|
+
function needsRuntimeDiscovery(config) {
|
|
697
|
+
if (!config) return true;
|
|
698
|
+
return !config.tokenEndpoint || !config.jwksEndpoint || !config.authorizationEndpoint;
|
|
699
|
+
}
|
|
1015
700
|
/**
|
|
1016
|
-
*
|
|
701
|
+
* Runs runtime OIDC discovery when the stored config is missing required
|
|
702
|
+
* endpoints, and merges the hydrated fields back into the config.
|
|
703
|
+
* Throws if discovery fails.
|
|
1017
704
|
*/
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
705
|
+
async function ensureRuntimeDiscovery(config, issuer, isTrustedOrigin) {
|
|
706
|
+
if (!needsRuntimeDiscovery(config)) return config;
|
|
707
|
+
const hydrated = await discoverOIDCConfig({
|
|
708
|
+
issuer,
|
|
709
|
+
existingConfig: config,
|
|
710
|
+
isTrustedOrigin
|
|
711
|
+
});
|
|
712
|
+
return {
|
|
713
|
+
...config,
|
|
714
|
+
authorizationEndpoint: hydrated.authorizationEndpoint,
|
|
715
|
+
tokenEndpoint: hydrated.tokenEndpoint,
|
|
716
|
+
tokenEndpointAuthentication: hydrated.tokenEndpointAuthentication,
|
|
717
|
+
userInfoEndpoint: hydrated.userInfoEndpoint,
|
|
718
|
+
jwksEndpoint: hydrated.jwksEndpoint
|
|
719
|
+
};
|
|
720
|
+
}
|
|
1024
721
|
//#endregion
|
|
1025
|
-
//#region src/oidc/
|
|
722
|
+
//#region src/oidc/errors.ts
|
|
1026
723
|
/**
|
|
1027
|
-
* OIDC Discovery
|
|
1028
|
-
*
|
|
1029
|
-
* Implements OIDC discovery document fetching, validation, and hydration.
|
|
1030
|
-
* This module is used both at provider registration time (to persist validated config)
|
|
1031
|
-
* and at runtime (to hydrate legacy providers that are missing metadata).
|
|
724
|
+
* OIDC Discovery Error Mapping
|
|
1032
725
|
*
|
|
1033
|
-
*
|
|
726
|
+
* Maps DiscoveryError codes to appropriate APIError responses.
|
|
727
|
+
* Used at the boundary between the discovery pipeline and HTTP handlers.
|
|
1034
728
|
*/
|
|
1035
|
-
/** Default timeout for discovery requests (10 seconds) */
|
|
1036
|
-
const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
|
|
1037
729
|
/**
|
|
1038
|
-
*
|
|
730
|
+
* Maps a DiscoveryError to an appropriate APIError for HTTP responses.
|
|
1039
731
|
*
|
|
1040
|
-
*
|
|
1041
|
-
*
|
|
1042
|
-
*
|
|
1043
|
-
*
|
|
1044
|
-
*
|
|
1045
|
-
*
|
|
1046
|
-
*
|
|
1047
|
-
*
|
|
732
|
+
* Error code mapping:
|
|
733
|
+
* - discovery_invalid_url → 400 BAD_REQUEST
|
|
734
|
+
* - discovery_not_found → 400 BAD_REQUEST
|
|
735
|
+
* - discovery_untrusted_origin → 400 BAD_REQUEST
|
|
736
|
+
* - discovery_private_host → 400 BAD_REQUEST
|
|
737
|
+
* - discovery_invalid_json → 400 BAD_REQUEST
|
|
738
|
+
* - discovery_incomplete → 400 BAD_REQUEST
|
|
739
|
+
* - issuer_mismatch → 400 BAD_REQUEST
|
|
740
|
+
* - unsupported_token_auth_method → 400 BAD_REQUEST
|
|
741
|
+
* - discovery_timeout → 502 BAD_GATEWAY
|
|
742
|
+
* - discovery_unexpected_error → 502 BAD_GATEWAY
|
|
1048
743
|
*
|
|
1049
|
-
* @param
|
|
1050
|
-
* @
|
|
1051
|
-
* @returns Hydrated OIDC configuration ready for persistence
|
|
1052
|
-
* @throws DiscoveryError on any failure
|
|
744
|
+
* @param error - The DiscoveryError to map
|
|
745
|
+
* @returns An APIError with appropriate status and message
|
|
1053
746
|
*/
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
747
|
+
function mapDiscoveryErrorToAPIError(error) {
|
|
748
|
+
switch (error.code) {
|
|
749
|
+
case "discovery_timeout": return new APIError("BAD_GATEWAY", {
|
|
750
|
+
message: `OIDC discovery timed out: ${error.message}`,
|
|
751
|
+
code: error.code
|
|
752
|
+
});
|
|
753
|
+
case "discovery_unexpected_error": return new APIError("BAD_GATEWAY", {
|
|
754
|
+
message: `OIDC discovery failed: ${error.message}`,
|
|
755
|
+
code: error.code
|
|
756
|
+
});
|
|
757
|
+
case "discovery_not_found": return new APIError("BAD_REQUEST", {
|
|
758
|
+
message: `OIDC discovery endpoint not found. The issuer may not support OIDC discovery, or the URL is incorrect. ${error.message}`,
|
|
759
|
+
code: error.code
|
|
760
|
+
});
|
|
761
|
+
case "discovery_invalid_url": return new APIError("BAD_REQUEST", {
|
|
762
|
+
message: `Invalid OIDC endpoint URL: ${error.message}`,
|
|
763
|
+
code: error.code
|
|
764
|
+
});
|
|
765
|
+
case "discovery_untrusted_origin": return new APIError("BAD_REQUEST", {
|
|
766
|
+
message: `Untrusted OIDC discovery URL: ${error.message}`,
|
|
767
|
+
code: error.code
|
|
768
|
+
});
|
|
769
|
+
case "discovery_private_host": return new APIError("BAD_REQUEST", {
|
|
770
|
+
message: error.message,
|
|
771
|
+
code: error.code
|
|
772
|
+
});
|
|
773
|
+
case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
|
|
774
|
+
message: `OIDC discovery returned invalid data: ${error.message}`,
|
|
775
|
+
code: error.code
|
|
776
|
+
});
|
|
777
|
+
case "discovery_incomplete": return new APIError("BAD_REQUEST", {
|
|
778
|
+
message: `OIDC discovery document is missing required fields: ${error.message}`,
|
|
779
|
+
code: error.code
|
|
780
|
+
});
|
|
781
|
+
case "issuer_mismatch": return new APIError("BAD_REQUEST", {
|
|
782
|
+
message: `OIDC issuer mismatch: ${error.message}`,
|
|
783
|
+
code: error.code
|
|
784
|
+
});
|
|
785
|
+
case "unsupported_token_auth_method": return new APIError("BAD_REQUEST", {
|
|
786
|
+
message: `Incompatible OIDC provider: ${error.message}`,
|
|
787
|
+
code: error.code
|
|
788
|
+
});
|
|
789
|
+
default:
|
|
790
|
+
error.code;
|
|
791
|
+
return new APIError("INTERNAL_SERVER_ERROR", {
|
|
792
|
+
message: `Unexpected discovery error: ${error.message}`,
|
|
793
|
+
code: "discovery_unexpected_error"
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
//#endregion
|
|
798
|
+
//#region src/saml/parser.ts
|
|
799
|
+
const xmlParser = new XMLParser({
|
|
800
|
+
ignoreAttributes: false,
|
|
801
|
+
attributeNamePrefix: "@_",
|
|
802
|
+
removeNSPrefix: true,
|
|
803
|
+
processEntities: false
|
|
804
|
+
});
|
|
805
|
+
function findNode(obj, nodeName) {
|
|
806
|
+
if (!obj || typeof obj !== "object") return null;
|
|
807
|
+
const record = obj;
|
|
808
|
+
if (nodeName in record) return record[nodeName];
|
|
809
|
+
for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
|
|
810
|
+
const found = findNode(item, nodeName);
|
|
811
|
+
if (found) return found;
|
|
812
|
+
}
|
|
813
|
+
else if (typeof value === "object" && value !== null) {
|
|
814
|
+
const found = findNode(value, nodeName);
|
|
815
|
+
if (found) return found;
|
|
816
|
+
}
|
|
817
|
+
return null;
|
|
818
|
+
}
|
|
819
|
+
function countAllNodes(obj, nodeName) {
|
|
820
|
+
if (!obj || typeof obj !== "object") return 0;
|
|
821
|
+
let count = 0;
|
|
822
|
+
const record = obj;
|
|
823
|
+
if (nodeName in record) {
|
|
824
|
+
const node = record[nodeName];
|
|
825
|
+
count += Array.isArray(node) ? node.length : 1;
|
|
826
|
+
}
|
|
827
|
+
for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) count += countAllNodes(item, nodeName);
|
|
828
|
+
else if (typeof value === "object" && value !== null) count += countAllNodes(value, nodeName);
|
|
829
|
+
return count;
|
|
830
|
+
}
|
|
831
|
+
//#endregion
|
|
832
|
+
//#region src/saml/algorithms.ts
|
|
833
|
+
const SignatureAlgorithm = {
|
|
834
|
+
RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
|
|
835
|
+
RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
|
|
836
|
+
RSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
|
|
837
|
+
RSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
|
|
838
|
+
ECDSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
|
|
839
|
+
ECDSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
|
|
840
|
+
ECDSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"
|
|
841
|
+
};
|
|
842
|
+
const DigestAlgorithm = {
|
|
843
|
+
SHA1: "http://www.w3.org/2000/09/xmldsig#sha1",
|
|
844
|
+
SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
|
|
845
|
+
SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
|
|
846
|
+
SHA512: "http://www.w3.org/2001/04/xmlenc#sha512"
|
|
847
|
+
};
|
|
848
|
+
const KeyEncryptionAlgorithm = {
|
|
849
|
+
RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
|
|
850
|
+
RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
|
|
851
|
+
RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep"
|
|
852
|
+
};
|
|
853
|
+
const DataEncryptionAlgorithm = {
|
|
854
|
+
TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
|
|
855
|
+
AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
|
|
856
|
+
AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
|
|
857
|
+
AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
|
|
858
|
+
AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
|
|
859
|
+
AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
|
|
860
|
+
AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
|
|
861
|
+
};
|
|
862
|
+
const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
|
|
863
|
+
const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS = [KeyEncryptionAlgorithm.RSA_1_5];
|
|
864
|
+
const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS = [DataEncryptionAlgorithm.TRIPLEDES_CBC];
|
|
865
|
+
const DEPRECATED_DIGEST_ALGORITHMS = [DigestAlgorithm.SHA1];
|
|
866
|
+
const SECURE_SIGNATURE_ALGORITHMS = [
|
|
867
|
+
SignatureAlgorithm.RSA_SHA256,
|
|
868
|
+
SignatureAlgorithm.RSA_SHA384,
|
|
869
|
+
SignatureAlgorithm.RSA_SHA512,
|
|
870
|
+
SignatureAlgorithm.ECDSA_SHA256,
|
|
871
|
+
SignatureAlgorithm.ECDSA_SHA384,
|
|
872
|
+
SignatureAlgorithm.ECDSA_SHA512
|
|
873
|
+
];
|
|
874
|
+
const SECURE_DIGEST_ALGORITHMS = [
|
|
875
|
+
DigestAlgorithm.SHA256,
|
|
876
|
+
DigestAlgorithm.SHA384,
|
|
877
|
+
DigestAlgorithm.SHA512
|
|
878
|
+
];
|
|
879
|
+
const SHORT_FORM_SIGNATURE_TO_URI = {
|
|
880
|
+
sha1: SignatureAlgorithm.RSA_SHA1,
|
|
881
|
+
sha256: SignatureAlgorithm.RSA_SHA256,
|
|
882
|
+
sha384: SignatureAlgorithm.RSA_SHA384,
|
|
883
|
+
sha512: SignatureAlgorithm.RSA_SHA512,
|
|
884
|
+
"rsa-sha1": SignatureAlgorithm.RSA_SHA1,
|
|
885
|
+
"rsa-sha256": SignatureAlgorithm.RSA_SHA256,
|
|
886
|
+
"rsa-sha384": SignatureAlgorithm.RSA_SHA384,
|
|
887
|
+
"rsa-sha512": SignatureAlgorithm.RSA_SHA512,
|
|
888
|
+
"ecdsa-sha256": SignatureAlgorithm.ECDSA_SHA256,
|
|
889
|
+
"ecdsa-sha384": SignatureAlgorithm.ECDSA_SHA384,
|
|
890
|
+
"ecdsa-sha512": SignatureAlgorithm.ECDSA_SHA512
|
|
891
|
+
};
|
|
892
|
+
const SHORT_FORM_DIGEST_TO_URI = {
|
|
893
|
+
sha1: DigestAlgorithm.SHA1,
|
|
894
|
+
sha256: DigestAlgorithm.SHA256,
|
|
895
|
+
sha384: DigestAlgorithm.SHA384,
|
|
896
|
+
sha512: DigestAlgorithm.SHA512
|
|
897
|
+
};
|
|
898
|
+
function normalizeSignatureAlgorithm(alg) {
|
|
899
|
+
return SHORT_FORM_SIGNATURE_TO_URI[alg.toLowerCase()] ?? alg;
|
|
1072
900
|
}
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
*
|
|
1076
|
-
* Per OIDC Discovery spec, the discovery document is located at:
|
|
1077
|
-
* <issuer>/.well-known/openid-configuration
|
|
1078
|
-
*
|
|
1079
|
-
* Handles trailing slashes correctly.
|
|
1080
|
-
*/
|
|
1081
|
-
function computeDiscoveryUrl(issuer) {
|
|
1082
|
-
return `${issuer.endsWith("/") ? issuer.slice(0, -1) : issuer}/.well-known/openid-configuration`;
|
|
901
|
+
function normalizeDigestAlgorithm(alg) {
|
|
902
|
+
return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
|
|
1083
903
|
}
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
904
|
+
function extractEncryptionAlgorithms(xml) {
|
|
905
|
+
try {
|
|
906
|
+
const parsed = xmlParser.parse(xml);
|
|
907
|
+
const keyAlg = (findNode(parsed, "EncryptedKey")?.EncryptionMethod)?.["@_Algorithm"];
|
|
908
|
+
const dataAlg = (findNode(parsed, "EncryptedData")?.EncryptionMethod)?.["@_Algorithm"];
|
|
909
|
+
return {
|
|
910
|
+
keyEncryption: keyAlg || null,
|
|
911
|
+
dataEncryption: dataAlg || null
|
|
912
|
+
};
|
|
913
|
+
} catch {
|
|
914
|
+
return {
|
|
915
|
+
keyEncryption: null,
|
|
916
|
+
dataEncryption: null
|
|
917
|
+
};
|
|
918
|
+
}
|
|
1094
919
|
}
|
|
1095
|
-
|
|
1096
|
-
* Fetch the OIDC discovery document from the IdP.
|
|
1097
|
-
*
|
|
1098
|
-
* @param url - The discovery endpoint URL
|
|
1099
|
-
* @param timeout - Request timeout in milliseconds
|
|
1100
|
-
* @returns The parsed discovery document
|
|
1101
|
-
* @throws DiscoveryError on network errors, timeouts, or invalid responses
|
|
1102
|
-
*/
|
|
1103
|
-
async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
|
|
920
|
+
function hasEncryptedAssertion(xml) {
|
|
1104
921
|
try {
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
922
|
+
return findNode(xmlParser.parse(xml), "EncryptedAssertion") !== null;
|
|
923
|
+
} catch {
|
|
924
|
+
return false;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
function handleDeprecatedAlgorithm(message, behavior, errorCode) {
|
|
928
|
+
switch (behavior) {
|
|
929
|
+
case "reject": throw new APIError("BAD_REQUEST", {
|
|
930
|
+
message,
|
|
931
|
+
code: errorCode
|
|
1108
932
|
});
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
933
|
+
case "warn":
|
|
934
|
+
console.warn(`[SAML Security Warning] ${message}`);
|
|
935
|
+
break;
|
|
936
|
+
case "allow": break;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
function validateSignatureAlgorithm(algorithm, options = {}) {
|
|
940
|
+
if (!algorithm) return;
|
|
941
|
+
const { onDeprecated = "warn", allowedSignatureAlgorithms } = options;
|
|
942
|
+
if (allowedSignatureAlgorithms) {
|
|
943
|
+
if (!allowedSignatureAlgorithms.includes(algorithm)) throw new APIError("BAD_REQUEST", {
|
|
944
|
+
message: `SAML signature algorithm not in allow-list: ${algorithm}`,
|
|
945
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
946
|
+
});
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(algorithm)) {
|
|
950
|
+
handleDeprecatedAlgorithm(`SAML response uses deprecated signature algorithm: ${algorithm}. Please configure your IdP to use SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
if (!SECURE_SIGNATURE_ALGORITHMS.includes(algorithm)) throw new APIError("BAD_REQUEST", {
|
|
954
|
+
message: `SAML signature algorithm not recognized: ${algorithm}`,
|
|
955
|
+
code: "SAML_UNKNOWN_ALGORITHM"
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
function validateEncryptionAlgorithms(algorithms, options = {}) {
|
|
959
|
+
const { onDeprecated = "warn", allowedKeyEncryptionAlgorithms, allowedDataEncryptionAlgorithms } = options;
|
|
960
|
+
const { keyEncryption, dataEncryption } = algorithms;
|
|
961
|
+
if (keyEncryption) {
|
|
962
|
+
if (allowedKeyEncryptionAlgorithms) {
|
|
963
|
+
if (!allowedKeyEncryptionAlgorithms.includes(keyEncryption)) throw new APIError("BAD_REQUEST", {
|
|
964
|
+
message: `SAML key encryption algorithm not in allow-list: ${keyEncryption}`,
|
|
965
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
1114
966
|
});
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
967
|
+
} 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");
|
|
968
|
+
}
|
|
969
|
+
if (dataEncryption) {
|
|
970
|
+
if (allowedDataEncryptionAlgorithms) {
|
|
971
|
+
if (!allowedDataEncryptionAlgorithms.includes(dataEncryption)) throw new APIError("BAD_REQUEST", {
|
|
972
|
+
message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
|
|
973
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
1118
974
|
});
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
975
|
+
} 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");
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
function validateSAMLAlgorithms(response, options) {
|
|
979
|
+
validateSignatureAlgorithm(response.sigAlg, options);
|
|
980
|
+
if (hasEncryptedAssertion(response.samlContent)) validateEncryptionAlgorithms(extractEncryptionAlgorithms(response.samlContent), options);
|
|
981
|
+
}
|
|
982
|
+
function validateConfigAlgorithms(config, options = {}) {
|
|
983
|
+
const { onDeprecated = "warn", allowedSignatureAlgorithms, allowedDigestAlgorithms } = options;
|
|
984
|
+
if (config.signatureAlgorithm) {
|
|
985
|
+
const normalized = normalizeSignatureAlgorithm(config.signatureAlgorithm);
|
|
986
|
+
if (allowedSignatureAlgorithms) {
|
|
987
|
+
if (!allowedSignatureAlgorithms.map(normalizeSignatureAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
988
|
+
message: `SAML signature algorithm not in allow-list: ${config.signatureAlgorithm}`,
|
|
989
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
1122
990
|
});
|
|
1123
|
-
}
|
|
1124
|
-
if (!
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
url,
|
|
1128
|
-
bodyPreview: data.slice(0, 200)
|
|
991
|
+
} 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");
|
|
992
|
+
else if (!SECURE_SIGNATURE_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
993
|
+
message: `SAML signature algorithm not recognized: ${config.signatureAlgorithm}`,
|
|
994
|
+
code: "SAML_UNKNOWN_ALGORITHM"
|
|
1129
995
|
});
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
if (
|
|
1134
|
-
|
|
1135
|
-
|
|
996
|
+
}
|
|
997
|
+
if (config.digestAlgorithm) {
|
|
998
|
+
const normalized = normalizeDigestAlgorithm(config.digestAlgorithm);
|
|
999
|
+
if (allowedDigestAlgorithms) {
|
|
1000
|
+
if (!allowedDigestAlgorithms.map(normalizeDigestAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
1001
|
+
message: `SAML digest algorithm not in allow-list: ${config.digestAlgorithm}`,
|
|
1002
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
1003
|
+
});
|
|
1004
|
+
} 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");
|
|
1005
|
+
else if (!SECURE_DIGEST_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
1006
|
+
message: `SAML digest algorithm not recognized: ${config.digestAlgorithm}`,
|
|
1007
|
+
code: "SAML_UNKNOWN_ALGORITHM"
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
//#endregion
|
|
1012
|
+
//#region src/saml/assertions.ts
|
|
1013
|
+
function countAssertions(xml) {
|
|
1014
|
+
let parsed;
|
|
1015
|
+
try {
|
|
1016
|
+
parsed = xmlParser.parse(xml);
|
|
1017
|
+
} catch {
|
|
1018
|
+
throw new APIError("BAD_REQUEST", {
|
|
1019
|
+
message: "Failed to parse SAML response XML",
|
|
1020
|
+
code: "SAML_INVALID_XML"
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
const assertions = countAllNodes(parsed, "Assertion");
|
|
1024
|
+
const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
|
|
1025
|
+
return {
|
|
1026
|
+
assertions,
|
|
1027
|
+
encryptedAssertions,
|
|
1028
|
+
total: assertions + encryptedAssertions
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
function validateSingleAssertion(samlResponse) {
|
|
1032
|
+
let xml;
|
|
1033
|
+
try {
|
|
1034
|
+
xml = new TextDecoder().decode(base64.decode(samlResponse.replace(/\s+/g, "")));
|
|
1035
|
+
if (!xml.includes("<")) throw new Error("Not XML");
|
|
1036
|
+
} catch {
|
|
1037
|
+
throw new APIError("BAD_REQUEST", {
|
|
1038
|
+
message: "Invalid base64-encoded SAML response",
|
|
1039
|
+
code: "SAML_INVALID_ENCODING"
|
|
1136
1040
|
});
|
|
1137
|
-
throw new DiscoveryError("discovery_unexpected_error", `Unexpected error during discovery: ${error instanceof Error ? error.message : String(error)}`, { url }, { cause: error });
|
|
1138
1041
|
}
|
|
1042
|
+
const counts = countAssertions(xml);
|
|
1043
|
+
if (counts.total === 0) throw new APIError("BAD_REQUEST", {
|
|
1044
|
+
message: "SAML response contains no assertions",
|
|
1045
|
+
code: "SAML_NO_ASSERTION"
|
|
1046
|
+
});
|
|
1047
|
+
if (counts.total > 1) throw new APIError("BAD_REQUEST", {
|
|
1048
|
+
message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
|
|
1049
|
+
code: "SAML_MULTIPLE_ASSERTIONS"
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
//#endregion
|
|
1053
|
+
//#region src/routes/schemas.ts
|
|
1054
|
+
const oidcMappingSchema = z.object({
|
|
1055
|
+
id: z.string().optional(),
|
|
1056
|
+
email: z.string().optional(),
|
|
1057
|
+
emailVerified: z.string().optional(),
|
|
1058
|
+
name: z.string().optional(),
|
|
1059
|
+
image: z.string().optional(),
|
|
1060
|
+
extraFields: z.record(z.string(), z.any()).optional()
|
|
1061
|
+
}).optional();
|
|
1062
|
+
const samlMappingSchema = z.object({
|
|
1063
|
+
id: z.string().optional(),
|
|
1064
|
+
email: z.string().optional(),
|
|
1065
|
+
emailVerified: z.string().optional(),
|
|
1066
|
+
name: z.string().optional(),
|
|
1067
|
+
firstName: z.string().optional(),
|
|
1068
|
+
lastName: z.string().optional(),
|
|
1069
|
+
extraFields: z.record(z.string(), z.any()).optional()
|
|
1070
|
+
}).optional();
|
|
1071
|
+
const oidcConfigSchema = z.object({
|
|
1072
|
+
clientId: z.string().optional(),
|
|
1073
|
+
clientSecret: z.string().optional(),
|
|
1074
|
+
authorizationEndpoint: z.url().optional(),
|
|
1075
|
+
tokenEndpoint: z.url().optional(),
|
|
1076
|
+
userInfoEndpoint: z.url().optional(),
|
|
1077
|
+
tokenEndpointAuthentication: z.enum(["client_secret_post", "client_secret_basic"]).optional(),
|
|
1078
|
+
jwksEndpoint: z.url().optional(),
|
|
1079
|
+
discoveryEndpoint: z.url().optional(),
|
|
1080
|
+
scopes: z.array(z.string()).optional(),
|
|
1081
|
+
pkce: z.boolean().optional(),
|
|
1082
|
+
overrideUserInfo: z.boolean().optional(),
|
|
1083
|
+
mapping: oidcMappingSchema
|
|
1084
|
+
});
|
|
1085
|
+
const samlConfigSchema = z.object({
|
|
1086
|
+
entryPoint: z.string().url().optional(),
|
|
1087
|
+
cert: z.string().optional(),
|
|
1088
|
+
callbackUrl: z.string().url().optional(),
|
|
1089
|
+
audience: z.string().optional(),
|
|
1090
|
+
idpMetadata: z.object({
|
|
1091
|
+
metadata: z.string().optional(),
|
|
1092
|
+
entityID: z.string().optional(),
|
|
1093
|
+
cert: z.string().optional(),
|
|
1094
|
+
privateKey: z.string().optional(),
|
|
1095
|
+
privateKeyPass: z.string().optional(),
|
|
1096
|
+
isAssertionEncrypted: z.boolean().optional(),
|
|
1097
|
+
encPrivateKey: z.string().optional(),
|
|
1098
|
+
encPrivateKeyPass: z.string().optional(),
|
|
1099
|
+
singleSignOnService: z.array(z.object({
|
|
1100
|
+
Binding: z.string(),
|
|
1101
|
+
Location: z.string().url()
|
|
1102
|
+
})).optional()
|
|
1103
|
+
}).optional(),
|
|
1104
|
+
spMetadata: z.object({
|
|
1105
|
+
metadata: z.string().optional(),
|
|
1106
|
+
entityID: z.string().optional(),
|
|
1107
|
+
binding: z.string().optional(),
|
|
1108
|
+
privateKey: z.string().optional(),
|
|
1109
|
+
privateKeyPass: z.string().optional(),
|
|
1110
|
+
isAssertionEncrypted: z.boolean().optional(),
|
|
1111
|
+
encPrivateKey: z.string().optional(),
|
|
1112
|
+
encPrivateKeyPass: z.string().optional()
|
|
1113
|
+
}).optional(),
|
|
1114
|
+
wantAssertionsSigned: z.boolean().optional(),
|
|
1115
|
+
authnRequestsSigned: z.boolean().optional(),
|
|
1116
|
+
signatureAlgorithm: z.string().optional(),
|
|
1117
|
+
digestAlgorithm: z.string().optional(),
|
|
1118
|
+
identifierFormat: z.string().optional(),
|
|
1119
|
+
privateKey: z.string().optional(),
|
|
1120
|
+
decryptionPvk: z.string().optional(),
|
|
1121
|
+
additionalParams: z.record(z.string(), z.any()).optional(),
|
|
1122
|
+
mapping: samlMappingSchema
|
|
1123
|
+
});
|
|
1124
|
+
const updateSSOProviderBodySchema = z.object({
|
|
1125
|
+
issuer: z.string().url().optional(),
|
|
1126
|
+
domain: z.string().optional(),
|
|
1127
|
+
oidcConfig: oidcConfigSchema.optional(),
|
|
1128
|
+
samlConfig: samlConfigSchema.optional()
|
|
1129
|
+
});
|
|
1130
|
+
//#endregion
|
|
1131
|
+
//#region src/routes/providers.ts
|
|
1132
|
+
const ADMIN_ROLES = ["owner", "admin"];
|
|
1133
|
+
function hasOrgAdminRole(member) {
|
|
1134
|
+
return member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()));
|
|
1139
1135
|
}
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
*
|
|
1151
|
-
* @param doc - The discovery document to validate
|
|
1152
|
-
* @param configuredIssuer - The expected issuer value
|
|
1153
|
-
* @throws DiscoveryError if validation fails
|
|
1154
|
-
*/
|
|
1155
|
-
function validateDiscoveryDocument(doc, configuredIssuer) {
|
|
1156
|
-
const missingFields = [];
|
|
1157
|
-
for (const field of REQUIRED_DISCOVERY_FIELDS) if (!doc[field]) missingFields.push(field);
|
|
1158
|
-
if (missingFields.length > 0) throw new DiscoveryError("discovery_incomplete", `Discovery document is missing required fields: ${missingFields.join(", ")}`, { missingFields });
|
|
1159
|
-
if ((doc.issuer.endsWith("/") ? doc.issuer.slice(0, -1) : doc.issuer) !== (configuredIssuer.endsWith("/") ? configuredIssuer.slice(0, -1) : configuredIssuer)) throw new DiscoveryError("issuer_mismatch", `Discovered issuer "${doc.issuer}" does not match configured issuer "${configuredIssuer}"`, {
|
|
1160
|
-
discovered: doc.issuer,
|
|
1161
|
-
configured: configuredIssuer
|
|
1136
|
+
async function isOrgAdmin(ctx, userId, organizationId) {
|
|
1137
|
+
const member = await ctx.context.adapter.findOne({
|
|
1138
|
+
model: "member",
|
|
1139
|
+
where: [{
|
|
1140
|
+
field: "userId",
|
|
1141
|
+
value: userId
|
|
1142
|
+
}, {
|
|
1143
|
+
field: "organizationId",
|
|
1144
|
+
value: organizationId
|
|
1145
|
+
}]
|
|
1162
1146
|
});
|
|
1147
|
+
return member ? hasOrgAdminRole(member) : false;
|
|
1163
1148
|
}
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
doc.jwks_uri = normalizeAndValidateUrl("jwks_uri", doc.jwks_uri, issuer, isTrustedOrigin);
|
|
1177
|
-
if (doc.userinfo_endpoint) doc.userinfo_endpoint = normalizeAndValidateUrl("userinfo_endpoint", doc.userinfo_endpoint, issuer, isTrustedOrigin);
|
|
1178
|
-
if (doc.revocation_endpoint) doc.revocation_endpoint = normalizeAndValidateUrl("revocation_endpoint", doc.revocation_endpoint, issuer, isTrustedOrigin);
|
|
1179
|
-
if (doc.end_session_endpoint) doc.end_session_endpoint = normalizeAndValidateUrl("end_session_endpoint", doc.end_session_endpoint, issuer, isTrustedOrigin);
|
|
1180
|
-
if (doc.introspection_endpoint) doc.introspection_endpoint = normalizeAndValidateUrl("introspection_endpoint", doc.introspection_endpoint, issuer, isTrustedOrigin);
|
|
1181
|
-
return doc;
|
|
1182
|
-
}
|
|
1183
|
-
/**
|
|
1184
|
-
* Normalizes and validates a single URL endpoint
|
|
1185
|
-
* @param name The url name
|
|
1186
|
-
* @param endpoint The url to validate
|
|
1187
|
-
* @param issuer The issuer base url
|
|
1188
|
-
* @param isTrustedOrigin - Origin verification tester function
|
|
1189
|
-
* @returns
|
|
1190
|
-
*/
|
|
1191
|
-
function normalizeAndValidateUrl(name, endpoint, issuer, isTrustedOrigin) {
|
|
1192
|
-
const url = normalizeUrl(name, endpoint, issuer);
|
|
1193
|
-
if (!isTrustedOrigin(url)) throw new DiscoveryError("discovery_untrusted_origin", `The ${name} "${url}" is not trusted by your trusted origins configuration.`, {
|
|
1194
|
-
endpoint: name,
|
|
1195
|
-
url
|
|
1149
|
+
async function batchCheckOrgAdmin(ctx, userId, organizationIds) {
|
|
1150
|
+
if (organizationIds.length === 0) return /* @__PURE__ */ new Set();
|
|
1151
|
+
const members = await ctx.context.adapter.findMany({
|
|
1152
|
+
model: "member",
|
|
1153
|
+
where: [{
|
|
1154
|
+
field: "userId",
|
|
1155
|
+
value: userId
|
|
1156
|
+
}, {
|
|
1157
|
+
field: "organizationId",
|
|
1158
|
+
value: organizationIds,
|
|
1159
|
+
operator: "in"
|
|
1160
|
+
}]
|
|
1196
1161
|
});
|
|
1197
|
-
|
|
1162
|
+
const adminOrgIds = /* @__PURE__ */ new Set();
|
|
1163
|
+
for (const member of members) if (hasOrgAdminRole(member)) adminOrgIds.add(member.organizationId);
|
|
1164
|
+
return adminOrgIds;
|
|
1198
1165
|
}
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
* @param name - The endpoint name (e.g token_endpoint)
|
|
1203
|
-
* @param endpoint - The endpoint URL to normalize
|
|
1204
|
-
* @param issuer - The base issuer URL
|
|
1205
|
-
* @returns The normalized endpoint URL
|
|
1206
|
-
*/
|
|
1207
|
-
function normalizeUrl(name, endpoint, issuer) {
|
|
1166
|
+
function sanitizeProvider(provider, baseURL) {
|
|
1167
|
+
let oidcConfig = null;
|
|
1168
|
+
let samlConfig = null;
|
|
1208
1169
|
try {
|
|
1209
|
-
|
|
1170
|
+
oidcConfig = safeJsonParse(provider.oidcConfig);
|
|
1210
1171
|
} catch {
|
|
1211
|
-
|
|
1212
|
-
const basePath = issuerURL.pathname.replace(/\/+$/, "");
|
|
1213
|
-
const endpointPath = endpoint.replace(/^\/+/, "");
|
|
1214
|
-
return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
|
|
1172
|
+
oidcConfig = null;
|
|
1215
1173
|
}
|
|
1216
|
-
}
|
|
1217
|
-
/**
|
|
1218
|
-
* Parses the given URL or throws in case of invalid or unsupported protocols
|
|
1219
|
-
*
|
|
1220
|
-
* @param name the url name
|
|
1221
|
-
* @param endpoint the endpoint url
|
|
1222
|
-
* @param [base] optional base path
|
|
1223
|
-
* @returns
|
|
1224
|
-
*/
|
|
1225
|
-
function parseURL(name, endpoint, base) {
|
|
1226
|
-
let endpointURL;
|
|
1227
1174
|
try {
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
|
|
1175
|
+
samlConfig = safeJsonParse(provider.samlConfig);
|
|
1176
|
+
} catch {
|
|
1177
|
+
samlConfig = null;
|
|
1232
1178
|
}
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1179
|
+
const type = samlConfig ? "saml" : "oidc";
|
|
1180
|
+
return {
|
|
1181
|
+
providerId: provider.providerId,
|
|
1182
|
+
type,
|
|
1183
|
+
issuer: provider.issuer,
|
|
1184
|
+
domain: provider.domain,
|
|
1185
|
+
organizationId: provider.organizationId || null,
|
|
1186
|
+
domainVerified: provider.domainVerified ?? false,
|
|
1187
|
+
oidcConfig: oidcConfig ? {
|
|
1188
|
+
discoveryEndpoint: oidcConfig.discoveryEndpoint,
|
|
1189
|
+
clientIdLastFour: maskClientId(oidcConfig.clientId),
|
|
1190
|
+
pkce: oidcConfig.pkce,
|
|
1191
|
+
authorizationEndpoint: oidcConfig.authorizationEndpoint,
|
|
1192
|
+
tokenEndpoint: oidcConfig.tokenEndpoint,
|
|
1193
|
+
userInfoEndpoint: oidcConfig.userInfoEndpoint,
|
|
1194
|
+
jwksEndpoint: oidcConfig.jwksEndpoint,
|
|
1195
|
+
scopes: oidcConfig.scopes,
|
|
1196
|
+
tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication
|
|
1197
|
+
} : void 0,
|
|
1198
|
+
samlConfig: samlConfig ? {
|
|
1199
|
+
entryPoint: samlConfig.entryPoint,
|
|
1200
|
+
callbackUrl: samlConfig.callbackUrl,
|
|
1201
|
+
audience: samlConfig.audience,
|
|
1202
|
+
wantAssertionsSigned: samlConfig.wantAssertionsSigned,
|
|
1203
|
+
authnRequestsSigned: samlConfig.authnRequestsSigned,
|
|
1204
|
+
identifierFormat: samlConfig.identifierFormat,
|
|
1205
|
+
signatureAlgorithm: samlConfig.signatureAlgorithm,
|
|
1206
|
+
digestAlgorithm: samlConfig.digestAlgorithm,
|
|
1207
|
+
certificate: (() => {
|
|
1208
|
+
try {
|
|
1209
|
+
return parseCertificate(samlConfig.cert);
|
|
1210
|
+
} catch {
|
|
1211
|
+
return { error: "Failed to parse certificate" };
|
|
1212
|
+
}
|
|
1213
|
+
})()
|
|
1214
|
+
} : void 0,
|
|
1215
|
+
spMetadataUrl: `${baseURL}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(provider.providerId)}`
|
|
1216
|
+
};
|
|
1237
1217
|
}
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1218
|
+
const listSSOProviders = () => {
|
|
1219
|
+
return createAuthEndpoint("/sso/providers", {
|
|
1220
|
+
method: "GET",
|
|
1221
|
+
use: [sessionMiddleware],
|
|
1222
|
+
metadata: { openapi: {
|
|
1223
|
+
operationId: "listSSOProviders",
|
|
1224
|
+
summary: "List SSO providers",
|
|
1225
|
+
description: "Returns a list of SSO providers the user has access to",
|
|
1226
|
+
responses: { "200": { description: "List of SSO providers" } }
|
|
1227
|
+
} }
|
|
1228
|
+
}, async (ctx) => {
|
|
1229
|
+
const userId = ctx.context.session.user.id;
|
|
1230
|
+
const allProviders = await ctx.context.adapter.findMany({ model: "ssoProvider" });
|
|
1231
|
+
const userOwnedProviders = allProviders.filter((p) => p.userId === userId && !p.organizationId);
|
|
1232
|
+
const orgProviders = allProviders.filter((p) => p.organizationId !== null && p.organizationId !== void 0);
|
|
1233
|
+
const orgPluginEnabled = ctx.context.hasPlugin("organization");
|
|
1234
|
+
let accessibleProviders = [...userOwnedProviders];
|
|
1235
|
+
if (orgPluginEnabled && orgProviders.length > 0) {
|
|
1236
|
+
const adminOrgIds = await batchCheckOrgAdmin(ctx, userId, [...new Set(orgProviders.map((p) => p.organizationId).filter((id) => id !== null && id !== void 0))]);
|
|
1237
|
+
const orgAccessibleProviders = orgProviders.filter((provider) => provider.organizationId && adminOrgIds.has(provider.organizationId));
|
|
1238
|
+
accessibleProviders = [...accessibleProviders, ...orgAccessibleProviders];
|
|
1239
|
+
} else if (!orgPluginEnabled) {
|
|
1240
|
+
const userOwnedOrgProviders = orgProviders.filter((p) => p.userId === userId);
|
|
1241
|
+
accessibleProviders = [...accessibleProviders, ...userOwnedOrgProviders];
|
|
1242
|
+
}
|
|
1243
|
+
const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL));
|
|
1244
|
+
return ctx.json({ providers });
|
|
1245
|
+
});
|
|
1246
|
+
};
|
|
1247
|
+
const getSSOProviderQuerySchema = z.object({ providerId: z.string() });
|
|
1248
|
+
async function checkProviderAccess(ctx, providerId) {
|
|
1249
|
+
const userId = ctx.context.session.user.id;
|
|
1250
|
+
const provider = await ctx.context.adapter.findOne({
|
|
1251
|
+
model: "ssoProvider",
|
|
1252
|
+
where: [{
|
|
1253
|
+
field: "providerId",
|
|
1254
|
+
value: providerId
|
|
1255
|
+
}]
|
|
1256
|
+
});
|
|
1257
|
+
if (!provider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
|
|
1258
|
+
let hasAccess = false;
|
|
1259
|
+
if (provider.organizationId) if (ctx.context.hasPlugin("organization")) hasAccess = await isOrgAdmin(ctx, userId, provider.organizationId);
|
|
1260
|
+
else hasAccess = provider.userId === userId;
|
|
1261
|
+
else hasAccess = provider.userId === userId;
|
|
1262
|
+
if (!hasAccess) throw new APIError("FORBIDDEN", { message: "You don't have access to this provider" });
|
|
1263
|
+
return provider;
|
|
1252
1264
|
}
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1265
|
+
const getSSOProvider = () => {
|
|
1266
|
+
return createAuthEndpoint("/sso/get-provider", {
|
|
1267
|
+
method: "GET",
|
|
1268
|
+
use: [sessionMiddleware],
|
|
1269
|
+
query: getSSOProviderQuerySchema,
|
|
1270
|
+
metadata: { openapi: {
|
|
1271
|
+
operationId: "getSSOProvider",
|
|
1272
|
+
summary: "Get SSO provider details",
|
|
1273
|
+
description: "Returns sanitized details for a specific SSO provider",
|
|
1274
|
+
responses: {
|
|
1275
|
+
"200": { description: "SSO provider details" },
|
|
1276
|
+
"404": { description: "Provider not found" },
|
|
1277
|
+
"403": { description: "Access denied" }
|
|
1278
|
+
}
|
|
1279
|
+
} }
|
|
1280
|
+
}, async (ctx) => {
|
|
1281
|
+
const { providerId } = ctx.query;
|
|
1282
|
+
const provider = await checkProviderAccess(ctx, providerId);
|
|
1283
|
+
return ctx.json(sanitizeProvider(provider, ctx.context.baseURL));
|
|
1284
|
+
});
|
|
1285
|
+
};
|
|
1286
|
+
function parseAndValidateConfig(configString, configType) {
|
|
1287
|
+
let config = null;
|
|
1288
|
+
try {
|
|
1289
|
+
config = safeJsonParse(configString);
|
|
1290
|
+
} catch {
|
|
1291
|
+
config = null;
|
|
1292
|
+
}
|
|
1293
|
+
if (!config) throw new APIError("BAD_REQUEST", { message: `Cannot update ${configType} config for a provider that doesn't have ${configType} configured` });
|
|
1294
|
+
return config;
|
|
1268
1295
|
}
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
*/
|
|
1274
|
-
async function ensureRuntimeDiscovery(config, issuer, isTrustedOrigin) {
|
|
1275
|
-
if (!needsRuntimeDiscovery(config)) return config;
|
|
1276
|
-
const hydrated = await discoverOIDCConfig({
|
|
1296
|
+
function mergeSAMLConfig(current, updates, issuer) {
|
|
1297
|
+
return {
|
|
1298
|
+
...current,
|
|
1299
|
+
...updates,
|
|
1277
1300
|
issuer,
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1301
|
+
entryPoint: updates.entryPoint ?? current.entryPoint,
|
|
1302
|
+
cert: updates.cert ?? current.cert,
|
|
1303
|
+
callbackUrl: updates.callbackUrl ?? current.callbackUrl,
|
|
1304
|
+
spMetadata: updates.spMetadata ?? current.spMetadata,
|
|
1305
|
+
idpMetadata: updates.idpMetadata ?? current.idpMetadata,
|
|
1306
|
+
mapping: updates.mapping ?? current.mapping,
|
|
1307
|
+
audience: updates.audience ?? current.audience,
|
|
1308
|
+
wantAssertionsSigned: updates.wantAssertionsSigned ?? current.wantAssertionsSigned,
|
|
1309
|
+
authnRequestsSigned: updates.authnRequestsSigned ?? current.authnRequestsSigned,
|
|
1310
|
+
identifierFormat: updates.identifierFormat ?? current.identifierFormat,
|
|
1311
|
+
signatureAlgorithm: updates.signatureAlgorithm ?? current.signatureAlgorithm,
|
|
1312
|
+
digestAlgorithm: updates.digestAlgorithm ?? current.digestAlgorithm
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
function mergeOIDCConfig(current, updates, issuer) {
|
|
1281
1316
|
return {
|
|
1282
|
-
...
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1317
|
+
...current,
|
|
1318
|
+
...updates,
|
|
1319
|
+
issuer,
|
|
1320
|
+
pkce: updates.pkce ?? current.pkce ?? true,
|
|
1321
|
+
clientId: updates.clientId ?? current.clientId,
|
|
1322
|
+
clientSecret: updates.clientSecret ?? current.clientSecret,
|
|
1323
|
+
discoveryEndpoint: updates.discoveryEndpoint ?? current.discoveryEndpoint,
|
|
1324
|
+
mapping: updates.mapping ?? current.mapping,
|
|
1325
|
+
scopes: updates.scopes ?? current.scopes,
|
|
1326
|
+
authorizationEndpoint: updates.authorizationEndpoint ?? current.authorizationEndpoint,
|
|
1327
|
+
tokenEndpoint: updates.tokenEndpoint ?? current.tokenEndpoint,
|
|
1328
|
+
userInfoEndpoint: updates.userInfoEndpoint ?? current.userInfoEndpoint,
|
|
1329
|
+
jwksEndpoint: updates.jwksEndpoint ?? current.jwksEndpoint,
|
|
1330
|
+
tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication
|
|
1288
1331
|
};
|
|
1289
1332
|
}
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1333
|
+
const updateSSOProvider = (options) => {
|
|
1334
|
+
return createAuthEndpoint("/sso/update-provider", {
|
|
1335
|
+
method: "POST",
|
|
1336
|
+
use: [sessionMiddleware],
|
|
1337
|
+
body: updateSSOProviderBodySchema.extend({ providerId: z.string() }),
|
|
1338
|
+
metadata: { openapi: {
|
|
1339
|
+
operationId: "updateSSOProvider",
|
|
1340
|
+
summary: "Update SSO provider",
|
|
1341
|
+
description: "Partially update an SSO provider. Only provided fields are updated. If domain changes, domainVerified is reset to false.",
|
|
1342
|
+
responses: {
|
|
1343
|
+
"200": { description: "SSO provider updated successfully" },
|
|
1344
|
+
"404": { description: "Provider not found" },
|
|
1345
|
+
"403": { description: "Access denied" }
|
|
1346
|
+
}
|
|
1347
|
+
} }
|
|
1348
|
+
}, async (ctx) => {
|
|
1349
|
+
const { providerId, ...body } = ctx.body;
|
|
1350
|
+
const { issuer, domain, samlConfig, oidcConfig } = body;
|
|
1351
|
+
if (!issuer && !domain && !samlConfig && !oidcConfig) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
|
|
1352
|
+
const existingProvider = await checkProviderAccess(ctx, providerId);
|
|
1353
|
+
const updateData = {};
|
|
1354
|
+
if (body.issuer !== void 0) updateData.issuer = body.issuer;
|
|
1355
|
+
if (body.domain !== void 0) {
|
|
1356
|
+
updateData.domain = body.domain;
|
|
1357
|
+
if (body.domain !== existingProvider.domain) updateData.domainVerified = false;
|
|
1358
|
+
}
|
|
1359
|
+
if (body.samlConfig) {
|
|
1360
|
+
if (body.samlConfig.idpMetadata?.metadata) {
|
|
1361
|
+
const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
|
|
1362
|
+
if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
|
|
1363
|
+
}
|
|
1364
|
+
if (body.samlConfig.signatureAlgorithm !== void 0 || body.samlConfig.digestAlgorithm !== void 0) validateConfigAlgorithms({
|
|
1365
|
+
signatureAlgorithm: body.samlConfig.signatureAlgorithm,
|
|
1366
|
+
digestAlgorithm: body.samlConfig.digestAlgorithm
|
|
1367
|
+
}, options?.saml?.algorithms);
|
|
1368
|
+
const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
|
|
1369
|
+
const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
|
|
1370
|
+
updateData.samlConfig = JSON.stringify(updatedSamlConfig);
|
|
1371
|
+
}
|
|
1372
|
+
if (body.oidcConfig) {
|
|
1373
|
+
try {
|
|
1374
|
+
validateSkipDiscoveryEndpoints(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
|
|
1375
|
+
} catch (error) {
|
|
1376
|
+
if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
|
|
1377
|
+
throw error;
|
|
1378
|
+
}
|
|
1379
|
+
const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
|
|
1380
|
+
const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
|
|
1381
|
+
updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
|
|
1382
|
+
}
|
|
1383
|
+
await ctx.context.adapter.update({
|
|
1384
|
+
model: "ssoProvider",
|
|
1385
|
+
where: [{
|
|
1386
|
+
field: "providerId",
|
|
1387
|
+
value: providerId
|
|
1388
|
+
}],
|
|
1389
|
+
update: updateData
|
|
1343
1390
|
});
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1391
|
+
const fullProvider = await ctx.context.adapter.findOne({
|
|
1392
|
+
model: "ssoProvider",
|
|
1393
|
+
where: [{
|
|
1394
|
+
field: "providerId",
|
|
1395
|
+
value: providerId
|
|
1396
|
+
}]
|
|
1347
1397
|
});
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1398
|
+
if (!fullProvider) throw new APIError("NOT_FOUND", { message: "Provider not found after update" });
|
|
1399
|
+
return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL));
|
|
1400
|
+
});
|
|
1401
|
+
};
|
|
1402
|
+
const deleteSSOProvider = () => {
|
|
1403
|
+
return createAuthEndpoint("/sso/delete-provider", {
|
|
1404
|
+
method: "POST",
|
|
1405
|
+
use: [sessionMiddleware],
|
|
1406
|
+
body: z.object({ providerId: z.string() }),
|
|
1407
|
+
metadata: { openapi: {
|
|
1408
|
+
operationId: "deleteSSOProvider",
|
|
1409
|
+
summary: "Delete SSO provider",
|
|
1410
|
+
description: "Deletes an SSO provider",
|
|
1411
|
+
responses: {
|
|
1412
|
+
"200": { description: "SSO provider deleted successfully" },
|
|
1413
|
+
"404": { description: "Provider not found" },
|
|
1414
|
+
"403": { description: "Access denied" }
|
|
1415
|
+
}
|
|
1416
|
+
} }
|
|
1417
|
+
}, async (ctx) => {
|
|
1418
|
+
const { providerId } = ctx.body;
|
|
1419
|
+
await checkProviderAccess(ctx, providerId);
|
|
1420
|
+
await ctx.context.adapter.delete({
|
|
1421
|
+
model: "ssoProvider",
|
|
1422
|
+
where: [{
|
|
1423
|
+
field: "providerId",
|
|
1424
|
+
value: providerId
|
|
1425
|
+
}]
|
|
1351
1426
|
});
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
message: `Unexpected discovery error: ${error.message}`,
|
|
1356
|
-
code: "discovery_unexpected_error"
|
|
1357
|
-
});
|
|
1358
|
-
}
|
|
1359
|
-
}
|
|
1427
|
+
return ctx.json({ success: true });
|
|
1428
|
+
});
|
|
1429
|
+
};
|
|
1360
1430
|
//#endregion
|
|
1361
1431
|
//#region src/saml/error-codes.ts
|
|
1362
1432
|
const SAML_ERROR_CODES = defineErrorCodes({
|
|
@@ -1740,23 +1810,35 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1740
1810
|
}
|
|
1741
1811
|
const isTrustedProvider = ctx.context.trustedProviders.includes(providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
1742
1812
|
const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1743
|
-
const
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1813
|
+
const errorUrl = relayState?.errorURL || samlRedirectUrl;
|
|
1814
|
+
let result;
|
|
1815
|
+
try {
|
|
1816
|
+
result = await handleOAuthUserInfo(ctx, {
|
|
1817
|
+
userInfo: {
|
|
1818
|
+
email: userInfo.email,
|
|
1819
|
+
name: userInfo.name || userInfo.email,
|
|
1820
|
+
id: userInfo.id,
|
|
1821
|
+
emailVerified: Boolean(userInfo.emailVerified)
|
|
1822
|
+
},
|
|
1823
|
+
account: {
|
|
1824
|
+
providerId,
|
|
1825
|
+
accountId: userInfo.id,
|
|
1826
|
+
accessToken: "",
|
|
1827
|
+
refreshToken: ""
|
|
1828
|
+
},
|
|
1829
|
+
callbackURL: callbackUrl,
|
|
1830
|
+
disableSignUp: options?.disableImplicitSignUp,
|
|
1831
|
+
isTrustedProvider
|
|
1832
|
+
});
|
|
1833
|
+
} catch (e) {
|
|
1834
|
+
if (isAPIError(e) && e.body?.code) {
|
|
1835
|
+
const params = new URLSearchParams({ error: e.body.code });
|
|
1836
|
+
if (e.body.message) params.set("error_description", e.body.message);
|
|
1837
|
+
const sep = errorUrl.includes("?") ? "&" : "?";
|
|
1838
|
+
throw ctx.redirect(`${errorUrl}${sep}${params.toString()}`);
|
|
1839
|
+
}
|
|
1840
|
+
throw e;
|
|
1841
|
+
}
|
|
1760
1842
|
if (result.error) throw ctx.redirect(`${callbackUrl}?error=${result.error.split(" ").join("_")}`);
|
|
1761
1843
|
const { session, user } = result.data;
|
|
1762
1844
|
if (options?.provisionUser && (result.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
|
|
@@ -1865,12 +1947,12 @@ const ssoProviderBodySchema = z.object({
|
|
|
1865
1947
|
oidcConfig: z.object({
|
|
1866
1948
|
clientId: z.string({}).meta({ description: "The client ID" }),
|
|
1867
1949
|
clientSecret: z.string({}).meta({ description: "The client secret" }),
|
|
1868
|
-
authorizationEndpoint: z.
|
|
1869
|
-
tokenEndpoint: z.
|
|
1870
|
-
userInfoEndpoint: z.
|
|
1950
|
+
authorizationEndpoint: z.url().meta({ description: "The authorization endpoint" }).optional(),
|
|
1951
|
+
tokenEndpoint: z.url().meta({ description: "The token endpoint" }).optional(),
|
|
1952
|
+
userInfoEndpoint: z.url().meta({ description: "The user info endpoint" }).optional(),
|
|
1871
1953
|
tokenEndpointAuthentication: z.enum(["client_secret_post", "client_secret_basic"]).optional(),
|
|
1872
|
-
jwksEndpoint: z.
|
|
1873
|
-
discoveryEndpoint: z.
|
|
1954
|
+
jwksEndpoint: z.url().meta({ description: "The JWKS endpoint" }).optional(),
|
|
1955
|
+
discoveryEndpoint: z.url().optional(),
|
|
1874
1956
|
skipDiscovery: z.boolean().meta({ description: "Skip OIDC discovery during registration. When true, you must provide authorizationEndpoint, tokenEndpoint, and jwksEndpoint manually." }).optional(),
|
|
1875
1957
|
scopes: z.array(z.string(), {}).meta({ description: "The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']" }).optional(),
|
|
1876
1958
|
pkce: z.boolean({}).meta({ description: "Whether to use PKCE for the authorization flow" }).default(true).optional(),
|
|
@@ -2123,7 +2205,7 @@ const registerSSOProvider = (options) => {
|
|
|
2123
2205
|
if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
|
|
2124
2206
|
}
|
|
2125
2207
|
if (ctx.body.organizationId) {
|
|
2126
|
-
|
|
2208
|
+
const member = await ctx.context.adapter.findOne({
|
|
2127
2209
|
model: "member",
|
|
2128
2210
|
where: [{
|
|
2129
2211
|
field: "userId",
|
|
@@ -2132,7 +2214,9 @@ const registerSSOProvider = (options) => {
|
|
|
2132
2214
|
field: "organizationId",
|
|
2133
2215
|
value: ctx.body.organizationId
|
|
2134
2216
|
}]
|
|
2135
|
-
})
|
|
2217
|
+
});
|
|
2218
|
+
if (!member) throw new APIError("BAD_REQUEST", { message: "You are not a member of the organization" });
|
|
2219
|
+
if (ctx.context.hasPlugin("organization") && !hasOrgAdminRole(member)) throw new APIError("FORBIDDEN", { message: "You must be an organization owner or admin to register SSO providers" });
|
|
2136
2220
|
}
|
|
2137
2221
|
if (await ctx.context.adapter.findOne({
|
|
2138
2222
|
model: "ssoProvider",
|
|
@@ -2144,6 +2228,12 @@ const registerSSOProvider = (options) => {
|
|
|
2144
2228
|
ctx.context.logger.info(`SSO provider creation attempt with existing providerId: ${body.providerId}`);
|
|
2145
2229
|
throw new APIError("UNPROCESSABLE_ENTITY", { message: "SSO provider with this providerId already exists" });
|
|
2146
2230
|
}
|
|
2231
|
+
if (body.oidcConfig) try {
|
|
2232
|
+
validateSkipDiscoveryEndpoints(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
|
|
2233
|
+
} catch (error) {
|
|
2234
|
+
if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
|
|
2235
|
+
throw error;
|
|
2236
|
+
}
|
|
2147
2237
|
let hydratedOIDCConfig = null;
|
|
2148
2238
|
if (body.oidcConfig && !body.oidcConfig.skipDiscovery) try {
|
|
2149
2239
|
hydratedOIDCConfig = await discoverOIDCConfig({
|
|
@@ -2621,30 +2711,47 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2621
2711
|
} else throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=user_info_endpoint_not_found`);
|
|
2622
2712
|
if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=missing_user_info`);
|
|
2623
2713
|
const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfo.email, provider.domain);
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2714
|
+
let linked;
|
|
2715
|
+
try {
|
|
2716
|
+
linked = await handleOAuthUserInfo(ctx, {
|
|
2717
|
+
userInfo: {
|
|
2718
|
+
email: userInfo.email,
|
|
2719
|
+
name: userInfo.name || "",
|
|
2720
|
+
id: userInfo.id,
|
|
2721
|
+
image: userInfo.image,
|
|
2722
|
+
emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
|
|
2723
|
+
},
|
|
2724
|
+
account: {
|
|
2725
|
+
idToken: tokenResponse.idToken,
|
|
2726
|
+
accessToken: tokenResponse.accessToken,
|
|
2727
|
+
refreshToken: tokenResponse.refreshToken,
|
|
2728
|
+
accountId: userInfo.id,
|
|
2729
|
+
providerId: provider.providerId,
|
|
2730
|
+
accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
|
|
2731
|
+
refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
|
|
2732
|
+
scope: tokenResponse.scopes?.join(",")
|
|
2733
|
+
},
|
|
2734
|
+
callbackURL,
|
|
2735
|
+
disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
|
|
2736
|
+
overrideUserInfo: config.overrideUserInfo,
|
|
2737
|
+
isTrustedProvider
|
|
2738
|
+
});
|
|
2739
|
+
} catch (e) {
|
|
2740
|
+
if (isAPIError(e) && e.body?.code) {
|
|
2741
|
+
const baseURL = errorURL || callbackURL;
|
|
2742
|
+
const params = new URLSearchParams({ error: e.body.code });
|
|
2743
|
+
if (e.body.message) params.set("error_description", e.body.message);
|
|
2744
|
+
const sep = baseURL.includes("?") ? "&" : "?";
|
|
2745
|
+
throw ctx.redirect(`${baseURL}${sep}${params.toString()}`);
|
|
2746
|
+
}
|
|
2747
|
+
throw e;
|
|
2748
|
+
}
|
|
2749
|
+
if (linked.error) {
|
|
2750
|
+
const baseURL = errorURL || callbackURL;
|
|
2751
|
+
const params = new URLSearchParams({ error: linked.error });
|
|
2752
|
+
const sep = baseURL.includes("?") ? "&" : "?";
|
|
2753
|
+
throw ctx.redirect(`${baseURL}${sep}${params.toString()}`);
|
|
2754
|
+
}
|
|
2648
2755
|
const { session, user } = linked.data;
|
|
2649
2756
|
if (options?.provisionUser && (linked.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
|
|
2650
2757
|
user,
|