@better-auth/sso 1.7.0-beta.3 → 1.7.0-beta.5
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/client.d.mts +1 -1
- package/dist/client.mjs +1 -1
- package/dist/{index-CagV4mMx.d.mts → index-DCkGGu_2.d.mts} +117 -72
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1484 -1221
- package/dist/{version-CLqkeI3u.mjs → version-DzWb5tB_.mjs} +1 -1
- package/package.json +11 -11
package/dist/index.mjs
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
import { t as PACKAGE_VERSION } from "./version-
|
|
2
|
-
import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
|
|
1
|
+
import { t as PACKAGE_VERSION } from "./version-DzWb5tB_.mjs";
|
|
2
|
+
import { APIError, addOAuthServerContext, 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 { classifyHost, isPublicRoutableHost } from "@better-auth/core/utils/host";
|
|
9
9
|
import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
|
|
10
|
-
import {
|
|
10
|
+
import { base64 } from "@better-auth/utils/base64";
|
|
11
|
+
import { defineErrorCodes } from "@better-auth/core/utils/error-codes";
|
|
12
|
+
import { isAPIError } from "@better-auth/core/utils/is-api-error";
|
|
13
|
+
import { HIDE_METADATA, PRIVATE_KEY_JWT_SIGNING_ALGORITHMS, createAuthorizationURL, createPrivateKeyJwtClientAssertionGetter, generateGenericState, generateState, parseGenericState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
|
|
11
14
|
import { deleteSessionCookie, setSessionCookie } from "better-auth/cookies";
|
|
12
|
-
import {
|
|
15
|
+
import { additionalAuthorizationParamsSchema, signInWithOAuthIdentity } from "better-auth/oauth2";
|
|
13
16
|
import { decodeJwt } from "jose";
|
|
14
|
-
import { defineErrorCodes } from "@better-auth/core/utils/error-codes";
|
|
15
17
|
import * as samlifyNamespace from "samlify";
|
|
16
18
|
import samlifyDefault from "samlify";
|
|
17
19
|
//#region src/constants.ts
|
|
@@ -55,7 +57,6 @@ const DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024;
|
|
|
55
57
|
* Protects against oversized metadata documents.
|
|
56
58
|
*/
|
|
57
59
|
const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024;
|
|
58
|
-
const SAML_STATUS_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success";
|
|
59
60
|
//#endregion
|
|
60
61
|
//#region src/utils.ts
|
|
61
62
|
/**
|
|
@@ -102,6 +103,10 @@ function parseCertificate(certPem) {
|
|
|
102
103
|
publicKeyAlgorithm: cert.publicKey.asymmetricKeyType?.toUpperCase() || "UNKNOWN"
|
|
103
104
|
};
|
|
104
105
|
}
|
|
106
|
+
function normalizePem(key) {
|
|
107
|
+
if (!key) return key;
|
|
108
|
+
return `${key.split("\n").map((line) => line.trim()).join("\n").trim()}\n`;
|
|
109
|
+
}
|
|
105
110
|
function getHostnameFromDomain(domain) {
|
|
106
111
|
return getHostname(domain) || null;
|
|
107
112
|
}
|
|
@@ -371,1107 +376,1297 @@ const verifyDomain = (options) => {
|
|
|
371
376
|
});
|
|
372
377
|
};
|
|
373
378
|
//#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;
|
|
379
|
+
//#region src/oidc/types.ts
|
|
380
|
+
/**
|
|
381
|
+
* Custom error class for OIDC discovery failures.
|
|
382
|
+
* Can be caught and mapped to APIError at the edge.
|
|
383
|
+
*/
|
|
384
|
+
var DiscoveryError = class DiscoveryError extends Error {
|
|
385
|
+
code;
|
|
386
|
+
details;
|
|
387
|
+
constructor(code, message, details, options) {
|
|
388
|
+
super(message, options);
|
|
389
|
+
this.name = "DiscoveryError";
|
|
390
|
+
this.code = code;
|
|
391
|
+
this.details = details;
|
|
392
|
+
if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
|
|
402
393
|
}
|
|
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
|
-
};
|
|
424
|
-
const KeyEncryptionAlgorithm = {
|
|
425
|
-
RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
|
|
426
|
-
RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
|
|
427
|
-
RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep"
|
|
428
|
-
};
|
|
429
|
-
const DataEncryptionAlgorithm = {
|
|
430
|
-
TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
|
|
431
|
-
AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
|
|
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
394
|
};
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
|
395
|
+
/**
|
|
396
|
+
* Required fields that must be present in a valid discovery document.
|
|
397
|
+
*/
|
|
398
|
+
const REQUIRED_DISCOVERY_FIELDS = [
|
|
399
|
+
"issuer",
|
|
400
|
+
"authorization_endpoint",
|
|
401
|
+
"token_endpoint",
|
|
402
|
+
"jwks_uri"
|
|
454
403
|
];
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
404
|
+
//#endregion
|
|
405
|
+
//#region src/oidc/discovery.ts
|
|
406
|
+
/**
|
|
407
|
+
* OIDC Discovery Pipeline
|
|
408
|
+
*
|
|
409
|
+
* Implements OIDC discovery document fetching, validation, and hydration.
|
|
410
|
+
* This module is used both at provider registration time (to persist validated config)
|
|
411
|
+
* and at runtime (to hydrate legacy providers that are missing metadata).
|
|
412
|
+
*
|
|
413
|
+
* @see https://openid.net/specs/openid-connect-discovery-1_0.html
|
|
414
|
+
*/
|
|
415
|
+
/** Default timeout for discovery requests (10 seconds) */
|
|
416
|
+
const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
|
|
417
|
+
/**
|
|
418
|
+
* Main entry point: Discover and hydrate OIDC configuration from an issuer.
|
|
419
|
+
*
|
|
420
|
+
* This function:
|
|
421
|
+
* 1. Computes the discovery URL from the issuer
|
|
422
|
+
* 2. Validates the discovery URL
|
|
423
|
+
* 3. Fetches the discovery document
|
|
424
|
+
* 4. Validates the discovery document (issuer match + required fields)
|
|
425
|
+
* 5. Normalizes URLs
|
|
426
|
+
* 6. Selects token endpoint auth method
|
|
427
|
+
* 7. Merges with existing config (existing values take precedence)
|
|
428
|
+
*
|
|
429
|
+
* @param params - Discovery parameters
|
|
430
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
431
|
+
* @returns Hydrated OIDC configuration ready for persistence
|
|
432
|
+
* @throws DiscoveryError on any failure
|
|
433
|
+
*/
|
|
434
|
+
async function discoverOIDCConfig(params) {
|
|
435
|
+
const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
|
|
436
|
+
const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
|
|
437
|
+
validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
|
|
438
|
+
const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
|
|
439
|
+
validateDiscoveryDocument(discoveryDoc, issuer);
|
|
440
|
+
const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
|
|
441
|
+
const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
|
|
442
|
+
return {
|
|
443
|
+
issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
|
|
444
|
+
discoveryEndpoint: existingConfig?.discoveryEndpoint ?? discoveryUrl,
|
|
445
|
+
authorizationEndpoint: existingConfig?.authorizationEndpoint ?? normalizedDoc.authorization_endpoint,
|
|
446
|
+
tokenEndpoint: existingConfig?.tokenEndpoint ?? normalizedDoc.token_endpoint,
|
|
447
|
+
jwksEndpoint: existingConfig?.jwksEndpoint ?? normalizedDoc.jwks_uri,
|
|
448
|
+
userInfoEndpoint: existingConfig?.userInfoEndpoint ?? normalizedDoc.userinfo_endpoint,
|
|
449
|
+
tokenEndpointAuthentication: existingConfig?.tokenEndpointAuthentication ?? tokenEndpointAuth,
|
|
450
|
+
scopesSupported: existingConfig?.scopesSupported ?? normalizedDoc.scopes_supported
|
|
451
|
+
};
|
|
476
452
|
}
|
|
477
|
-
|
|
478
|
-
|
|
453
|
+
/**
|
|
454
|
+
* Compute the discovery URL from an issuer URL.
|
|
455
|
+
*
|
|
456
|
+
* Per OIDC Discovery spec, the discovery document is located at:
|
|
457
|
+
* <issuer>/.well-known/openid-configuration
|
|
458
|
+
*
|
|
459
|
+
* Handles trailing slashes correctly.
|
|
460
|
+
*/
|
|
461
|
+
function computeDiscoveryUrl(issuer) {
|
|
462
|
+
return `${issuer.endsWith("/") ? issuer.slice(0, -1) : issuer}/.well-known/openid-configuration`;
|
|
479
463
|
}
|
|
480
|
-
|
|
464
|
+
/**
|
|
465
|
+
* Validate a discovery URL before fetching.
|
|
466
|
+
*
|
|
467
|
+
* @param url - The discovery URL to validate
|
|
468
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
469
|
+
* @throws DiscoveryError if URL is invalid
|
|
470
|
+
*/
|
|
471
|
+
function validateDiscoveryUrl(url, isTrustedOrigin) {
|
|
472
|
+
const discoveryEndpoint = parseURL("discoveryEndpoint", url).toString();
|
|
473
|
+
if (!isTrustedOrigin(discoveryEndpoint)) throw new DiscoveryError("discovery_untrusted_origin", `The main discovery endpoint "${discoveryEndpoint}" is not trusted by your trusted origins configuration.`, { url: discoveryEndpoint });
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Validate that a user-supplied OIDC endpoint URL is safe to fetch.
|
|
477
|
+
*
|
|
478
|
+
* Layered checks (in order):
|
|
479
|
+
* 1. URL parsing + http(s) scheme → discovery_invalid_url
|
|
480
|
+
* 2. Public-routable host (RFC 6890) → allowed
|
|
481
|
+
* 3. Operator-allowlisted via trustedOrigins → allowed (opt-in for internal IdPs)
|
|
482
|
+
* 4. Otherwise → discovery_private_host
|
|
483
|
+
*
|
|
484
|
+
* Step 2 rejects loopback, RFC 1918, link-local, ULA, shared-address,
|
|
485
|
+
* cloud-metadata FQDNs (e.g. `169.254.169.254`, `metadata.google.internal`),
|
|
486
|
+
* multicast, and reserved ranges. See `isPublicRoutableHost` in
|
|
487
|
+
* `@better-auth/core/utils/host`.
|
|
488
|
+
*
|
|
489
|
+
* Step 3 is the documented escape hatch for customers whose IdP runs on a
|
|
490
|
+
* private network or behind a corporate VPN: they add the IdP origin to their
|
|
491
|
+
* `trustedOrigins` configuration.
|
|
492
|
+
*
|
|
493
|
+
* @param name - The endpoint field name, used in error messages
|
|
494
|
+
* @param endpoint - The URL to validate
|
|
495
|
+
* @param isTrustedOrigin - Predicate matching the configured `trustedOrigins`
|
|
496
|
+
* @throws DiscoveryError(discovery_invalid_url) — malformed URL or non-http(s) scheme
|
|
497
|
+
* @throws DiscoveryError(discovery_private_host) — host is not publicly routable and not allowlisted
|
|
498
|
+
*/
|
|
499
|
+
function validateSkipDiscoveryEndpoint(name, endpoint, isTrustedOrigin) {
|
|
500
|
+
const parsed = parseURL(name, endpoint);
|
|
501
|
+
if (isPublicRoutableHost(parsed.hostname)) return;
|
|
502
|
+
if (isTrustedOrigin(parsed.toString())) return;
|
|
503
|
+
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.`, {
|
|
504
|
+
endpoint: name,
|
|
505
|
+
url: endpoint,
|
|
506
|
+
hostname: parsed.hostname
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Validate every present OIDC endpoint URL in a registration or update body.
|
|
511
|
+
*
|
|
512
|
+
* Each provided URL is checked with {@link validateSkipDiscoveryEndpoint}.
|
|
513
|
+
* Omitted (undefined / null / empty) fields are skipped.
|
|
514
|
+
*
|
|
515
|
+
* @param config - OIDC endpoint URLs from the request body
|
|
516
|
+
* @param isTrustedOrigin - Predicate matching the configured `trustedOrigins`
|
|
517
|
+
* @throws DiscoveryError on the first invalid endpoint
|
|
518
|
+
*/
|
|
519
|
+
function validateSkipDiscoveryEndpoints(config, isTrustedOrigin) {
|
|
520
|
+
const fields = [
|
|
521
|
+
["authorizationEndpoint", config.authorizationEndpoint],
|
|
522
|
+
["tokenEndpoint", config.tokenEndpoint],
|
|
523
|
+
["userInfoEndpoint", config.userInfoEndpoint],
|
|
524
|
+
["jwksEndpoint", config.jwksEndpoint],
|
|
525
|
+
["discoveryEndpoint", config.discoveryEndpoint]
|
|
526
|
+
];
|
|
527
|
+
for (const [name, url] of fields) if (url) validateSkipDiscoveryEndpoint(name, url, isTrustedOrigin);
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Re-validate an endpoint by resolving its hostname and rejecting any resolved
|
|
531
|
+
* address that is not publicly routable.
|
|
532
|
+
*
|
|
533
|
+
* {@link validateSkipDiscoveryEndpoint} only classifies the literal hostname, so
|
|
534
|
+
* a host like `idp.example` whose DNS record points at `127.0.0.1`,
|
|
535
|
+
* `169.254.169.254`, or an RFC 1918 address passes that check unchanged. This
|
|
536
|
+
* function closes that gap by performing the same RFC 6890 classification on the
|
|
537
|
+
* addresses the host actually resolves to, right before the server-side fetch.
|
|
538
|
+
*
|
|
539
|
+
* Best-effort by design:
|
|
540
|
+
* - Operator-allowlisted origins (trustedOrigins) are skipped — this is the
|
|
541
|
+
* documented escape hatch for internal IdPs.
|
|
542
|
+
* - IP-literal hosts are already fully covered by the synchronous check.
|
|
543
|
+
* - On runtimes without `node:dns` (e.g. Cloudflare Workers / edge), DNS
|
|
544
|
+
* resolution is unavailable; we fall back to the synchronous host check and
|
|
545
|
+
* the platform's own egress controls.
|
|
546
|
+
*
|
|
547
|
+
* Note: this resolves once and validates the result; it does not pin the address
|
|
548
|
+
* for the subsequent connection, so a change in the resolved address between
|
|
549
|
+
* this lookup and the fetch remains theoretically possible. It nonetheless
|
|
550
|
+
* rejects the common case of a DNS record that statically points at an internal
|
|
551
|
+
* address.
|
|
552
|
+
*
|
|
553
|
+
* @throws DiscoveryError(discovery_private_host) if any resolved address is not public
|
|
554
|
+
*/
|
|
555
|
+
async function assertEndpointResolvesPublic(name, endpoint, isTrustedOrigin) {
|
|
556
|
+
const parsed = parseURL(name, endpoint);
|
|
557
|
+
if (isTrustedOrigin(parsed.toString())) return;
|
|
558
|
+
const host = parsed.hostname;
|
|
559
|
+
if (classifyHost(host).literal !== "fqdn") return;
|
|
560
|
+
let dns;
|
|
481
561
|
try {
|
|
482
|
-
|
|
483
|
-
const keyAlg = (findNode(parsed, "EncryptedKey")?.EncryptionMethod)?.["@_Algorithm"];
|
|
484
|
-
const dataAlg = (findNode(parsed, "EncryptedData")?.EncryptionMethod)?.["@_Algorithm"];
|
|
485
|
-
return {
|
|
486
|
-
keyEncryption: keyAlg || null,
|
|
487
|
-
dataEncryption: dataAlg || null
|
|
488
|
-
};
|
|
562
|
+
dns = await import("node:dns/promises");
|
|
489
563
|
} catch {
|
|
490
|
-
return
|
|
491
|
-
keyEncryption: null,
|
|
492
|
-
dataEncryption: null
|
|
493
|
-
};
|
|
564
|
+
return;
|
|
494
565
|
}
|
|
495
|
-
|
|
496
|
-
function hasEncryptedAssertion(xml) {
|
|
566
|
+
let resolved;
|
|
497
567
|
try {
|
|
498
|
-
|
|
568
|
+
resolved = await dns.lookup(host, { all: true });
|
|
499
569
|
} catch {
|
|
500
|
-
return
|
|
570
|
+
return;
|
|
501
571
|
}
|
|
572
|
+
for (const { address } of resolved) if (!isPublicRoutableHost(address)) throw new DiscoveryError("discovery_private_host", `The ${name} host "${host}" resolves to a non-publicly-routable address (${address}). If this is an internal IdP, add its origin to trustedOrigins.`, {
|
|
573
|
+
endpoint: name,
|
|
574
|
+
url: endpoint,
|
|
575
|
+
hostname: host,
|
|
576
|
+
resolved: address
|
|
577
|
+
});
|
|
502
578
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
579
|
+
/**
|
|
580
|
+
* Re-validate, at fetch time, every OIDC endpoint that is fetched server-side
|
|
581
|
+
* (token, userinfo, jwks). Runs the synchronous host classification plus the
|
|
582
|
+
* best-effort DNS resolution check. `authorizationEndpoint` is intentionally
|
|
583
|
+
* excluded — it is a browser redirect target, not a server-side fetch, so these
|
|
584
|
+
* checks don't apply to it.
|
|
585
|
+
*/
|
|
586
|
+
async function assertOIDCEndpointsResolvePublic(config, isTrustedOrigin) {
|
|
587
|
+
const fields = [
|
|
588
|
+
["tokenEndpoint", config.tokenEndpoint],
|
|
589
|
+
["userInfoEndpoint", config.userInfoEndpoint],
|
|
590
|
+
["jwksEndpoint", config.jwksEndpoint]
|
|
591
|
+
];
|
|
592
|
+
for (const [name, url] of fields) {
|
|
593
|
+
if (!url) continue;
|
|
594
|
+
validateSkipDiscoveryEndpoint(name, url, isTrustedOrigin);
|
|
595
|
+
await assertEndpointResolvesPublic(name, url, isTrustedOrigin);
|
|
513
596
|
}
|
|
514
597
|
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
598
|
+
/**
|
|
599
|
+
* Fetch the OIDC discovery document from the IdP.
|
|
600
|
+
*
|
|
601
|
+
* @param url - The discovery endpoint URL
|
|
602
|
+
* @param timeout - Request timeout in milliseconds
|
|
603
|
+
* @returns The parsed discovery document
|
|
604
|
+
* @throws DiscoveryError on network errors, timeouts, or invalid responses
|
|
605
|
+
*/
|
|
606
|
+
async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
|
|
607
|
+
try {
|
|
608
|
+
const response = await betterFetch(url, {
|
|
609
|
+
method: "GET",
|
|
610
|
+
timeout,
|
|
611
|
+
redirect: "error"
|
|
522
612
|
});
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
}
|
|
529
|
-
if (!SECURE_SIGNATURE_ALGORITHMS.includes(algorithm)) throw new APIError("BAD_REQUEST", {
|
|
530
|
-
message: `SAML signature algorithm not recognized: ${algorithm}`,
|
|
531
|
-
code: "SAML_UNKNOWN_ALGORITHM"
|
|
532
|
-
});
|
|
533
|
-
}
|
|
534
|
-
function validateEncryptionAlgorithms(algorithms, options = {}) {
|
|
535
|
-
const { onDeprecated = "warn", allowedKeyEncryptionAlgorithms, allowedDataEncryptionAlgorithms } = options;
|
|
536
|
-
const { keyEncryption, dataEncryption } = algorithms;
|
|
537
|
-
if (keyEncryption) {
|
|
538
|
-
if (allowedKeyEncryptionAlgorithms) {
|
|
539
|
-
if (!allowedKeyEncryptionAlgorithms.includes(keyEncryption)) throw new APIError("BAD_REQUEST", {
|
|
540
|
-
message: `SAML key encryption algorithm not in allow-list: ${keyEncryption}`,
|
|
541
|
-
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
542
|
-
});
|
|
543
|
-
} 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");
|
|
544
|
-
}
|
|
545
|
-
if (dataEncryption) {
|
|
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"
|
|
613
|
+
if (response.error) {
|
|
614
|
+
const { status } = response.error;
|
|
615
|
+
if (status === 404) throw new DiscoveryError("discovery_not_found", "Discovery endpoint not found", {
|
|
616
|
+
url,
|
|
617
|
+
status
|
|
550
618
|
});
|
|
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"
|
|
619
|
+
if (status === 408) throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
|
|
620
|
+
url,
|
|
621
|
+
timeout
|
|
566
622
|
});
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
code: "SAML_UNKNOWN_ALGORITHM"
|
|
571
|
-
});
|
|
572
|
-
}
|
|
573
|
-
if (config.digestAlgorithm) {
|
|
574
|
-
const normalized = normalizeDigestAlgorithm(config.digestAlgorithm);
|
|
575
|
-
if (allowedDigestAlgorithms) {
|
|
576
|
-
if (!allowedDigestAlgorithms.map(normalizeDigestAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
577
|
-
message: `SAML digest algorithm not in allow-list: ${config.digestAlgorithm}`,
|
|
578
|
-
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
623
|
+
throw new DiscoveryError("discovery_unexpected_error", `Unexpected discovery error: ${response.error.statusText}`, {
|
|
624
|
+
url,
|
|
625
|
+
...response.error
|
|
579
626
|
});
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
627
|
+
}
|
|
628
|
+
if (!response.data) throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned an empty response", { url });
|
|
629
|
+
const data = response.data;
|
|
630
|
+
if (typeof data === "string") throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned invalid JSON", {
|
|
631
|
+
url,
|
|
632
|
+
bodyPreview: data.slice(0, 200)
|
|
584
633
|
});
|
|
585
|
-
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
try {
|
|
592
|
-
parsed = xmlParser.parse(xml);
|
|
593
|
-
} catch {
|
|
594
|
-
throw new APIError("BAD_REQUEST", {
|
|
595
|
-
message: "Failed to parse SAML response XML",
|
|
596
|
-
code: "SAML_INVALID_XML"
|
|
634
|
+
return data;
|
|
635
|
+
} catch (error) {
|
|
636
|
+
if (error instanceof DiscoveryError) throw error;
|
|
637
|
+
if (error instanceof Error && error.name === "AbortError") throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
|
|
638
|
+
url,
|
|
639
|
+
timeout
|
|
597
640
|
});
|
|
641
|
+
throw new DiscoveryError("discovery_unexpected_error", `Unexpected error during discovery: ${error instanceof Error ? error.message : String(error)}`, { url }, { cause: error });
|
|
598
642
|
}
|
|
599
|
-
const assertions = countAllNodes(parsed, "Assertion");
|
|
600
|
-
const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
|
|
601
|
-
return {
|
|
602
|
-
assertions,
|
|
603
|
-
encryptedAssertions,
|
|
604
|
-
total: assertions + encryptedAssertions
|
|
605
|
-
};
|
|
606
643
|
}
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
644
|
+
/**
|
|
645
|
+
* Validate a discovery document.
|
|
646
|
+
*
|
|
647
|
+
* Checks:
|
|
648
|
+
* 1. All required fields are present
|
|
649
|
+
* 2. Issuer matches the configured issuer (case-sensitive, exact match)
|
|
650
|
+
*
|
|
651
|
+
* Invariant: If this function returns without throwing, the document is safe
|
|
652
|
+
* to use for hydrating OIDC config (required fields present, issuer matches
|
|
653
|
+
* configured value, basic structural sanity verified).
|
|
654
|
+
*
|
|
655
|
+
* @param doc - The discovery document to validate
|
|
656
|
+
* @param configuredIssuer - The expected issuer value
|
|
657
|
+
* @throws DiscoveryError if validation fails
|
|
658
|
+
*/
|
|
659
|
+
function validateDiscoveryDocument(doc, configuredIssuer) {
|
|
660
|
+
const missingFields = [];
|
|
661
|
+
for (const field of REQUIRED_DISCOVERY_FIELDS) if (!doc[field]) missingFields.push(field);
|
|
662
|
+
if (missingFields.length > 0) throw new DiscoveryError("discovery_incomplete", `Discovery document is missing required fields: ${missingFields.join(", ")}`, { missingFields });
|
|
663
|
+
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}"`, {
|
|
664
|
+
discovered: doc.issuer,
|
|
665
|
+
configured: configuredIssuer
|
|
622
666
|
});
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Normalize URLs in the discovery document.
|
|
670
|
+
*
|
|
671
|
+
* @param document - The discovery document
|
|
672
|
+
* @param issuer - The base issuer URL
|
|
673
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
674
|
+
* @returns The normalized discovery document
|
|
675
|
+
*/
|
|
676
|
+
function normalizeDiscoveryUrls(document, issuer, isTrustedOrigin) {
|
|
677
|
+
const doc = { ...document };
|
|
678
|
+
doc.token_endpoint = normalizeAndValidateUrl("token_endpoint", doc.token_endpoint, issuer, isTrustedOrigin);
|
|
679
|
+
doc.authorization_endpoint = normalizeAndValidateUrl("authorization_endpoint", doc.authorization_endpoint, issuer, isTrustedOrigin);
|
|
680
|
+
doc.jwks_uri = normalizeAndValidateUrl("jwks_uri", doc.jwks_uri, issuer, isTrustedOrigin);
|
|
681
|
+
if (doc.userinfo_endpoint) doc.userinfo_endpoint = normalizeAndValidateUrl("userinfo_endpoint", doc.userinfo_endpoint, issuer, isTrustedOrigin);
|
|
682
|
+
if (doc.revocation_endpoint) doc.revocation_endpoint = normalizeAndValidateUrl("revocation_endpoint", doc.revocation_endpoint, issuer, isTrustedOrigin);
|
|
683
|
+
if (doc.end_session_endpoint) doc.end_session_endpoint = normalizeAndValidateUrl("end_session_endpoint", doc.end_session_endpoint, issuer, isTrustedOrigin);
|
|
684
|
+
if (doc.introspection_endpoint) doc.introspection_endpoint = normalizeAndValidateUrl("introspection_endpoint", doc.introspection_endpoint, issuer, isTrustedOrigin);
|
|
685
|
+
return doc;
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Normalizes and validates a single URL endpoint
|
|
689
|
+
* @param name The url name
|
|
690
|
+
* @param endpoint The url to validate
|
|
691
|
+
* @param issuer The issuer base url
|
|
692
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
693
|
+
* @returns
|
|
694
|
+
*/
|
|
695
|
+
function normalizeAndValidateUrl(name, endpoint, issuer, isTrustedOrigin) {
|
|
696
|
+
const url = normalizeUrl(name, endpoint, issuer);
|
|
697
|
+
if (!isTrustedOrigin(url)) throw new DiscoveryError("discovery_untrusted_origin", `The ${name} "${url}" is not trusted by your trusted origins configuration.`, {
|
|
698
|
+
endpoint: name,
|
|
699
|
+
url
|
|
626
700
|
});
|
|
701
|
+
return url;
|
|
627
702
|
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
703
|
+
/**
|
|
704
|
+
* Normalize a single URL endpoint.
|
|
705
|
+
*
|
|
706
|
+
* @param name - The endpoint name (e.g token_endpoint)
|
|
707
|
+
* @param endpoint - The endpoint URL to normalize
|
|
708
|
+
* @param issuer - The base issuer URL
|
|
709
|
+
* @returns The normalized endpoint URL
|
|
710
|
+
*/
|
|
711
|
+
function normalizeUrl(name, endpoint, issuer) {
|
|
631
712
|
try {
|
|
632
|
-
|
|
633
|
-
url.searchParams.set("error", error);
|
|
634
|
-
url.searchParams.set("error_description", description);
|
|
635
|
-
return url.toString();
|
|
713
|
+
return parseURL(name, endpoint).toString();
|
|
636
714
|
} catch {
|
|
637
|
-
const
|
|
638
|
-
const
|
|
639
|
-
const
|
|
640
|
-
return
|
|
715
|
+
const issuerURL = parseURL(name, issuer);
|
|
716
|
+
const basePath = issuerURL.pathname.replace(/\/+$/, "");
|
|
717
|
+
const endpointPath = endpoint.replace(/^\/+/, "");
|
|
718
|
+
return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
|
|
641
719
|
}
|
|
642
720
|
}
|
|
643
721
|
/**
|
|
644
|
-
*
|
|
722
|
+
* Parses the given URL or throws in case of invalid or unsupported protocols
|
|
645
723
|
*
|
|
646
|
-
*
|
|
647
|
-
*
|
|
648
|
-
*
|
|
724
|
+
* @param name the url name
|
|
725
|
+
* @param endpoint the endpoint url
|
|
726
|
+
* @param [base] optional base path
|
|
727
|
+
* @returns
|
|
728
|
+
*/
|
|
729
|
+
function parseURL(name, endpoint, base) {
|
|
730
|
+
let endpointURL;
|
|
731
|
+
try {
|
|
732
|
+
endpointURL = new URL(endpoint, base);
|
|
733
|
+
if (endpointURL.protocol === "http:" || endpointURL.protocol === "https:") return endpointURL;
|
|
734
|
+
} catch (error) {
|
|
735
|
+
throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
|
|
736
|
+
}
|
|
737
|
+
throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must use the http or https supported protocols: ${endpoint}`, {
|
|
738
|
+
url: endpoint,
|
|
739
|
+
protocol: endpointURL.protocol
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Select the token endpoint authentication method.
|
|
649
744
|
*
|
|
650
|
-
*
|
|
651
|
-
*
|
|
745
|
+
* @param doc - The discovery document
|
|
746
|
+
* @param existing - Existing authentication method from config
|
|
747
|
+
* @returns The selected authentication method
|
|
652
748
|
*/
|
|
653
|
-
|
|
654
|
-
if (
|
|
655
|
-
|
|
656
|
-
const
|
|
657
|
-
if (
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
|
|
663
|
-
} catch {
|
|
664
|
-
storedRequest = null;
|
|
665
|
-
}
|
|
666
|
-
if (!storedRequest) {
|
|
667
|
-
c.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
|
|
668
|
-
inResponseTo,
|
|
669
|
-
providerId: ctx.providerId
|
|
670
|
-
});
|
|
671
|
-
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Unknown or expired request ID"));
|
|
672
|
-
}
|
|
673
|
-
if (storedRequest.providerId !== ctx.providerId) {
|
|
674
|
-
c.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
|
|
675
|
-
inResponseTo,
|
|
676
|
-
expectedProvider: storedRequest.providerId,
|
|
677
|
-
actualProvider: ctx.providerId
|
|
678
|
-
});
|
|
679
|
-
await c.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
680
|
-
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Provider mismatch"));
|
|
681
|
-
}
|
|
682
|
-
await c.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
683
|
-
} else if (!allowIdpInitiated) {
|
|
684
|
-
c.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: ctx.providerId });
|
|
685
|
-
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "unsolicited_response", "IdP-initiated SSO not allowed"));
|
|
686
|
-
}
|
|
749
|
+
function selectTokenEndpointAuthMethod(doc, existing) {
|
|
750
|
+
if (existing === "private_key_jwt") return existing;
|
|
751
|
+
if (existing) return existing;
|
|
752
|
+
const supported = doc.token_endpoint_auth_methods_supported;
|
|
753
|
+
if (!supported || supported.length === 0) return "client_secret_basic";
|
|
754
|
+
if (supported.includes("client_secret_basic")) return "client_secret_basic";
|
|
755
|
+
if (supported.includes("client_secret_post")) return "client_secret_post";
|
|
756
|
+
if (supported.includes("private_key_jwt")) return "private_key_jwt";
|
|
757
|
+
return "client_secret_basic";
|
|
687
758
|
}
|
|
688
759
|
/**
|
|
689
|
-
*
|
|
760
|
+
* Check if a provider configuration needs runtime discovery.
|
|
690
761
|
*
|
|
691
|
-
*
|
|
692
|
-
*
|
|
693
|
-
*
|
|
694
|
-
*
|
|
762
|
+
* Returns true if we need discovery at runtime to complete the token exchange
|
|
763
|
+
* and validation. Specifically checks for:
|
|
764
|
+
* - `tokenEndpoint` - required for exchanging authorization code for tokens
|
|
765
|
+
* - `jwksEndpoint` - required for validating ID token signatures
|
|
766
|
+
* - `authorizationEndpoint` - required for redirecting users to the IdP for login
|
|
767
|
+
*
|
|
768
|
+
* @param config - Partial OIDC config from the provider
|
|
769
|
+
* @returns true if runtime discovery should be performed
|
|
695
770
|
*/
|
|
696
|
-
function
|
|
697
|
-
if (!
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
771
|
+
function needsRuntimeDiscovery(config) {
|
|
772
|
+
if (!config) return true;
|
|
773
|
+
return !config.tokenEndpoint || !config.jwksEndpoint || !config.authorizationEndpoint;
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Runs runtime OIDC discovery when the stored config is missing required
|
|
777
|
+
* endpoints, and merges the hydrated fields back into the config.
|
|
778
|
+
* Throws if discovery fails.
|
|
779
|
+
*/
|
|
780
|
+
async function ensureRuntimeDiscovery(config, issuer, isTrustedOrigin) {
|
|
781
|
+
let resolved = config;
|
|
782
|
+
if (needsRuntimeDiscovery(config)) {
|
|
783
|
+
const hydrated = await discoverOIDCConfig({
|
|
784
|
+
issuer,
|
|
785
|
+
existingConfig: config,
|
|
786
|
+
isTrustedOrigin
|
|
787
|
+
});
|
|
788
|
+
resolved = {
|
|
789
|
+
...config,
|
|
790
|
+
authorizationEndpoint: hydrated.authorizationEndpoint,
|
|
791
|
+
tokenEndpoint: hydrated.tokenEndpoint,
|
|
792
|
+
tokenEndpointAuthentication: hydrated.tokenEndpointAuthentication,
|
|
793
|
+
userInfoEndpoint: hydrated.userInfoEndpoint,
|
|
794
|
+
jwksEndpoint: hydrated.jwksEndpoint
|
|
795
|
+
};
|
|
705
796
|
}
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
797
|
+
await assertOIDCEndpointsResolvePublic(resolved, isTrustedOrigin);
|
|
798
|
+
return resolved;
|
|
799
|
+
}
|
|
800
|
+
//#endregion
|
|
801
|
+
//#region src/oidc/errors.ts
|
|
802
|
+
/**
|
|
803
|
+
* OIDC Discovery Error Mapping
|
|
804
|
+
*
|
|
805
|
+
* Maps DiscoveryError codes to appropriate APIError responses.
|
|
806
|
+
* Used at the boundary between the discovery pipeline and HTTP handlers.
|
|
807
|
+
*/
|
|
808
|
+
/**
|
|
809
|
+
* Maps a DiscoveryError to an appropriate APIError for HTTP responses.
|
|
810
|
+
*
|
|
811
|
+
* Error code mapping:
|
|
812
|
+
* - discovery_invalid_url → 400 BAD_REQUEST
|
|
813
|
+
* - discovery_not_found → 400 BAD_REQUEST
|
|
814
|
+
* - discovery_untrusted_origin → 400 BAD_REQUEST
|
|
815
|
+
* - discovery_private_host → 400 BAD_REQUEST
|
|
816
|
+
* - discovery_invalid_json → 400 BAD_REQUEST
|
|
817
|
+
* - discovery_incomplete → 400 BAD_REQUEST
|
|
818
|
+
* - issuer_mismatch → 400 BAD_REQUEST
|
|
819
|
+
* - unsupported_token_auth_method → 400 BAD_REQUEST
|
|
820
|
+
* - discovery_timeout → 502 BAD_GATEWAY
|
|
821
|
+
* - discovery_unexpected_error → 502 BAD_GATEWAY
|
|
822
|
+
*
|
|
823
|
+
* @param error - The DiscoveryError to map
|
|
824
|
+
* @returns An APIError with appropriate status and message
|
|
825
|
+
*/
|
|
826
|
+
function mapDiscoveryErrorToAPIError(error) {
|
|
827
|
+
switch (error.code) {
|
|
828
|
+
case "discovery_timeout": return new APIError("BAD_GATEWAY", {
|
|
829
|
+
message: `OIDC discovery timed out: ${error.message}`,
|
|
830
|
+
code: error.code
|
|
712
831
|
});
|
|
713
|
-
|
|
832
|
+
case "discovery_unexpected_error": return new APIError("BAD_GATEWAY", {
|
|
833
|
+
message: `OIDC discovery failed: ${error.message}`,
|
|
834
|
+
code: error.code
|
|
835
|
+
});
|
|
836
|
+
case "discovery_not_found": return new APIError("BAD_REQUEST", {
|
|
837
|
+
message: `OIDC discovery endpoint not found. The issuer may not support OIDC discovery, or the URL is incorrect. ${error.message}`,
|
|
838
|
+
code: error.code
|
|
839
|
+
});
|
|
840
|
+
case "discovery_invalid_url": return new APIError("BAD_REQUEST", {
|
|
841
|
+
message: `Invalid OIDC endpoint URL: ${error.message}`,
|
|
842
|
+
code: error.code
|
|
843
|
+
});
|
|
844
|
+
case "discovery_untrusted_origin": return new APIError("BAD_REQUEST", {
|
|
845
|
+
message: `Untrusted OIDC discovery URL: ${error.message}`,
|
|
846
|
+
code: error.code
|
|
847
|
+
});
|
|
848
|
+
case "discovery_private_host": return new APIError("BAD_REQUEST", {
|
|
849
|
+
message: error.message,
|
|
850
|
+
code: error.code
|
|
851
|
+
});
|
|
852
|
+
case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
|
|
853
|
+
message: `OIDC discovery returned invalid data: ${error.message}`,
|
|
854
|
+
code: error.code
|
|
855
|
+
});
|
|
856
|
+
case "discovery_incomplete": return new APIError("BAD_REQUEST", {
|
|
857
|
+
message: `OIDC discovery document is missing required fields: ${error.message}`,
|
|
858
|
+
code: error.code
|
|
859
|
+
});
|
|
860
|
+
case "issuer_mismatch": return new APIError("BAD_REQUEST", {
|
|
861
|
+
message: `OIDC issuer mismatch: ${error.message}`,
|
|
862
|
+
code: error.code
|
|
863
|
+
});
|
|
864
|
+
case "unsupported_token_auth_method": return new APIError("BAD_REQUEST", {
|
|
865
|
+
message: `Incompatible OIDC provider: ${error.message}`,
|
|
866
|
+
code: error.code
|
|
867
|
+
});
|
|
868
|
+
default:
|
|
869
|
+
error.code;
|
|
870
|
+
return new APIError("INTERNAL_SERVER_ERROR", {
|
|
871
|
+
message: `Unexpected discovery error: ${error.message}`,
|
|
872
|
+
code: "discovery_unexpected_error"
|
|
873
|
+
});
|
|
714
874
|
}
|
|
715
875
|
}
|
|
716
876
|
//#endregion
|
|
717
|
-
//#region src/
|
|
718
|
-
const
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
image: z.string().optional(),
|
|
724
|
-
extraFields: z.record(z.string(), z.any()).optional()
|
|
725
|
-
}).optional();
|
|
726
|
-
const samlMappingSchema = z.object({
|
|
727
|
-
id: z.string().optional(),
|
|
728
|
-
email: z.string().optional(),
|
|
729
|
-
emailVerified: z.string().optional(),
|
|
730
|
-
name: z.string().optional(),
|
|
731
|
-
firstName: z.string().optional(),
|
|
732
|
-
lastName: z.string().optional(),
|
|
733
|
-
extraFields: z.record(z.string(), z.any()).optional()
|
|
734
|
-
}).optional();
|
|
735
|
-
const oidcConfigSchema = z.object({
|
|
736
|
-
clientId: z.string().optional(),
|
|
737
|
-
clientSecret: z.string().optional(),
|
|
738
|
-
authorizationEndpoint: z.string().url().optional(),
|
|
739
|
-
tokenEndpoint: z.string().url().optional(),
|
|
740
|
-
userInfoEndpoint: z.string().url().optional(),
|
|
741
|
-
tokenEndpointAuthentication: z.enum([
|
|
742
|
-
"client_secret_post",
|
|
743
|
-
"client_secret_basic",
|
|
744
|
-
"private_key_jwt"
|
|
745
|
-
]).optional(),
|
|
746
|
-
privateKeyId: z.string().optional(),
|
|
747
|
-
privateKeyAlgorithm: z.string().optional(),
|
|
748
|
-
jwksEndpoint: z.string().url().optional(),
|
|
749
|
-
discoveryEndpoint: z.string().url().optional(),
|
|
750
|
-
scopes: z.array(z.string()).optional(),
|
|
751
|
-
pkce: z.boolean().optional(),
|
|
752
|
-
overrideUserInfo: z.boolean().optional(),
|
|
753
|
-
mapping: oidcMappingSchema
|
|
754
|
-
});
|
|
755
|
-
const samlConfigSchema = z.object({
|
|
756
|
-
entryPoint: z.string().url().optional(),
|
|
757
|
-
cert: z.string().optional(),
|
|
758
|
-
audience: z.string().optional(),
|
|
759
|
-
idpMetadata: z.object({
|
|
760
|
-
metadata: z.string().optional(),
|
|
761
|
-
entityID: z.string().optional(),
|
|
762
|
-
cert: z.string().optional(),
|
|
763
|
-
privateKey: z.string().optional(),
|
|
764
|
-
privateKeyPass: z.string().optional(),
|
|
765
|
-
isAssertionEncrypted: z.boolean().optional(),
|
|
766
|
-
encPrivateKey: z.string().optional(),
|
|
767
|
-
encPrivateKeyPass: z.string().optional(),
|
|
768
|
-
singleSignOnService: z.array(z.object({
|
|
769
|
-
Binding: z.string(),
|
|
770
|
-
Location: z.string().url()
|
|
771
|
-
})).optional()
|
|
772
|
-
}).optional(),
|
|
773
|
-
spMetadata: z.object({
|
|
774
|
-
metadata: z.string().optional(),
|
|
775
|
-
entityID: z.string().optional(),
|
|
776
|
-
binding: z.string().optional(),
|
|
777
|
-
privateKey: z.string().optional(),
|
|
778
|
-
privateKeyPass: z.string().optional(),
|
|
779
|
-
isAssertionEncrypted: z.boolean().optional(),
|
|
780
|
-
encPrivateKey: z.string().optional(),
|
|
781
|
-
encPrivateKeyPass: z.string().optional()
|
|
782
|
-
}).optional(),
|
|
783
|
-
wantAssertionsSigned: z.boolean().optional(),
|
|
784
|
-
authnRequestsSigned: z.boolean().optional(),
|
|
785
|
-
signatureAlgorithm: z.string().optional(),
|
|
786
|
-
digestAlgorithm: z.string().optional(),
|
|
787
|
-
identifierFormat: z.string().optional(),
|
|
788
|
-
privateKey: z.string().optional(),
|
|
789
|
-
mapping: samlMappingSchema
|
|
790
|
-
});
|
|
791
|
-
const updateSSOProviderBodySchema = z.object({
|
|
792
|
-
issuer: z.string().url().optional(),
|
|
793
|
-
domain: z.string().optional(),
|
|
794
|
-
oidcConfig: oidcConfigSchema.optional(),
|
|
795
|
-
samlConfig: samlConfigSchema.optional()
|
|
877
|
+
//#region src/saml/parser.ts
|
|
878
|
+
const xmlParser = new XMLParser({
|
|
879
|
+
ignoreAttributes: false,
|
|
880
|
+
attributeNamePrefix: "@_",
|
|
881
|
+
removeNSPrefix: true,
|
|
882
|
+
processEntities: false
|
|
796
883
|
});
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
const
|
|
800
|
-
|
|
801
|
-
const
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
field: "userId",
|
|
805
|
-
value: userId
|
|
806
|
-
}, {
|
|
807
|
-
field: "organizationId",
|
|
808
|
-
value: organizationId
|
|
809
|
-
}]
|
|
810
|
-
});
|
|
811
|
-
if (!member) return false;
|
|
812
|
-
return member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()));
|
|
813
|
-
}
|
|
814
|
-
async function batchCheckOrgAdmin(ctx, userId, organizationIds) {
|
|
815
|
-
if (organizationIds.length === 0) return /* @__PURE__ */ new Set();
|
|
816
|
-
const members = await ctx.context.adapter.findMany({
|
|
817
|
-
model: "member",
|
|
818
|
-
where: [{
|
|
819
|
-
field: "userId",
|
|
820
|
-
value: userId
|
|
821
|
-
}, {
|
|
822
|
-
field: "organizationId",
|
|
823
|
-
value: organizationIds,
|
|
824
|
-
operator: "in"
|
|
825
|
-
}]
|
|
826
|
-
});
|
|
827
|
-
const adminOrgIds = /* @__PURE__ */ new Set();
|
|
828
|
-
for (const member of members) if (member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()))) adminOrgIds.add(member.organizationId);
|
|
829
|
-
return adminOrgIds;
|
|
830
|
-
}
|
|
831
|
-
function sanitizeProvider(provider, baseURL) {
|
|
832
|
-
let oidcConfig = null;
|
|
833
|
-
let samlConfig = null;
|
|
834
|
-
try {
|
|
835
|
-
oidcConfig = safeJsonParse(provider.oidcConfig);
|
|
836
|
-
} catch {
|
|
837
|
-
oidcConfig = null;
|
|
884
|
+
function findNode(obj, nodeName) {
|
|
885
|
+
if (!obj || typeof obj !== "object") return null;
|
|
886
|
+
const record = obj;
|
|
887
|
+
if (nodeName in record) return record[nodeName];
|
|
888
|
+
for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
|
|
889
|
+
const found = findNode(item, nodeName);
|
|
890
|
+
if (found) return found;
|
|
838
891
|
}
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
samlConfig = null;
|
|
892
|
+
else if (typeof value === "object" && value !== null) {
|
|
893
|
+
const found = findNode(value, nodeName);
|
|
894
|
+
if (found) return found;
|
|
843
895
|
}
|
|
844
|
-
|
|
845
|
-
return {
|
|
846
|
-
providerId: provider.providerId,
|
|
847
|
-
type,
|
|
848
|
-
issuer: provider.issuer,
|
|
849
|
-
domain: provider.domain,
|
|
850
|
-
organizationId: provider.organizationId || null,
|
|
851
|
-
domainVerified: provider.domainVerified ?? false,
|
|
852
|
-
oidcConfig: oidcConfig ? {
|
|
853
|
-
discoveryEndpoint: oidcConfig.discoveryEndpoint,
|
|
854
|
-
clientIdLastFour: maskClientId(oidcConfig.clientId),
|
|
855
|
-
pkce: oidcConfig.pkce,
|
|
856
|
-
authorizationEndpoint: oidcConfig.authorizationEndpoint,
|
|
857
|
-
tokenEndpoint: oidcConfig.tokenEndpoint,
|
|
858
|
-
userInfoEndpoint: oidcConfig.userInfoEndpoint,
|
|
859
|
-
jwksEndpoint: oidcConfig.jwksEndpoint,
|
|
860
|
-
scopes: oidcConfig.scopes,
|
|
861
|
-
tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication
|
|
862
|
-
} : void 0,
|
|
863
|
-
samlConfig: samlConfig ? {
|
|
864
|
-
entryPoint: samlConfig.entryPoint,
|
|
865
|
-
audience: samlConfig.audience,
|
|
866
|
-
wantAssertionsSigned: samlConfig.wantAssertionsSigned,
|
|
867
|
-
authnRequestsSigned: samlConfig.authnRequestsSigned,
|
|
868
|
-
identifierFormat: samlConfig.identifierFormat,
|
|
869
|
-
signatureAlgorithm: samlConfig.signatureAlgorithm,
|
|
870
|
-
digestAlgorithm: samlConfig.digestAlgorithm,
|
|
871
|
-
certificate: (() => {
|
|
872
|
-
try {
|
|
873
|
-
return parseCertificate(samlConfig.cert);
|
|
874
|
-
} catch {
|
|
875
|
-
return { error: "Failed to parse certificate" };
|
|
876
|
-
}
|
|
877
|
-
})()
|
|
878
|
-
} : void 0,
|
|
879
|
-
spMetadataUrl: `${baseURL}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(provider.providerId)}`
|
|
880
|
-
};
|
|
896
|
+
return null;
|
|
881
897
|
}
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
const userId = ctx.context.session.user.id;
|
|
894
|
-
const allProviders = await ctx.context.adapter.findMany({ model: "ssoProvider" });
|
|
895
|
-
const userOwnedProviders = allProviders.filter((p) => p.userId === userId && !p.organizationId);
|
|
896
|
-
const orgProviders = allProviders.filter((p) => p.organizationId !== null && p.organizationId !== void 0);
|
|
897
|
-
const orgPluginEnabled = ctx.context.hasPlugin("organization");
|
|
898
|
-
let accessibleProviders = [...userOwnedProviders];
|
|
899
|
-
if (orgPluginEnabled && orgProviders.length > 0) {
|
|
900
|
-
const adminOrgIds = await batchCheckOrgAdmin(ctx, userId, [...new Set(orgProviders.map((p) => p.organizationId).filter((id) => id !== null && id !== void 0))]);
|
|
901
|
-
const orgAccessibleProviders = orgProviders.filter((provider) => provider.organizationId && adminOrgIds.has(provider.organizationId));
|
|
902
|
-
accessibleProviders = [...accessibleProviders, ...orgAccessibleProviders];
|
|
903
|
-
} else if (!orgPluginEnabled) {
|
|
904
|
-
const userOwnedOrgProviders = orgProviders.filter((p) => p.userId === userId);
|
|
905
|
-
accessibleProviders = [...accessibleProviders, ...userOwnedOrgProviders];
|
|
906
|
-
}
|
|
907
|
-
const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL));
|
|
908
|
-
return ctx.json({ providers });
|
|
909
|
-
});
|
|
910
|
-
};
|
|
911
|
-
const getSSOProviderQuerySchema = z.object({ providerId: z.string() });
|
|
912
|
-
async function checkProviderAccess(ctx, providerId) {
|
|
913
|
-
const userId = ctx.context.session.user.id;
|
|
914
|
-
const provider = await ctx.context.adapter.findOne({
|
|
915
|
-
model: "ssoProvider",
|
|
916
|
-
where: [{
|
|
917
|
-
field: "providerId",
|
|
918
|
-
value: providerId
|
|
919
|
-
}]
|
|
920
|
-
});
|
|
921
|
-
if (!provider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
|
|
922
|
-
let hasAccess = false;
|
|
923
|
-
if (provider.organizationId) if (ctx.context.hasPlugin("organization")) hasAccess = await isOrgAdmin(ctx, userId, provider.organizationId);
|
|
924
|
-
else hasAccess = provider.userId === userId;
|
|
925
|
-
else hasAccess = provider.userId === userId;
|
|
926
|
-
if (!hasAccess) throw new APIError("FORBIDDEN", { message: "You don't have access to this provider" });
|
|
927
|
-
return provider;
|
|
898
|
+
function countAllNodes(obj, nodeName) {
|
|
899
|
+
if (!obj || typeof obj !== "object") return 0;
|
|
900
|
+
let count = 0;
|
|
901
|
+
const record = obj;
|
|
902
|
+
if (nodeName in record) {
|
|
903
|
+
const node = record[nodeName];
|
|
904
|
+
count += Array.isArray(node) ? node.length : 1;
|
|
905
|
+
}
|
|
906
|
+
for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) count += countAllNodes(item, nodeName);
|
|
907
|
+
else if (typeof value === "object" && value !== null) count += countAllNodes(value, nodeName);
|
|
908
|
+
return count;
|
|
928
909
|
}
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
"200": { description: "SSO provider details" },
|
|
940
|
-
"404": { description: "Provider not found" },
|
|
941
|
-
"403": { description: "Access denied" }
|
|
942
|
-
}
|
|
943
|
-
} }
|
|
944
|
-
}, async (ctx) => {
|
|
945
|
-
const { providerId } = ctx.query;
|
|
946
|
-
const provider = await checkProviderAccess(ctx, providerId);
|
|
947
|
-
return ctx.json(sanitizeProvider(provider, ctx.context.baseURL));
|
|
948
|
-
});
|
|
910
|
+
//#endregion
|
|
911
|
+
//#region src/saml/algorithms.ts
|
|
912
|
+
const SignatureAlgorithm = {
|
|
913
|
+
RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
|
|
914
|
+
RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
|
|
915
|
+
RSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
|
|
916
|
+
RSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
|
|
917
|
+
ECDSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
|
|
918
|
+
ECDSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
|
|
919
|
+
ECDSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"
|
|
949
920
|
};
|
|
950
|
-
|
|
951
|
-
|
|
921
|
+
const DigestAlgorithm = {
|
|
922
|
+
SHA1: "http://www.w3.org/2000/09/xmldsig#sha1",
|
|
923
|
+
SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
|
|
924
|
+
SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
|
|
925
|
+
SHA512: "http://www.w3.org/2001/04/xmlenc#sha512"
|
|
926
|
+
};
|
|
927
|
+
const KeyEncryptionAlgorithm = {
|
|
928
|
+
RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
|
|
929
|
+
RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
|
|
930
|
+
RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep"
|
|
931
|
+
};
|
|
932
|
+
const DataEncryptionAlgorithm = {
|
|
933
|
+
TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
|
|
934
|
+
AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
|
|
935
|
+
AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
|
|
936
|
+
AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
|
|
937
|
+
AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
|
|
938
|
+
AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
|
|
939
|
+
AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
|
|
940
|
+
};
|
|
941
|
+
const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
|
|
942
|
+
const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS = [KeyEncryptionAlgorithm.RSA_1_5];
|
|
943
|
+
const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS = [DataEncryptionAlgorithm.TRIPLEDES_CBC];
|
|
944
|
+
const DEPRECATED_DIGEST_ALGORITHMS = [DigestAlgorithm.SHA1];
|
|
945
|
+
const SECURE_SIGNATURE_ALGORITHMS = [
|
|
946
|
+
SignatureAlgorithm.RSA_SHA256,
|
|
947
|
+
SignatureAlgorithm.RSA_SHA384,
|
|
948
|
+
SignatureAlgorithm.RSA_SHA512,
|
|
949
|
+
SignatureAlgorithm.ECDSA_SHA256,
|
|
950
|
+
SignatureAlgorithm.ECDSA_SHA384,
|
|
951
|
+
SignatureAlgorithm.ECDSA_SHA512
|
|
952
|
+
];
|
|
953
|
+
const SECURE_DIGEST_ALGORITHMS = [
|
|
954
|
+
DigestAlgorithm.SHA256,
|
|
955
|
+
DigestAlgorithm.SHA384,
|
|
956
|
+
DigestAlgorithm.SHA512
|
|
957
|
+
];
|
|
958
|
+
const SHORT_FORM_SIGNATURE_TO_URI = {
|
|
959
|
+
sha1: SignatureAlgorithm.RSA_SHA1,
|
|
960
|
+
sha256: SignatureAlgorithm.RSA_SHA256,
|
|
961
|
+
sha384: SignatureAlgorithm.RSA_SHA384,
|
|
962
|
+
sha512: SignatureAlgorithm.RSA_SHA512,
|
|
963
|
+
"rsa-sha1": SignatureAlgorithm.RSA_SHA1,
|
|
964
|
+
"rsa-sha256": SignatureAlgorithm.RSA_SHA256,
|
|
965
|
+
"rsa-sha384": SignatureAlgorithm.RSA_SHA384,
|
|
966
|
+
"rsa-sha512": SignatureAlgorithm.RSA_SHA512,
|
|
967
|
+
"ecdsa-sha256": SignatureAlgorithm.ECDSA_SHA256,
|
|
968
|
+
"ecdsa-sha384": SignatureAlgorithm.ECDSA_SHA384,
|
|
969
|
+
"ecdsa-sha512": SignatureAlgorithm.ECDSA_SHA512
|
|
970
|
+
};
|
|
971
|
+
const SHORT_FORM_DIGEST_TO_URI = {
|
|
972
|
+
sha1: DigestAlgorithm.SHA1,
|
|
973
|
+
sha256: DigestAlgorithm.SHA256,
|
|
974
|
+
sha384: DigestAlgorithm.SHA384,
|
|
975
|
+
sha512: DigestAlgorithm.SHA512
|
|
976
|
+
};
|
|
977
|
+
function normalizeSignatureAlgorithm(alg) {
|
|
978
|
+
return SHORT_FORM_SIGNATURE_TO_URI[alg.toLowerCase()] ?? alg;
|
|
979
|
+
}
|
|
980
|
+
function normalizeDigestAlgorithm(alg) {
|
|
981
|
+
return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
|
|
982
|
+
}
|
|
983
|
+
function extractEncryptionAlgorithms(xml) {
|
|
952
984
|
try {
|
|
953
|
-
|
|
985
|
+
const parsed = xmlParser.parse(xml);
|
|
986
|
+
const keyAlg = (findNode(parsed, "EncryptedKey")?.EncryptionMethod)?.["@_Algorithm"];
|
|
987
|
+
const dataAlg = (findNode(parsed, "EncryptedData")?.EncryptionMethod)?.["@_Algorithm"];
|
|
988
|
+
return {
|
|
989
|
+
keyEncryption: keyAlg || null,
|
|
990
|
+
dataEncryption: dataAlg || null
|
|
991
|
+
};
|
|
954
992
|
} catch {
|
|
955
|
-
|
|
993
|
+
return {
|
|
994
|
+
keyEncryption: null,
|
|
995
|
+
dataEncryption: null
|
|
996
|
+
};
|
|
956
997
|
}
|
|
957
|
-
if (!config) throw new APIError("BAD_REQUEST", { message: `Cannot update ${configType} config for a provider that doesn't have ${configType} configured` });
|
|
958
|
-
return config;
|
|
959
|
-
}
|
|
960
|
-
function mergeSAMLConfig(current, updates, issuer) {
|
|
961
|
-
return {
|
|
962
|
-
...current,
|
|
963
|
-
...updates,
|
|
964
|
-
issuer,
|
|
965
|
-
entryPoint: updates.entryPoint ?? current.entryPoint,
|
|
966
|
-
cert: updates.cert ?? current.cert,
|
|
967
|
-
spMetadata: updates.spMetadata ?? current.spMetadata,
|
|
968
|
-
idpMetadata: updates.idpMetadata ?? current.idpMetadata,
|
|
969
|
-
mapping: updates.mapping ?? current.mapping,
|
|
970
|
-
audience: updates.audience ?? current.audience,
|
|
971
|
-
wantAssertionsSigned: updates.wantAssertionsSigned ?? current.wantAssertionsSigned,
|
|
972
|
-
authnRequestsSigned: updates.authnRequestsSigned ?? current.authnRequestsSigned,
|
|
973
|
-
identifierFormat: updates.identifierFormat ?? current.identifierFormat,
|
|
974
|
-
signatureAlgorithm: updates.signatureAlgorithm ?? current.signatureAlgorithm,
|
|
975
|
-
digestAlgorithm: updates.digestAlgorithm ?? current.digestAlgorithm
|
|
976
|
-
};
|
|
977
998
|
}
|
|
978
|
-
function
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
clientId: updates.clientId ?? current.clientId,
|
|
985
|
-
clientSecret: updates.clientSecret ?? current.clientSecret,
|
|
986
|
-
discoveryEndpoint: updates.discoveryEndpoint ?? current.discoveryEndpoint,
|
|
987
|
-
mapping: updates.mapping ?? current.mapping,
|
|
988
|
-
scopes: updates.scopes ?? current.scopes,
|
|
989
|
-
authorizationEndpoint: updates.authorizationEndpoint ?? current.authorizationEndpoint,
|
|
990
|
-
tokenEndpoint: updates.tokenEndpoint ?? current.tokenEndpoint,
|
|
991
|
-
userInfoEndpoint: updates.userInfoEndpoint ?? current.userInfoEndpoint,
|
|
992
|
-
jwksEndpoint: updates.jwksEndpoint ?? current.jwksEndpoint,
|
|
993
|
-
tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication,
|
|
994
|
-
privateKeyId: updates.privateKeyId ?? current.privateKeyId,
|
|
995
|
-
privateKeyAlgorithm: updates.privateKeyAlgorithm ?? current.privateKeyAlgorithm
|
|
996
|
-
};
|
|
999
|
+
function hasEncryptedAssertion(xml) {
|
|
1000
|
+
try {
|
|
1001
|
+
return findNode(xmlParser.parse(xml), "EncryptedAssertion") !== null;
|
|
1002
|
+
} catch {
|
|
1003
|
+
return false;
|
|
1004
|
+
}
|
|
997
1005
|
}
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
metadata: { openapi: {
|
|
1004
|
-
operationId: "updateSSOProvider",
|
|
1005
|
-
summary: "Update SSO provider",
|
|
1006
|
-
description: "Partially update an SSO provider. Only provided fields are updated. If domain changes, domainVerified is reset to false.",
|
|
1007
|
-
responses: {
|
|
1008
|
-
"200": { description: "SSO provider updated successfully" },
|
|
1009
|
-
"404": { description: "Provider not found" },
|
|
1010
|
-
"403": { description: "Access denied" }
|
|
1011
|
-
}
|
|
1012
|
-
} }
|
|
1013
|
-
}, async (ctx) => {
|
|
1014
|
-
const { providerId, ...body } = ctx.body;
|
|
1015
|
-
const { issuer, domain, samlConfig, oidcConfig } = body;
|
|
1016
|
-
if (!issuer && !domain && !samlConfig && !oidcConfig) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
|
|
1017
|
-
const existingProvider = await checkProviderAccess(ctx, providerId);
|
|
1018
|
-
const updateData = {};
|
|
1019
|
-
if (body.issuer !== void 0) updateData.issuer = body.issuer;
|
|
1020
|
-
if (body.domain !== void 0) {
|
|
1021
|
-
updateData.domain = body.domain;
|
|
1022
|
-
if (body.domain !== existingProvider.domain) updateData.domainVerified = false;
|
|
1023
|
-
}
|
|
1024
|
-
if (body.samlConfig) {
|
|
1025
|
-
if (body.samlConfig.idpMetadata?.metadata) {
|
|
1026
|
-
const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
|
|
1027
|
-
if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
|
|
1028
|
-
}
|
|
1029
|
-
if (body.samlConfig.signatureAlgorithm !== void 0 || body.samlConfig.digestAlgorithm !== void 0) validateConfigAlgorithms({
|
|
1030
|
-
signatureAlgorithm: body.samlConfig.signatureAlgorithm,
|
|
1031
|
-
digestAlgorithm: body.samlConfig.digestAlgorithm
|
|
1032
|
-
}, options?.saml?.algorithms);
|
|
1033
|
-
const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
|
|
1034
|
-
const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
|
|
1035
|
-
updateData.samlConfig = JSON.stringify(updatedSamlConfig);
|
|
1036
|
-
}
|
|
1037
|
-
if (body.oidcConfig) {
|
|
1038
|
-
const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
|
|
1039
|
-
const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
|
|
1040
|
-
if (updatedOidcConfig.tokenEndpointAuthentication !== "private_key_jwt" && !updatedOidcConfig.clientSecret) throw new APIError("BAD_REQUEST", { message: "clientSecret is required when using client_secret_basic or client_secret_post authentication" });
|
|
1041
|
-
if (updatedOidcConfig.tokenEndpointAuthentication === "private_key_jwt" && !options?.resolvePrivateKey && !options?.defaultSSO?.some((p) => p.providerId === providerId && "privateKey" in p && p.privateKey)) throw new APIError("BAD_REQUEST", { message: "private_key_jwt authentication requires either a resolvePrivateKey callback or a privateKey in defaultSSO" });
|
|
1042
|
-
updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
|
|
1043
|
-
}
|
|
1044
|
-
await ctx.context.adapter.update({
|
|
1045
|
-
model: "ssoProvider",
|
|
1046
|
-
where: [{
|
|
1047
|
-
field: "providerId",
|
|
1048
|
-
value: providerId
|
|
1049
|
-
}],
|
|
1050
|
-
update: updateData
|
|
1006
|
+
function handleDeprecatedAlgorithm(message, behavior, errorCode) {
|
|
1007
|
+
switch (behavior) {
|
|
1008
|
+
case "reject": throw new APIError("BAD_REQUEST", {
|
|
1009
|
+
message,
|
|
1010
|
+
code: errorCode
|
|
1051
1011
|
});
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1012
|
+
case "warn":
|
|
1013
|
+
console.warn(`[SAML Security Warning] ${message}`);
|
|
1014
|
+
break;
|
|
1015
|
+
case "allow": break;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
function validateSignatureAlgorithm(algorithm, options = {}) {
|
|
1019
|
+
if (!algorithm) return;
|
|
1020
|
+
const { onDeprecated = "warn", allowedSignatureAlgorithms } = options;
|
|
1021
|
+
if (allowedSignatureAlgorithms) {
|
|
1022
|
+
if (!allowedSignatureAlgorithms.includes(algorithm)) throw new APIError("BAD_REQUEST", {
|
|
1023
|
+
message: `SAML signature algorithm not in allow-list: ${algorithm}`,
|
|
1024
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
1058
1025
|
});
|
|
1059
|
-
|
|
1060
|
-
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(algorithm)) {
|
|
1029
|
+
handleDeprecatedAlgorithm(`SAML response uses deprecated signature algorithm: ${algorithm}. Please configure your IdP to use SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
if (!SECURE_SIGNATURE_ALGORITHMS.includes(algorithm)) throw new APIError("BAD_REQUEST", {
|
|
1033
|
+
message: `SAML signature algorithm not recognized: ${algorithm}`,
|
|
1034
|
+
code: "SAML_UNKNOWN_ALGORITHM"
|
|
1061
1035
|
});
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1036
|
+
}
|
|
1037
|
+
function validateEncryptionAlgorithms(algorithms, options = {}) {
|
|
1038
|
+
const { onDeprecated = "warn", allowedKeyEncryptionAlgorithms, allowedDataEncryptionAlgorithms } = options;
|
|
1039
|
+
const { keyEncryption, dataEncryption } = algorithms;
|
|
1040
|
+
if (keyEncryption) {
|
|
1041
|
+
if (allowedKeyEncryptionAlgorithms) {
|
|
1042
|
+
if (!allowedKeyEncryptionAlgorithms.includes(keyEncryption)) throw new APIError("BAD_REQUEST", {
|
|
1043
|
+
message: `SAML key encryption algorithm not in allow-list: ${keyEncryption}`,
|
|
1044
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
1045
|
+
});
|
|
1046
|
+
} 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");
|
|
1047
|
+
}
|
|
1048
|
+
if (dataEncryption) {
|
|
1049
|
+
if (allowedDataEncryptionAlgorithms) {
|
|
1050
|
+
if (!allowedDataEncryptionAlgorithms.includes(dataEncryption)) throw new APIError("BAD_REQUEST", {
|
|
1051
|
+
message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
|
|
1052
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
1053
|
+
});
|
|
1054
|
+
} 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");
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
function validateSAMLAlgorithms(response, options) {
|
|
1058
|
+
validateSignatureAlgorithm(response.sigAlg, options);
|
|
1059
|
+
if (hasEncryptedAssertion(response.samlContent)) validateEncryptionAlgorithms(extractEncryptionAlgorithms(response.samlContent), options);
|
|
1060
|
+
}
|
|
1061
|
+
function validateConfigAlgorithms(config, options = {}) {
|
|
1062
|
+
const { onDeprecated = "warn", allowedSignatureAlgorithms, allowedDigestAlgorithms } = options;
|
|
1063
|
+
if (config.signatureAlgorithm) {
|
|
1064
|
+
const normalized = normalizeSignatureAlgorithm(config.signatureAlgorithm);
|
|
1065
|
+
if (allowedSignatureAlgorithms) {
|
|
1066
|
+
if (!allowedSignatureAlgorithms.map(normalizeSignatureAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
1067
|
+
message: `SAML signature algorithm not in allow-list: ${config.signatureAlgorithm}`,
|
|
1068
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
1069
|
+
});
|
|
1070
|
+
} 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");
|
|
1071
|
+
else if (!SECURE_SIGNATURE_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
1072
|
+
message: `SAML signature algorithm not recognized: ${config.signatureAlgorithm}`,
|
|
1073
|
+
code: "SAML_UNKNOWN_ALGORITHM"
|
|
1087
1074
|
});
|
|
1088
|
-
return ctx.json({ success: true });
|
|
1089
|
-
});
|
|
1090
|
-
};
|
|
1091
|
-
//#endregion
|
|
1092
|
-
//#region src/oidc/types.ts
|
|
1093
|
-
/**
|
|
1094
|
-
* Custom error class for OIDC discovery failures.
|
|
1095
|
-
* Can be caught and mapped to APIError at the edge.
|
|
1096
|
-
*/
|
|
1097
|
-
var DiscoveryError = class DiscoveryError extends Error {
|
|
1098
|
-
code;
|
|
1099
|
-
details;
|
|
1100
|
-
constructor(code, message, details, options) {
|
|
1101
|
-
super(message, options);
|
|
1102
|
-
this.name = "DiscoveryError";
|
|
1103
|
-
this.code = code;
|
|
1104
|
-
this.details = details;
|
|
1105
|
-
if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
|
|
1106
1075
|
}
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1076
|
+
if (config.digestAlgorithm) {
|
|
1077
|
+
const normalized = normalizeDigestAlgorithm(config.digestAlgorithm);
|
|
1078
|
+
if (allowedDigestAlgorithms) {
|
|
1079
|
+
if (!allowedDigestAlgorithms.map(normalizeDigestAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
1080
|
+
message: `SAML digest algorithm not in allow-list: ${config.digestAlgorithm}`,
|
|
1081
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
1082
|
+
});
|
|
1083
|
+
} 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");
|
|
1084
|
+
else if (!SECURE_DIGEST_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
1085
|
+
message: `SAML digest algorithm not recognized: ${config.digestAlgorithm}`,
|
|
1086
|
+
code: "SAML_UNKNOWN_ALGORITHM"
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1117
1090
|
//#endregion
|
|
1118
|
-
//#region src/
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
const
|
|
1130
|
-
|
|
1131
|
-
* Main entry point: Discover and hydrate OIDC configuration from an issuer.
|
|
1132
|
-
*
|
|
1133
|
-
* This function:
|
|
1134
|
-
* 1. Computes the discovery URL from the issuer
|
|
1135
|
-
* 2. Validates the discovery URL
|
|
1136
|
-
* 3. Fetches the discovery document
|
|
1137
|
-
* 4. Validates the discovery document (issuer match + required fields)
|
|
1138
|
-
* 5. Normalizes URLs
|
|
1139
|
-
* 6. Selects token endpoint auth method
|
|
1140
|
-
* 7. Merges with existing config (existing values take precedence)
|
|
1141
|
-
*
|
|
1142
|
-
* @param params - Discovery parameters
|
|
1143
|
-
* @param isTrustedOrigin - Origin verification tester function
|
|
1144
|
-
* @returns Hydrated OIDC configuration ready for persistence
|
|
1145
|
-
* @throws DiscoveryError on any failure
|
|
1146
|
-
*/
|
|
1147
|
-
async function discoverOIDCConfig(params) {
|
|
1148
|
-
const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
|
|
1149
|
-
const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
|
|
1150
|
-
validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
|
|
1151
|
-
const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
|
|
1152
|
-
validateDiscoveryDocument(discoveryDoc, issuer);
|
|
1153
|
-
const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
|
|
1154
|
-
const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
|
|
1091
|
+
//#region src/saml/assertions.ts
|
|
1092
|
+
function countAssertions(xml) {
|
|
1093
|
+
let parsed;
|
|
1094
|
+
try {
|
|
1095
|
+
parsed = xmlParser.parse(xml);
|
|
1096
|
+
} catch {
|
|
1097
|
+
throw new APIError("BAD_REQUEST", {
|
|
1098
|
+
message: "Failed to parse SAML response XML",
|
|
1099
|
+
code: "SAML_INVALID_XML"
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
const assertions = countAllNodes(parsed, "Assertion");
|
|
1103
|
+
const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
|
|
1155
1104
|
return {
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
tokenEndpoint: existingConfig?.tokenEndpoint ?? normalizedDoc.token_endpoint,
|
|
1160
|
-
jwksEndpoint: existingConfig?.jwksEndpoint ?? normalizedDoc.jwks_uri,
|
|
1161
|
-
userInfoEndpoint: existingConfig?.userInfoEndpoint ?? normalizedDoc.userinfo_endpoint,
|
|
1162
|
-
tokenEndpointAuthentication: existingConfig?.tokenEndpointAuthentication ?? tokenEndpointAuth,
|
|
1163
|
-
scopesSupported: existingConfig?.scopesSupported ?? normalizedDoc.scopes_supported
|
|
1105
|
+
assertions,
|
|
1106
|
+
encryptedAssertions,
|
|
1107
|
+
total: assertions + encryptedAssertions
|
|
1164
1108
|
};
|
|
1165
1109
|
}
|
|
1110
|
+
function validateSingleAssertion(samlResponse) {
|
|
1111
|
+
let xml;
|
|
1112
|
+
try {
|
|
1113
|
+
xml = new TextDecoder().decode(base64.decode(samlResponse.replace(/\s+/g, "")));
|
|
1114
|
+
if (!xml.includes("<")) throw new Error("Not XML");
|
|
1115
|
+
} catch {
|
|
1116
|
+
throw new APIError("BAD_REQUEST", {
|
|
1117
|
+
message: "Invalid base64-encoded SAML response",
|
|
1118
|
+
code: "SAML_INVALID_ENCODING"
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
const counts = countAssertions(xml);
|
|
1122
|
+
if (counts.total === 0) throw new APIError("BAD_REQUEST", {
|
|
1123
|
+
message: "SAML response contains no assertions",
|
|
1124
|
+
code: "SAML_NO_ASSERTION"
|
|
1125
|
+
});
|
|
1126
|
+
if (counts.total > 1) throw new APIError("BAD_REQUEST", {
|
|
1127
|
+
message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
|
|
1128
|
+
code: "SAML_MULTIPLE_ASSERTIONS"
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
//#endregion
|
|
1132
|
+
//#region src/saml/error-codes.ts
|
|
1133
|
+
const SAML_ERROR_CODES = defineErrorCodes({
|
|
1134
|
+
SINGLE_LOGOUT_NOT_ENABLED: "Single Logout is not enabled",
|
|
1135
|
+
INVALID_LOGOUT_RESPONSE: "Invalid LogoutResponse",
|
|
1136
|
+
INVALID_LOGOUT_REQUEST: "Invalid LogoutRequest",
|
|
1137
|
+
LOGOUT_FAILED_AT_IDP: "Logout failed at IdP",
|
|
1138
|
+
IDP_SLO_NOT_SUPPORTED: "IdP does not support Single Logout Service",
|
|
1139
|
+
SAML_PROVIDER_NOT_FOUND: "SAML provider not found",
|
|
1140
|
+
CERT_SOURCE_MISSING: "samlConfig requires either a signing certificate (cert or idpMetadata.cert) or an idpMetadata.metadata XML document."
|
|
1141
|
+
});
|
|
1142
|
+
//#endregion
|
|
1143
|
+
//#region src/saml/cert.ts
|
|
1166
1144
|
/**
|
|
1167
|
-
*
|
|
1168
|
-
*
|
|
1169
|
-
*
|
|
1170
|
-
*
|
|
1171
|
-
*
|
|
1172
|
-
* Handles trailing slashes correctly.
|
|
1145
|
+
* IdP signing-certificate rules for SAML configs. Centralized so the runtime
|
|
1146
|
+
* verification path (`createIdP`), the sanitizer (`getSSOProvider` and
|
|
1147
|
+
* friends), and the registration validator agree on precedence and the
|
|
1148
|
+
* "exactly one cert source" contract.
|
|
1173
1149
|
*/
|
|
1174
|
-
function computeDiscoveryUrl(issuer) {
|
|
1175
|
-
return `${issuer.endsWith("/") ? issuer.slice(0, -1) : issuer}/.well-known/openid-configuration`;
|
|
1176
|
-
}
|
|
1177
1150
|
/**
|
|
1178
|
-
*
|
|
1179
|
-
*
|
|
1180
|
-
*
|
|
1181
|
-
*
|
|
1182
|
-
* @throws DiscoveryError if URL is invalid
|
|
1151
|
+
* Returns the IdP signing certificates Better Auth trusts for this provider
|
|
1152
|
+
* as a list. `idpMetadata.cert` wins when both are set; the top-level `cert`
|
|
1153
|
+
* is the fallback. Returns `undefined` when neither is set (the certs come
|
|
1154
|
+
* from `idpMetadata.metadata` XML instead).
|
|
1183
1155
|
*/
|
|
1184
|
-
function
|
|
1185
|
-
const
|
|
1186
|
-
if (
|
|
1156
|
+
function resolveSigningCerts(config) {
|
|
1157
|
+
const cert = config.idpMetadata?.cert ?? config.cert;
|
|
1158
|
+
if (cert === void 0) return void 0;
|
|
1159
|
+
return Array.isArray(cert) ? cert : [cert];
|
|
1187
1160
|
}
|
|
1188
1161
|
/**
|
|
1189
|
-
*
|
|
1190
|
-
*
|
|
1191
|
-
*
|
|
1192
|
-
*
|
|
1193
|
-
* @returns The parsed discovery document
|
|
1194
|
-
* @throws DiscoveryError on network errors, timeouts, or invalid responses
|
|
1162
|
+
* Reject SAML configs with no signing-cert source. samlify needs either an
|
|
1163
|
+
* `idpMetadata.metadata` XML document (which embeds the certs) or an explicit
|
|
1164
|
+
* PEM under `cert` or `idpMetadata.cert`; without one of those it has nothing
|
|
1165
|
+
* to verify responses against.
|
|
1195
1166
|
*/
|
|
1196
|
-
|
|
1167
|
+
function validateCertSources(config) {
|
|
1168
|
+
const hasMetadataXml = !!config.idpMetadata?.metadata;
|
|
1169
|
+
const hasExplicitCert = config.idpMetadata?.cert !== void 0 || config.cert !== void 0;
|
|
1170
|
+
if (!hasMetadataXml && !hasExplicitCert) throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.CERT_SOURCE_MISSING);
|
|
1171
|
+
}
|
|
1172
|
+
//#endregion
|
|
1173
|
+
//#region src/saml/response-validation.ts
|
|
1174
|
+
function errorRedirectUrl(base, error, description) {
|
|
1197
1175
|
try {
|
|
1198
|
-
const
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
});
|
|
1208
|
-
if (status === 408) throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
|
|
1209
|
-
url,
|
|
1210
|
-
timeout
|
|
1211
|
-
});
|
|
1212
|
-
throw new DiscoveryError("discovery_unexpected_error", `Unexpected discovery error: ${response.error.statusText}`, {
|
|
1213
|
-
url,
|
|
1214
|
-
...response.error
|
|
1215
|
-
});
|
|
1216
|
-
}
|
|
1217
|
-
if (!response.data) throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned an empty response", { url });
|
|
1218
|
-
const data = response.data;
|
|
1219
|
-
if (typeof data === "string") throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned invalid JSON", {
|
|
1220
|
-
url,
|
|
1221
|
-
bodyPreview: data.slice(0, 200)
|
|
1222
|
-
});
|
|
1223
|
-
return data;
|
|
1224
|
-
} catch (error) {
|
|
1225
|
-
if (error instanceof DiscoveryError) throw error;
|
|
1226
|
-
if (error instanceof Error && error.name === "AbortError") throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
|
|
1227
|
-
url,
|
|
1228
|
-
timeout
|
|
1229
|
-
});
|
|
1230
|
-
throw new DiscoveryError("discovery_unexpected_error", `Unexpected error during discovery: ${error instanceof Error ? error.message : String(error)}`, { url }, { cause: error });
|
|
1176
|
+
const url = new URL(base);
|
|
1177
|
+
url.searchParams.set("error", error);
|
|
1178
|
+
url.searchParams.set("error_description", description);
|
|
1179
|
+
return url.toString();
|
|
1180
|
+
} catch {
|
|
1181
|
+
const hashIdx = base.indexOf("#");
|
|
1182
|
+
const path = hashIdx >= 0 ? base.slice(0, hashIdx) : base;
|
|
1183
|
+
const hash = hashIdx >= 0 ? base.slice(hashIdx + 1) : void 0;
|
|
1184
|
+
return `${path}${path.includes("?") ? "&" : "?"}${`error=${encodeURIComponent(error)}&error_description=${encodeURIComponent(description)}`}${hash ? `#${hash}` : ""}`;
|
|
1231
1185
|
}
|
|
1232
1186
|
}
|
|
1233
1187
|
/**
|
|
1234
|
-
*
|
|
1235
|
-
*
|
|
1236
|
-
* Checks:
|
|
1237
|
-
* 1. All required fields are present
|
|
1238
|
-
* 2. Issuer matches the configured issuer (case-sensitive, exact match)
|
|
1188
|
+
* Validates the InResponseTo attribute of a SAML Response.
|
|
1239
1189
|
*
|
|
1240
|
-
*
|
|
1241
|
-
*
|
|
1242
|
-
*
|
|
1190
|
+
* This binds the IdP's Response to a specific SP-initiated AuthnRequest,
|
|
1191
|
+
* preventing replay attacks, unsolicited response injection, and
|
|
1192
|
+
* cross-provider assertion swaps.
|
|
1243
1193
|
*
|
|
1244
|
-
*
|
|
1245
|
-
*
|
|
1246
|
-
* @throws DiscoveryError if validation fails
|
|
1194
|
+
* The InResponseTo value lives at `extract.response.inResponseTo` in
|
|
1195
|
+
* samlify's parsed output (not at the top level).
|
|
1247
1196
|
*/
|
|
1248
|
-
function
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
if (
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1197
|
+
async function validateInResponseTo(c, ctx) {
|
|
1198
|
+
if (ctx.options.enableInResponseToValidation === false) return;
|
|
1199
|
+
const inResponseTo = ctx.extract.response?.inResponseTo;
|
|
1200
|
+
const allowIdpInitiated = ctx.options.allowIdpInitiated ?? false;
|
|
1201
|
+
if (inResponseTo) {
|
|
1202
|
+
const consumed = await c.context.internalAdapter.consumeVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
1203
|
+
let storedRequest = null;
|
|
1204
|
+
if (consumed) try {
|
|
1205
|
+
storedRequest = JSON.parse(consumed.value);
|
|
1206
|
+
} catch {
|
|
1207
|
+
storedRequest = null;
|
|
1208
|
+
}
|
|
1209
|
+
if (!storedRequest) {
|
|
1210
|
+
c.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
|
|
1211
|
+
inResponseTo,
|
|
1212
|
+
providerId: ctx.providerId
|
|
1213
|
+
});
|
|
1214
|
+
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Unknown or expired request ID"));
|
|
1215
|
+
}
|
|
1216
|
+
if (storedRequest.providerId !== ctx.providerId) {
|
|
1217
|
+
c.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
|
|
1218
|
+
inResponseTo,
|
|
1219
|
+
expectedProvider: storedRequest.providerId,
|
|
1220
|
+
actualProvider: ctx.providerId
|
|
1221
|
+
});
|
|
1222
|
+
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Provider mismatch"));
|
|
1223
|
+
}
|
|
1224
|
+
} else if (!allowIdpInitiated) {
|
|
1225
|
+
c.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: ctx.providerId });
|
|
1226
|
+
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "unsolicited_response", "IdP-initiated SSO not allowed"));
|
|
1227
|
+
}
|
|
1256
1228
|
}
|
|
1257
1229
|
/**
|
|
1258
|
-
*
|
|
1230
|
+
* Validates the AudienceRestriction of a SAML assertion.
|
|
1259
1231
|
*
|
|
1260
|
-
*
|
|
1261
|
-
*
|
|
1262
|
-
*
|
|
1263
|
-
*
|
|
1232
|
+
* Per SAML 2.0 Core §2.5.1, an assertion's Audience element specifies
|
|
1233
|
+
* the intended recipient SP. Without this check, an assertion issued
|
|
1234
|
+
* for a different SP (e.g., another application sharing the same IdP)
|
|
1235
|
+
* could be accepted.
|
|
1264
1236
|
*/
|
|
1265
|
-
function
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
if (
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1237
|
+
function validateAudience(c, ctx) {
|
|
1238
|
+
if (!ctx.expectedAudience) {
|
|
1239
|
+
c.context.logger.warn("Could not determine SP entity ID for audience validation; skipping", { providerId: ctx.providerId });
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
const audience = ctx.extract.audience;
|
|
1243
|
+
if (!audience) {
|
|
1244
|
+
c.context.logger.error("SAML assertion missing AudienceRestriction but audience is configured — rejecting", { providerId: ctx.providerId });
|
|
1245
|
+
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Audience restriction missing"));
|
|
1246
|
+
}
|
|
1247
|
+
const audiences = Array.isArray(audience) ? audience : [audience];
|
|
1248
|
+
if (!audiences.includes(ctx.expectedAudience)) {
|
|
1249
|
+
c.context.logger.error("SAML audience mismatch: assertion was issued for a different service provider", {
|
|
1250
|
+
expected: ctx.expectedAudience,
|
|
1251
|
+
received: audiences,
|
|
1252
|
+
providerId: ctx.providerId
|
|
1253
|
+
});
|
|
1254
|
+
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Audience mismatch"));
|
|
1255
|
+
}
|
|
1275
1256
|
}
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1257
|
+
//#endregion
|
|
1258
|
+
//#region src/routes/schemas.ts
|
|
1259
|
+
const oidcMappingSchema = z.object({
|
|
1260
|
+
id: z.string().meta({ description: "Field mapping for user ID (defaults to 'sub')" }),
|
|
1261
|
+
email: z.string().meta({ description: "Field mapping for email (defaults to 'email')" }),
|
|
1262
|
+
emailVerified: z.string().meta({ description: "Field mapping for email verification (defaults to 'email_verified')" }).optional(),
|
|
1263
|
+
name: z.string().meta({ description: "Field mapping for name (defaults to 'name')" }),
|
|
1264
|
+
image: z.string().meta({ description: "Field mapping for image (defaults to 'picture')" }).optional(),
|
|
1265
|
+
extraFields: z.record(z.string(), z.any()).optional()
|
|
1266
|
+
}).optional();
|
|
1267
|
+
const samlMappingSchema = z.object({
|
|
1268
|
+
id: z.string().meta({ description: "Field mapping for user ID (defaults to 'nameID')" }),
|
|
1269
|
+
email: z.string().meta({ description: "Field mapping for email (defaults to 'email')" }),
|
|
1270
|
+
emailVerified: z.string().meta({ description: "Field mapping for email verification" }).optional(),
|
|
1271
|
+
name: z.string().meta({ description: "Field mapping for name (defaults to 'displayName')" }),
|
|
1272
|
+
firstName: z.string().meta({ description: "Field mapping for first name (defaults to 'givenName')" }).optional(),
|
|
1273
|
+
lastName: z.string().meta({ description: "Field mapping for last name (defaults to 'surname')" }).optional(),
|
|
1274
|
+
extraFields: z.record(z.string(), z.any()).optional()
|
|
1275
|
+
}).optional();
|
|
1276
|
+
const signingCertSchema = z.union([z.string(), z.array(z.string()).nonempty()]).meta({ description: "IdP signing certificate(s). Pass a single PEM string or an array for rolling rotation." });
|
|
1277
|
+
const oidcConfigSchema = z.object({
|
|
1278
|
+
clientId: z.string().meta({ description: "The client ID" }),
|
|
1279
|
+
clientSecret: z.string().meta({ description: "The client secret. Required for client_secret_basic/client_secret_post. Optional for private_key_jwt." }).optional(),
|
|
1280
|
+
authorizationEndpoint: z.string().url().meta({ description: "The authorization endpoint" }).optional(),
|
|
1281
|
+
tokenEndpoint: z.string().url().meta({ description: "The token endpoint" }).optional(),
|
|
1282
|
+
userInfoEndpoint: z.string().url().meta({ description: "The user info endpoint" }).optional(),
|
|
1283
|
+
tokenEndpointAuthentication: z.enum([
|
|
1284
|
+
"client_secret_post",
|
|
1285
|
+
"client_secret_basic",
|
|
1286
|
+
"private_key_jwt"
|
|
1287
|
+
]).optional(),
|
|
1288
|
+
privateKeyId: z.string().optional(),
|
|
1289
|
+
privateKeyAlgorithm: z.string().optional(),
|
|
1290
|
+
jwksEndpoint: z.string().url().meta({ description: "The JWKS endpoint" }).optional(),
|
|
1291
|
+
discoveryEndpoint: z.string().url().optional(),
|
|
1292
|
+
skipDiscovery: z.boolean().meta({ description: "Skip OIDC discovery during registration. When true, you must provide authorizationEndpoint, tokenEndpoint, and jwksEndpoint manually." }).optional(),
|
|
1293
|
+
scopes: z.array(z.string()).meta({ description: "The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']" }).optional(),
|
|
1294
|
+
pkce: z.boolean().meta({ description: "Whether to use PKCE for the authorization flow" }).default(true).optional(),
|
|
1295
|
+
overrideUserInfo: z.boolean().optional(),
|
|
1296
|
+
mapping: oidcMappingSchema
|
|
1297
|
+
});
|
|
1298
|
+
const samlConfigSchema = z.object({
|
|
1299
|
+
entryPoint: z.string().url().meta({ description: "The IdP SSO URL (entry point)" }),
|
|
1300
|
+
cert: signingCertSchema.meta({ description: "IdP signing certificate(s). Pass a single PEM string or an array for rolling rotation. Omit when `idpMetadata.metadata` XML carries the certs. When both this and `idpMetadata.cert` are set, `idpMetadata.cert` wins." }).optional(),
|
|
1301
|
+
audience: z.string().optional(),
|
|
1302
|
+
idpMetadata: z.object({
|
|
1303
|
+
metadata: z.string().optional(),
|
|
1304
|
+
entityID: z.string().optional(),
|
|
1305
|
+
cert: signingCertSchema.meta({ description: "IdP signing certificate(s). Pass a single PEM string or an array for rolling rotation. Takes precedence over the top-level `cert`." }).optional(),
|
|
1306
|
+
privateKey: z.string().optional(),
|
|
1307
|
+
privateKeyPass: z.string().optional(),
|
|
1308
|
+
isAssertionEncrypted: z.boolean().optional(),
|
|
1309
|
+
encPrivateKey: z.string().optional(),
|
|
1310
|
+
encPrivateKeyPass: z.string().optional(),
|
|
1311
|
+
singleSignOnService: z.array(z.object({
|
|
1312
|
+
Binding: z.string().meta({ description: "The binding type for the SSO service" }),
|
|
1313
|
+
Location: z.string().url().meta({ description: "The URL for the SSO service" })
|
|
1314
|
+
})).meta({ description: "Single Sign-On service configuration" }).optional(),
|
|
1315
|
+
singleLogoutService: z.array(z.object({
|
|
1316
|
+
Binding: z.string(),
|
|
1317
|
+
Location: z.string().url()
|
|
1318
|
+
})).optional()
|
|
1319
|
+
}).optional(),
|
|
1320
|
+
spMetadata: z.object({
|
|
1321
|
+
metadata: z.string().optional(),
|
|
1322
|
+
entityID: z.string().optional(),
|
|
1323
|
+
binding: z.string().optional(),
|
|
1324
|
+
privateKey: z.string().optional(),
|
|
1325
|
+
privateKeyPass: z.string().optional(),
|
|
1326
|
+
isAssertionEncrypted: z.boolean().optional(),
|
|
1327
|
+
encPrivateKey: z.string().optional(),
|
|
1328
|
+
encPrivateKeyPass: z.string().optional()
|
|
1329
|
+
}).optional(),
|
|
1330
|
+
wantAssertionsSigned: z.boolean().optional(),
|
|
1331
|
+
authnRequestsSigned: z.boolean().optional(),
|
|
1332
|
+
signatureAlgorithm: z.string().optional(),
|
|
1333
|
+
digestAlgorithm: z.string().optional(),
|
|
1334
|
+
identifierFormat: z.string().optional(),
|
|
1335
|
+
privateKey: z.string().optional(),
|
|
1336
|
+
mapping: samlMappingSchema
|
|
1337
|
+
});
|
|
1338
|
+
const registerSSOProviderBodySchema = z.object({
|
|
1339
|
+
providerId: z.string().meta({ description: "The ID of the provider. This is used to identify the provider during login and callback" }),
|
|
1340
|
+
issuer: z.string().url().meta({ description: "The issuer URL of the provider" }),
|
|
1341
|
+
domain: z.string().meta({ description: "The domain(s) of the provider. For enterprise multi-domain SSO where a single IdP serves multiple email domains, use comma-separated values (e.g., 'company.com,subsidiary.com,acquired-company.com')" }),
|
|
1342
|
+
oidcConfig: oidcConfigSchema.optional(),
|
|
1343
|
+
samlConfig: samlConfigSchema.optional(),
|
|
1344
|
+
organizationId: z.string().meta({ description: "If organization plugin is enabled, the organization id to link the provider to" }).optional(),
|
|
1345
|
+
overrideUserInfo: z.boolean().meta({ description: "Override user info with the provider info. Defaults to false" }).default(false).optional()
|
|
1346
|
+
});
|
|
1347
|
+
const updateSSOProviderBodySchema = z.object({
|
|
1348
|
+
issuer: z.string().url().optional(),
|
|
1349
|
+
domain: z.string().optional(),
|
|
1350
|
+
oidcConfig: oidcConfigSchema.partial().optional(),
|
|
1351
|
+
samlConfig: samlConfigSchema.partial().optional()
|
|
1352
|
+
});
|
|
1353
|
+
//#endregion
|
|
1354
|
+
//#region src/routes/providers.ts
|
|
1355
|
+
const ADMIN_ROLES = ["owner", "admin"];
|
|
1356
|
+
function hasOrgAdminRole(member) {
|
|
1357
|
+
return member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()));
|
|
1358
|
+
}
|
|
1359
|
+
function parseCertOrError(cert) {
|
|
1360
|
+
try {
|
|
1361
|
+
return parseCertificate(cert);
|
|
1362
|
+
} catch {
|
|
1363
|
+
return { error: "Failed to parse certificate" };
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
function sanitizeSigningCerts(config) {
|
|
1367
|
+
const certs = resolveSigningCerts(config);
|
|
1368
|
+
if (certs === void 0) return void 0;
|
|
1369
|
+
return certs.map(parseCertOrError);
|
|
1370
|
+
}
|
|
1371
|
+
async function isOrgAdmin(ctx, userId, organizationId) {
|
|
1372
|
+
const member = await ctx.context.adapter.findOne({
|
|
1373
|
+
model: "member",
|
|
1374
|
+
where: [{
|
|
1375
|
+
field: "userId",
|
|
1376
|
+
value: userId
|
|
1377
|
+
}, {
|
|
1378
|
+
field: "organizationId",
|
|
1379
|
+
value: organizationId
|
|
1380
|
+
}]
|
|
1289
1381
|
});
|
|
1290
|
-
return
|
|
1382
|
+
return member ? hasOrgAdminRole(member) : false;
|
|
1291
1383
|
}
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1384
|
+
async function batchCheckOrgAdmin(ctx, userId, organizationIds) {
|
|
1385
|
+
if (organizationIds.length === 0) return /* @__PURE__ */ new Set();
|
|
1386
|
+
const members = await ctx.context.adapter.findMany({
|
|
1387
|
+
model: "member",
|
|
1388
|
+
where: [{
|
|
1389
|
+
field: "userId",
|
|
1390
|
+
value: userId
|
|
1391
|
+
}, {
|
|
1392
|
+
field: "organizationId",
|
|
1393
|
+
value: organizationIds,
|
|
1394
|
+
operator: "in"
|
|
1395
|
+
}]
|
|
1396
|
+
});
|
|
1397
|
+
const adminOrgIds = /* @__PURE__ */ new Set();
|
|
1398
|
+
for (const member of members) if (hasOrgAdminRole(member)) adminOrgIds.add(member.organizationId);
|
|
1399
|
+
return adminOrgIds;
|
|
1400
|
+
}
|
|
1401
|
+
function sanitizeProvider(provider, baseURL) {
|
|
1402
|
+
let oidcConfig = null;
|
|
1403
|
+
let samlConfig = null;
|
|
1301
1404
|
try {
|
|
1302
|
-
|
|
1405
|
+
oidcConfig = safeJsonParse(provider.oidcConfig);
|
|
1303
1406
|
} catch {
|
|
1304
|
-
|
|
1305
|
-
const basePath = issuerURL.pathname.replace(/\/+$/, "");
|
|
1306
|
-
const endpointPath = endpoint.replace(/^\/+/, "");
|
|
1307
|
-
return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
|
|
1407
|
+
oidcConfig = null;
|
|
1308
1408
|
}
|
|
1309
|
-
}
|
|
1310
|
-
/**
|
|
1311
|
-
* Parses the given URL or throws in case of invalid or unsupported protocols
|
|
1312
|
-
*
|
|
1313
|
-
* @param name the url name
|
|
1314
|
-
* @param endpoint the endpoint url
|
|
1315
|
-
* @param [base] optional base path
|
|
1316
|
-
* @returns
|
|
1317
|
-
*/
|
|
1318
|
-
function parseURL(name, endpoint, base) {
|
|
1319
|
-
let endpointURL;
|
|
1320
1409
|
try {
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
|
|
1410
|
+
samlConfig = safeJsonParse(provider.samlConfig);
|
|
1411
|
+
} catch {
|
|
1412
|
+
samlConfig = null;
|
|
1325
1413
|
}
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1414
|
+
const type = samlConfig ? "saml" : "oidc";
|
|
1415
|
+
return {
|
|
1416
|
+
providerId: provider.providerId,
|
|
1417
|
+
type,
|
|
1418
|
+
issuer: provider.issuer,
|
|
1419
|
+
domain: provider.domain,
|
|
1420
|
+
organizationId: provider.organizationId || null,
|
|
1421
|
+
domainVerified: provider.domainVerified ?? false,
|
|
1422
|
+
oidcConfig: oidcConfig ? {
|
|
1423
|
+
discoveryEndpoint: oidcConfig.discoveryEndpoint,
|
|
1424
|
+
clientIdLastFour: maskClientId(oidcConfig.clientId),
|
|
1425
|
+
pkce: oidcConfig.pkce,
|
|
1426
|
+
authorizationEndpoint: oidcConfig.authorizationEndpoint,
|
|
1427
|
+
tokenEndpoint: oidcConfig.tokenEndpoint,
|
|
1428
|
+
userInfoEndpoint: oidcConfig.userInfoEndpoint,
|
|
1429
|
+
jwksEndpoint: oidcConfig.jwksEndpoint,
|
|
1430
|
+
scopes: oidcConfig.scopes,
|
|
1431
|
+
tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication
|
|
1432
|
+
} : void 0,
|
|
1433
|
+
samlConfig: samlConfig ? {
|
|
1434
|
+
entryPoint: samlConfig.entryPoint,
|
|
1435
|
+
audience: samlConfig.audience,
|
|
1436
|
+
wantAssertionsSigned: samlConfig.wantAssertionsSigned,
|
|
1437
|
+
authnRequestsSigned: samlConfig.authnRequestsSigned,
|
|
1438
|
+
identifierFormat: samlConfig.identifierFormat,
|
|
1439
|
+
signatureAlgorithm: samlConfig.signatureAlgorithm,
|
|
1440
|
+
digestAlgorithm: samlConfig.digestAlgorithm,
|
|
1441
|
+
certificate: sanitizeSigningCerts(samlConfig)
|
|
1442
|
+
} : void 0,
|
|
1443
|
+
spMetadataUrl: `${baseURL}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(provider.providerId)}`
|
|
1444
|
+
};
|
|
1347
1445
|
}
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1446
|
+
const listSSOProviders = () => {
|
|
1447
|
+
return createAuthEndpoint("/sso/providers", {
|
|
1448
|
+
method: "GET",
|
|
1449
|
+
use: [sessionMiddleware],
|
|
1450
|
+
metadata: { openapi: {
|
|
1451
|
+
operationId: "listSSOProviders",
|
|
1452
|
+
summary: "List SSO providers",
|
|
1453
|
+
description: "Returns a list of SSO providers the user has access to",
|
|
1454
|
+
responses: { "200": { description: "List of SSO providers. The `certificate` field is an array of parsed certificates for SAML providers, or absent when certs live inside `idpMetadata.metadata`." } }
|
|
1455
|
+
} }
|
|
1456
|
+
}, async (ctx) => {
|
|
1457
|
+
const userId = ctx.context.session.user.id;
|
|
1458
|
+
const allProviders = await ctx.context.adapter.findMany({ model: "ssoProvider" });
|
|
1459
|
+
const userOwnedProviders = allProviders.filter((p) => p.userId === userId && !p.organizationId);
|
|
1460
|
+
const orgProviders = allProviders.filter((p) => p.organizationId !== null && p.organizationId !== void 0);
|
|
1461
|
+
const orgPluginEnabled = ctx.context.hasPlugin("organization");
|
|
1462
|
+
let accessibleProviders = [...userOwnedProviders];
|
|
1463
|
+
if (orgPluginEnabled && orgProviders.length > 0) {
|
|
1464
|
+
const adminOrgIds = await batchCheckOrgAdmin(ctx, userId, [...new Set(orgProviders.map((p) => p.organizationId).filter((id) => id !== null && id !== void 0))]);
|
|
1465
|
+
const orgAccessibleProviders = orgProviders.filter((provider) => provider.organizationId && adminOrgIds.has(provider.organizationId));
|
|
1466
|
+
accessibleProviders = [...accessibleProviders, ...orgAccessibleProviders];
|
|
1467
|
+
} else if (!orgPluginEnabled) {
|
|
1468
|
+
const userOwnedOrgProviders = orgProviders.filter((p) => p.userId === userId);
|
|
1469
|
+
accessibleProviders = [...accessibleProviders, ...userOwnedOrgProviders];
|
|
1470
|
+
}
|
|
1471
|
+
const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL));
|
|
1472
|
+
return ctx.json({ providers });
|
|
1473
|
+
});
|
|
1474
|
+
};
|
|
1475
|
+
const getSSOProviderQuerySchema = z.object({ providerId: z.string() });
|
|
1476
|
+
async function checkProviderAccess(ctx, providerId) {
|
|
1477
|
+
const userId = ctx.context.session.user.id;
|
|
1478
|
+
const provider = await ctx.context.adapter.findOne({
|
|
1479
|
+
model: "ssoProvider",
|
|
1480
|
+
where: [{
|
|
1481
|
+
field: "providerId",
|
|
1482
|
+
value: providerId
|
|
1483
|
+
}]
|
|
1484
|
+
});
|
|
1485
|
+
if (!provider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
|
|
1486
|
+
let hasAccess = false;
|
|
1487
|
+
if (provider.organizationId) if (ctx.context.hasPlugin("organization")) hasAccess = await isOrgAdmin(ctx, userId, provider.organizationId);
|
|
1488
|
+
else hasAccess = provider.userId === userId;
|
|
1489
|
+
else hasAccess = provider.userId === userId;
|
|
1490
|
+
if (!hasAccess) throw new APIError("FORBIDDEN", { message: "You don't have access to this provider" });
|
|
1491
|
+
return provider;
|
|
1363
1492
|
}
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1493
|
+
const getSSOProvider = () => {
|
|
1494
|
+
return createAuthEndpoint("/sso/get-provider", {
|
|
1495
|
+
method: "GET",
|
|
1496
|
+
use: [sessionMiddleware],
|
|
1497
|
+
query: getSSOProviderQuerySchema,
|
|
1498
|
+
metadata: { openapi: {
|
|
1499
|
+
operationId: "getSSOProvider",
|
|
1500
|
+
summary: "Get SSO provider details",
|
|
1501
|
+
description: "Returns sanitized details for a specific SSO provider",
|
|
1502
|
+
responses: {
|
|
1503
|
+
"200": { description: "SSO provider details. The `certificate` field is an array of parsed certificates for SAML providers, or absent when certs live inside `idpMetadata.metadata`." },
|
|
1504
|
+
"404": { description: "Provider not found" },
|
|
1505
|
+
"403": { description: "Access denied" }
|
|
1506
|
+
}
|
|
1507
|
+
} }
|
|
1508
|
+
}, async (ctx) => {
|
|
1509
|
+
const { providerId } = ctx.query;
|
|
1510
|
+
const provider = await checkProviderAccess(ctx, providerId);
|
|
1511
|
+
return ctx.json(sanitizeProvider(provider, ctx.context.baseURL));
|
|
1375
1512
|
});
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
}
|
|
1513
|
+
};
|
|
1514
|
+
function parseAndValidateConfig(configString, configType) {
|
|
1515
|
+
let config = null;
|
|
1516
|
+
try {
|
|
1517
|
+
config = safeJsonParse(configString);
|
|
1518
|
+
} catch {
|
|
1519
|
+
config = null;
|
|
1520
|
+
}
|
|
1521
|
+
if (!config) throw new APIError("BAD_REQUEST", { message: `Cannot update ${configType} config for a provider that doesn't have ${configType} configured` });
|
|
1522
|
+
return config;
|
|
1384
1523
|
}
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1524
|
+
function mergeSAMLConfig(current, updates, issuer) {
|
|
1525
|
+
return {
|
|
1526
|
+
...current,
|
|
1527
|
+
...updates,
|
|
1528
|
+
issuer,
|
|
1529
|
+
entryPoint: updates.entryPoint ?? current.entryPoint,
|
|
1530
|
+
cert: updates.cert ?? current.cert,
|
|
1531
|
+
spMetadata: updates.spMetadata ?? current.spMetadata,
|
|
1532
|
+
idpMetadata: updates.idpMetadata ?? current.idpMetadata,
|
|
1533
|
+
mapping: updates.mapping ?? current.mapping,
|
|
1534
|
+
audience: updates.audience ?? current.audience,
|
|
1535
|
+
wantAssertionsSigned: updates.wantAssertionsSigned ?? current.wantAssertionsSigned,
|
|
1536
|
+
authnRequestsSigned: updates.authnRequestsSigned ?? current.authnRequestsSigned,
|
|
1537
|
+
identifierFormat: updates.identifierFormat ?? current.identifierFormat,
|
|
1538
|
+
signatureAlgorithm: updates.signatureAlgorithm ?? current.signatureAlgorithm,
|
|
1539
|
+
digestAlgorithm: updates.digestAlgorithm ?? current.digestAlgorithm
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1542
|
+
function mergeOIDCConfig(current, updates, issuer) {
|
|
1543
|
+
return {
|
|
1544
|
+
...current,
|
|
1545
|
+
...updates,
|
|
1546
|
+
issuer,
|
|
1547
|
+
pkce: updates.pkce ?? current.pkce ?? true,
|
|
1548
|
+
clientId: updates.clientId ?? current.clientId,
|
|
1549
|
+
clientSecret: updates.clientSecret ?? current.clientSecret,
|
|
1550
|
+
discoveryEndpoint: updates.discoveryEndpoint ?? current.discoveryEndpoint,
|
|
1551
|
+
mapping: updates.mapping ?? current.mapping,
|
|
1552
|
+
scopes: updates.scopes ?? current.scopes,
|
|
1553
|
+
authorizationEndpoint: updates.authorizationEndpoint ?? current.authorizationEndpoint,
|
|
1554
|
+
tokenEndpoint: updates.tokenEndpoint ?? current.tokenEndpoint,
|
|
1555
|
+
userInfoEndpoint: updates.userInfoEndpoint ?? current.userInfoEndpoint,
|
|
1556
|
+
jwksEndpoint: updates.jwksEndpoint ?? current.jwksEndpoint,
|
|
1557
|
+
tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication,
|
|
1558
|
+
privateKeyId: updates.privateKeyId ?? current.privateKeyId,
|
|
1559
|
+
privateKeyAlgorithm: updates.privateKeyAlgorithm ?? current.privateKeyAlgorithm
|
|
1560
|
+
};
|
|
1561
|
+
}
|
|
1562
|
+
const updateSSOProvider = (options) => {
|
|
1563
|
+
return createAuthEndpoint("/sso/update-provider", {
|
|
1564
|
+
method: "POST",
|
|
1565
|
+
use: [sessionMiddleware],
|
|
1566
|
+
body: updateSSOProviderBodySchema.extend({ providerId: z.string() }),
|
|
1567
|
+
metadata: { openapi: {
|
|
1568
|
+
operationId: "updateSSOProvider",
|
|
1569
|
+
summary: "Update SSO provider",
|
|
1570
|
+
description: "Partially update an SSO provider. Only provided fields are updated. If domain changes, domainVerified is reset to false.",
|
|
1571
|
+
responses: {
|
|
1572
|
+
"200": { description: "SSO provider updated successfully. The `certificate` field is an array of parsed certificates for SAML providers, or absent when certs live inside `idpMetadata.metadata`." },
|
|
1573
|
+
"404": { description: "Provider not found" },
|
|
1574
|
+
"403": { description: "Access denied" }
|
|
1575
|
+
}
|
|
1576
|
+
} }
|
|
1577
|
+
}, async (ctx) => {
|
|
1578
|
+
const { providerId, ...body } = ctx.body;
|
|
1579
|
+
const { issuer, domain, samlConfig, oidcConfig } = body;
|
|
1580
|
+
if (!issuer && !domain && !samlConfig && !oidcConfig) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
|
|
1581
|
+
const existingProvider = await checkProviderAccess(ctx, providerId);
|
|
1582
|
+
const updateData = {};
|
|
1583
|
+
if (body.issuer !== void 0) updateData.issuer = body.issuer;
|
|
1584
|
+
if (body.domain !== void 0) {
|
|
1585
|
+
updateData.domain = body.domain;
|
|
1586
|
+
if (body.domain !== existingProvider.domain) updateData.domainVerified = false;
|
|
1587
|
+
}
|
|
1588
|
+
if (body.samlConfig) {
|
|
1589
|
+
if (body.samlConfig.idpMetadata?.metadata) {
|
|
1590
|
+
const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
|
|
1591
|
+
if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
|
|
1592
|
+
}
|
|
1593
|
+
if (body.samlConfig.signatureAlgorithm !== void 0 || body.samlConfig.digestAlgorithm !== void 0) validateConfigAlgorithms({
|
|
1594
|
+
signatureAlgorithm: body.samlConfig.signatureAlgorithm,
|
|
1595
|
+
digestAlgorithm: body.samlConfig.digestAlgorithm
|
|
1596
|
+
}, options?.saml?.algorithms);
|
|
1597
|
+
const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
|
|
1598
|
+
const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
|
|
1599
|
+
validateCertSources(updatedSamlConfig);
|
|
1600
|
+
updateData.samlConfig = JSON.stringify(updatedSamlConfig);
|
|
1601
|
+
}
|
|
1602
|
+
if (body.oidcConfig) {
|
|
1603
|
+
try {
|
|
1604
|
+
validateSkipDiscoveryEndpoints(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
|
|
1605
|
+
} catch (error) {
|
|
1606
|
+
if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
|
|
1607
|
+
throw error;
|
|
1608
|
+
}
|
|
1609
|
+
const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
|
|
1610
|
+
const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
|
|
1611
|
+
if (updatedOidcConfig.tokenEndpointAuthentication !== "private_key_jwt" && !updatedOidcConfig.clientSecret) throw new APIError("BAD_REQUEST", { message: "clientSecret is required when using client_secret_basic or client_secret_post authentication" });
|
|
1612
|
+
if (updatedOidcConfig.tokenEndpointAuthentication === "private_key_jwt" && !options?.resolvePrivateKey && !options?.defaultSSO?.some((p) => p.providerId === providerId && "privateKey" in p && p.privateKey)) throw new APIError("BAD_REQUEST", { message: "private_key_jwt authentication requires either a resolvePrivateKey callback or a privateKey in defaultSSO" });
|
|
1613
|
+
updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
|
|
1614
|
+
}
|
|
1615
|
+
await ctx.context.adapter.update({
|
|
1616
|
+
model: "ssoProvider",
|
|
1617
|
+
where: [{
|
|
1618
|
+
field: "providerId",
|
|
1619
|
+
value: providerId
|
|
1620
|
+
}],
|
|
1621
|
+
update: updateData
|
|
1438
1622
|
});
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1623
|
+
const fullProvider = await ctx.context.adapter.findOne({
|
|
1624
|
+
model: "ssoProvider",
|
|
1625
|
+
where: [{
|
|
1626
|
+
field: "providerId",
|
|
1627
|
+
value: providerId
|
|
1628
|
+
}]
|
|
1442
1629
|
});
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1630
|
+
if (!fullProvider) throw new APIError("NOT_FOUND", { message: "Provider not found after update" });
|
|
1631
|
+
return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL));
|
|
1632
|
+
});
|
|
1633
|
+
};
|
|
1634
|
+
const deleteSSOProvider = () => {
|
|
1635
|
+
return createAuthEndpoint("/sso/delete-provider", {
|
|
1636
|
+
method: "POST",
|
|
1637
|
+
use: [sessionMiddleware],
|
|
1638
|
+
body: z.object({ providerId: z.string() }),
|
|
1639
|
+
metadata: { openapi: {
|
|
1640
|
+
operationId: "deleteSSOProvider",
|
|
1641
|
+
summary: "Delete SSO provider",
|
|
1642
|
+
description: "Deletes an SSO provider",
|
|
1643
|
+
responses: {
|
|
1644
|
+
"200": { description: "SSO provider deleted successfully" },
|
|
1645
|
+
"404": { description: "Provider not found" },
|
|
1646
|
+
"403": { description: "Access denied" }
|
|
1647
|
+
}
|
|
1648
|
+
} }
|
|
1649
|
+
}, async (ctx) => {
|
|
1650
|
+
const { providerId } = ctx.body;
|
|
1651
|
+
await checkProviderAccess(ctx, providerId);
|
|
1652
|
+
await ctx.context.adapter.delete({
|
|
1653
|
+
model: "ssoProvider",
|
|
1654
|
+
where: [{
|
|
1655
|
+
field: "providerId",
|
|
1656
|
+
value: providerId
|
|
1657
|
+
}]
|
|
1446
1658
|
});
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
message: `Unexpected discovery error: ${error.message}`,
|
|
1451
|
-
code: "discovery_unexpected_error"
|
|
1452
|
-
});
|
|
1453
|
-
}
|
|
1454
|
-
}
|
|
1455
|
-
//#endregion
|
|
1456
|
-
//#region src/saml/error-codes.ts
|
|
1457
|
-
const SAML_ERROR_CODES = defineErrorCodes({
|
|
1458
|
-
SINGLE_LOGOUT_NOT_ENABLED: "Single Logout is not enabled",
|
|
1459
|
-
INVALID_LOGOUT_RESPONSE: "Invalid LogoutResponse",
|
|
1460
|
-
INVALID_LOGOUT_REQUEST: "Invalid LogoutRequest",
|
|
1461
|
-
LOGOUT_FAILED_AT_IDP: "Logout failed at IdP",
|
|
1462
|
-
IDP_SLO_NOT_SUPPORTED: "IdP does not support Single Logout Service",
|
|
1463
|
-
SAML_PROVIDER_NOT_FOUND: "SAML provider not found"
|
|
1464
|
-
});
|
|
1659
|
+
return ctx.json({ success: true });
|
|
1660
|
+
});
|
|
1661
|
+
};
|
|
1465
1662
|
//#endregion
|
|
1466
1663
|
//#region src/saml-state.ts
|
|
1467
|
-
async function generateRelayState(c, link
|
|
1664
|
+
async function generateRelayState(c, link) {
|
|
1468
1665
|
const callbackURL = c.body.callbackURL;
|
|
1469
1666
|
if (!callbackURL) throw new APIError("BAD_REQUEST", { message: "callbackURL is required" });
|
|
1470
|
-
const codeVerifier = generateRandomString(128);
|
|
1471
1667
|
const stateData = {
|
|
1472
|
-
...additionalData ? additionalData : {},
|
|
1473
1668
|
callbackURL,
|
|
1474
|
-
codeVerifier,
|
|
1669
|
+
codeVerifier: generateRandomString(128),
|
|
1475
1670
|
errorURL: c.body.errorCallbackURL,
|
|
1476
1671
|
newUserURL: c.body.newUserCallbackURL,
|
|
1477
1672
|
link,
|
|
@@ -1511,14 +1706,12 @@ const saml = typeof samlifyNamespace.SPMetadata === "function" && typeof samlify
|
|
|
1511
1706
|
//#endregion
|
|
1512
1707
|
//#region src/routes/helpers.ts
|
|
1513
1708
|
/**
|
|
1514
|
-
*
|
|
1515
|
-
*
|
|
1516
|
-
* blocks with leading whitespace, which is common when keys are stored in
|
|
1517
|
-
* indented config files, environment variables, or JSON.
|
|
1709
|
+
* Same as `normalizePem`, but applied across the resolved list of IdP signing
|
|
1710
|
+
* certificates so multi-cert rotation configs survive the line-trim step.
|
|
1518
1711
|
*/
|
|
1519
|
-
function
|
|
1520
|
-
if (!
|
|
1521
|
-
return
|
|
1712
|
+
function normalizePemList(certs) {
|
|
1713
|
+
if (!certs) return certs;
|
|
1714
|
+
return certs.map((pem) => normalizePem(pem) ?? pem);
|
|
1522
1715
|
}
|
|
1523
1716
|
async function findSAMLProvider(providerId, options, adapter) {
|
|
1524
1717
|
if (options?.defaultSSO?.length) {
|
|
@@ -1575,7 +1768,8 @@ function createSP(config, baseURL, providerId, opts) {
|
|
|
1575
1768
|
isAssertionEncrypted: spData?.isAssertionEncrypted || false,
|
|
1576
1769
|
encPrivateKey: normalizePem(spData?.encPrivateKey),
|
|
1577
1770
|
encPrivateKeyPass: spData?.encPrivateKeyPass,
|
|
1578
|
-
relayState: opts?.relayState
|
|
1771
|
+
relayState: opts?.relayState,
|
|
1772
|
+
clockDrifts: opts?.clockSkew && opts?.clockSkew !== 0 ? [-opts.clockSkew, opts.clockSkew] : void 0
|
|
1579
1773
|
});
|
|
1580
1774
|
}
|
|
1581
1775
|
function createIdP(config) {
|
|
@@ -1595,7 +1789,7 @@ function createIdP(config) {
|
|
|
1595
1789
|
Location: config.entryPoint
|
|
1596
1790
|
}],
|
|
1597
1791
|
singleLogoutService: idpData?.singleLogoutService,
|
|
1598
|
-
signingCert:
|
|
1792
|
+
signingCert: normalizePemList(resolveSigningCerts(config)),
|
|
1599
1793
|
wantAuthnRequestsSigned: config.authnRequestsSigned || false,
|
|
1600
1794
|
isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
|
|
1601
1795
|
encPrivateKey: normalizePem(idpData?.encPrivateKey),
|
|
@@ -1731,7 +1925,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1731
1925
|
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
1732
1926
|
const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
|
|
1733
1927
|
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
1734
|
-
const sp = createSP(parsedSamlConfig, ctx.context.baseURL, providerId);
|
|
1928
|
+
const sp = createSP(parsedSamlConfig, ctx.context.baseURL, providerId, { clockSkew: options?.saml?.clockSkew });
|
|
1735
1929
|
const idp = createIdP(parsedSamlConfig);
|
|
1736
1930
|
const samlRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL, params.currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
1737
1931
|
validateSingleAssertion(SAMLResponse);
|
|
@@ -1813,12 +2007,16 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1813
2007
|
} else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
|
|
1814
2008
|
const attributes = extract.attributes || {};
|
|
1815
2009
|
const mapping = parsedSamlConfig.mapping ?? {};
|
|
2010
|
+
const attr = (key) => {
|
|
2011
|
+
const value = attributes[key];
|
|
2012
|
+
return Array.isArray(value) ? value[0] : value;
|
|
2013
|
+
};
|
|
1816
2014
|
const userInfo = {
|
|
1817
2015
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
|
|
1818
|
-
id:
|
|
1819
|
-
email: (
|
|
1820
|
-
name: [
|
|
1821
|
-
emailVerified: options?.trustEmailVerified && mapping.emailVerified ?
|
|
2016
|
+
id: attr(mapping.id || "nameID") || extract.nameID,
|
|
2017
|
+
email: (attr(mapping.email || "email") || extract.nameID || "").toLowerCase(),
|
|
2018
|
+
name: [attr(mapping.firstName || "givenName"), attr(mapping.lastName || "surname")].filter(Boolean).join(" ") || attr(mapping.name || "displayName") || extract.nameID,
|
|
2019
|
+
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attr(mapping.emailVerified) || false : false
|
|
1822
2020
|
};
|
|
1823
2021
|
if (!userInfo.id || !userInfo.email) {
|
|
1824
2022
|
ctx.context.logger.error("Missing essential user info from SAML response", {
|
|
@@ -1829,25 +2027,42 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1829
2027
|
});
|
|
1830
2028
|
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
1831
2029
|
}
|
|
1832
|
-
const isTrustedProvider =
|
|
2030
|
+
const isTrustedProvider = "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
1833
2031
|
const postAuthRedirect = relayState?.callbackURL || ctx.context.baseURL;
|
|
1834
|
-
const
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
2032
|
+
const errorUrl = relayState?.errorURL || samlRedirectUrl;
|
|
2033
|
+
let result;
|
|
2034
|
+
try {
|
|
2035
|
+
result = await signInWithOAuthIdentity(ctx, {
|
|
2036
|
+
userInfo: {
|
|
2037
|
+
email: userInfo.email,
|
|
2038
|
+
name: userInfo.name || userInfo.email,
|
|
2039
|
+
id: userInfo.id,
|
|
2040
|
+
emailVerified: Boolean(userInfo.emailVerified)
|
|
2041
|
+
},
|
|
1842
2042
|
providerId,
|
|
1843
2043
|
accountId: userInfo.id,
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
2044
|
+
tokens: {},
|
|
2045
|
+
callbackURL: postAuthRedirect,
|
|
2046
|
+
disableSignUp: options?.disableImplicitSignUp,
|
|
2047
|
+
source: {
|
|
2048
|
+
method: "sso-saml",
|
|
2049
|
+
sso: {
|
|
2050
|
+
providerId,
|
|
2051
|
+
profile: attributes
|
|
2052
|
+
}
|
|
2053
|
+
},
|
|
2054
|
+
isTrustedProvider,
|
|
2055
|
+
trustProviderByName: false
|
|
2056
|
+
});
|
|
2057
|
+
} catch (e) {
|
|
2058
|
+
if (isAPIError(e) && e.body?.code) {
|
|
2059
|
+
const params = new URLSearchParams({ error: e.body.code });
|
|
2060
|
+
if (e.body.message) params.set("error_description", e.body.message);
|
|
2061
|
+
const sep = errorUrl.includes("?") ? "&" : "?";
|
|
2062
|
+
throw ctx.redirect(`${errorUrl}${sep}${params.toString()}`);
|
|
2063
|
+
}
|
|
2064
|
+
throw e;
|
|
2065
|
+
}
|
|
1851
2066
|
if (result.error) throw ctx.redirect(`${samlRedirectUrl}?error=${result.error.split(" ").join("_")}`);
|
|
1852
2067
|
const { session, user } = result.data;
|
|
1853
2068
|
if (options?.provisionUser && (result.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
|
|
@@ -1876,6 +2091,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1876
2091
|
const samlSessionKey = `${SAML_SESSION_KEY_PREFIX}${providerId}:${extract.nameID}`;
|
|
1877
2092
|
const samlSessionData = {
|
|
1878
2093
|
sessionId: session.id,
|
|
2094
|
+
sessionToken: session.token,
|
|
1879
2095
|
providerId,
|
|
1880
2096
|
nameID: extract.nameID,
|
|
1881
2097
|
sessionIndex: extract.sessionIndex?.sessionIndex
|
|
@@ -1931,88 +2147,10 @@ const spMetadata = (options) => {
|
|
|
1931
2147
|
return new Response(sp.getMetadata(), { headers: { "Content-Type": "application/xml" } });
|
|
1932
2148
|
});
|
|
1933
2149
|
};
|
|
1934
|
-
const ssoProviderBodySchema = z.object({
|
|
1935
|
-
providerId: z.string({}).meta({ description: "The ID of the provider. This is used to identify the provider during login and callback" }),
|
|
1936
|
-
issuer: z.string({}).meta({ description: "The issuer of the provider" }),
|
|
1937
|
-
domain: z.string({}).meta({ description: "The domain(s) of the provider. For enterprise multi-domain SSO where a single IdP serves multiple email domains, use comma-separated values (e.g., 'company.com,subsidiary.com,acquired-company.com')" }),
|
|
1938
|
-
oidcConfig: z.object({
|
|
1939
|
-
clientId: z.string({}).meta({ description: "The client ID" }),
|
|
1940
|
-
clientSecret: z.string({}).optional().meta({ description: "The client secret. Required for client_secret_basic/client_secret_post. Optional for private_key_jwt." }),
|
|
1941
|
-
authorizationEndpoint: z.string({}).meta({ description: "The authorization endpoint" }).optional(),
|
|
1942
|
-
tokenEndpoint: z.string({}).meta({ description: "The token endpoint" }).optional(),
|
|
1943
|
-
userInfoEndpoint: z.string({}).meta({ description: "The user info endpoint" }).optional(),
|
|
1944
|
-
tokenEndpointAuthentication: z.enum([
|
|
1945
|
-
"client_secret_post",
|
|
1946
|
-
"client_secret_basic",
|
|
1947
|
-
"private_key_jwt"
|
|
1948
|
-
]).optional(),
|
|
1949
|
-
privateKeyId: z.string().optional(),
|
|
1950
|
-
privateKeyAlgorithm: z.string().optional(),
|
|
1951
|
-
jwksEndpoint: z.string({}).meta({ description: "The JWKS endpoint" }).optional(),
|
|
1952
|
-
discoveryEndpoint: z.string().optional(),
|
|
1953
|
-
skipDiscovery: z.boolean().meta({ description: "Skip OIDC discovery during registration. When true, you must provide authorizationEndpoint, tokenEndpoint, and jwksEndpoint manually." }).optional(),
|
|
1954
|
-
scopes: z.array(z.string(), {}).meta({ description: "The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']" }).optional(),
|
|
1955
|
-
pkce: z.boolean({}).meta({ description: "Whether to use PKCE for the authorization flow" }).default(true).optional(),
|
|
1956
|
-
mapping: z.object({
|
|
1957
|
-
id: z.string({}).meta({ description: "Field mapping for user ID (defaults to 'sub')" }),
|
|
1958
|
-
email: z.string({}).meta({ description: "Field mapping for email (defaults to 'email')" }),
|
|
1959
|
-
emailVerified: z.string({}).meta({ description: "Field mapping for email verification (defaults to 'email_verified')" }).optional(),
|
|
1960
|
-
name: z.string({}).meta({ description: "Field mapping for name (defaults to 'name')" }),
|
|
1961
|
-
image: z.string({}).meta({ description: "Field mapping for image (defaults to 'picture')" }).optional(),
|
|
1962
|
-
extraFields: z.record(z.string(), z.any()).optional()
|
|
1963
|
-
}).optional()
|
|
1964
|
-
}).optional(),
|
|
1965
|
-
samlConfig: z.object({
|
|
1966
|
-
entryPoint: z.string({}).meta({ description: "The entry point of the provider" }),
|
|
1967
|
-
cert: z.string({}).meta({ description: "The certificate of the provider" }),
|
|
1968
|
-
audience: z.string().optional(),
|
|
1969
|
-
idpMetadata: z.object({
|
|
1970
|
-
metadata: z.string().optional(),
|
|
1971
|
-
entityID: z.string().optional(),
|
|
1972
|
-
cert: z.string().optional(),
|
|
1973
|
-
privateKey: z.string().optional(),
|
|
1974
|
-
privateKeyPass: z.string().optional(),
|
|
1975
|
-
isAssertionEncrypted: z.boolean().optional(),
|
|
1976
|
-
encPrivateKey: z.string().optional(),
|
|
1977
|
-
encPrivateKeyPass: z.string().optional(),
|
|
1978
|
-
singleSignOnService: z.array(z.object({
|
|
1979
|
-
Binding: z.string().meta({ description: "The binding type for the SSO service" }),
|
|
1980
|
-
Location: z.string().meta({ description: "The URL for the SSO service" })
|
|
1981
|
-
})).optional().meta({ description: "Single Sign-On service configuration" })
|
|
1982
|
-
}).optional(),
|
|
1983
|
-
spMetadata: z.object({
|
|
1984
|
-
metadata: z.string().optional(),
|
|
1985
|
-
entityID: z.string().optional(),
|
|
1986
|
-
binding: z.string().optional(),
|
|
1987
|
-
privateKey: z.string().optional(),
|
|
1988
|
-
privateKeyPass: z.string().optional(),
|
|
1989
|
-
isAssertionEncrypted: z.boolean().optional(),
|
|
1990
|
-
encPrivateKey: z.string().optional(),
|
|
1991
|
-
encPrivateKeyPass: z.string().optional()
|
|
1992
|
-
}).optional(),
|
|
1993
|
-
wantAssertionsSigned: z.boolean().optional(),
|
|
1994
|
-
authnRequestsSigned: z.boolean().optional(),
|
|
1995
|
-
signatureAlgorithm: z.string().optional(),
|
|
1996
|
-
digestAlgorithm: z.string().optional(),
|
|
1997
|
-
identifierFormat: z.string().optional(),
|
|
1998
|
-
privateKey: z.string().optional(),
|
|
1999
|
-
mapping: z.object({
|
|
2000
|
-
id: z.string({}).meta({ description: "Field mapping for user ID (defaults to 'nameID')" }),
|
|
2001
|
-
email: z.string({}).meta({ description: "Field mapping for email (defaults to 'email')" }),
|
|
2002
|
-
emailVerified: z.string({}).meta({ description: "Field mapping for email verification" }).optional(),
|
|
2003
|
-
name: z.string({}).meta({ description: "Field mapping for name (defaults to 'displayName')" }),
|
|
2004
|
-
firstName: z.string({}).meta({ description: "Field mapping for first name (defaults to 'givenName')" }).optional(),
|
|
2005
|
-
lastName: z.string({}).meta({ description: "Field mapping for last name (defaults to 'surname')" }).optional(),
|
|
2006
|
-
extraFields: z.record(z.string(), z.any()).optional()
|
|
2007
|
-
}).optional()
|
|
2008
|
-
}).optional(),
|
|
2009
|
-
organizationId: z.string({}).meta({ description: "If organization plugin is enabled, the organization id to link the provider to" }).optional(),
|
|
2010
|
-
overrideUserInfo: z.boolean({}).meta({ description: "Override user info with the provider info. Defaults to false" }).default(false).optional()
|
|
2011
|
-
});
|
|
2012
2150
|
const registerSSOProvider = (options) => {
|
|
2013
2151
|
return createAuthEndpoint("/sso/register", {
|
|
2014
2152
|
method: "POST",
|
|
2015
|
-
body:
|
|
2153
|
+
body: registerSSOProviderBodySchema,
|
|
2016
2154
|
use: [sessionMiddleware],
|
|
2017
2155
|
metadata: { openapi: {
|
|
2018
2156
|
operationId: "registerSSOProvider",
|
|
@@ -2193,13 +2331,12 @@ const registerSSOProvider = (options) => {
|
|
|
2193
2331
|
}]
|
|
2194
2332
|
})).length >= limit) throw new APIError("FORBIDDEN", { message: "You have reached the maximum number of SSO providers" });
|
|
2195
2333
|
const body = ctx.body;
|
|
2196
|
-
if (z.string().url().safeParse(body.issuer).error) throw new APIError("BAD_REQUEST", { message: "Invalid issuer. Must be a valid URL" });
|
|
2197
2334
|
if (body.samlConfig?.idpMetadata?.metadata) {
|
|
2198
2335
|
const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
|
|
2199
2336
|
if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
|
|
2200
2337
|
}
|
|
2201
2338
|
if (ctx.body.organizationId) {
|
|
2202
|
-
|
|
2339
|
+
const member = await ctx.context.adapter.findOne({
|
|
2203
2340
|
model: "member",
|
|
2204
2341
|
where: [{
|
|
2205
2342
|
field: "userId",
|
|
@@ -2208,7 +2345,17 @@ const registerSSOProvider = (options) => {
|
|
|
2208
2345
|
field: "organizationId",
|
|
2209
2346
|
value: ctx.body.organizationId
|
|
2210
2347
|
}]
|
|
2211
|
-
})
|
|
2348
|
+
});
|
|
2349
|
+
if (!member) throw new APIError("BAD_REQUEST", { message: "You are not a member of the organization" });
|
|
2350
|
+
if (ctx.context.hasPlugin("organization") && !hasOrgAdminRole(member)) throw new APIError("FORBIDDEN", { message: "You must be an organization owner or admin to register SSO providers" });
|
|
2351
|
+
}
|
|
2352
|
+
if (new Set([
|
|
2353
|
+
"credential",
|
|
2354
|
+
...ctx.context.socialProviders.map((p) => p.id),
|
|
2355
|
+
...ctx.context.trustedProviders
|
|
2356
|
+
]).has(body.providerId)) {
|
|
2357
|
+
ctx.context.logger.warn(`SSO provider registration rejected for reserved providerId: ${body.providerId}`);
|
|
2358
|
+
throw new APIError("UNPROCESSABLE_ENTITY", { message: "This providerId is reserved and cannot be used for an SSO provider" });
|
|
2212
2359
|
}
|
|
2213
2360
|
if (await ctx.context.adapter.findOne({
|
|
2214
2361
|
model: "ssoProvider",
|
|
@@ -2220,6 +2367,12 @@ const registerSSOProvider = (options) => {
|
|
|
2220
2367
|
ctx.context.logger.info(`SSO provider creation attempt with existing providerId: ${body.providerId}`);
|
|
2221
2368
|
throw new APIError("UNPROCESSABLE_ENTITY", { message: "SSO provider with this providerId already exists" });
|
|
2222
2369
|
}
|
|
2370
|
+
if (body.oidcConfig) try {
|
|
2371
|
+
validateSkipDiscoveryEndpoints(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
|
|
2372
|
+
} catch (error) {
|
|
2373
|
+
if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
|
|
2374
|
+
throw error;
|
|
2375
|
+
}
|
|
2223
2376
|
let hydratedOIDCConfig = null;
|
|
2224
2377
|
if (body.oidcConfig && !body.oidcConfig.skipDiscovery) try {
|
|
2225
2378
|
hydratedOIDCConfig = await discoverOIDCConfig({
|
|
@@ -2281,6 +2434,7 @@ const registerSSOProvider = (options) => {
|
|
|
2281
2434
|
signatureAlgorithm: body.samlConfig.signatureAlgorithm,
|
|
2282
2435
|
digestAlgorithm: body.samlConfig.digestAlgorithm
|
|
2283
2436
|
}, options?.saml?.algorithms);
|
|
2437
|
+
validateCertSources(body.samlConfig);
|
|
2284
2438
|
const hasIdpMetadata = body.samlConfig.idpMetadata?.metadata;
|
|
2285
2439
|
let hasEntryPoint = false;
|
|
2286
2440
|
if (body.samlConfig.entryPoint) try {
|
|
@@ -2348,17 +2502,18 @@ const registerSSOProvider = (options) => {
|
|
|
2348
2502
|
});
|
|
2349
2503
|
};
|
|
2350
2504
|
const signInSSOBodySchema = z.object({
|
|
2351
|
-
email: z.string({}).meta({ description: "The email address to sign in with.
|
|
2352
|
-
organizationSlug: z.string({}).meta({ description: "The slug of the organization to sign in with" }).optional(),
|
|
2353
|
-
providerId: z.string({}).meta({ description: "The ID of the provider to sign in with.
|
|
2354
|
-
domain: z.string({}).meta({ description: "The domain of the provider." }).optional(),
|
|
2355
|
-
callbackURL: z.string({}).meta({ description: "The URL to redirect to after
|
|
2356
|
-
errorCallbackURL: z.string({}).meta({ description: "The URL to redirect to
|
|
2357
|
-
newUserCallbackURL: z.string({}).meta({ description: "The URL to redirect to after
|
|
2505
|
+
email: z.string({}).meta({ description: "The email address to sign in with. Used to resolve the provider via the email domain; optional if providerId, domain, or organizationSlug is provided." }).optional(),
|
|
2506
|
+
organizationSlug: z.string({}).meta({ description: "The slug of the organization to sign in with." }).optional(),
|
|
2507
|
+
providerId: z.string({}).meta({ description: "The ID of the provider to sign in with. Can be provided instead of email." }).optional(),
|
|
2508
|
+
domain: z.string({}).meta({ description: "The email domain of the provider. Can be provided instead of email." }).optional(),
|
|
2509
|
+
callbackURL: z.string({}).meta({ description: "The URL to redirect to after successful sign-in." }),
|
|
2510
|
+
errorCallbackURL: z.string({}).meta({ description: "The URL to redirect to if the sign-in flow fails." }).optional(),
|
|
2511
|
+
newUserCallbackURL: z.string({}).meta({ description: "The URL to redirect to after sign-in if the user is newly registered." }).optional(),
|
|
2358
2512
|
scopes: z.array(z.string(), {}).meta({ description: "Scopes to request from the provider." }).optional(),
|
|
2359
|
-
loginHint: z.string({}).meta({ description: "Login hint to send to the identity provider (e.g., email or identifier). If supported,
|
|
2360
|
-
|
|
2361
|
-
|
|
2513
|
+
loginHint: z.string({}).meta({ description: "Login hint to send to the identity provider (e.g., email or identifier). If supported, sent as 'login_hint'." }).optional(),
|
|
2514
|
+
additionalParams: additionalAuthorizationParamsSchema,
|
|
2515
|
+
requestSignUp: z.boolean({}).meta({ description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider." }).optional(),
|
|
2516
|
+
providerType: z.enum(["oidc", "saml"]).meta({ description: "The provider protocol to sign in with." }).optional()
|
|
2362
2517
|
});
|
|
2363
2518
|
const signInSSO = (options) => {
|
|
2364
2519
|
return createAuthEndpoint("/sign-in/sso", {
|
|
@@ -2373,31 +2528,54 @@ const signInSSO = (options) => {
|
|
|
2373
2528
|
properties: {
|
|
2374
2529
|
email: {
|
|
2375
2530
|
type: "string",
|
|
2376
|
-
description: "The email address to sign in with.
|
|
2531
|
+
description: "The email address to sign in with. Used to resolve the provider via the email domain; optional if providerId, domain, or organizationSlug is provided."
|
|
2377
2532
|
},
|
|
2378
|
-
|
|
2533
|
+
organizationSlug: {
|
|
2379
2534
|
type: "string",
|
|
2380
|
-
description: "The
|
|
2535
|
+
description: "The slug of the organization to sign in with."
|
|
2381
2536
|
},
|
|
2382
2537
|
providerId: {
|
|
2383
2538
|
type: "string",
|
|
2384
|
-
description: "The ID of the provider to sign in with.
|
|
2539
|
+
description: "The ID of the provider to sign in with. Can be provided instead of email."
|
|
2540
|
+
},
|
|
2541
|
+
domain: {
|
|
2542
|
+
type: "string",
|
|
2543
|
+
description: "The email domain of the provider. Can be provided instead of email."
|
|
2385
2544
|
},
|
|
2386
2545
|
callbackURL: {
|
|
2387
2546
|
type: "string",
|
|
2388
|
-
description: "The URL to redirect to after
|
|
2547
|
+
description: "The URL to redirect to after successful sign-in."
|
|
2389
2548
|
},
|
|
2390
2549
|
errorCallbackURL: {
|
|
2391
2550
|
type: "string",
|
|
2392
|
-
description: "The URL to redirect to
|
|
2551
|
+
description: "The URL to redirect to if the sign-in flow fails."
|
|
2393
2552
|
},
|
|
2394
2553
|
newUserCallbackURL: {
|
|
2395
2554
|
type: "string",
|
|
2396
|
-
description: "The URL to redirect to after
|
|
2555
|
+
description: "The URL to redirect to after sign-in if the user is newly registered."
|
|
2556
|
+
},
|
|
2557
|
+
scopes: {
|
|
2558
|
+
type: "array",
|
|
2559
|
+
items: { type: "string" },
|
|
2560
|
+
description: "Scopes to request from the provider."
|
|
2397
2561
|
},
|
|
2398
2562
|
loginHint: {
|
|
2399
2563
|
type: "string",
|
|
2400
2564
|
description: "Login hint to send to the identity provider (e.g., email or identifier). If supported, sent as 'login_hint'."
|
|
2565
|
+
},
|
|
2566
|
+
additionalParams: {
|
|
2567
|
+
type: "object",
|
|
2568
|
+
additionalProperties: { type: "string" },
|
|
2569
|
+
description: "Extra query parameters to append to the OIDC provider authorization URL. RFC 6749 reserved keys (state, client_id, redirect_uri, response_type, code_challenge, code_challenge_method, scope) are rejected. Not supported for SAML providers."
|
|
2570
|
+
},
|
|
2571
|
+
requestSignUp: {
|
|
2572
|
+
type: "boolean",
|
|
2573
|
+
description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider."
|
|
2574
|
+
},
|
|
2575
|
+
providerType: {
|
|
2576
|
+
type: "string",
|
|
2577
|
+
enum: ["oidc", "saml"],
|
|
2578
|
+
description: "The provider protocol to sign in with."
|
|
2401
2579
|
}
|
|
2402
2580
|
},
|
|
2403
2581
|
required: ["callbackURL"]
|
|
@@ -2494,9 +2672,16 @@ const signInSSO = (options) => {
|
|
|
2494
2672
|
throw error;
|
|
2495
2673
|
}
|
|
2496
2674
|
if (!config.authorizationEndpoint) throw new APIError("BAD_REQUEST", { message: "Invalid OIDC configuration. Authorization URL not found." });
|
|
2497
|
-
const
|
|
2675
|
+
const requestedScopes = ctx.body.scopes || config.scopes || [
|
|
2676
|
+
"openid",
|
|
2677
|
+
"email",
|
|
2678
|
+
"profile",
|
|
2679
|
+
"offline_access"
|
|
2680
|
+
];
|
|
2681
|
+
if (options?.redirectURI?.trim()) await addOAuthServerContext({ ssoProviderId: provider.providerId });
|
|
2682
|
+
const state = await generateState(ctx, { requestedScopes });
|
|
2498
2683
|
const redirectURI = getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options);
|
|
2499
|
-
const authorizationURL = await createAuthorizationURL({
|
|
2684
|
+
const { url: authorizationURL } = await createAuthorizationURL({
|
|
2500
2685
|
id: provider.issuer,
|
|
2501
2686
|
options: {
|
|
2502
2687
|
clientId: config.clientId,
|
|
@@ -2505,14 +2690,10 @@ const signInSSO = (options) => {
|
|
|
2505
2690
|
redirectURI,
|
|
2506
2691
|
state: state.state,
|
|
2507
2692
|
codeVerifier: config.pkce ? state.codeVerifier : void 0,
|
|
2508
|
-
scopes:
|
|
2509
|
-
"openid",
|
|
2510
|
-
"email",
|
|
2511
|
-
"profile",
|
|
2512
|
-
"offline_access"
|
|
2513
|
-
],
|
|
2693
|
+
scopes: requestedScopes,
|
|
2514
2694
|
loginHint: ctx.body.loginHint || email,
|
|
2515
|
-
authorizationEndpoint: config.authorizationEndpoint
|
|
2695
|
+
authorizationEndpoint: config.authorizationEndpoint,
|
|
2696
|
+
additionalParams: ctx.body.additionalParams
|
|
2516
2697
|
});
|
|
2517
2698
|
return ctx.json({
|
|
2518
2699
|
url: authorizationURL.toString(),
|
|
@@ -2520,10 +2701,11 @@ const signInSSO = (options) => {
|
|
|
2520
2701
|
});
|
|
2521
2702
|
}
|
|
2522
2703
|
if (provider.samlConfig) {
|
|
2704
|
+
if (ctx.body.additionalParams) throw new APIError("BAD_REQUEST", { message: "additionalParams is not supported for SAML providers; the SAML AuthnRequest is signed and cannot carry caller-supplied query parameters." });
|
|
2523
2705
|
const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
|
|
2524
2706
|
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
2525
2707
|
if (parsedSamlConfig.authnRequestsSigned && !parsedSamlConfig.spMetadata?.privateKey && !parsedSamlConfig.privateKey) throw new APIError("BAD_REQUEST", { message: "authnRequestsSigned is enabled but no privateKey provided in spMetadata or samlConfig" });
|
|
2526
|
-
const { state: relayState } = await generateRelayState(ctx, void 0
|
|
2708
|
+
const { state: relayState } = await generateRelayState(ctx, void 0);
|
|
2527
2709
|
const sp = createSP(parsedSamlConfig, ctx.context.baseURL, provider.providerId, { relayState });
|
|
2528
2710
|
const idp = createIdP(parsedSamlConfig);
|
|
2529
2711
|
const loginRequest = sp.createLoginRequest(idp, "redirect");
|
|
@@ -2552,7 +2734,7 @@ const signInSSO = (options) => {
|
|
|
2552
2734
|
};
|
|
2553
2735
|
const callbackSSOQuerySchema = z.object({
|
|
2554
2736
|
code: z.string().optional(),
|
|
2555
|
-
state: z.string(),
|
|
2737
|
+
state: z.string().optional(),
|
|
2556
2738
|
error: z.string().optional(),
|
|
2557
2739
|
error_description: z.string().optional()
|
|
2558
2740
|
});
|
|
@@ -2571,31 +2753,9 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2571
2753
|
const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
|
|
2572
2754
|
throw ctx.redirect(`${errorURL}?error=invalid_state`);
|
|
2573
2755
|
}
|
|
2574
|
-
const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
|
|
2756
|
+
const { callbackURL, errorURL, newUserURL, requestSignUp, requestedScopes } = stateData;
|
|
2575
2757
|
if (!code || error) throw ctx.redirect(`${errorURL || callbackURL}?error=${error}&error_description=${error_description}`);
|
|
2576
|
-
|
|
2577
|
-
if (options?.defaultSSO?.length) {
|
|
2578
|
-
const matchingDefault = options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId);
|
|
2579
|
-
if (matchingDefault) provider = {
|
|
2580
|
-
...matchingDefault,
|
|
2581
|
-
issuer: matchingDefault.oidcConfig?.issuer || "",
|
|
2582
|
-
userId: "default",
|
|
2583
|
-
...options.domainVerification?.enabled ? { domainVerified: true } : {}
|
|
2584
|
-
};
|
|
2585
|
-
}
|
|
2586
|
-
if (!provider) provider = await ctx.context.adapter.findOne({
|
|
2587
|
-
model: "ssoProvider",
|
|
2588
|
-
where: [{
|
|
2589
|
-
field: "providerId",
|
|
2590
|
-
value: providerId
|
|
2591
|
-
}]
|
|
2592
|
-
}).then((res) => {
|
|
2593
|
-
if (!res) return null;
|
|
2594
|
-
return {
|
|
2595
|
-
...res,
|
|
2596
|
-
oidcConfig: safeJsonParse(res.oidcConfig) || void 0
|
|
2597
|
-
};
|
|
2598
|
-
});
|
|
2758
|
+
const provider = await resolveOIDCProvider(ctx, options, providerId);
|
|
2599
2759
|
if (!provider) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
|
|
2600
2760
|
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
2601
2761
|
let config = provider.oidcConfig;
|
|
@@ -2616,11 +2776,8 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2616
2776
|
]
|
|
2617
2777
|
};
|
|
2618
2778
|
if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_endpoint_not_found`);
|
|
2619
|
-
let
|
|
2620
|
-
if (config.tokenEndpointAuthentication === "
|
|
2621
|
-
else if (config.tokenEndpointAuthentication === "private_key_jwt") authMethod = "private_key_jwt";
|
|
2622
|
-
let clientAssertionConfig;
|
|
2623
|
-
if (authMethod === "private_key_jwt") {
|
|
2779
|
+
let tokenEndpointAuth = config.tokenEndpointAuthentication === "client_secret_post" ? { method: "client_secret_post" } : { method: "client_secret_basic" };
|
|
2780
|
+
if (config.tokenEndpointAuthentication === "private_key_jwt") {
|
|
2624
2781
|
let resolved;
|
|
2625
2782
|
const matchingDefault = options?.defaultSSO?.find((p) => p.providerId === provider.providerId && "privateKey" in p && p.privateKey);
|
|
2626
2783
|
if (matchingDefault && "privateKey" in matchingDefault) resolved = matchingDefault.privateKey;
|
|
@@ -2629,28 +2786,28 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2629
2786
|
keyId: config.privateKeyId,
|
|
2630
2787
|
issuer: config.issuer
|
|
2631
2788
|
});
|
|
2632
|
-
if (!resolved) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=no_private_key_available`);
|
|
2789
|
+
if (!resolved || !resolved.privateKeyJwk && !resolved.privateKeyPem) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=no_private_key_available`);
|
|
2633
2790
|
const rawAlg = config.privateKeyAlgorithm ?? resolved.algorithm;
|
|
2634
|
-
const algorithm = rawAlg &&
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2791
|
+
const algorithm = rawAlg && PRIVATE_KEY_JWT_SIGNING_ALGORITHMS.includes(rawAlg) ? rawAlg : void 0;
|
|
2792
|
+
tokenEndpointAuth = {
|
|
2793
|
+
method: "private_key_jwt",
|
|
2794
|
+
getClientAssertion: createPrivateKeyJwtClientAssertionGetter({
|
|
2795
|
+
privateKeyJwk: resolved.privateKeyJwk,
|
|
2796
|
+
privateKeyPem: resolved.privateKeyPem,
|
|
2797
|
+
kid: config.privateKeyId ?? resolved.kid,
|
|
2798
|
+
algorithm
|
|
2799
|
+
})
|
|
2641
2800
|
};
|
|
2642
2801
|
}
|
|
2802
|
+
const tokenRequestOptions = { clientId: config.clientId };
|
|
2803
|
+
if (tokenEndpointAuth.method !== "private_key_jwt") tokenRequestOptions.clientSecret = config.clientSecret;
|
|
2643
2804
|
const tokenResponse = await validateAuthorizationCode({
|
|
2644
2805
|
code,
|
|
2645
2806
|
codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
|
|
2646
2807
|
redirectURI: getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options),
|
|
2647
|
-
options:
|
|
2648
|
-
clientId: config.clientId,
|
|
2649
|
-
clientSecret: config.clientSecret
|
|
2650
|
-
},
|
|
2808
|
+
options: tokenRequestOptions,
|
|
2651
2809
|
tokenEndpoint: config.tokenEndpoint,
|
|
2652
|
-
|
|
2653
|
-
clientAssertion: clientAssertionConfig
|
|
2810
|
+
tokenEndpointAuth
|
|
2654
2811
|
}).catch((e) => {
|
|
2655
2812
|
ctx.context.logger.error("Error validating authorization code", e);
|
|
2656
2813
|
if (e instanceof BetterFetchError) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${e.message}`);
|
|
@@ -2659,10 +2816,15 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2659
2816
|
if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_response_not_found`);
|
|
2660
2817
|
let userInfo = null;
|
|
2661
2818
|
const mapping = config.mapping || {};
|
|
2819
|
+
let rawProfile;
|
|
2662
2820
|
if (config.userInfoEndpoint) {
|
|
2663
|
-
const userInfoResponse = await betterFetch(config.userInfoEndpoint, {
|
|
2821
|
+
const userInfoResponse = await betterFetch(config.userInfoEndpoint, {
|
|
2822
|
+
headers: { Authorization: `Bearer ${tokenResponse.accessToken}` },
|
|
2823
|
+
redirect: "error"
|
|
2824
|
+
});
|
|
2664
2825
|
if (userInfoResponse.error) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${userInfoResponse.error.message}`);
|
|
2665
2826
|
const rawUserInfo = userInfoResponse.data;
|
|
2827
|
+
rawProfile = rawUserInfo;
|
|
2666
2828
|
userInfo = {
|
|
2667
2829
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, rawUserInfo[value]])),
|
|
2668
2830
|
id: rawUserInfo[mapping.id || "sub"],
|
|
@@ -2673,6 +2835,7 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2673
2835
|
};
|
|
2674
2836
|
} else if (tokenResponse.idToken) {
|
|
2675
2837
|
const idToken = decodeJwt(tokenResponse.idToken);
|
|
2838
|
+
rawProfile = idToken;
|
|
2676
2839
|
if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=jwks_endpoint_not_found`);
|
|
2677
2840
|
const verified = await validateToken(tokenResponse.idToken, config.jwksEndpoint, {
|
|
2678
2841
|
audience: config.clientId,
|
|
@@ -2693,30 +2856,49 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2693
2856
|
} else throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=user_info_endpoint_not_found`);
|
|
2694
2857
|
if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=missing_user_info`);
|
|
2695
2858
|
const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfo.email, provider.domain);
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
accessToken: tokenResponse.accessToken,
|
|
2707
|
-
refreshToken: tokenResponse.refreshToken,
|
|
2708
|
-
accountId: userInfo.id,
|
|
2859
|
+
let linked;
|
|
2860
|
+
try {
|
|
2861
|
+
linked = await signInWithOAuthIdentity(ctx, {
|
|
2862
|
+
userInfo: {
|
|
2863
|
+
email: userInfo.email,
|
|
2864
|
+
name: userInfo.name || "",
|
|
2865
|
+
id: userInfo.id,
|
|
2866
|
+
image: userInfo.image,
|
|
2867
|
+
emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
|
|
2868
|
+
},
|
|
2709
2869
|
providerId: provider.providerId,
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2870
|
+
accountId: userInfo.id,
|
|
2871
|
+
tokens: tokenResponse,
|
|
2872
|
+
requestedScopes,
|
|
2873
|
+
callbackURL,
|
|
2874
|
+
disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
|
|
2875
|
+
overrideUserInfo: config.overrideUserInfo,
|
|
2876
|
+
source: {
|
|
2877
|
+
method: "sso-oidc",
|
|
2878
|
+
sso: {
|
|
2879
|
+
providerId: provider.providerId,
|
|
2880
|
+
profile: rawProfile
|
|
2881
|
+
}
|
|
2882
|
+
},
|
|
2883
|
+
isTrustedProvider,
|
|
2884
|
+
trustProviderByName: false
|
|
2885
|
+
});
|
|
2886
|
+
} catch (e) {
|
|
2887
|
+
if (isAPIError(e) && e.body?.code) {
|
|
2888
|
+
const baseURL = errorURL || callbackURL;
|
|
2889
|
+
const params = new URLSearchParams({ error: e.body.code });
|
|
2890
|
+
if (e.body.message) params.set("error_description", e.body.message);
|
|
2891
|
+
const sep = baseURL.includes("?") ? "&" : "?";
|
|
2892
|
+
throw ctx.redirect(`${baseURL}${sep}${params.toString()}`);
|
|
2893
|
+
}
|
|
2894
|
+
throw e;
|
|
2895
|
+
}
|
|
2896
|
+
if (linked.error) {
|
|
2897
|
+
const baseURL = errorURL || callbackURL;
|
|
2898
|
+
const params = new URLSearchParams({ error: linked.error });
|
|
2899
|
+
const sep = baseURL.includes("?") ? "&" : "?";
|
|
2900
|
+
throw ctx.redirect(`${baseURL}${sep}${params.toString()}`);
|
|
2901
|
+
}
|
|
2720
2902
|
const { session, user } = linked.data;
|
|
2721
2903
|
if (options?.provisionUser && (linked.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
|
|
2722
2904
|
user,
|
|
@@ -2764,9 +2946,91 @@ const callbackSSOEndpointConfig = {
|
|
|
2764
2946
|
}
|
|
2765
2947
|
}
|
|
2766
2948
|
};
|
|
2949
|
+
/**
|
|
2950
|
+
* Resolves an SSO provider by `providerId`, first checking `options.defaultSSO`
|
|
2951
|
+
* and falling back to the `ssoProvider` table. Returns `null` when no match is
|
|
2952
|
+
* found so the caller can decide how to react (redirect, silently skip, etc.).
|
|
2953
|
+
*/
|
|
2954
|
+
async function resolveOIDCProvider(ctx, options, providerId) {
|
|
2955
|
+
const matchingDefault = options?.defaultSSO?.find((defaultProvider) => defaultProvider.providerId === providerId);
|
|
2956
|
+
if (matchingDefault) return {
|
|
2957
|
+
...matchingDefault,
|
|
2958
|
+
issuer: matchingDefault.oidcConfig?.issuer || "",
|
|
2959
|
+
userId: "default",
|
|
2960
|
+
...options?.domainVerification?.enabled ? { domainVerified: true } : {}
|
|
2961
|
+
};
|
|
2962
|
+
return ctx.context.adapter.findOne({
|
|
2963
|
+
model: "ssoProvider",
|
|
2964
|
+
where: [{
|
|
2965
|
+
field: "providerId",
|
|
2966
|
+
value: providerId
|
|
2967
|
+
}]
|
|
2968
|
+
}).then((res) => {
|
|
2969
|
+
if (!res) return null;
|
|
2970
|
+
return {
|
|
2971
|
+
...res,
|
|
2972
|
+
oidcConfig: safeJsonParse(res.oidcConfig) || void 0
|
|
2973
|
+
};
|
|
2974
|
+
});
|
|
2975
|
+
}
|
|
2976
|
+
/**
|
|
2977
|
+
* Restarts the OAuth flow server-side when a stateless callback arrives for
|
|
2978
|
+
* an OIDC provider that opted into IDP-initiated flows. Silently returns
|
|
2979
|
+
* otherwise, letting the normal handler produce its error redirect.
|
|
2980
|
+
*/
|
|
2981
|
+
async function bounceIfIdpInitiated(ctx, options, providerId) {
|
|
2982
|
+
const provider = await resolveOIDCProvider(ctx, options, providerId);
|
|
2983
|
+
if (!provider?.oidcConfig?.allowIdpInitiated) return;
|
|
2984
|
+
let config = provider.oidcConfig;
|
|
2985
|
+
try {
|
|
2986
|
+
config = await ensureRuntimeDiscovery(config, provider.issuer, (url) => ctx.context.isTrustedOrigin(url));
|
|
2987
|
+
} catch (error) {
|
|
2988
|
+
ctx.context.logger.error("IDP-initiated bounce skipped: OIDC discovery failed", {
|
|
2989
|
+
providerId: provider.providerId,
|
|
2990
|
+
issuer: provider.issuer,
|
|
2991
|
+
error
|
|
2992
|
+
});
|
|
2993
|
+
return;
|
|
2994
|
+
}
|
|
2995
|
+
if (!config.authorizationEndpoint) {
|
|
2996
|
+
ctx.context.logger.error("IDP-initiated bounce skipped: authorizationEndpoint missing after discovery", {
|
|
2997
|
+
providerId: provider.providerId,
|
|
2998
|
+
issuer: provider.issuer
|
|
2999
|
+
});
|
|
3000
|
+
return;
|
|
3001
|
+
}
|
|
3002
|
+
if (options?.redirectURI?.trim()) await addOAuthServerContext({ ssoProviderId: provider.providerId });
|
|
3003
|
+
const state = await generateState(ctx, { requestedScopes: config.scopes || [
|
|
3004
|
+
"openid",
|
|
3005
|
+
"email",
|
|
3006
|
+
"profile",
|
|
3007
|
+
"offline_access"
|
|
3008
|
+
] });
|
|
3009
|
+
const redirectURI = getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options);
|
|
3010
|
+
const { url: authorizationURL } = await createAuthorizationURL({
|
|
3011
|
+
id: provider.issuer,
|
|
3012
|
+
options: {
|
|
3013
|
+
clientId: config.clientId,
|
|
3014
|
+
clientSecret: config.clientSecret
|
|
3015
|
+
},
|
|
3016
|
+
redirectURI,
|
|
3017
|
+
state: state.state,
|
|
3018
|
+
codeVerifier: config.pkce ? state.codeVerifier : void 0,
|
|
3019
|
+
scopes: config.scopes || [
|
|
3020
|
+
"openid",
|
|
3021
|
+
"email",
|
|
3022
|
+
"profile",
|
|
3023
|
+
"offline_access"
|
|
3024
|
+
],
|
|
3025
|
+
authorizationEndpoint: config.authorizationEndpoint
|
|
3026
|
+
});
|
|
3027
|
+
throw ctx.redirect(authorizationURL.toString());
|
|
3028
|
+
}
|
|
2767
3029
|
const callbackSSO = (options) => {
|
|
2768
3030
|
return createAuthEndpoint("/sso/callback/:providerId", callbackSSOEndpointConfig, async (ctx) => {
|
|
2769
|
-
|
|
3031
|
+
const providerId = ctx.params.providerId;
|
|
3032
|
+
if (ctx.query.state === void 0 && ctx.query.code) await bounceIfIdpInitiated(ctx, options, providerId);
|
|
3033
|
+
return handleOIDCCallback(ctx, options, providerId);
|
|
2770
3034
|
});
|
|
2771
3035
|
};
|
|
2772
3036
|
/**
|
|
@@ -2792,7 +3056,7 @@ const callbackSSOShared = (options) => {
|
|
|
2792
3056
|
const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
|
|
2793
3057
|
throw ctx.redirect(`${errorURL}?error=invalid_state`);
|
|
2794
3058
|
}
|
|
2795
|
-
const providerId = stateData.ssoProviderId;
|
|
3059
|
+
const providerId = stateData.serverContext?.ssoProviderId;
|
|
2796
3060
|
if (!providerId) {
|
|
2797
3061
|
const errorURL = stateData.errorURL || stateData.callbackURL;
|
|
2798
3062
|
throw ctx.redirect(`${errorURL}?error=invalid_state&error_description=missing_provider_id`);
|
|
@@ -2941,7 +3205,7 @@ async function handleLogoutRequest(ctx, sp, idp, relayState, providerId) {
|
|
|
2941
3205
|
if (stored) {
|
|
2942
3206
|
const data = safeJsonParse(stored.value);
|
|
2943
3207
|
if (data) if (!sessionIndex || !data.sessionIndex || sessionIndex === data.sessionIndex) {
|
|
2944
|
-
await ctx.context.internalAdapter.deleteSession(data.
|
|
3208
|
+
await ctx.context.internalAdapter.deleteSession(data.sessionToken).catch((e) => ctx.context.logger.warn("Failed to delete session during SLO", { error: e }));
|
|
2945
3209
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${SAML_SESSION_BY_ID_PREFIX}${data.sessionId}`).catch((e) => ctx.context.logger.warn("Failed to delete SAML session lookup during SLO", e));
|
|
2946
3210
|
} else ctx.context.logger.warn("SessionIndex mismatch in LogoutRequest - skipping session deletion", {
|
|
2947
3211
|
providerId,
|
|
@@ -2951,10 +3215,9 @@ async function handleLogoutRequest(ctx, sp, idp, relayState, providerId) {
|
|
|
2951
3215
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(key).catch((e) => ctx.context.logger.warn("Failed to delete SAML session key during SLO", e));
|
|
2952
3216
|
}
|
|
2953
3217
|
const currentSession = await getSessionFromCtx(ctx);
|
|
2954
|
-
if (currentSession?.session) await ctx.context.internalAdapter.deleteSession(currentSession.session.
|
|
3218
|
+
if (currentSession?.session) await ctx.context.internalAdapter.deleteSession(currentSession.session.token);
|
|
2955
3219
|
deleteSessionCookie(ctx);
|
|
2956
|
-
const
|
|
2957
|
-
const res = sp.createLogoutResponse(idp, null, binding, relayState || "", (template) => template.replace("{InResponseTo}", requestId).replace("{StatusCode}", SAML_STATUS_SUCCESS));
|
|
3220
|
+
const res = sp.createLogoutResponse(idp, parsed, binding, relayState || "");
|
|
2958
3221
|
if (binding === "post" && res.entityEndpoint) return createSAMLPostForm(res.entityEndpoint, "SAMLResponse", res.context, relayState);
|
|
2959
3222
|
throw ctx.redirect(res.context);
|
|
2960
3223
|
}
|
|
@@ -3007,7 +3270,7 @@ const initiateSLO = (options) => {
|
|
|
3007
3270
|
});
|
|
3008
3271
|
if (samlSessionKey) await ctx.context.internalAdapter.deleteVerificationByIdentifier(samlSessionKey).catch((e) => ctx.context.logger.warn("Failed to delete SAML session key during logout", e));
|
|
3009
3272
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(sessionLookupKey).catch((e) => ctx.context.logger.warn("Failed to delete session lookup key during logout", e));
|
|
3010
|
-
await ctx.context.internalAdapter.deleteSession(session.session.
|
|
3273
|
+
await ctx.context.internalAdapter.deleteSession(session.session.token);
|
|
3011
3274
|
deleteSessionCookie(ctx);
|
|
3012
3275
|
throw ctx.redirect(logoutRequest.context);
|
|
3013
3276
|
});
|