@better-auth/sso 1.7.0-beta.2 → 1.7.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
import { t as PACKAGE_VERSION } from "./version-
|
|
1
|
+
import { t as PACKAGE_VERSION } from "./version-5EiO_U3Z.mjs";
|
|
2
2
|
import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
|
|
3
3
|
import { XMLParser, XMLValidator } from "fast-xml-parser";
|
|
4
4
|
import { X509Certificate } from "node:crypto";
|
|
5
5
|
import { getHostname } from "tldts";
|
|
6
6
|
import { generateRandomString } from "better-auth/crypto";
|
|
7
7
|
import * as z from "zod";
|
|
8
|
-
import {
|
|
8
|
+
import { isPublicRoutableHost } from "@better-auth/core/utils/host";
|
|
9
9
|
import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
|
|
10
|
-
import {
|
|
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 { handleOAuthUserInfo } from "better-auth/oauth2";
|
|
15
|
+
import { additionalAuthorizationParamsSchema, handleOAuthUserInfo } 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,1097 +376,1218 @@ 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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
+
};
|
|
495
452
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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`;
|
|
502
463
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
}
|
|
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 });
|
|
514
474
|
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
|
532
507
|
});
|
|
533
508
|
}
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
+
* Fetch the OIDC discovery document from the IdP.
|
|
531
|
+
*
|
|
532
|
+
* @param url - The discovery endpoint URL
|
|
533
|
+
* @param timeout - Request timeout in milliseconds
|
|
534
|
+
* @returns The parsed discovery document
|
|
535
|
+
* @throws DiscoveryError on network errors, timeouts, or invalid responses
|
|
536
|
+
*/
|
|
537
|
+
async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
|
|
538
|
+
try {
|
|
539
|
+
const response = await betterFetch(url, {
|
|
540
|
+
method: "GET",
|
|
541
|
+
timeout
|
|
542
|
+
});
|
|
543
|
+
if (response.error) {
|
|
544
|
+
const { status } = response.error;
|
|
545
|
+
if (status === 404) throw new DiscoveryError("discovery_not_found", "Discovery endpoint not found", {
|
|
546
|
+
url,
|
|
547
|
+
status
|
|
542
548
|
});
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
if (allowedDataEncryptionAlgorithms) {
|
|
547
|
-
if (!allowedDataEncryptionAlgorithms.includes(dataEncryption)) throw new APIError("BAD_REQUEST", {
|
|
548
|
-
message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
|
|
549
|
-
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
549
|
+
if (status === 408) throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
|
|
550
|
+
url,
|
|
551
|
+
timeout
|
|
550
552
|
});
|
|
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"
|
|
553
|
+
throw new DiscoveryError("discovery_unexpected_error", `Unexpected discovery error: ${response.error.statusText}`, {
|
|
554
|
+
url,
|
|
555
|
+
...response.error
|
|
566
556
|
});
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
557
|
+
}
|
|
558
|
+
if (!response.data) throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned an empty response", { url });
|
|
559
|
+
const data = response.data;
|
|
560
|
+
if (typeof data === "string") throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned invalid JSON", {
|
|
561
|
+
url,
|
|
562
|
+
bodyPreview: data.slice(0, 200)
|
|
571
563
|
});
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
if (
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
579
|
-
});
|
|
580
|
-
} else if (DEPRECATED_DIGEST_ALGORITHMS.includes(normalized)) handleDeprecatedAlgorithm(`SAML config uses deprecated digest algorithm: ${config.digestAlgorithm}. Consider using SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_CONFIG_ALGORITHM");
|
|
581
|
-
else if (!SECURE_DIGEST_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
582
|
-
message: `SAML digest algorithm not recognized: ${config.digestAlgorithm}`,
|
|
583
|
-
code: "SAML_UNKNOWN_ALGORITHM"
|
|
564
|
+
return data;
|
|
565
|
+
} catch (error) {
|
|
566
|
+
if (error instanceof DiscoveryError) throw error;
|
|
567
|
+
if (error instanceof Error && error.name === "AbortError") throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
|
|
568
|
+
url,
|
|
569
|
+
timeout
|
|
584
570
|
});
|
|
571
|
+
throw new DiscoveryError("discovery_unexpected_error", `Unexpected error during discovery: ${error instanceof Error ? error.message : String(error)}`, { url }, { cause: error });
|
|
585
572
|
}
|
|
586
573
|
}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
574
|
+
/**
|
|
575
|
+
* Validate a discovery document.
|
|
576
|
+
*
|
|
577
|
+
* Checks:
|
|
578
|
+
* 1. All required fields are present
|
|
579
|
+
* 2. Issuer matches the configured issuer (case-sensitive, exact match)
|
|
580
|
+
*
|
|
581
|
+
* Invariant: If this function returns without throwing, the document is safe
|
|
582
|
+
* to use for hydrating OIDC config (required fields present, issuer matches
|
|
583
|
+
* configured value, basic structural sanity verified).
|
|
584
|
+
*
|
|
585
|
+
* @param doc - The discovery document to validate
|
|
586
|
+
* @param configuredIssuer - The expected issuer value
|
|
587
|
+
* @throws DiscoveryError if validation fails
|
|
588
|
+
*/
|
|
589
|
+
function validateDiscoveryDocument(doc, configuredIssuer) {
|
|
590
|
+
const missingFields = [];
|
|
591
|
+
for (const field of REQUIRED_DISCOVERY_FIELDS) if (!doc[field]) missingFields.push(field);
|
|
592
|
+
if (missingFields.length > 0) throw new DiscoveryError("discovery_incomplete", `Discovery document is missing required fields: ${missingFields.join(", ")}`, { missingFields });
|
|
593
|
+
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}"`, {
|
|
594
|
+
discovered: doc.issuer,
|
|
595
|
+
configured: configuredIssuer
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Normalize URLs in the discovery document.
|
|
600
|
+
*
|
|
601
|
+
* @param document - The discovery document
|
|
602
|
+
* @param issuer - The base issuer URL
|
|
603
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
604
|
+
* @returns The normalized discovery document
|
|
605
|
+
*/
|
|
606
|
+
function normalizeDiscoveryUrls(document, issuer, isTrustedOrigin) {
|
|
607
|
+
const doc = { ...document };
|
|
608
|
+
doc.token_endpoint = normalizeAndValidateUrl("token_endpoint", doc.token_endpoint, issuer, isTrustedOrigin);
|
|
609
|
+
doc.authorization_endpoint = normalizeAndValidateUrl("authorization_endpoint", doc.authorization_endpoint, issuer, isTrustedOrigin);
|
|
610
|
+
doc.jwks_uri = normalizeAndValidateUrl("jwks_uri", doc.jwks_uri, issuer, isTrustedOrigin);
|
|
611
|
+
if (doc.userinfo_endpoint) doc.userinfo_endpoint = normalizeAndValidateUrl("userinfo_endpoint", doc.userinfo_endpoint, issuer, isTrustedOrigin);
|
|
612
|
+
if (doc.revocation_endpoint) doc.revocation_endpoint = normalizeAndValidateUrl("revocation_endpoint", doc.revocation_endpoint, issuer, isTrustedOrigin);
|
|
613
|
+
if (doc.end_session_endpoint) doc.end_session_endpoint = normalizeAndValidateUrl("end_session_endpoint", doc.end_session_endpoint, issuer, isTrustedOrigin);
|
|
614
|
+
if (doc.introspection_endpoint) doc.introspection_endpoint = normalizeAndValidateUrl("introspection_endpoint", doc.introspection_endpoint, issuer, isTrustedOrigin);
|
|
615
|
+
return doc;
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Normalizes and validates a single URL endpoint
|
|
619
|
+
* @param name The url name
|
|
620
|
+
* @param endpoint The url to validate
|
|
621
|
+
* @param issuer The issuer base url
|
|
622
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
623
|
+
* @returns
|
|
624
|
+
*/
|
|
625
|
+
function normalizeAndValidateUrl(name, endpoint, issuer, isTrustedOrigin) {
|
|
626
|
+
const url = normalizeUrl(name, endpoint, issuer);
|
|
627
|
+
if (!isTrustedOrigin(url)) throw new DiscoveryError("discovery_untrusted_origin", `The ${name} "${url}" is not trusted by your trusted origins configuration.`, {
|
|
628
|
+
endpoint: name,
|
|
629
|
+
url
|
|
630
|
+
});
|
|
631
|
+
return url;
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Normalize a single URL endpoint.
|
|
635
|
+
*
|
|
636
|
+
* @param name - The endpoint name (e.g token_endpoint)
|
|
637
|
+
* @param endpoint - The endpoint URL to normalize
|
|
638
|
+
* @param issuer - The base issuer URL
|
|
639
|
+
* @returns The normalized endpoint URL
|
|
640
|
+
*/
|
|
641
|
+
function normalizeUrl(name, endpoint, issuer) {
|
|
591
642
|
try {
|
|
592
|
-
|
|
643
|
+
return parseURL(name, endpoint).toString();
|
|
593
644
|
} catch {
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
645
|
+
const issuerURL = parseURL(name, issuer);
|
|
646
|
+
const basePath = issuerURL.pathname.replace(/\/+$/, "");
|
|
647
|
+
const endpointPath = endpoint.replace(/^\/+/, "");
|
|
648
|
+
return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
|
|
598
649
|
}
|
|
599
|
-
const assertions = countAllNodes(parsed, "Assertion");
|
|
600
|
-
const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
|
|
601
|
-
return {
|
|
602
|
-
assertions,
|
|
603
|
-
encryptedAssertions,
|
|
604
|
-
total: assertions + encryptedAssertions
|
|
605
|
-
};
|
|
606
650
|
}
|
|
607
|
-
|
|
608
|
-
|
|
651
|
+
/**
|
|
652
|
+
* Parses the given URL or throws in case of invalid or unsupported protocols
|
|
653
|
+
*
|
|
654
|
+
* @param name the url name
|
|
655
|
+
* @param endpoint the endpoint url
|
|
656
|
+
* @param [base] optional base path
|
|
657
|
+
* @returns
|
|
658
|
+
*/
|
|
659
|
+
function parseURL(name, endpoint, base) {
|
|
660
|
+
let endpointURL;
|
|
609
661
|
try {
|
|
610
|
-
|
|
611
|
-
if (
|
|
612
|
-
} catch {
|
|
613
|
-
throw new
|
|
614
|
-
message: "Invalid base64-encoded SAML response",
|
|
615
|
-
code: "SAML_INVALID_ENCODING"
|
|
616
|
-
});
|
|
662
|
+
endpointURL = new URL(endpoint, base);
|
|
663
|
+
if (endpointURL.protocol === "http:" || endpointURL.protocol === "https:") return endpointURL;
|
|
664
|
+
} catch (error) {
|
|
665
|
+
throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
|
|
617
666
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
code: "SAML_NO_ASSERTION"
|
|
622
|
-
});
|
|
623
|
-
if (counts.total > 1) throw new APIError("BAD_REQUEST", {
|
|
624
|
-
message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
|
|
625
|
-
code: "SAML_MULTIPLE_ASSERTIONS"
|
|
667
|
+
throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must use the http or https supported protocols: ${endpoint}`, {
|
|
668
|
+
url: endpoint,
|
|
669
|
+
protocol: endpointURL.protocol
|
|
626
670
|
});
|
|
627
671
|
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
672
|
+
/**
|
|
673
|
+
* Select the token endpoint authentication method.
|
|
674
|
+
*
|
|
675
|
+
* @param doc - The discovery document
|
|
676
|
+
* @param existing - Existing authentication method from config
|
|
677
|
+
* @returns The selected authentication method
|
|
678
|
+
*/
|
|
679
|
+
function selectTokenEndpointAuthMethod(doc, existing) {
|
|
680
|
+
if (existing === "private_key_jwt") return existing;
|
|
681
|
+
if (existing) return existing;
|
|
682
|
+
const supported = doc.token_endpoint_auth_methods_supported;
|
|
683
|
+
if (!supported || supported.length === 0) return "client_secret_basic";
|
|
684
|
+
if (supported.includes("client_secret_basic")) return "client_secret_basic";
|
|
685
|
+
if (supported.includes("client_secret_post")) return "client_secret_post";
|
|
686
|
+
if (supported.includes("private_key_jwt")) return "private_key_jwt";
|
|
687
|
+
return "client_secret_basic";
|
|
642
688
|
}
|
|
643
689
|
/**
|
|
644
|
-
*
|
|
690
|
+
* Check if a provider configuration needs runtime discovery.
|
|
645
691
|
*
|
|
646
|
-
*
|
|
647
|
-
*
|
|
648
|
-
*
|
|
692
|
+
* Returns true if we need discovery at runtime to complete the token exchange
|
|
693
|
+
* and validation. Specifically checks for:
|
|
694
|
+
* - `tokenEndpoint` - required for exchanging authorization code for tokens
|
|
695
|
+
* - `jwksEndpoint` - required for validating ID token signatures
|
|
696
|
+
* - `authorizationEndpoint` - required for redirecting users to the IdP for login
|
|
649
697
|
*
|
|
650
|
-
*
|
|
651
|
-
*
|
|
698
|
+
* @param config - Partial OIDC config from the provider
|
|
699
|
+
* @returns true if runtime discovery should be performed
|
|
652
700
|
*/
|
|
653
|
-
|
|
654
|
-
if (
|
|
655
|
-
|
|
656
|
-
const allowIdpInitiated = ctx.options.allowIdpInitiated ?? false;
|
|
657
|
-
if (inResponseTo) {
|
|
658
|
-
let storedRequest = null;
|
|
659
|
-
const verification = await c.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
660
|
-
if (verification) try {
|
|
661
|
-
storedRequest = JSON.parse(verification.value);
|
|
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
|
-
}
|
|
701
|
+
function needsRuntimeDiscovery(config) {
|
|
702
|
+
if (!config) return true;
|
|
703
|
+
return !config.tokenEndpoint || !config.jwksEndpoint || !config.authorizationEndpoint;
|
|
687
704
|
}
|
|
688
705
|
/**
|
|
689
|
-
*
|
|
706
|
+
* Runs runtime OIDC discovery when the stored config is missing required
|
|
707
|
+
* endpoints, and merges the hydrated fields back into the config.
|
|
708
|
+
* Throws if discovery fails.
|
|
709
|
+
*/
|
|
710
|
+
async function ensureRuntimeDiscovery(config, issuer, isTrustedOrigin) {
|
|
711
|
+
if (!needsRuntimeDiscovery(config)) return config;
|
|
712
|
+
const hydrated = await discoverOIDCConfig({
|
|
713
|
+
issuer,
|
|
714
|
+
existingConfig: config,
|
|
715
|
+
isTrustedOrigin
|
|
716
|
+
});
|
|
717
|
+
return {
|
|
718
|
+
...config,
|
|
719
|
+
authorizationEndpoint: hydrated.authorizationEndpoint,
|
|
720
|
+
tokenEndpoint: hydrated.tokenEndpoint,
|
|
721
|
+
tokenEndpointAuthentication: hydrated.tokenEndpointAuthentication,
|
|
722
|
+
userInfoEndpoint: hydrated.userInfoEndpoint,
|
|
723
|
+
jwksEndpoint: hydrated.jwksEndpoint
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
//#endregion
|
|
727
|
+
//#region src/oidc/errors.ts
|
|
728
|
+
/**
|
|
729
|
+
* OIDC Discovery Error Mapping
|
|
690
730
|
*
|
|
691
|
-
*
|
|
692
|
-
* the
|
|
693
|
-
* for a different SP (e.g., another application sharing the same IdP)
|
|
694
|
-
* could be accepted.
|
|
731
|
+
* Maps DiscoveryError codes to appropriate APIError responses.
|
|
732
|
+
* Used at the boundary between the discovery pipeline and HTTP handlers.
|
|
695
733
|
*/
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
734
|
+
/**
|
|
735
|
+
* Maps a DiscoveryError to an appropriate APIError for HTTP responses.
|
|
736
|
+
*
|
|
737
|
+
* Error code mapping:
|
|
738
|
+
* - discovery_invalid_url → 400 BAD_REQUEST
|
|
739
|
+
* - discovery_not_found → 400 BAD_REQUEST
|
|
740
|
+
* - discovery_untrusted_origin → 400 BAD_REQUEST
|
|
741
|
+
* - discovery_private_host → 400 BAD_REQUEST
|
|
742
|
+
* - discovery_invalid_json → 400 BAD_REQUEST
|
|
743
|
+
* - discovery_incomplete → 400 BAD_REQUEST
|
|
744
|
+
* - issuer_mismatch → 400 BAD_REQUEST
|
|
745
|
+
* - unsupported_token_auth_method → 400 BAD_REQUEST
|
|
746
|
+
* - discovery_timeout → 502 BAD_GATEWAY
|
|
747
|
+
* - discovery_unexpected_error → 502 BAD_GATEWAY
|
|
748
|
+
*
|
|
749
|
+
* @param error - The DiscoveryError to map
|
|
750
|
+
* @returns An APIError with appropriate status and message
|
|
751
|
+
*/
|
|
752
|
+
function mapDiscoveryErrorToAPIError(error) {
|
|
753
|
+
switch (error.code) {
|
|
754
|
+
case "discovery_timeout": return new APIError("BAD_GATEWAY", {
|
|
755
|
+
message: `OIDC discovery timed out: ${error.message}`,
|
|
756
|
+
code: error.code
|
|
712
757
|
});
|
|
713
|
-
|
|
758
|
+
case "discovery_unexpected_error": return new APIError("BAD_GATEWAY", {
|
|
759
|
+
message: `OIDC discovery failed: ${error.message}`,
|
|
760
|
+
code: error.code
|
|
761
|
+
});
|
|
762
|
+
case "discovery_not_found": return new APIError("BAD_REQUEST", {
|
|
763
|
+
message: `OIDC discovery endpoint not found. The issuer may not support OIDC discovery, or the URL is incorrect. ${error.message}`,
|
|
764
|
+
code: error.code
|
|
765
|
+
});
|
|
766
|
+
case "discovery_invalid_url": return new APIError("BAD_REQUEST", {
|
|
767
|
+
message: `Invalid OIDC endpoint URL: ${error.message}`,
|
|
768
|
+
code: error.code
|
|
769
|
+
});
|
|
770
|
+
case "discovery_untrusted_origin": return new APIError("BAD_REQUEST", {
|
|
771
|
+
message: `Untrusted OIDC discovery URL: ${error.message}`,
|
|
772
|
+
code: error.code
|
|
773
|
+
});
|
|
774
|
+
case "discovery_private_host": return new APIError("BAD_REQUEST", {
|
|
775
|
+
message: error.message,
|
|
776
|
+
code: error.code
|
|
777
|
+
});
|
|
778
|
+
case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
|
|
779
|
+
message: `OIDC discovery returned invalid data: ${error.message}`,
|
|
780
|
+
code: error.code
|
|
781
|
+
});
|
|
782
|
+
case "discovery_incomplete": return new APIError("BAD_REQUEST", {
|
|
783
|
+
message: `OIDC discovery document is missing required fields: ${error.message}`,
|
|
784
|
+
code: error.code
|
|
785
|
+
});
|
|
786
|
+
case "issuer_mismatch": return new APIError("BAD_REQUEST", {
|
|
787
|
+
message: `OIDC issuer mismatch: ${error.message}`,
|
|
788
|
+
code: error.code
|
|
789
|
+
});
|
|
790
|
+
case "unsupported_token_auth_method": return new APIError("BAD_REQUEST", {
|
|
791
|
+
message: `Incompatible OIDC provider: ${error.message}`,
|
|
792
|
+
code: error.code
|
|
793
|
+
});
|
|
794
|
+
default:
|
|
795
|
+
error.code;
|
|
796
|
+
return new APIError("INTERNAL_SERVER_ERROR", {
|
|
797
|
+
message: `Unexpected discovery error: ${error.message}`,
|
|
798
|
+
code: "discovery_unexpected_error"
|
|
799
|
+
});
|
|
714
800
|
}
|
|
715
801
|
}
|
|
716
802
|
//#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()
|
|
803
|
+
//#region src/saml/parser.ts
|
|
804
|
+
const xmlParser = new XMLParser({
|
|
805
|
+
ignoreAttributes: false,
|
|
806
|
+
attributeNamePrefix: "@_",
|
|
807
|
+
removeNSPrefix: true,
|
|
808
|
+
processEntities: false
|
|
796
809
|
});
|
|
810
|
+
function findNode(obj, nodeName) {
|
|
811
|
+
if (!obj || typeof obj !== "object") return null;
|
|
812
|
+
const record = obj;
|
|
813
|
+
if (nodeName in record) return record[nodeName];
|
|
814
|
+
for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
|
|
815
|
+
const found = findNode(item, nodeName);
|
|
816
|
+
if (found) return found;
|
|
817
|
+
}
|
|
818
|
+
else if (typeof value === "object" && value !== null) {
|
|
819
|
+
const found = findNode(value, nodeName);
|
|
820
|
+
if (found) return found;
|
|
821
|
+
}
|
|
822
|
+
return null;
|
|
823
|
+
}
|
|
824
|
+
function countAllNodes(obj, nodeName) {
|
|
825
|
+
if (!obj || typeof obj !== "object") return 0;
|
|
826
|
+
let count = 0;
|
|
827
|
+
const record = obj;
|
|
828
|
+
if (nodeName in record) {
|
|
829
|
+
const node = record[nodeName];
|
|
830
|
+
count += Array.isArray(node) ? node.length : 1;
|
|
831
|
+
}
|
|
832
|
+
for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) count += countAllNodes(item, nodeName);
|
|
833
|
+
else if (typeof value === "object" && value !== null) count += countAllNodes(value, nodeName);
|
|
834
|
+
return count;
|
|
835
|
+
}
|
|
797
836
|
//#endregion
|
|
798
|
-
//#region src/
|
|
799
|
-
const
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
837
|
+
//#region src/saml/algorithms.ts
|
|
838
|
+
const SignatureAlgorithm = {
|
|
839
|
+
RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
|
|
840
|
+
RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
|
|
841
|
+
RSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
|
|
842
|
+
RSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
|
|
843
|
+
ECDSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
|
|
844
|
+
ECDSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
|
|
845
|
+
ECDSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"
|
|
846
|
+
};
|
|
847
|
+
const DigestAlgorithm = {
|
|
848
|
+
SHA1: "http://www.w3.org/2000/09/xmldsig#sha1",
|
|
849
|
+
SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
|
|
850
|
+
SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
|
|
851
|
+
SHA512: "http://www.w3.org/2001/04/xmlenc#sha512"
|
|
852
|
+
};
|
|
853
|
+
const KeyEncryptionAlgorithm = {
|
|
854
|
+
RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
|
|
855
|
+
RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
|
|
856
|
+
RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep"
|
|
857
|
+
};
|
|
858
|
+
const DataEncryptionAlgorithm = {
|
|
859
|
+
TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
|
|
860
|
+
AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
|
|
861
|
+
AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
|
|
862
|
+
AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
|
|
863
|
+
AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
|
|
864
|
+
AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
|
|
865
|
+
AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
|
|
866
|
+
};
|
|
867
|
+
const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
|
|
868
|
+
const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS = [KeyEncryptionAlgorithm.RSA_1_5];
|
|
869
|
+
const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS = [DataEncryptionAlgorithm.TRIPLEDES_CBC];
|
|
870
|
+
const DEPRECATED_DIGEST_ALGORITHMS = [DigestAlgorithm.SHA1];
|
|
871
|
+
const SECURE_SIGNATURE_ALGORITHMS = [
|
|
872
|
+
SignatureAlgorithm.RSA_SHA256,
|
|
873
|
+
SignatureAlgorithm.RSA_SHA384,
|
|
874
|
+
SignatureAlgorithm.RSA_SHA512,
|
|
875
|
+
SignatureAlgorithm.ECDSA_SHA256,
|
|
876
|
+
SignatureAlgorithm.ECDSA_SHA384,
|
|
877
|
+
SignatureAlgorithm.ECDSA_SHA512
|
|
878
|
+
];
|
|
879
|
+
const SECURE_DIGEST_ALGORITHMS = [
|
|
880
|
+
DigestAlgorithm.SHA256,
|
|
881
|
+
DigestAlgorithm.SHA384,
|
|
882
|
+
DigestAlgorithm.SHA512
|
|
883
|
+
];
|
|
884
|
+
const SHORT_FORM_SIGNATURE_TO_URI = {
|
|
885
|
+
sha1: SignatureAlgorithm.RSA_SHA1,
|
|
886
|
+
sha256: SignatureAlgorithm.RSA_SHA256,
|
|
887
|
+
sha384: SignatureAlgorithm.RSA_SHA384,
|
|
888
|
+
sha512: SignatureAlgorithm.RSA_SHA512,
|
|
889
|
+
"rsa-sha1": SignatureAlgorithm.RSA_SHA1,
|
|
890
|
+
"rsa-sha256": SignatureAlgorithm.RSA_SHA256,
|
|
891
|
+
"rsa-sha384": SignatureAlgorithm.RSA_SHA384,
|
|
892
|
+
"rsa-sha512": SignatureAlgorithm.RSA_SHA512,
|
|
893
|
+
"ecdsa-sha256": SignatureAlgorithm.ECDSA_SHA256,
|
|
894
|
+
"ecdsa-sha384": SignatureAlgorithm.ECDSA_SHA384,
|
|
895
|
+
"ecdsa-sha512": SignatureAlgorithm.ECDSA_SHA512
|
|
896
|
+
};
|
|
897
|
+
const SHORT_FORM_DIGEST_TO_URI = {
|
|
898
|
+
sha1: DigestAlgorithm.SHA1,
|
|
899
|
+
sha256: DigestAlgorithm.SHA256,
|
|
900
|
+
sha384: DigestAlgorithm.SHA384,
|
|
901
|
+
sha512: DigestAlgorithm.SHA512
|
|
902
|
+
};
|
|
903
|
+
function normalizeSignatureAlgorithm(alg) {
|
|
904
|
+
return SHORT_FORM_SIGNATURE_TO_URI[alg.toLowerCase()] ?? alg;
|
|
813
905
|
}
|
|
814
|
-
|
|
815
|
-
|
|
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;
|
|
906
|
+
function normalizeDigestAlgorithm(alg) {
|
|
907
|
+
return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
|
|
830
908
|
}
|
|
831
|
-
function
|
|
832
|
-
let oidcConfig = null;
|
|
833
|
-
let samlConfig = null;
|
|
909
|
+
function extractEncryptionAlgorithms(xml) {
|
|
834
910
|
try {
|
|
835
|
-
|
|
911
|
+
const parsed = xmlParser.parse(xml);
|
|
912
|
+
const keyAlg = (findNode(parsed, "EncryptedKey")?.EncryptionMethod)?.["@_Algorithm"];
|
|
913
|
+
const dataAlg = (findNode(parsed, "EncryptedData")?.EncryptionMethod)?.["@_Algorithm"];
|
|
914
|
+
return {
|
|
915
|
+
keyEncryption: keyAlg || null,
|
|
916
|
+
dataEncryption: dataAlg || null
|
|
917
|
+
};
|
|
836
918
|
} catch {
|
|
837
|
-
|
|
919
|
+
return {
|
|
920
|
+
keyEncryption: null,
|
|
921
|
+
dataEncryption: null
|
|
922
|
+
};
|
|
838
923
|
}
|
|
924
|
+
}
|
|
925
|
+
function hasEncryptedAssertion(xml) {
|
|
839
926
|
try {
|
|
840
|
-
|
|
927
|
+
return findNode(xmlParser.parse(xml), "EncryptedAssertion") !== null;
|
|
841
928
|
} catch {
|
|
842
|
-
|
|
929
|
+
return false;
|
|
843
930
|
}
|
|
844
|
-
const type = samlConfig ? "saml" : "oidc";
|
|
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
|
-
};
|
|
881
931
|
}
|
|
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;
|
|
932
|
+
function handleDeprecatedAlgorithm(message, behavior, errorCode) {
|
|
933
|
+
switch (behavior) {
|
|
934
|
+
case "reject": throw new APIError("BAD_REQUEST", {
|
|
935
|
+
message,
|
|
936
|
+
code: errorCode
|
|
937
|
+
});
|
|
938
|
+
case "warn":
|
|
939
|
+
console.warn(`[SAML Security Warning] ${message}`);
|
|
940
|
+
break;
|
|
941
|
+
case "allow": break;
|
|
942
|
+
}
|
|
928
943
|
}
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
const provider = await checkProviderAccess(ctx, providerId);
|
|
947
|
-
return ctx.json(sanitizeProvider(provider, ctx.context.baseURL));
|
|
944
|
+
function validateSignatureAlgorithm(algorithm, options = {}) {
|
|
945
|
+
if (!algorithm) return;
|
|
946
|
+
const { onDeprecated = "warn", allowedSignatureAlgorithms } = options;
|
|
947
|
+
if (allowedSignatureAlgorithms) {
|
|
948
|
+
if (!allowedSignatureAlgorithms.includes(algorithm)) throw new APIError("BAD_REQUEST", {
|
|
949
|
+
message: `SAML signature algorithm not in allow-list: ${algorithm}`,
|
|
950
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
951
|
+
});
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(algorithm)) {
|
|
955
|
+
handleDeprecatedAlgorithm(`SAML response uses deprecated signature algorithm: ${algorithm}. Please configure your IdP to use SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
if (!SECURE_SIGNATURE_ALGORITHMS.includes(algorithm)) throw new APIError("BAD_REQUEST", {
|
|
959
|
+
message: `SAML signature algorithm not recognized: ${algorithm}`,
|
|
960
|
+
code: "SAML_UNKNOWN_ALGORITHM"
|
|
948
961
|
});
|
|
949
|
-
}
|
|
950
|
-
function
|
|
951
|
-
|
|
962
|
+
}
|
|
963
|
+
function validateEncryptionAlgorithms(algorithms, options = {}) {
|
|
964
|
+
const { onDeprecated = "warn", allowedKeyEncryptionAlgorithms, allowedDataEncryptionAlgorithms } = options;
|
|
965
|
+
const { keyEncryption, dataEncryption } = algorithms;
|
|
966
|
+
if (keyEncryption) {
|
|
967
|
+
if (allowedKeyEncryptionAlgorithms) {
|
|
968
|
+
if (!allowedKeyEncryptionAlgorithms.includes(keyEncryption)) throw new APIError("BAD_REQUEST", {
|
|
969
|
+
message: `SAML key encryption algorithm not in allow-list: ${keyEncryption}`,
|
|
970
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
971
|
+
});
|
|
972
|
+
} 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");
|
|
973
|
+
}
|
|
974
|
+
if (dataEncryption) {
|
|
975
|
+
if (allowedDataEncryptionAlgorithms) {
|
|
976
|
+
if (!allowedDataEncryptionAlgorithms.includes(dataEncryption)) throw new APIError("BAD_REQUEST", {
|
|
977
|
+
message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
|
|
978
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
979
|
+
});
|
|
980
|
+
} 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");
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
function validateSAMLAlgorithms(response, options) {
|
|
984
|
+
validateSignatureAlgorithm(response.sigAlg, options);
|
|
985
|
+
if (hasEncryptedAssertion(response.samlContent)) validateEncryptionAlgorithms(extractEncryptionAlgorithms(response.samlContent), options);
|
|
986
|
+
}
|
|
987
|
+
function validateConfigAlgorithms(config, options = {}) {
|
|
988
|
+
const { onDeprecated = "warn", allowedSignatureAlgorithms, allowedDigestAlgorithms } = options;
|
|
989
|
+
if (config.signatureAlgorithm) {
|
|
990
|
+
const normalized = normalizeSignatureAlgorithm(config.signatureAlgorithm);
|
|
991
|
+
if (allowedSignatureAlgorithms) {
|
|
992
|
+
if (!allowedSignatureAlgorithms.map(normalizeSignatureAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
993
|
+
message: `SAML signature algorithm not in allow-list: ${config.signatureAlgorithm}`,
|
|
994
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
995
|
+
});
|
|
996
|
+
} 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");
|
|
997
|
+
else if (!SECURE_SIGNATURE_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
998
|
+
message: `SAML signature algorithm not recognized: ${config.signatureAlgorithm}`,
|
|
999
|
+
code: "SAML_UNKNOWN_ALGORITHM"
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
if (config.digestAlgorithm) {
|
|
1003
|
+
const normalized = normalizeDigestAlgorithm(config.digestAlgorithm);
|
|
1004
|
+
if (allowedDigestAlgorithms) {
|
|
1005
|
+
if (!allowedDigestAlgorithms.map(normalizeDigestAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
1006
|
+
message: `SAML digest algorithm not in allow-list: ${config.digestAlgorithm}`,
|
|
1007
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
1008
|
+
});
|
|
1009
|
+
} 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");
|
|
1010
|
+
else if (!SECURE_DIGEST_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
1011
|
+
message: `SAML digest algorithm not recognized: ${config.digestAlgorithm}`,
|
|
1012
|
+
code: "SAML_UNKNOWN_ALGORITHM"
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
//#endregion
|
|
1017
|
+
//#region src/saml/assertions.ts
|
|
1018
|
+
function countAssertions(xml) {
|
|
1019
|
+
let parsed;
|
|
952
1020
|
try {
|
|
953
|
-
|
|
1021
|
+
parsed = xmlParser.parse(xml);
|
|
954
1022
|
} catch {
|
|
955
|
-
|
|
1023
|
+
throw new APIError("BAD_REQUEST", {
|
|
1024
|
+
message: "Failed to parse SAML response XML",
|
|
1025
|
+
code: "SAML_INVALID_XML"
|
|
1026
|
+
});
|
|
956
1027
|
}
|
|
957
|
-
|
|
958
|
-
|
|
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
|
-
}
|
|
978
|
-
function mergeOIDCConfig(current, updates, issuer) {
|
|
1028
|
+
const assertions = countAllNodes(parsed, "Assertion");
|
|
1029
|
+
const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
|
|
979
1030
|
return {
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
pkce: updates.pkce ?? current.pkce ?? true,
|
|
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
|
|
1031
|
+
assertions,
|
|
1032
|
+
encryptedAssertions,
|
|
1033
|
+
total: assertions + encryptedAssertions
|
|
996
1034
|
};
|
|
997
1035
|
}
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
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
|
|
1051
|
-
});
|
|
1052
|
-
const fullProvider = await ctx.context.adapter.findOne({
|
|
1053
|
-
model: "ssoProvider",
|
|
1054
|
-
where: [{
|
|
1055
|
-
field: "providerId",
|
|
1056
|
-
value: providerId
|
|
1057
|
-
}]
|
|
1036
|
+
function validateSingleAssertion(samlResponse) {
|
|
1037
|
+
let xml;
|
|
1038
|
+
try {
|
|
1039
|
+
xml = new TextDecoder().decode(base64.decode(samlResponse.replace(/\s+/g, "")));
|
|
1040
|
+
if (!xml.includes("<")) throw new Error("Not XML");
|
|
1041
|
+
} catch {
|
|
1042
|
+
throw new APIError("BAD_REQUEST", {
|
|
1043
|
+
message: "Invalid base64-encoded SAML response",
|
|
1044
|
+
code: "SAML_INVALID_ENCODING"
|
|
1058
1045
|
});
|
|
1059
|
-
|
|
1060
|
-
|
|
1046
|
+
}
|
|
1047
|
+
const counts = countAssertions(xml);
|
|
1048
|
+
if (counts.total === 0) throw new APIError("BAD_REQUEST", {
|
|
1049
|
+
message: "SAML response contains no assertions",
|
|
1050
|
+
code: "SAML_NO_ASSERTION"
|
|
1061
1051
|
});
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
method: "POST",
|
|
1066
|
-
use: [sessionMiddleware],
|
|
1067
|
-
body: z.object({ providerId: z.string() }),
|
|
1068
|
-
metadata: { openapi: {
|
|
1069
|
-
operationId: "deleteSSOProvider",
|
|
1070
|
-
summary: "Delete SSO provider",
|
|
1071
|
-
description: "Deletes an SSO provider",
|
|
1072
|
-
responses: {
|
|
1073
|
-
"200": { description: "SSO provider deleted successfully" },
|
|
1074
|
-
"404": { description: "Provider not found" },
|
|
1075
|
-
"403": { description: "Access denied" }
|
|
1076
|
-
}
|
|
1077
|
-
} }
|
|
1078
|
-
}, async (ctx) => {
|
|
1079
|
-
const { providerId } = ctx.body;
|
|
1080
|
-
await checkProviderAccess(ctx, providerId);
|
|
1081
|
-
await ctx.context.adapter.delete({
|
|
1082
|
-
model: "ssoProvider",
|
|
1083
|
-
where: [{
|
|
1084
|
-
field: "providerId",
|
|
1085
|
-
value: providerId
|
|
1086
|
-
}]
|
|
1087
|
-
});
|
|
1088
|
-
return ctx.json({ success: true });
|
|
1052
|
+
if (counts.total > 1) throw new APIError("BAD_REQUEST", {
|
|
1053
|
+
message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
|
|
1054
|
+
code: "SAML_MULTIPLE_ASSERTIONS"
|
|
1089
1055
|
});
|
|
1090
|
-
}
|
|
1056
|
+
}
|
|
1091
1057
|
//#endregion
|
|
1092
|
-
//#region src/
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
this.name = "DiscoveryError";
|
|
1103
|
-
this.code = code;
|
|
1104
|
-
this.details = details;
|
|
1105
|
-
if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
|
|
1106
|
-
}
|
|
1107
|
-
};
|
|
1108
|
-
/**
|
|
1109
|
-
* Required fields that must be present in a valid discovery document.
|
|
1110
|
-
*/
|
|
1111
|
-
const REQUIRED_DISCOVERY_FIELDS = [
|
|
1112
|
-
"issuer",
|
|
1113
|
-
"authorization_endpoint",
|
|
1114
|
-
"token_endpoint",
|
|
1115
|
-
"jwks_uri"
|
|
1116
|
-
];
|
|
1058
|
+
//#region src/saml/error-codes.ts
|
|
1059
|
+
const SAML_ERROR_CODES = defineErrorCodes({
|
|
1060
|
+
SINGLE_LOGOUT_NOT_ENABLED: "Single Logout is not enabled",
|
|
1061
|
+
INVALID_LOGOUT_RESPONSE: "Invalid LogoutResponse",
|
|
1062
|
+
INVALID_LOGOUT_REQUEST: "Invalid LogoutRequest",
|
|
1063
|
+
LOGOUT_FAILED_AT_IDP: "Logout failed at IdP",
|
|
1064
|
+
IDP_SLO_NOT_SUPPORTED: "IdP does not support Single Logout Service",
|
|
1065
|
+
SAML_PROVIDER_NOT_FOUND: "SAML provider not found",
|
|
1066
|
+
CERT_SOURCE_MISSING: "samlConfig requires either a signing certificate (cert or idpMetadata.cert) or an idpMetadata.metadata XML document."
|
|
1067
|
+
});
|
|
1117
1068
|
//#endregion
|
|
1118
|
-
//#region src/
|
|
1119
|
-
/**
|
|
1120
|
-
* OIDC Discovery Pipeline
|
|
1121
|
-
*
|
|
1122
|
-
* Implements OIDC discovery document fetching, validation, and hydration.
|
|
1123
|
-
* This module is used both at provider registration time (to persist validated config)
|
|
1124
|
-
* and at runtime (to hydrate legacy providers that are missing metadata).
|
|
1125
|
-
*
|
|
1126
|
-
* @see https://openid.net/specs/openid-connect-discovery-1_0.html
|
|
1127
|
-
*/
|
|
1128
|
-
/** Default timeout for discovery requests (10 seconds) */
|
|
1129
|
-
const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
|
|
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);
|
|
1155
|
-
return {
|
|
1156
|
-
issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
|
|
1157
|
-
discoveryEndpoint: existingConfig?.discoveryEndpoint ?? discoveryUrl,
|
|
1158
|
-
authorizationEndpoint: existingConfig?.authorizationEndpoint ?? normalizedDoc.authorization_endpoint,
|
|
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
|
|
1164
|
-
};
|
|
1165
|
-
}
|
|
1069
|
+
//#region src/saml/cert.ts
|
|
1166
1070
|
/**
|
|
1167
|
-
*
|
|
1168
|
-
*
|
|
1169
|
-
*
|
|
1170
|
-
*
|
|
1171
|
-
*
|
|
1172
|
-
* Handles trailing slashes correctly.
|
|
1173
|
-
*/
|
|
1174
|
-
function computeDiscoveryUrl(issuer) {
|
|
1175
|
-
return `${issuer.endsWith("/") ? issuer.slice(0, -1) : issuer}/.well-known/openid-configuration`;
|
|
1176
|
-
}
|
|
1177
|
-
/**
|
|
1178
|
-
* Validate a discovery URL before fetching.
|
|
1179
|
-
*
|
|
1180
|
-
* @param url - The discovery URL to validate
|
|
1181
|
-
* @param isTrustedOrigin - Origin verification tester function
|
|
1182
|
-
* @throws DiscoveryError if URL is invalid
|
|
1071
|
+
* IdP signing-certificate rules for SAML configs. Centralized so the runtime
|
|
1072
|
+
* verification path (`createIdP`), the sanitizer (`getSSOProvider` and
|
|
1073
|
+
* friends), and the registration validator agree on precedence and the
|
|
1074
|
+
* "exactly one cert source" contract.
|
|
1183
1075
|
*/
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1076
|
+
/**
|
|
1077
|
+
* Returns the IdP signing certificates Better Auth trusts for this provider
|
|
1078
|
+
* as a list. `idpMetadata.cert` wins when both are set; the top-level `cert`
|
|
1079
|
+
* is the fallback. Returns `undefined` when neither is set (the certs come
|
|
1080
|
+
* from `idpMetadata.metadata` XML instead).
|
|
1081
|
+
*/
|
|
1082
|
+
function resolveSigningCerts(config) {
|
|
1083
|
+
const cert = config.idpMetadata?.cert ?? config.cert;
|
|
1084
|
+
if (cert === void 0) return void 0;
|
|
1085
|
+
return Array.isArray(cert) ? cert : [cert];
|
|
1187
1086
|
}
|
|
1188
1087
|
/**
|
|
1189
|
-
*
|
|
1190
|
-
*
|
|
1191
|
-
*
|
|
1192
|
-
*
|
|
1193
|
-
* @returns The parsed discovery document
|
|
1194
|
-
* @throws DiscoveryError on network errors, timeouts, or invalid responses
|
|
1088
|
+
* Reject SAML configs with no signing-cert source. samlify needs either an
|
|
1089
|
+
* `idpMetadata.metadata` XML document (which embeds the certs) or an explicit
|
|
1090
|
+
* PEM under `cert` or `idpMetadata.cert`; without one of those it has nothing
|
|
1091
|
+
* to verify responses against.
|
|
1195
1092
|
*/
|
|
1196
|
-
|
|
1093
|
+
function validateCertSources(config) {
|
|
1094
|
+
const hasMetadataXml = !!config.idpMetadata?.metadata;
|
|
1095
|
+
const hasExplicitCert = config.idpMetadata?.cert !== void 0 || config.cert !== void 0;
|
|
1096
|
+
if (!hasMetadataXml && !hasExplicitCert) throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.CERT_SOURCE_MISSING);
|
|
1097
|
+
}
|
|
1098
|
+
//#endregion
|
|
1099
|
+
//#region src/saml/response-validation.ts
|
|
1100
|
+
function errorRedirectUrl(base, error, description) {
|
|
1197
1101
|
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 });
|
|
1102
|
+
const url = new URL(base);
|
|
1103
|
+
url.searchParams.set("error", error);
|
|
1104
|
+
url.searchParams.set("error_description", description);
|
|
1105
|
+
return url.toString();
|
|
1106
|
+
} catch {
|
|
1107
|
+
const hashIdx = base.indexOf("#");
|
|
1108
|
+
const path = hashIdx >= 0 ? base.slice(0, hashIdx) : base;
|
|
1109
|
+
const hash = hashIdx >= 0 ? base.slice(hashIdx + 1) : void 0;
|
|
1110
|
+
return `${path}${path.includes("?") ? "&" : "?"}${`error=${encodeURIComponent(error)}&error_description=${encodeURIComponent(description)}`}${hash ? `#${hash}` : ""}`;
|
|
1231
1111
|
}
|
|
1232
1112
|
}
|
|
1233
1113
|
/**
|
|
1234
|
-
*
|
|
1235
|
-
*
|
|
1236
|
-
* Checks:
|
|
1237
|
-
* 1. All required fields are present
|
|
1238
|
-
* 2. Issuer matches the configured issuer (case-sensitive, exact match)
|
|
1114
|
+
* Validates the InResponseTo attribute of a SAML Response.
|
|
1239
1115
|
*
|
|
1240
|
-
*
|
|
1241
|
-
*
|
|
1242
|
-
*
|
|
1116
|
+
* This binds the IdP's Response to a specific SP-initiated AuthnRequest,
|
|
1117
|
+
* preventing replay attacks, unsolicited response injection, and
|
|
1118
|
+
* cross-provider assertion swaps.
|
|
1243
1119
|
*
|
|
1244
|
-
*
|
|
1245
|
-
*
|
|
1246
|
-
* @throws DiscoveryError if validation fails
|
|
1120
|
+
* The InResponseTo value lives at `extract.response.inResponseTo` in
|
|
1121
|
+
* samlify's parsed output (not at the top level).
|
|
1247
1122
|
*/
|
|
1248
|
-
function
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
if (
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1123
|
+
async function validateInResponseTo(c, ctx) {
|
|
1124
|
+
if (ctx.options.enableInResponseToValidation === false) return;
|
|
1125
|
+
const inResponseTo = ctx.extract.response?.inResponseTo;
|
|
1126
|
+
const allowIdpInitiated = ctx.options.allowIdpInitiated ?? false;
|
|
1127
|
+
if (inResponseTo) {
|
|
1128
|
+
let storedRequest = null;
|
|
1129
|
+
const verification = await c.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
1130
|
+
if (verification) try {
|
|
1131
|
+
storedRequest = JSON.parse(verification.value);
|
|
1132
|
+
if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
|
|
1133
|
+
} catch {
|
|
1134
|
+
storedRequest = null;
|
|
1135
|
+
}
|
|
1136
|
+
if (!storedRequest) {
|
|
1137
|
+
c.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
|
|
1138
|
+
inResponseTo,
|
|
1139
|
+
providerId: ctx.providerId
|
|
1140
|
+
});
|
|
1141
|
+
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Unknown or expired request ID"));
|
|
1142
|
+
}
|
|
1143
|
+
if (storedRequest.providerId !== ctx.providerId) {
|
|
1144
|
+
c.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
|
|
1145
|
+
inResponseTo,
|
|
1146
|
+
expectedProvider: storedRequest.providerId,
|
|
1147
|
+
actualProvider: ctx.providerId
|
|
1148
|
+
});
|
|
1149
|
+
await c.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
1150
|
+
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Provider mismatch"));
|
|
1151
|
+
}
|
|
1152
|
+
await c.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
1153
|
+
} else if (!allowIdpInitiated) {
|
|
1154
|
+
c.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: ctx.providerId });
|
|
1155
|
+
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "unsolicited_response", "IdP-initiated SSO not allowed"));
|
|
1156
|
+
}
|
|
1256
1157
|
}
|
|
1257
1158
|
/**
|
|
1258
|
-
*
|
|
1159
|
+
* Validates the AudienceRestriction of a SAML assertion.
|
|
1259
1160
|
*
|
|
1260
|
-
*
|
|
1261
|
-
*
|
|
1262
|
-
*
|
|
1263
|
-
*
|
|
1161
|
+
* Per SAML 2.0 Core §2.5.1, an assertion's Audience element specifies
|
|
1162
|
+
* the intended recipient SP. Without this check, an assertion issued
|
|
1163
|
+
* for a different SP (e.g., another application sharing the same IdP)
|
|
1164
|
+
* could be accepted.
|
|
1264
1165
|
*/
|
|
1265
|
-
function
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
if (
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1166
|
+
function validateAudience(c, ctx) {
|
|
1167
|
+
if (!ctx.expectedAudience) {
|
|
1168
|
+
c.context.logger.warn("Could not determine SP entity ID for audience validation; skipping", { providerId: ctx.providerId });
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
const audience = ctx.extract.audience;
|
|
1172
|
+
if (!audience) {
|
|
1173
|
+
c.context.logger.error("SAML assertion missing AudienceRestriction but audience is configured — rejecting", { providerId: ctx.providerId });
|
|
1174
|
+
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Audience restriction missing"));
|
|
1175
|
+
}
|
|
1176
|
+
const audiences = Array.isArray(audience) ? audience : [audience];
|
|
1177
|
+
if (!audiences.includes(ctx.expectedAudience)) {
|
|
1178
|
+
c.context.logger.error("SAML audience mismatch: assertion was issued for a different service provider", {
|
|
1179
|
+
expected: ctx.expectedAudience,
|
|
1180
|
+
received: audiences,
|
|
1181
|
+
providerId: ctx.providerId
|
|
1182
|
+
});
|
|
1183
|
+
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Audience mismatch"));
|
|
1184
|
+
}
|
|
1275
1185
|
}
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1186
|
+
//#endregion
|
|
1187
|
+
//#region src/routes/schemas.ts
|
|
1188
|
+
const oidcMappingSchema = z.object({
|
|
1189
|
+
id: z.string().meta({ description: "Field mapping for user ID (defaults to 'sub')" }),
|
|
1190
|
+
email: z.string().meta({ description: "Field mapping for email (defaults to 'email')" }),
|
|
1191
|
+
emailVerified: z.string().meta({ description: "Field mapping for email verification (defaults to 'email_verified')" }).optional(),
|
|
1192
|
+
name: z.string().meta({ description: "Field mapping for name (defaults to 'name')" }),
|
|
1193
|
+
image: z.string().meta({ description: "Field mapping for image (defaults to 'picture')" }).optional(),
|
|
1194
|
+
extraFields: z.record(z.string(), z.any()).optional()
|
|
1195
|
+
}).optional();
|
|
1196
|
+
const samlMappingSchema = z.object({
|
|
1197
|
+
id: z.string().meta({ description: "Field mapping for user ID (defaults to 'nameID')" }),
|
|
1198
|
+
email: z.string().meta({ description: "Field mapping for email (defaults to 'email')" }),
|
|
1199
|
+
emailVerified: z.string().meta({ description: "Field mapping for email verification" }).optional(),
|
|
1200
|
+
name: z.string().meta({ description: "Field mapping for name (defaults to 'displayName')" }),
|
|
1201
|
+
firstName: z.string().meta({ description: "Field mapping for first name (defaults to 'givenName')" }).optional(),
|
|
1202
|
+
lastName: z.string().meta({ description: "Field mapping for last name (defaults to 'surname')" }).optional(),
|
|
1203
|
+
extraFields: z.record(z.string(), z.any()).optional()
|
|
1204
|
+
}).optional();
|
|
1205
|
+
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." });
|
|
1206
|
+
const oidcConfigSchema = z.object({
|
|
1207
|
+
clientId: z.string().meta({ description: "The client ID" }),
|
|
1208
|
+
clientSecret: z.string().meta({ description: "The client secret. Required for client_secret_basic/client_secret_post. Optional for private_key_jwt." }).optional(),
|
|
1209
|
+
authorizationEndpoint: z.string().url().meta({ description: "The authorization endpoint" }).optional(),
|
|
1210
|
+
tokenEndpoint: z.string().url().meta({ description: "The token endpoint" }).optional(),
|
|
1211
|
+
userInfoEndpoint: z.string().url().meta({ description: "The user info endpoint" }).optional(),
|
|
1212
|
+
tokenEndpointAuthentication: z.enum([
|
|
1213
|
+
"client_secret_post",
|
|
1214
|
+
"client_secret_basic",
|
|
1215
|
+
"private_key_jwt"
|
|
1216
|
+
]).optional(),
|
|
1217
|
+
privateKeyId: z.string().optional(),
|
|
1218
|
+
privateKeyAlgorithm: z.string().optional(),
|
|
1219
|
+
jwksEndpoint: z.string().url().meta({ description: "The JWKS endpoint" }).optional(),
|
|
1220
|
+
discoveryEndpoint: z.string().url().optional(),
|
|
1221
|
+
skipDiscovery: z.boolean().meta({ description: "Skip OIDC discovery during registration. When true, you must provide authorizationEndpoint, tokenEndpoint, and jwksEndpoint manually." }).optional(),
|
|
1222
|
+
scopes: z.array(z.string()).meta({ description: "The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']" }).optional(),
|
|
1223
|
+
pkce: z.boolean().meta({ description: "Whether to use PKCE for the authorization flow" }).default(true).optional(),
|
|
1224
|
+
overrideUserInfo: z.boolean().optional(),
|
|
1225
|
+
mapping: oidcMappingSchema
|
|
1226
|
+
});
|
|
1227
|
+
const samlConfigSchema = z.object({
|
|
1228
|
+
entryPoint: z.string().url().meta({ description: "The IdP SSO URL (entry point)" }),
|
|
1229
|
+
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(),
|
|
1230
|
+
audience: z.string().optional(),
|
|
1231
|
+
idpMetadata: z.object({
|
|
1232
|
+
metadata: z.string().optional(),
|
|
1233
|
+
entityID: z.string().optional(),
|
|
1234
|
+
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(),
|
|
1235
|
+
privateKey: z.string().optional(),
|
|
1236
|
+
privateKeyPass: z.string().optional(),
|
|
1237
|
+
isAssertionEncrypted: z.boolean().optional(),
|
|
1238
|
+
encPrivateKey: z.string().optional(),
|
|
1239
|
+
encPrivateKeyPass: z.string().optional(),
|
|
1240
|
+
singleSignOnService: z.array(z.object({
|
|
1241
|
+
Binding: z.string().meta({ description: "The binding type for the SSO service" }),
|
|
1242
|
+
Location: z.string().url().meta({ description: "The URL for the SSO service" })
|
|
1243
|
+
})).meta({ description: "Single Sign-On service configuration" }).optional(),
|
|
1244
|
+
singleLogoutService: z.array(z.object({
|
|
1245
|
+
Binding: z.string(),
|
|
1246
|
+
Location: z.string().url()
|
|
1247
|
+
})).optional()
|
|
1248
|
+
}).optional(),
|
|
1249
|
+
spMetadata: z.object({
|
|
1250
|
+
metadata: z.string().optional(),
|
|
1251
|
+
entityID: z.string().optional(),
|
|
1252
|
+
binding: z.string().optional(),
|
|
1253
|
+
privateKey: z.string().optional(),
|
|
1254
|
+
privateKeyPass: z.string().optional(),
|
|
1255
|
+
isAssertionEncrypted: z.boolean().optional(),
|
|
1256
|
+
encPrivateKey: z.string().optional(),
|
|
1257
|
+
encPrivateKeyPass: z.string().optional()
|
|
1258
|
+
}).optional(),
|
|
1259
|
+
wantAssertionsSigned: z.boolean().optional(),
|
|
1260
|
+
authnRequestsSigned: z.boolean().optional(),
|
|
1261
|
+
signatureAlgorithm: z.string().optional(),
|
|
1262
|
+
digestAlgorithm: z.string().optional(),
|
|
1263
|
+
identifierFormat: z.string().optional(),
|
|
1264
|
+
privateKey: z.string().optional(),
|
|
1265
|
+
mapping: samlMappingSchema
|
|
1266
|
+
});
|
|
1267
|
+
const registerSSOProviderBodySchema = z.object({
|
|
1268
|
+
providerId: z.string().meta({ description: "The ID of the provider. This is used to identify the provider during login and callback" }),
|
|
1269
|
+
issuer: z.string().url().meta({ description: "The issuer URL of the provider" }),
|
|
1270
|
+
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')" }),
|
|
1271
|
+
oidcConfig: oidcConfigSchema.optional(),
|
|
1272
|
+
samlConfig: samlConfigSchema.optional(),
|
|
1273
|
+
organizationId: z.string().meta({ description: "If organization plugin is enabled, the organization id to link the provider to" }).optional(),
|
|
1274
|
+
overrideUserInfo: z.boolean().meta({ description: "Override user info with the provider info. Defaults to false" }).default(false).optional()
|
|
1275
|
+
});
|
|
1276
|
+
const updateSSOProviderBodySchema = z.object({
|
|
1277
|
+
issuer: z.string().url().optional(),
|
|
1278
|
+
domain: z.string().optional(),
|
|
1279
|
+
oidcConfig: oidcConfigSchema.partial().optional(),
|
|
1280
|
+
samlConfig: samlConfigSchema.partial().optional()
|
|
1281
|
+
});
|
|
1282
|
+
//#endregion
|
|
1283
|
+
//#region src/routes/providers.ts
|
|
1284
|
+
const ADMIN_ROLES = ["owner", "admin"];
|
|
1285
|
+
function hasOrgAdminRole(member) {
|
|
1286
|
+
return member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()));
|
|
1287
|
+
}
|
|
1288
|
+
function parseCertOrError(cert) {
|
|
1289
|
+
try {
|
|
1290
|
+
return parseCertificate(cert);
|
|
1291
|
+
} catch {
|
|
1292
|
+
return { error: "Failed to parse certificate" };
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
function sanitizeSigningCerts(config) {
|
|
1296
|
+
const certs = resolveSigningCerts(config);
|
|
1297
|
+
if (certs === void 0) return void 0;
|
|
1298
|
+
return certs.map(parseCertOrError);
|
|
1299
|
+
}
|
|
1300
|
+
async function isOrgAdmin(ctx, userId, organizationId) {
|
|
1301
|
+
const member = await ctx.context.adapter.findOne({
|
|
1302
|
+
model: "member",
|
|
1303
|
+
where: [{
|
|
1304
|
+
field: "userId",
|
|
1305
|
+
value: userId
|
|
1306
|
+
}, {
|
|
1307
|
+
field: "organizationId",
|
|
1308
|
+
value: organizationId
|
|
1309
|
+
}]
|
|
1310
|
+
});
|
|
1311
|
+
return member ? hasOrgAdminRole(member) : false;
|
|
1312
|
+
}
|
|
1313
|
+
async function batchCheckOrgAdmin(ctx, userId, organizationIds) {
|
|
1314
|
+
if (organizationIds.length === 0) return /* @__PURE__ */ new Set();
|
|
1315
|
+
const members = await ctx.context.adapter.findMany({
|
|
1316
|
+
model: "member",
|
|
1317
|
+
where: [{
|
|
1318
|
+
field: "userId",
|
|
1319
|
+
value: userId
|
|
1320
|
+
}, {
|
|
1321
|
+
field: "organizationId",
|
|
1322
|
+
value: organizationIds,
|
|
1323
|
+
operator: "in"
|
|
1324
|
+
}]
|
|
1289
1325
|
});
|
|
1290
|
-
|
|
1326
|
+
const adminOrgIds = /* @__PURE__ */ new Set();
|
|
1327
|
+
for (const member of members) if (hasOrgAdminRole(member)) adminOrgIds.add(member.organizationId);
|
|
1328
|
+
return adminOrgIds;
|
|
1291
1329
|
}
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
* @param name - The endpoint name (e.g token_endpoint)
|
|
1296
|
-
* @param endpoint - The endpoint URL to normalize
|
|
1297
|
-
* @param issuer - The base issuer URL
|
|
1298
|
-
* @returns The normalized endpoint URL
|
|
1299
|
-
*/
|
|
1300
|
-
function normalizeUrl(name, endpoint, issuer) {
|
|
1330
|
+
function sanitizeProvider(provider, baseURL) {
|
|
1331
|
+
let oidcConfig = null;
|
|
1332
|
+
let samlConfig = null;
|
|
1301
1333
|
try {
|
|
1302
|
-
|
|
1334
|
+
oidcConfig = safeJsonParse(provider.oidcConfig);
|
|
1303
1335
|
} catch {
|
|
1304
|
-
|
|
1305
|
-
const basePath = issuerURL.pathname.replace(/\/+$/, "");
|
|
1306
|
-
const endpointPath = endpoint.replace(/^\/+/, "");
|
|
1307
|
-
return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
|
|
1336
|
+
oidcConfig = null;
|
|
1308
1337
|
}
|
|
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
1338
|
try {
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
|
|
1339
|
+
samlConfig = safeJsonParse(provider.samlConfig);
|
|
1340
|
+
} catch {
|
|
1341
|
+
samlConfig = null;
|
|
1325
1342
|
}
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1343
|
+
const type = samlConfig ? "saml" : "oidc";
|
|
1344
|
+
return {
|
|
1345
|
+
providerId: provider.providerId,
|
|
1346
|
+
type,
|
|
1347
|
+
issuer: provider.issuer,
|
|
1348
|
+
domain: provider.domain,
|
|
1349
|
+
organizationId: provider.organizationId || null,
|
|
1350
|
+
domainVerified: provider.domainVerified ?? false,
|
|
1351
|
+
oidcConfig: oidcConfig ? {
|
|
1352
|
+
discoveryEndpoint: oidcConfig.discoveryEndpoint,
|
|
1353
|
+
clientIdLastFour: maskClientId(oidcConfig.clientId),
|
|
1354
|
+
pkce: oidcConfig.pkce,
|
|
1355
|
+
authorizationEndpoint: oidcConfig.authorizationEndpoint,
|
|
1356
|
+
tokenEndpoint: oidcConfig.tokenEndpoint,
|
|
1357
|
+
userInfoEndpoint: oidcConfig.userInfoEndpoint,
|
|
1358
|
+
jwksEndpoint: oidcConfig.jwksEndpoint,
|
|
1359
|
+
scopes: oidcConfig.scopes,
|
|
1360
|
+
tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication
|
|
1361
|
+
} : void 0,
|
|
1362
|
+
samlConfig: samlConfig ? {
|
|
1363
|
+
entryPoint: samlConfig.entryPoint,
|
|
1364
|
+
audience: samlConfig.audience,
|
|
1365
|
+
wantAssertionsSigned: samlConfig.wantAssertionsSigned,
|
|
1366
|
+
authnRequestsSigned: samlConfig.authnRequestsSigned,
|
|
1367
|
+
identifierFormat: samlConfig.identifierFormat,
|
|
1368
|
+
signatureAlgorithm: samlConfig.signatureAlgorithm,
|
|
1369
|
+
digestAlgorithm: samlConfig.digestAlgorithm,
|
|
1370
|
+
certificate: sanitizeSigningCerts(samlConfig)
|
|
1371
|
+
} : void 0,
|
|
1372
|
+
spMetadataUrl: `${baseURL}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(provider.providerId)}`
|
|
1373
|
+
};
|
|
1347
1374
|
}
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1375
|
+
const listSSOProviders = () => {
|
|
1376
|
+
return createAuthEndpoint("/sso/providers", {
|
|
1377
|
+
method: "GET",
|
|
1378
|
+
use: [sessionMiddleware],
|
|
1379
|
+
metadata: { openapi: {
|
|
1380
|
+
operationId: "listSSOProviders",
|
|
1381
|
+
summary: "List SSO providers",
|
|
1382
|
+
description: "Returns a list of SSO providers the user has access to",
|
|
1383
|
+
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`." } }
|
|
1384
|
+
} }
|
|
1385
|
+
}, async (ctx) => {
|
|
1386
|
+
const userId = ctx.context.session.user.id;
|
|
1387
|
+
const allProviders = await ctx.context.adapter.findMany({ model: "ssoProvider" });
|
|
1388
|
+
const userOwnedProviders = allProviders.filter((p) => p.userId === userId && !p.organizationId);
|
|
1389
|
+
const orgProviders = allProviders.filter((p) => p.organizationId !== null && p.organizationId !== void 0);
|
|
1390
|
+
const orgPluginEnabled = ctx.context.hasPlugin("organization");
|
|
1391
|
+
let accessibleProviders = [...userOwnedProviders];
|
|
1392
|
+
if (orgPluginEnabled && orgProviders.length > 0) {
|
|
1393
|
+
const adminOrgIds = await batchCheckOrgAdmin(ctx, userId, [...new Set(orgProviders.map((p) => p.organizationId).filter((id) => id !== null && id !== void 0))]);
|
|
1394
|
+
const orgAccessibleProviders = orgProviders.filter((provider) => provider.organizationId && adminOrgIds.has(provider.organizationId));
|
|
1395
|
+
accessibleProviders = [...accessibleProviders, ...orgAccessibleProviders];
|
|
1396
|
+
} else if (!orgPluginEnabled) {
|
|
1397
|
+
const userOwnedOrgProviders = orgProviders.filter((p) => p.userId === userId);
|
|
1398
|
+
accessibleProviders = [...accessibleProviders, ...userOwnedOrgProviders];
|
|
1399
|
+
}
|
|
1400
|
+
const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL));
|
|
1401
|
+
return ctx.json({ providers });
|
|
1402
|
+
});
|
|
1403
|
+
};
|
|
1404
|
+
const getSSOProviderQuerySchema = z.object({ providerId: z.string() });
|
|
1405
|
+
async function checkProviderAccess(ctx, providerId) {
|
|
1406
|
+
const userId = ctx.context.session.user.id;
|
|
1407
|
+
const provider = await ctx.context.adapter.findOne({
|
|
1408
|
+
model: "ssoProvider",
|
|
1409
|
+
where: [{
|
|
1410
|
+
field: "providerId",
|
|
1411
|
+
value: providerId
|
|
1412
|
+
}]
|
|
1413
|
+
});
|
|
1414
|
+
if (!provider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
|
|
1415
|
+
let hasAccess = false;
|
|
1416
|
+
if (provider.organizationId) if (ctx.context.hasPlugin("organization")) hasAccess = await isOrgAdmin(ctx, userId, provider.organizationId);
|
|
1417
|
+
else hasAccess = provider.userId === userId;
|
|
1418
|
+
else hasAccess = provider.userId === userId;
|
|
1419
|
+
if (!hasAccess) throw new APIError("FORBIDDEN", { message: "You don't have access to this provider" });
|
|
1420
|
+
return provider;
|
|
1363
1421
|
}
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1422
|
+
const getSSOProvider = () => {
|
|
1423
|
+
return createAuthEndpoint("/sso/get-provider", {
|
|
1424
|
+
method: "GET",
|
|
1425
|
+
use: [sessionMiddleware],
|
|
1426
|
+
query: getSSOProviderQuerySchema,
|
|
1427
|
+
metadata: { openapi: {
|
|
1428
|
+
operationId: "getSSOProvider",
|
|
1429
|
+
summary: "Get SSO provider details",
|
|
1430
|
+
description: "Returns sanitized details for a specific SSO provider",
|
|
1431
|
+
responses: {
|
|
1432
|
+
"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`." },
|
|
1433
|
+
"404": { description: "Provider not found" },
|
|
1434
|
+
"403": { description: "Access denied" }
|
|
1435
|
+
}
|
|
1436
|
+
} }
|
|
1437
|
+
}, async (ctx) => {
|
|
1438
|
+
const { providerId } = ctx.query;
|
|
1439
|
+
const provider = await checkProviderAccess(ctx, providerId);
|
|
1440
|
+
return ctx.json(sanitizeProvider(provider, ctx.context.baseURL));
|
|
1375
1441
|
});
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
}
|
|
1442
|
+
};
|
|
1443
|
+
function parseAndValidateConfig(configString, configType) {
|
|
1444
|
+
let config = null;
|
|
1445
|
+
try {
|
|
1446
|
+
config = safeJsonParse(configString);
|
|
1447
|
+
} catch {
|
|
1448
|
+
config = null;
|
|
1449
|
+
}
|
|
1450
|
+
if (!config) throw new APIError("BAD_REQUEST", { message: `Cannot update ${configType} config for a provider that doesn't have ${configType} configured` });
|
|
1451
|
+
return config;
|
|
1384
1452
|
}
|
|
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
|
-
|
|
1453
|
+
function mergeSAMLConfig(current, updates, issuer) {
|
|
1454
|
+
return {
|
|
1455
|
+
...current,
|
|
1456
|
+
...updates,
|
|
1457
|
+
issuer,
|
|
1458
|
+
entryPoint: updates.entryPoint ?? current.entryPoint,
|
|
1459
|
+
cert: updates.cert ?? current.cert,
|
|
1460
|
+
spMetadata: updates.spMetadata ?? current.spMetadata,
|
|
1461
|
+
idpMetadata: updates.idpMetadata ?? current.idpMetadata,
|
|
1462
|
+
mapping: updates.mapping ?? current.mapping,
|
|
1463
|
+
audience: updates.audience ?? current.audience,
|
|
1464
|
+
wantAssertionsSigned: updates.wantAssertionsSigned ?? current.wantAssertionsSigned,
|
|
1465
|
+
authnRequestsSigned: updates.authnRequestsSigned ?? current.authnRequestsSigned,
|
|
1466
|
+
identifierFormat: updates.identifierFormat ?? current.identifierFormat,
|
|
1467
|
+
signatureAlgorithm: updates.signatureAlgorithm ?? current.signatureAlgorithm,
|
|
1468
|
+
digestAlgorithm: updates.digestAlgorithm ?? current.digestAlgorithm
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
function mergeOIDCConfig(current, updates, issuer) {
|
|
1472
|
+
return {
|
|
1473
|
+
...current,
|
|
1474
|
+
...updates,
|
|
1475
|
+
issuer,
|
|
1476
|
+
pkce: updates.pkce ?? current.pkce ?? true,
|
|
1477
|
+
clientId: updates.clientId ?? current.clientId,
|
|
1478
|
+
clientSecret: updates.clientSecret ?? current.clientSecret,
|
|
1479
|
+
discoveryEndpoint: updates.discoveryEndpoint ?? current.discoveryEndpoint,
|
|
1480
|
+
mapping: updates.mapping ?? current.mapping,
|
|
1481
|
+
scopes: updates.scopes ?? current.scopes,
|
|
1482
|
+
authorizationEndpoint: updates.authorizationEndpoint ?? current.authorizationEndpoint,
|
|
1483
|
+
tokenEndpoint: updates.tokenEndpoint ?? current.tokenEndpoint,
|
|
1484
|
+
userInfoEndpoint: updates.userInfoEndpoint ?? current.userInfoEndpoint,
|
|
1485
|
+
jwksEndpoint: updates.jwksEndpoint ?? current.jwksEndpoint,
|
|
1486
|
+
tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication,
|
|
1487
|
+
privateKeyId: updates.privateKeyId ?? current.privateKeyId,
|
|
1488
|
+
privateKeyAlgorithm: updates.privateKeyAlgorithm ?? current.privateKeyAlgorithm
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
const updateSSOProvider = (options) => {
|
|
1492
|
+
return createAuthEndpoint("/sso/update-provider", {
|
|
1493
|
+
method: "POST",
|
|
1494
|
+
use: [sessionMiddleware],
|
|
1495
|
+
body: updateSSOProviderBodySchema.extend({ providerId: z.string() }),
|
|
1496
|
+
metadata: { openapi: {
|
|
1497
|
+
operationId: "updateSSOProvider",
|
|
1498
|
+
summary: "Update SSO provider",
|
|
1499
|
+
description: "Partially update an SSO provider. Only provided fields are updated. If domain changes, domainVerified is reset to false.",
|
|
1500
|
+
responses: {
|
|
1501
|
+
"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`." },
|
|
1502
|
+
"404": { description: "Provider not found" },
|
|
1503
|
+
"403": { description: "Access denied" }
|
|
1504
|
+
}
|
|
1505
|
+
} }
|
|
1506
|
+
}, async (ctx) => {
|
|
1507
|
+
const { providerId, ...body } = ctx.body;
|
|
1508
|
+
const { issuer, domain, samlConfig, oidcConfig } = body;
|
|
1509
|
+
if (!issuer && !domain && !samlConfig && !oidcConfig) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
|
|
1510
|
+
const existingProvider = await checkProviderAccess(ctx, providerId);
|
|
1511
|
+
const updateData = {};
|
|
1512
|
+
if (body.issuer !== void 0) updateData.issuer = body.issuer;
|
|
1513
|
+
if (body.domain !== void 0) {
|
|
1514
|
+
updateData.domain = body.domain;
|
|
1515
|
+
if (body.domain !== existingProvider.domain) updateData.domainVerified = false;
|
|
1516
|
+
}
|
|
1517
|
+
if (body.samlConfig) {
|
|
1518
|
+
if (body.samlConfig.idpMetadata?.metadata) {
|
|
1519
|
+
const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
|
|
1520
|
+
if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
|
|
1521
|
+
}
|
|
1522
|
+
if (body.samlConfig.signatureAlgorithm !== void 0 || body.samlConfig.digestAlgorithm !== void 0) validateConfigAlgorithms({
|
|
1523
|
+
signatureAlgorithm: body.samlConfig.signatureAlgorithm,
|
|
1524
|
+
digestAlgorithm: body.samlConfig.digestAlgorithm
|
|
1525
|
+
}, options?.saml?.algorithms);
|
|
1526
|
+
const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
|
|
1527
|
+
const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
|
|
1528
|
+
validateCertSources(updatedSamlConfig);
|
|
1529
|
+
updateData.samlConfig = JSON.stringify(updatedSamlConfig);
|
|
1530
|
+
}
|
|
1531
|
+
if (body.oidcConfig) {
|
|
1532
|
+
try {
|
|
1533
|
+
validateSkipDiscoveryEndpoints(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
|
|
1534
|
+
} catch (error) {
|
|
1535
|
+
if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
|
|
1536
|
+
throw error;
|
|
1537
|
+
}
|
|
1538
|
+
const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
|
|
1539
|
+
const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
|
|
1540
|
+
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" });
|
|
1541
|
+
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" });
|
|
1542
|
+
updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
|
|
1543
|
+
}
|
|
1544
|
+
await ctx.context.adapter.update({
|
|
1545
|
+
model: "ssoProvider",
|
|
1546
|
+
where: [{
|
|
1547
|
+
field: "providerId",
|
|
1548
|
+
value: providerId
|
|
1549
|
+
}],
|
|
1550
|
+
update: updateData
|
|
1438
1551
|
});
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1552
|
+
const fullProvider = await ctx.context.adapter.findOne({
|
|
1553
|
+
model: "ssoProvider",
|
|
1554
|
+
where: [{
|
|
1555
|
+
field: "providerId",
|
|
1556
|
+
value: providerId
|
|
1557
|
+
}]
|
|
1442
1558
|
});
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1559
|
+
if (!fullProvider) throw new APIError("NOT_FOUND", { message: "Provider not found after update" });
|
|
1560
|
+
return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL));
|
|
1561
|
+
});
|
|
1562
|
+
};
|
|
1563
|
+
const deleteSSOProvider = () => {
|
|
1564
|
+
return createAuthEndpoint("/sso/delete-provider", {
|
|
1565
|
+
method: "POST",
|
|
1566
|
+
use: [sessionMiddleware],
|
|
1567
|
+
body: z.object({ providerId: z.string() }),
|
|
1568
|
+
metadata: { openapi: {
|
|
1569
|
+
operationId: "deleteSSOProvider",
|
|
1570
|
+
summary: "Delete SSO provider",
|
|
1571
|
+
description: "Deletes an SSO provider",
|
|
1572
|
+
responses: {
|
|
1573
|
+
"200": { description: "SSO provider deleted successfully" },
|
|
1574
|
+
"404": { description: "Provider not found" },
|
|
1575
|
+
"403": { description: "Access denied" }
|
|
1576
|
+
}
|
|
1577
|
+
} }
|
|
1578
|
+
}, async (ctx) => {
|
|
1579
|
+
const { providerId } = ctx.body;
|
|
1580
|
+
await checkProviderAccess(ctx, providerId);
|
|
1581
|
+
await ctx.context.adapter.delete({
|
|
1582
|
+
model: "ssoProvider",
|
|
1583
|
+
where: [{
|
|
1584
|
+
field: "providerId",
|
|
1585
|
+
value: providerId
|
|
1586
|
+
}]
|
|
1446
1587
|
});
|
|
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
|
-
});
|
|
1588
|
+
return ctx.json({ success: true });
|
|
1589
|
+
});
|
|
1590
|
+
};
|
|
1465
1591
|
//#endregion
|
|
1466
1592
|
//#region src/saml-state.ts
|
|
1467
1593
|
async function generateRelayState(c, link, additionalData) {
|
|
@@ -1511,14 +1637,12 @@ const saml = typeof samlifyNamespace.SPMetadata === "function" && typeof samlify
|
|
|
1511
1637
|
//#endregion
|
|
1512
1638
|
//#region src/routes/helpers.ts
|
|
1513
1639
|
/**
|
|
1514
|
-
*
|
|
1515
|
-
*
|
|
1516
|
-
* blocks with leading whitespace, which is common when keys are stored in
|
|
1517
|
-
* indented config files, environment variables, or JSON.
|
|
1640
|
+
* Same as `normalizePem`, but applied across the resolved list of IdP signing
|
|
1641
|
+
* certificates so multi-cert rotation configs survive the line-trim step.
|
|
1518
1642
|
*/
|
|
1519
|
-
function
|
|
1520
|
-
if (!
|
|
1521
|
-
return
|
|
1643
|
+
function normalizePemList(certs) {
|
|
1644
|
+
if (!certs) return certs;
|
|
1645
|
+
return certs.map((pem) => normalizePem(pem) ?? pem);
|
|
1522
1646
|
}
|
|
1523
1647
|
async function findSAMLProvider(providerId, options, adapter) {
|
|
1524
1648
|
if (options?.defaultSSO?.length) {
|
|
@@ -1595,7 +1719,7 @@ function createIdP(config) {
|
|
|
1595
1719
|
Location: config.entryPoint
|
|
1596
1720
|
}],
|
|
1597
1721
|
singleLogoutService: idpData?.singleLogoutService,
|
|
1598
|
-
signingCert:
|
|
1722
|
+
signingCert: normalizePemList(resolveSigningCerts(config)),
|
|
1599
1723
|
wantAuthnRequestsSigned: config.authnRequestsSigned || false,
|
|
1600
1724
|
isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
|
|
1601
1725
|
encPrivateKey: normalizePem(idpData?.encPrivateKey),
|
|
@@ -1813,12 +1937,16 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1813
1937
|
} else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
|
|
1814
1938
|
const attributes = extract.attributes || {};
|
|
1815
1939
|
const mapping = parsedSamlConfig.mapping ?? {};
|
|
1940
|
+
const attr = (key) => {
|
|
1941
|
+
const value = attributes[key];
|
|
1942
|
+
return Array.isArray(value) ? value[0] : value;
|
|
1943
|
+
};
|
|
1816
1944
|
const userInfo = {
|
|
1817
1945
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
|
|
1818
|
-
id:
|
|
1819
|
-
email: (
|
|
1820
|
-
name: [
|
|
1821
|
-
emailVerified: options?.trustEmailVerified && mapping.emailVerified ?
|
|
1946
|
+
id: attr(mapping.id || "nameID") || extract.nameID,
|
|
1947
|
+
email: (attr(mapping.email || "email") || extract.nameID || "").toLowerCase(),
|
|
1948
|
+
name: [attr(mapping.firstName || "givenName"), attr(mapping.lastName || "surname")].filter(Boolean).join(" ") || attr(mapping.name || "displayName") || extract.nameID,
|
|
1949
|
+
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attr(mapping.emailVerified) || false : false
|
|
1822
1950
|
};
|
|
1823
1951
|
if (!userInfo.id || !userInfo.email) {
|
|
1824
1952
|
ctx.context.logger.error("Missing essential user info from SAML response", {
|
|
@@ -1831,23 +1959,35 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1831
1959
|
}
|
|
1832
1960
|
const isTrustedProvider = ctx.context.trustedProviders.includes(providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
1833
1961
|
const postAuthRedirect = relayState?.callbackURL || ctx.context.baseURL;
|
|
1834
|
-
const
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1962
|
+
const errorUrl = relayState?.errorURL || samlRedirectUrl;
|
|
1963
|
+
let result;
|
|
1964
|
+
try {
|
|
1965
|
+
result = await handleOAuthUserInfo(ctx, {
|
|
1966
|
+
userInfo: {
|
|
1967
|
+
email: userInfo.email,
|
|
1968
|
+
name: userInfo.name || userInfo.email,
|
|
1969
|
+
id: userInfo.id,
|
|
1970
|
+
emailVerified: Boolean(userInfo.emailVerified)
|
|
1971
|
+
},
|
|
1972
|
+
account: {
|
|
1973
|
+
providerId,
|
|
1974
|
+
accountId: userInfo.id,
|
|
1975
|
+
accessToken: "",
|
|
1976
|
+
refreshToken: ""
|
|
1977
|
+
},
|
|
1978
|
+
callbackURL: postAuthRedirect,
|
|
1979
|
+
disableSignUp: options?.disableImplicitSignUp,
|
|
1980
|
+
isTrustedProvider
|
|
1981
|
+
});
|
|
1982
|
+
} catch (e) {
|
|
1983
|
+
if (isAPIError(e) && e.body?.code) {
|
|
1984
|
+
const params = new URLSearchParams({ error: e.body.code });
|
|
1985
|
+
if (e.body.message) params.set("error_description", e.body.message);
|
|
1986
|
+
const sep = errorUrl.includes("?") ? "&" : "?";
|
|
1987
|
+
throw ctx.redirect(`${errorUrl}${sep}${params.toString()}`);
|
|
1988
|
+
}
|
|
1989
|
+
throw e;
|
|
1990
|
+
}
|
|
1851
1991
|
if (result.error) throw ctx.redirect(`${samlRedirectUrl}?error=${result.error.split(" ").join("_")}`);
|
|
1852
1992
|
const { session, user } = result.data;
|
|
1853
1993
|
if (options?.provisionUser && (result.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
|
|
@@ -1876,6 +2016,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1876
2016
|
const samlSessionKey = `${SAML_SESSION_KEY_PREFIX}${providerId}:${extract.nameID}`;
|
|
1877
2017
|
const samlSessionData = {
|
|
1878
2018
|
sessionId: session.id,
|
|
2019
|
+
sessionToken: session.token,
|
|
1879
2020
|
providerId,
|
|
1880
2021
|
nameID: extract.nameID,
|
|
1881
2022
|
sessionIndex: extract.sessionIndex?.sessionIndex
|
|
@@ -1920,15 +2061,9 @@ const spMetadata = (options) => {
|
|
|
1920
2061
|
responses: { "200": { description: "SAML metadata in XML format" } }
|
|
1921
2062
|
} }
|
|
1922
2063
|
}, async (ctx) => {
|
|
1923
|
-
const provider = await ctx.context.adapter
|
|
1924
|
-
model: "ssoProvider",
|
|
1925
|
-
where: [{
|
|
1926
|
-
field: "providerId",
|
|
1927
|
-
value: ctx.query.providerId
|
|
1928
|
-
}]
|
|
1929
|
-
});
|
|
2064
|
+
const provider = await findSAMLProvider(ctx.query.providerId, options, ctx.context.adapter);
|
|
1930
2065
|
if (!provider) throw new APIError("NOT_FOUND", { message: "No provider found for the given providerId" });
|
|
1931
|
-
const parsedSamlConfig =
|
|
2066
|
+
const parsedSamlConfig = provider.samlConfig;
|
|
1932
2067
|
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
1933
2068
|
const sp = createSP(parsedSamlConfig, ctx.context.baseURL, ctx.query.providerId, options?.saml?.enableSingleLogout ? { sloOptions: {
|
|
1934
2069
|
wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned,
|
|
@@ -1937,88 +2072,10 @@ const spMetadata = (options) => {
|
|
|
1937
2072
|
return new Response(sp.getMetadata(), { headers: { "Content-Type": "application/xml" } });
|
|
1938
2073
|
});
|
|
1939
2074
|
};
|
|
1940
|
-
const ssoProviderBodySchema = z.object({
|
|
1941
|
-
providerId: z.string({}).meta({ description: "The ID of the provider. This is used to identify the provider during login and callback" }),
|
|
1942
|
-
issuer: z.string({}).meta({ description: "The issuer of the provider" }),
|
|
1943
|
-
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')" }),
|
|
1944
|
-
oidcConfig: z.object({
|
|
1945
|
-
clientId: z.string({}).meta({ description: "The client ID" }),
|
|
1946
|
-
clientSecret: z.string({}).optional().meta({ description: "The client secret. Required for client_secret_basic/client_secret_post. Optional for private_key_jwt." }),
|
|
1947
|
-
authorizationEndpoint: z.string({}).meta({ description: "The authorization endpoint" }).optional(),
|
|
1948
|
-
tokenEndpoint: z.string({}).meta({ description: "The token endpoint" }).optional(),
|
|
1949
|
-
userInfoEndpoint: z.string({}).meta({ description: "The user info endpoint" }).optional(),
|
|
1950
|
-
tokenEndpointAuthentication: z.enum([
|
|
1951
|
-
"client_secret_post",
|
|
1952
|
-
"client_secret_basic",
|
|
1953
|
-
"private_key_jwt"
|
|
1954
|
-
]).optional(),
|
|
1955
|
-
privateKeyId: z.string().optional(),
|
|
1956
|
-
privateKeyAlgorithm: z.string().optional(),
|
|
1957
|
-
jwksEndpoint: z.string({}).meta({ description: "The JWKS endpoint" }).optional(),
|
|
1958
|
-
discoveryEndpoint: z.string().optional(),
|
|
1959
|
-
skipDiscovery: z.boolean().meta({ description: "Skip OIDC discovery during registration. When true, you must provide authorizationEndpoint, tokenEndpoint, and jwksEndpoint manually." }).optional(),
|
|
1960
|
-
scopes: z.array(z.string(), {}).meta({ description: "The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']" }).optional(),
|
|
1961
|
-
pkce: z.boolean({}).meta({ description: "Whether to use PKCE for the authorization flow" }).default(true).optional(),
|
|
1962
|
-
mapping: z.object({
|
|
1963
|
-
id: z.string({}).meta({ description: "Field mapping for user ID (defaults to 'sub')" }),
|
|
1964
|
-
email: z.string({}).meta({ description: "Field mapping for email (defaults to 'email')" }),
|
|
1965
|
-
emailVerified: z.string({}).meta({ description: "Field mapping for email verification (defaults to 'email_verified')" }).optional(),
|
|
1966
|
-
name: z.string({}).meta({ description: "Field mapping for name (defaults to 'name')" }),
|
|
1967
|
-
image: z.string({}).meta({ description: "Field mapping for image (defaults to 'picture')" }).optional(),
|
|
1968
|
-
extraFields: z.record(z.string(), z.any()).optional()
|
|
1969
|
-
}).optional()
|
|
1970
|
-
}).optional(),
|
|
1971
|
-
samlConfig: z.object({
|
|
1972
|
-
entryPoint: z.string({}).meta({ description: "The entry point of the provider" }),
|
|
1973
|
-
cert: z.string({}).meta({ description: "The certificate of the provider" }),
|
|
1974
|
-
audience: z.string().optional(),
|
|
1975
|
-
idpMetadata: z.object({
|
|
1976
|
-
metadata: z.string().optional(),
|
|
1977
|
-
entityID: z.string().optional(),
|
|
1978
|
-
cert: z.string().optional(),
|
|
1979
|
-
privateKey: z.string().optional(),
|
|
1980
|
-
privateKeyPass: z.string().optional(),
|
|
1981
|
-
isAssertionEncrypted: z.boolean().optional(),
|
|
1982
|
-
encPrivateKey: z.string().optional(),
|
|
1983
|
-
encPrivateKeyPass: z.string().optional(),
|
|
1984
|
-
singleSignOnService: z.array(z.object({
|
|
1985
|
-
Binding: z.string().meta({ description: "The binding type for the SSO service" }),
|
|
1986
|
-
Location: z.string().meta({ description: "The URL for the SSO service" })
|
|
1987
|
-
})).optional().meta({ description: "Single Sign-On service configuration" })
|
|
1988
|
-
}).optional(),
|
|
1989
|
-
spMetadata: z.object({
|
|
1990
|
-
metadata: z.string().optional(),
|
|
1991
|
-
entityID: z.string().optional(),
|
|
1992
|
-
binding: z.string().optional(),
|
|
1993
|
-
privateKey: z.string().optional(),
|
|
1994
|
-
privateKeyPass: z.string().optional(),
|
|
1995
|
-
isAssertionEncrypted: z.boolean().optional(),
|
|
1996
|
-
encPrivateKey: z.string().optional(),
|
|
1997
|
-
encPrivateKeyPass: z.string().optional()
|
|
1998
|
-
}).optional(),
|
|
1999
|
-
wantAssertionsSigned: z.boolean().optional(),
|
|
2000
|
-
authnRequestsSigned: z.boolean().optional(),
|
|
2001
|
-
signatureAlgorithm: z.string().optional(),
|
|
2002
|
-
digestAlgorithm: z.string().optional(),
|
|
2003
|
-
identifierFormat: z.string().optional(),
|
|
2004
|
-
privateKey: z.string().optional(),
|
|
2005
|
-
mapping: z.object({
|
|
2006
|
-
id: z.string({}).meta({ description: "Field mapping for user ID (defaults to 'nameID')" }),
|
|
2007
|
-
email: z.string({}).meta({ description: "Field mapping for email (defaults to 'email')" }),
|
|
2008
|
-
emailVerified: z.string({}).meta({ description: "Field mapping for email verification" }).optional(),
|
|
2009
|
-
name: z.string({}).meta({ description: "Field mapping for name (defaults to 'displayName')" }),
|
|
2010
|
-
firstName: z.string({}).meta({ description: "Field mapping for first name (defaults to 'givenName')" }).optional(),
|
|
2011
|
-
lastName: z.string({}).meta({ description: "Field mapping for last name (defaults to 'surname')" }).optional(),
|
|
2012
|
-
extraFields: z.record(z.string(), z.any()).optional()
|
|
2013
|
-
}).optional()
|
|
2014
|
-
}).optional(),
|
|
2015
|
-
organizationId: z.string({}).meta({ description: "If organization plugin is enabled, the organization id to link the provider to" }).optional(),
|
|
2016
|
-
overrideUserInfo: z.boolean({}).meta({ description: "Override user info with the provider info. Defaults to false" }).default(false).optional()
|
|
2017
|
-
});
|
|
2018
2075
|
const registerSSOProvider = (options) => {
|
|
2019
2076
|
return createAuthEndpoint("/sso/register", {
|
|
2020
2077
|
method: "POST",
|
|
2021
|
-
body:
|
|
2078
|
+
body: registerSSOProviderBodySchema,
|
|
2022
2079
|
use: [sessionMiddleware],
|
|
2023
2080
|
metadata: { openapi: {
|
|
2024
2081
|
operationId: "registerSSOProvider",
|
|
@@ -2199,13 +2256,12 @@ const registerSSOProvider = (options) => {
|
|
|
2199
2256
|
}]
|
|
2200
2257
|
})).length >= limit) throw new APIError("FORBIDDEN", { message: "You have reached the maximum number of SSO providers" });
|
|
2201
2258
|
const body = ctx.body;
|
|
2202
|
-
if (z.string().url().safeParse(body.issuer).error) throw new APIError("BAD_REQUEST", { message: "Invalid issuer. Must be a valid URL" });
|
|
2203
2259
|
if (body.samlConfig?.idpMetadata?.metadata) {
|
|
2204
2260
|
const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
|
|
2205
2261
|
if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
|
|
2206
2262
|
}
|
|
2207
2263
|
if (ctx.body.organizationId) {
|
|
2208
|
-
|
|
2264
|
+
const member = await ctx.context.adapter.findOne({
|
|
2209
2265
|
model: "member",
|
|
2210
2266
|
where: [{
|
|
2211
2267
|
field: "userId",
|
|
@@ -2214,7 +2270,9 @@ const registerSSOProvider = (options) => {
|
|
|
2214
2270
|
field: "organizationId",
|
|
2215
2271
|
value: ctx.body.organizationId
|
|
2216
2272
|
}]
|
|
2217
|
-
})
|
|
2273
|
+
});
|
|
2274
|
+
if (!member) throw new APIError("BAD_REQUEST", { message: "You are not a member of the organization" });
|
|
2275
|
+
if (ctx.context.hasPlugin("organization") && !hasOrgAdminRole(member)) throw new APIError("FORBIDDEN", { message: "You must be an organization owner or admin to register SSO providers" });
|
|
2218
2276
|
}
|
|
2219
2277
|
if (await ctx.context.adapter.findOne({
|
|
2220
2278
|
model: "ssoProvider",
|
|
@@ -2226,6 +2284,12 @@ const registerSSOProvider = (options) => {
|
|
|
2226
2284
|
ctx.context.logger.info(`SSO provider creation attempt with existing providerId: ${body.providerId}`);
|
|
2227
2285
|
throw new APIError("UNPROCESSABLE_ENTITY", { message: "SSO provider with this providerId already exists" });
|
|
2228
2286
|
}
|
|
2287
|
+
if (body.oidcConfig) try {
|
|
2288
|
+
validateSkipDiscoveryEndpoints(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
|
|
2289
|
+
} catch (error) {
|
|
2290
|
+
if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
|
|
2291
|
+
throw error;
|
|
2292
|
+
}
|
|
2229
2293
|
let hydratedOIDCConfig = null;
|
|
2230
2294
|
if (body.oidcConfig && !body.oidcConfig.skipDiscovery) try {
|
|
2231
2295
|
hydratedOIDCConfig = await discoverOIDCConfig({
|
|
@@ -2287,6 +2351,7 @@ const registerSSOProvider = (options) => {
|
|
|
2287
2351
|
signatureAlgorithm: body.samlConfig.signatureAlgorithm,
|
|
2288
2352
|
digestAlgorithm: body.samlConfig.digestAlgorithm
|
|
2289
2353
|
}, options?.saml?.algorithms);
|
|
2354
|
+
validateCertSources(body.samlConfig);
|
|
2290
2355
|
const hasIdpMetadata = body.samlConfig.idpMetadata?.metadata;
|
|
2291
2356
|
let hasEntryPoint = false;
|
|
2292
2357
|
if (body.samlConfig.entryPoint) try {
|
|
@@ -2354,17 +2419,18 @@ const registerSSOProvider = (options) => {
|
|
|
2354
2419
|
});
|
|
2355
2420
|
};
|
|
2356
2421
|
const signInSSOBodySchema = z.object({
|
|
2357
|
-
email: z.string({}).meta({ description: "The email address to sign in with.
|
|
2358
|
-
organizationSlug: z.string({}).meta({ description: "The slug of the organization to sign in with" }).optional(),
|
|
2359
|
-
providerId: z.string({}).meta({ description: "The ID of the provider to sign in with.
|
|
2360
|
-
domain: z.string({}).meta({ description: "The domain of the provider." }).optional(),
|
|
2361
|
-
callbackURL: z.string({}).meta({ description: "The URL to redirect to after
|
|
2362
|
-
errorCallbackURL: z.string({}).meta({ description: "The URL to redirect to
|
|
2363
|
-
newUserCallbackURL: z.string({}).meta({ description: "The URL to redirect to after
|
|
2422
|
+
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(),
|
|
2423
|
+
organizationSlug: z.string({}).meta({ description: "The slug of the organization to sign in with." }).optional(),
|
|
2424
|
+
providerId: z.string({}).meta({ description: "The ID of the provider to sign in with. Can be provided instead of email." }).optional(),
|
|
2425
|
+
domain: z.string({}).meta({ description: "The email domain of the provider. Can be provided instead of email." }).optional(),
|
|
2426
|
+
callbackURL: z.string({}).meta({ description: "The URL to redirect to after successful sign-in." }),
|
|
2427
|
+
errorCallbackURL: z.string({}).meta({ description: "The URL to redirect to if the sign-in flow fails." }).optional(),
|
|
2428
|
+
newUserCallbackURL: z.string({}).meta({ description: "The URL to redirect to after sign-in if the user is newly registered." }).optional(),
|
|
2364
2429
|
scopes: z.array(z.string(), {}).meta({ description: "Scopes to request from the provider." }).optional(),
|
|
2365
|
-
loginHint: z.string({}).meta({ description: "Login hint to send to the identity provider (e.g., email or identifier). If supported,
|
|
2366
|
-
|
|
2367
|
-
|
|
2430
|
+
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(),
|
|
2431
|
+
additionalParams: additionalAuthorizationParamsSchema,
|
|
2432
|
+
requestSignUp: z.boolean({}).meta({ description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider." }).optional(),
|
|
2433
|
+
providerType: z.enum(["oidc", "saml"]).meta({ description: "The provider protocol to sign in with." }).optional()
|
|
2368
2434
|
});
|
|
2369
2435
|
const signInSSO = (options) => {
|
|
2370
2436
|
return createAuthEndpoint("/sign-in/sso", {
|
|
@@ -2379,31 +2445,54 @@ const signInSSO = (options) => {
|
|
|
2379
2445
|
properties: {
|
|
2380
2446
|
email: {
|
|
2381
2447
|
type: "string",
|
|
2382
|
-
description: "The email address to sign in with.
|
|
2448
|
+
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."
|
|
2383
2449
|
},
|
|
2384
|
-
|
|
2450
|
+
organizationSlug: {
|
|
2385
2451
|
type: "string",
|
|
2386
|
-
description: "The
|
|
2452
|
+
description: "The slug of the organization to sign in with."
|
|
2387
2453
|
},
|
|
2388
2454
|
providerId: {
|
|
2389
2455
|
type: "string",
|
|
2390
|
-
description: "The ID of the provider to sign in with.
|
|
2456
|
+
description: "The ID of the provider to sign in with. Can be provided instead of email."
|
|
2457
|
+
},
|
|
2458
|
+
domain: {
|
|
2459
|
+
type: "string",
|
|
2460
|
+
description: "The email domain of the provider. Can be provided instead of email."
|
|
2391
2461
|
},
|
|
2392
2462
|
callbackURL: {
|
|
2393
2463
|
type: "string",
|
|
2394
|
-
description: "The URL to redirect to after
|
|
2464
|
+
description: "The URL to redirect to after successful sign-in."
|
|
2395
2465
|
},
|
|
2396
2466
|
errorCallbackURL: {
|
|
2397
2467
|
type: "string",
|
|
2398
|
-
description: "The URL to redirect to
|
|
2468
|
+
description: "The URL to redirect to if the sign-in flow fails."
|
|
2399
2469
|
},
|
|
2400
2470
|
newUserCallbackURL: {
|
|
2401
2471
|
type: "string",
|
|
2402
|
-
description: "The URL to redirect to after
|
|
2472
|
+
description: "The URL to redirect to after sign-in if the user is newly registered."
|
|
2473
|
+
},
|
|
2474
|
+
scopes: {
|
|
2475
|
+
type: "array",
|
|
2476
|
+
items: { type: "string" },
|
|
2477
|
+
description: "Scopes to request from the provider."
|
|
2403
2478
|
},
|
|
2404
2479
|
loginHint: {
|
|
2405
2480
|
type: "string",
|
|
2406
2481
|
description: "Login hint to send to the identity provider (e.g., email or identifier). If supported, sent as 'login_hint'."
|
|
2482
|
+
},
|
|
2483
|
+
additionalParams: {
|
|
2484
|
+
type: "object",
|
|
2485
|
+
additionalProperties: { type: "string" },
|
|
2486
|
+
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."
|
|
2487
|
+
},
|
|
2488
|
+
requestSignUp: {
|
|
2489
|
+
type: "boolean",
|
|
2490
|
+
description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider."
|
|
2491
|
+
},
|
|
2492
|
+
providerType: {
|
|
2493
|
+
type: "string",
|
|
2494
|
+
enum: ["oidc", "saml"],
|
|
2495
|
+
description: "The provider protocol to sign in with."
|
|
2407
2496
|
}
|
|
2408
2497
|
},
|
|
2409
2498
|
required: ["callbackURL"]
|
|
@@ -2518,7 +2607,8 @@ const signInSSO = (options) => {
|
|
|
2518
2607
|
"offline_access"
|
|
2519
2608
|
],
|
|
2520
2609
|
loginHint: ctx.body.loginHint || email,
|
|
2521
|
-
authorizationEndpoint: config.authorizationEndpoint
|
|
2610
|
+
authorizationEndpoint: config.authorizationEndpoint,
|
|
2611
|
+
additionalParams: ctx.body.additionalParams
|
|
2522
2612
|
});
|
|
2523
2613
|
return ctx.json({
|
|
2524
2614
|
url: authorizationURL.toString(),
|
|
@@ -2526,6 +2616,7 @@ const signInSSO = (options) => {
|
|
|
2526
2616
|
});
|
|
2527
2617
|
}
|
|
2528
2618
|
if (provider.samlConfig) {
|
|
2619
|
+
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." });
|
|
2529
2620
|
const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
|
|
2530
2621
|
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
2531
2622
|
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" });
|
|
@@ -2558,7 +2649,7 @@ const signInSSO = (options) => {
|
|
|
2558
2649
|
};
|
|
2559
2650
|
const callbackSSOQuerySchema = z.object({
|
|
2560
2651
|
code: z.string().optional(),
|
|
2561
|
-
state: z.string(),
|
|
2652
|
+
state: z.string().optional(),
|
|
2562
2653
|
error: z.string().optional(),
|
|
2563
2654
|
error_description: z.string().optional()
|
|
2564
2655
|
});
|
|
@@ -2579,29 +2670,7 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2579
2670
|
}
|
|
2580
2671
|
const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
|
|
2581
2672
|
if (!code || error) throw ctx.redirect(`${errorURL || callbackURL}?error=${error}&error_description=${error_description}`);
|
|
2582
|
-
|
|
2583
|
-
if (options?.defaultSSO?.length) {
|
|
2584
|
-
const matchingDefault = options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId);
|
|
2585
|
-
if (matchingDefault) provider = {
|
|
2586
|
-
...matchingDefault,
|
|
2587
|
-
issuer: matchingDefault.oidcConfig?.issuer || "",
|
|
2588
|
-
userId: "default",
|
|
2589
|
-
...options.domainVerification?.enabled ? { domainVerified: true } : {}
|
|
2590
|
-
};
|
|
2591
|
-
}
|
|
2592
|
-
if (!provider) provider = await ctx.context.adapter.findOne({
|
|
2593
|
-
model: "ssoProvider",
|
|
2594
|
-
where: [{
|
|
2595
|
-
field: "providerId",
|
|
2596
|
-
value: providerId
|
|
2597
|
-
}]
|
|
2598
|
-
}).then((res) => {
|
|
2599
|
-
if (!res) return null;
|
|
2600
|
-
return {
|
|
2601
|
-
...res,
|
|
2602
|
-
oidcConfig: safeJsonParse(res.oidcConfig) || void 0
|
|
2603
|
-
};
|
|
2604
|
-
});
|
|
2673
|
+
const provider = await resolveOIDCProvider(ctx, options, providerId);
|
|
2605
2674
|
if (!provider) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
|
|
2606
2675
|
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
2607
2676
|
let config = provider.oidcConfig;
|
|
@@ -2622,11 +2691,8 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2622
2691
|
]
|
|
2623
2692
|
};
|
|
2624
2693
|
if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_endpoint_not_found`);
|
|
2625
|
-
let
|
|
2626
|
-
if (config.tokenEndpointAuthentication === "
|
|
2627
|
-
else if (config.tokenEndpointAuthentication === "private_key_jwt") authMethod = "private_key_jwt";
|
|
2628
|
-
let clientAssertionConfig;
|
|
2629
|
-
if (authMethod === "private_key_jwt") {
|
|
2694
|
+
let tokenEndpointAuth = config.tokenEndpointAuthentication === "client_secret_post" ? { method: "client_secret_post" } : { method: "client_secret_basic" };
|
|
2695
|
+
if (config.tokenEndpointAuthentication === "private_key_jwt") {
|
|
2630
2696
|
let resolved;
|
|
2631
2697
|
const matchingDefault = options?.defaultSSO?.find((p) => p.providerId === provider.providerId && "privateKey" in p && p.privateKey);
|
|
2632
2698
|
if (matchingDefault && "privateKey" in matchingDefault) resolved = matchingDefault.privateKey;
|
|
@@ -2635,28 +2701,28 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2635
2701
|
keyId: config.privateKeyId,
|
|
2636
2702
|
issuer: config.issuer
|
|
2637
2703
|
});
|
|
2638
|
-
if (!resolved) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=no_private_key_available`);
|
|
2704
|
+
if (!resolved || !resolved.privateKeyJwk && !resolved.privateKeyPem) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=no_private_key_available`);
|
|
2639
2705
|
const rawAlg = config.privateKeyAlgorithm ?? resolved.algorithm;
|
|
2640
|
-
const algorithm = rawAlg &&
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2706
|
+
const algorithm = rawAlg && PRIVATE_KEY_JWT_SIGNING_ALGORITHMS.includes(rawAlg) ? rawAlg : void 0;
|
|
2707
|
+
tokenEndpointAuth = {
|
|
2708
|
+
method: "private_key_jwt",
|
|
2709
|
+
getClientAssertion: createPrivateKeyJwtClientAssertionGetter({
|
|
2710
|
+
privateKeyJwk: resolved.privateKeyJwk,
|
|
2711
|
+
privateKeyPem: resolved.privateKeyPem,
|
|
2712
|
+
kid: config.privateKeyId ?? resolved.kid,
|
|
2713
|
+
algorithm
|
|
2714
|
+
})
|
|
2647
2715
|
};
|
|
2648
2716
|
}
|
|
2717
|
+
const tokenRequestOptions = { clientId: config.clientId };
|
|
2718
|
+
if (tokenEndpointAuth.method !== "private_key_jwt") tokenRequestOptions.clientSecret = config.clientSecret;
|
|
2649
2719
|
const tokenResponse = await validateAuthorizationCode({
|
|
2650
2720
|
code,
|
|
2651
2721
|
codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
|
|
2652
2722
|
redirectURI: getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options),
|
|
2653
|
-
options:
|
|
2654
|
-
clientId: config.clientId,
|
|
2655
|
-
clientSecret: config.clientSecret
|
|
2656
|
-
},
|
|
2723
|
+
options: tokenRequestOptions,
|
|
2657
2724
|
tokenEndpoint: config.tokenEndpoint,
|
|
2658
|
-
|
|
2659
|
-
clientAssertion: clientAssertionConfig
|
|
2725
|
+
tokenEndpointAuth
|
|
2660
2726
|
}).catch((e) => {
|
|
2661
2727
|
ctx.context.logger.error("Error validating authorization code", e);
|
|
2662
2728
|
if (e instanceof BetterFetchError) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${e.message}`);
|
|
@@ -2699,30 +2765,47 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2699
2765
|
} else throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=user_info_endpoint_not_found`);
|
|
2700
2766
|
if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=missing_user_info`);
|
|
2701
2767
|
const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfo.email, provider.domain);
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2768
|
+
let linked;
|
|
2769
|
+
try {
|
|
2770
|
+
linked = await handleOAuthUserInfo(ctx, {
|
|
2771
|
+
userInfo: {
|
|
2772
|
+
email: userInfo.email,
|
|
2773
|
+
name: userInfo.name || "",
|
|
2774
|
+
id: userInfo.id,
|
|
2775
|
+
image: userInfo.image,
|
|
2776
|
+
emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
|
|
2777
|
+
},
|
|
2778
|
+
account: {
|
|
2779
|
+
idToken: tokenResponse.idToken,
|
|
2780
|
+
accessToken: tokenResponse.accessToken,
|
|
2781
|
+
refreshToken: tokenResponse.refreshToken,
|
|
2782
|
+
accountId: userInfo.id,
|
|
2783
|
+
providerId: provider.providerId,
|
|
2784
|
+
accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
|
|
2785
|
+
refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
|
|
2786
|
+
scope: tokenResponse.scopes?.join(",")
|
|
2787
|
+
},
|
|
2788
|
+
callbackURL,
|
|
2789
|
+
disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
|
|
2790
|
+
overrideUserInfo: config.overrideUserInfo,
|
|
2791
|
+
isTrustedProvider
|
|
2792
|
+
});
|
|
2793
|
+
} catch (e) {
|
|
2794
|
+
if (isAPIError(e) && e.body?.code) {
|
|
2795
|
+
const baseURL = errorURL || callbackURL;
|
|
2796
|
+
const params = new URLSearchParams({ error: e.body.code });
|
|
2797
|
+
if (e.body.message) params.set("error_description", e.body.message);
|
|
2798
|
+
const sep = baseURL.includes("?") ? "&" : "?";
|
|
2799
|
+
throw ctx.redirect(`${baseURL}${sep}${params.toString()}`);
|
|
2800
|
+
}
|
|
2801
|
+
throw e;
|
|
2802
|
+
}
|
|
2803
|
+
if (linked.error) {
|
|
2804
|
+
const baseURL = errorURL || callbackURL;
|
|
2805
|
+
const params = new URLSearchParams({ error: linked.error });
|
|
2806
|
+
const sep = baseURL.includes("?") ? "&" : "?";
|
|
2807
|
+
throw ctx.redirect(`${baseURL}${sep}${params.toString()}`);
|
|
2808
|
+
}
|
|
2726
2809
|
const { session, user } = linked.data;
|
|
2727
2810
|
if (options?.provisionUser && (linked.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
|
|
2728
2811
|
user,
|
|
@@ -2770,9 +2853,85 @@ const callbackSSOEndpointConfig = {
|
|
|
2770
2853
|
}
|
|
2771
2854
|
}
|
|
2772
2855
|
};
|
|
2856
|
+
/**
|
|
2857
|
+
* Resolves an SSO provider by `providerId`, first checking `options.defaultSSO`
|
|
2858
|
+
* and falling back to the `ssoProvider` table. Returns `null` when no match is
|
|
2859
|
+
* found so the caller can decide how to react (redirect, silently skip, etc.).
|
|
2860
|
+
*/
|
|
2861
|
+
async function resolveOIDCProvider(ctx, options, providerId) {
|
|
2862
|
+
const matchingDefault = options?.defaultSSO?.find((defaultProvider) => defaultProvider.providerId === providerId);
|
|
2863
|
+
if (matchingDefault) return {
|
|
2864
|
+
...matchingDefault,
|
|
2865
|
+
issuer: matchingDefault.oidcConfig?.issuer || "",
|
|
2866
|
+
userId: "default",
|
|
2867
|
+
...options?.domainVerification?.enabled ? { domainVerified: true } : {}
|
|
2868
|
+
};
|
|
2869
|
+
return ctx.context.adapter.findOne({
|
|
2870
|
+
model: "ssoProvider",
|
|
2871
|
+
where: [{
|
|
2872
|
+
field: "providerId",
|
|
2873
|
+
value: providerId
|
|
2874
|
+
}]
|
|
2875
|
+
}).then((res) => {
|
|
2876
|
+
if (!res) return null;
|
|
2877
|
+
return {
|
|
2878
|
+
...res,
|
|
2879
|
+
oidcConfig: safeJsonParse(res.oidcConfig) || void 0
|
|
2880
|
+
};
|
|
2881
|
+
});
|
|
2882
|
+
}
|
|
2883
|
+
/**
|
|
2884
|
+
* Restarts the OAuth flow server-side when a stateless callback arrives for
|
|
2885
|
+
* an OIDC provider that opted into IDP-initiated flows. Silently returns
|
|
2886
|
+
* otherwise, letting the normal handler produce its error redirect.
|
|
2887
|
+
*/
|
|
2888
|
+
async function bounceIfIdpInitiated(ctx, options, providerId) {
|
|
2889
|
+
const provider = await resolveOIDCProvider(ctx, options, providerId);
|
|
2890
|
+
if (!provider?.oidcConfig?.allowIdpInitiated) return;
|
|
2891
|
+
let config = provider.oidcConfig;
|
|
2892
|
+
try {
|
|
2893
|
+
config = await ensureRuntimeDiscovery(config, provider.issuer, (url) => ctx.context.isTrustedOrigin(url));
|
|
2894
|
+
} catch (error) {
|
|
2895
|
+
ctx.context.logger.error("IDP-initiated bounce skipped: OIDC discovery failed", {
|
|
2896
|
+
providerId: provider.providerId,
|
|
2897
|
+
issuer: provider.issuer,
|
|
2898
|
+
error
|
|
2899
|
+
});
|
|
2900
|
+
return;
|
|
2901
|
+
}
|
|
2902
|
+
if (!config.authorizationEndpoint) {
|
|
2903
|
+
ctx.context.logger.error("IDP-initiated bounce skipped: authorizationEndpoint missing after discovery", {
|
|
2904
|
+
providerId: provider.providerId,
|
|
2905
|
+
issuer: provider.issuer
|
|
2906
|
+
});
|
|
2907
|
+
return;
|
|
2908
|
+
}
|
|
2909
|
+
const state = await generateState(ctx, void 0, options?.redirectURI?.trim() ? { ssoProviderId: provider.providerId } : false);
|
|
2910
|
+
const redirectURI = getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options);
|
|
2911
|
+
const authorizationURL = await createAuthorizationURL({
|
|
2912
|
+
id: provider.issuer,
|
|
2913
|
+
options: {
|
|
2914
|
+
clientId: config.clientId,
|
|
2915
|
+
clientSecret: config.clientSecret
|
|
2916
|
+
},
|
|
2917
|
+
redirectURI,
|
|
2918
|
+
state: state.state,
|
|
2919
|
+
codeVerifier: config.pkce ? state.codeVerifier : void 0,
|
|
2920
|
+
scopes: config.scopes || [
|
|
2921
|
+
"openid",
|
|
2922
|
+
"email",
|
|
2923
|
+
"profile",
|
|
2924
|
+
"offline_access"
|
|
2925
|
+
],
|
|
2926
|
+
authorizationEndpoint: config.authorizationEndpoint
|
|
2927
|
+
});
|
|
2928
|
+
throw ctx.redirect(authorizationURL.toString());
|
|
2929
|
+
}
|
|
2773
2930
|
const callbackSSO = (options) => {
|
|
2774
2931
|
return createAuthEndpoint("/sso/callback/:providerId", callbackSSOEndpointConfig, async (ctx) => {
|
|
2775
|
-
|
|
2932
|
+
const providerId = ctx.params.providerId;
|
|
2933
|
+
if (ctx.query.state === void 0 && ctx.query.code) await bounceIfIdpInitiated(ctx, options, providerId);
|
|
2934
|
+
return handleOIDCCallback(ctx, options, providerId);
|
|
2776
2935
|
});
|
|
2777
2936
|
};
|
|
2778
2937
|
/**
|
|
@@ -2947,7 +3106,7 @@ async function handleLogoutRequest(ctx, sp, idp, relayState, providerId) {
|
|
|
2947
3106
|
if (stored) {
|
|
2948
3107
|
const data = safeJsonParse(stored.value);
|
|
2949
3108
|
if (data) if (!sessionIndex || !data.sessionIndex || sessionIndex === data.sessionIndex) {
|
|
2950
|
-
await ctx.context.internalAdapter.deleteSession(data.
|
|
3109
|
+
await ctx.context.internalAdapter.deleteSession(data.sessionToken).catch((e) => ctx.context.logger.warn("Failed to delete session during SLO", { error: e }));
|
|
2951
3110
|
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));
|
|
2952
3111
|
} else ctx.context.logger.warn("SessionIndex mismatch in LogoutRequest - skipping session deletion", {
|
|
2953
3112
|
providerId,
|
|
@@ -2957,10 +3116,9 @@ async function handleLogoutRequest(ctx, sp, idp, relayState, providerId) {
|
|
|
2957
3116
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(key).catch((e) => ctx.context.logger.warn("Failed to delete SAML session key during SLO", e));
|
|
2958
3117
|
}
|
|
2959
3118
|
const currentSession = await getSessionFromCtx(ctx);
|
|
2960
|
-
if (currentSession?.session) await ctx.context.internalAdapter.deleteSession(currentSession.session.
|
|
3119
|
+
if (currentSession?.session) await ctx.context.internalAdapter.deleteSession(currentSession.session.token);
|
|
2961
3120
|
deleteSessionCookie(ctx);
|
|
2962
|
-
const
|
|
2963
|
-
const res = sp.createLogoutResponse(idp, null, binding, relayState || "", (template) => template.replace("{InResponseTo}", requestId).replace("{StatusCode}", SAML_STATUS_SUCCESS));
|
|
3121
|
+
const res = sp.createLogoutResponse(idp, parsed, binding, relayState || "");
|
|
2964
3122
|
if (binding === "post" && res.entityEndpoint) return createSAMLPostForm(res.entityEndpoint, "SAMLResponse", res.context, relayState);
|
|
2965
3123
|
throw ctx.redirect(res.context);
|
|
2966
3124
|
}
|
|
@@ -3013,7 +3171,7 @@ const initiateSLO = (options) => {
|
|
|
3013
3171
|
});
|
|
3014
3172
|
if (samlSessionKey) await ctx.context.internalAdapter.deleteVerificationByIdentifier(samlSessionKey).catch((e) => ctx.context.logger.warn("Failed to delete SAML session key during logout", e));
|
|
3015
3173
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(sessionLookupKey).catch((e) => ctx.context.logger.warn("Failed to delete session lookup key during logout", e));
|
|
3016
|
-
await ctx.context.internalAdapter.deleteSession(session.session.
|
|
3174
|
+
await ctx.context.internalAdapter.deleteSession(session.session.token);
|
|
3017
3175
|
deleteSessionCookie(ctx);
|
|
3018
3176
|
throw ctx.redirect(logoutRequest.context);
|
|
3019
3177
|
});
|