@better-auth/sso 1.7.0-beta.1 → 1.7.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,18 +1,24 @@
1
- import { t as PACKAGE_VERSION } from "./version-C22JHwcK.mjs";
2
- import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
1
+ import { t as PACKAGE_VERSION } from "./version-C-W6yUfI.mjs";
2
+ import { APIError, addOAuthServerContext, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
3
3
  import { XMLParser, XMLValidator } from "fast-xml-parser";
4
- import * as saml from "samlify";
5
4
  import { X509Certificate } from "node:crypto";
6
5
  import { getHostname } from "tldts";
7
6
  import { generateRandomString } from "better-auth/crypto";
8
7
  import * as z from "zod";
8
+ import { getCurrentAdapter, runWithTransaction } from "@better-auth/core/context";
9
+ import { filterOutputFields } from "@better-auth/core/utils/db";
10
+ import { classifyHost, isPublicRoutableHost } from "@better-auth/core/utils/host";
11
+ import { betterFetch } from "@better-fetch/fetch";
12
+ import { createRemoteJWKSet, customFetch, decodeJwt, jwtVerify } from "jose";
9
13
  import { base64 } from "@better-auth/utils/base64";
10
- import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
11
- import { ASSERTION_SIGNING_ALGORITHMS, HIDE_METADATA, createAuthorizationURL, generateGenericState, generateState, parseGenericState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
12
- import { deleteSessionCookie, setSessionCookie } from "better-auth/cookies";
13
- import { handleOAuthUserInfo } from "better-auth/oauth2";
14
- import { decodeJwt } from "jose";
15
14
  import { defineErrorCodes } from "@better-auth/core/utils/error-codes";
15
+ import { parseInputData, toZodSchema } from "better-auth/db";
16
+ import { isAPIError } from "@better-auth/core/utils/is-api-error";
17
+ import { HIDE_METADATA, PRIVATE_KEY_JWT_SIGNING_ALGORITHMS, authorizationCodeRequest, createAuthorizationURL, createPrivateKeyJwtClientAssertionGetter, generateGenericState, generateState, getOAuth2Tokens, parseGenericState, parseState } from "better-auth";
18
+ import { deleteSessionCookie, setSessionCookie } from "better-auth/cookies";
19
+ import { additionalAuthorizationParamsSchema, handleOAuthUserInfo } from "better-auth/oauth2";
20
+ import * as samlifyNamespace from "samlify";
21
+ import samlifyDefault from "samlify";
16
22
  //#region src/constants.ts
17
23
  /**
18
24
  * SAML Constants
@@ -21,8 +27,6 @@ import { defineErrorCodes } from "@better-auth/core/utils/error-codes";
21
27
  */
22
28
  /** Prefix for AuthnRequest IDs used in InResponseTo validation */
23
29
  const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
24
- /** Prefix for used Assertion IDs used in replay protection */
25
- const USED_ASSERTION_KEY_PREFIX = "saml-used-assertion:";
26
30
  /** Prefix for SAML session data (NameID + SessionIndex) for SLO */
27
31
  const SAML_SESSION_KEY_PREFIX = "saml-session:";
28
32
  /** Prefix for reverse lookup of SAML session by Better Auth session ID */
@@ -54,7 +58,6 @@ const DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024;
54
58
  * Protects against oversized metadata documents.
55
59
  */
56
60
  const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024;
57
- const SAML_STATUS_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success";
58
61
  //#endregion
59
62
  //#region src/utils.ts
60
63
  /**
@@ -80,10 +83,22 @@ function safeJsonParse(value) {
80
83
  * Checks if a domain matches any domain in a comma-separated list.
81
84
  */
82
85
  const domainMatches = (searchDomain, domainList) => {
83
- const search = searchDomain.toLowerCase();
84
- return domainList.split(",").map((d) => d.trim().toLowerCase()).filter(Boolean).some((d) => search === d || search.endsWith(`.${d}`));
86
+ const search = searchDomain.trim().toLowerCase();
87
+ const domains = parseProviderDomains(domainList);
88
+ if (!search || !domains) return false;
89
+ return domains.some((domain) => search === domain || search.endsWith(`.${domain}`));
85
90
  };
86
91
  /**
92
+ * Strictly parse a provider-supplied email-verification claim.
93
+ *
94
+ * OIDC userInfo, OIDC id-token, and SAML attribute values are frequently
95
+ * strings, so a loose `Boolean(value)` or truthy fallback treats the string
96
+ * `"false"` as verified. Only a boolean `true` or the exact string `"true"`
97
+ * count as verified; every other value, including `"false"`, `"0"`, `""`,
98
+ * numbers, arrays, and objects, is unverified.
99
+ */
100
+ const parseProviderEmailVerified = (value) => value === true || value === "true";
101
+ /**
87
102
  * Validates email domain against allowed domain(s).
88
103
  * Supports comma-separated domains for multi-domain SSO.
89
104
  */
@@ -101,9 +116,31 @@ function parseCertificate(certPem) {
101
116
  publicKeyAlgorithm: cert.publicKey.asymmetricKeyType?.toUpperCase() || "UNKNOWN"
102
117
  };
103
118
  }
119
+ function normalizePem(key) {
120
+ if (!key) return key;
121
+ return `${key.split("\n").map((line) => line.trim()).join("\n").trim()}\n`;
122
+ }
104
123
  function getHostnameFromDomain(domain) {
105
124
  return getHostname(domain) || null;
106
125
  }
126
+ /**
127
+ * Normalize a provider `domain` value to the email domains it authorizes.
128
+ *
129
+ * TODO(next): replace the serialized provider.domain string with a canonical
130
+ * domains array and reject URL/path-shaped values at register/update once main
131
+ * and next provider schemas are reconciled.
132
+ */
133
+ function parseProviderDomains(domain) {
134
+ const entries = domain.split(",").map((entry) => entry.trim()).filter(Boolean);
135
+ if (entries.length === 0) return null;
136
+ const domains = /* @__PURE__ */ new Set();
137
+ for (const entry of entries) {
138
+ const parsedDomain = getHostnameFromDomain(entry)?.toLowerCase();
139
+ if (!parsedDomain) return null;
140
+ domains.add(parsedDomain);
141
+ }
142
+ return [...domains];
143
+ }
107
144
  function maskClientId(clientId) {
108
145
  if (clientId.length <= 4) return "****";
109
146
  return `****${clientId.slice(-4)}`;
@@ -205,1272 +242,1757 @@ async function assignOrganizationByDomain(ctx, options) {
205
242
  });
206
243
  }
207
244
  //#endregion
208
- //#region src/routes/domain-verification.ts
209
- const DNS_LABEL_MAX_LENGTH = 63;
210
- const DEFAULT_TOKEN_PREFIX = "better-auth-token";
211
- const domainVerificationBodySchema = z.object({ providerId: z.string() });
212
- function getVerificationIdentifier(options, providerId) {
213
- return `_${options.domainVerification?.tokenPrefix || DEFAULT_TOKEN_PREFIX}-${providerId}`;
214
- }
215
- const requestDomainVerification = (options) => {
216
- return createAuthEndpoint("/sso/request-domain-verification", {
217
- method: "POST",
218
- body: domainVerificationBodySchema,
219
- metadata: { openapi: {
220
- summary: "Request a domain verification",
221
- description: "Request a domain verification for the given SSO provider",
222
- responses: {
223
- "404": { description: "Provider not found" },
224
- "409": { description: "Domain has already been verified" },
225
- "201": { description: "Domain submitted for verification" }
226
- }
227
- } },
228
- use: [sessionMiddleware]
229
- }, async (ctx) => {
230
- const body = ctx.body;
231
- const provider = await ctx.context.adapter.findOne({
232
- model: "ssoProvider",
233
- where: [{
234
- field: "providerId",
235
- value: body.providerId
236
- }]
237
- });
238
- if (!provider) throw new APIError("NOT_FOUND", {
239
- message: "Provider not found",
240
- code: "PROVIDER_NOT_FOUND"
241
- });
242
- const userId = ctx.context.session.user.id;
243
- let isOrgMember = true;
244
- if (provider.organizationId) isOrgMember = await ctx.context.adapter.count({
245
- model: "member",
246
- where: [{
247
- field: "userId",
248
- value: userId
249
- }, {
250
- field: "organizationId",
251
- value: provider.organizationId
252
- }]
253
- }) > 0;
254
- if (provider.userId !== userId || !isOrgMember) throw new APIError("FORBIDDEN", {
255
- message: "User must be owner of or belong to the SSO provider organization",
256
- code: "INSUFICCIENT_ACCESS"
257
- });
258
- if ("domainVerified" in provider && provider.domainVerified) throw new APIError("CONFLICT", {
259
- message: "Domain has already been verified",
260
- code: "DOMAIN_VERIFIED"
261
- });
262
- const identifier = getVerificationIdentifier(options, provider.providerId);
263
- const activeVerification = await ctx.context.internalAdapter.findVerificationValue(identifier);
264
- if (activeVerification && new Date(activeVerification.expiresAt) > /* @__PURE__ */ new Date()) {
265
- ctx.setStatus(201);
266
- return ctx.json({ domainVerificationToken: activeVerification.value });
267
- }
268
- const domainVerificationToken = generateRandomString(24);
269
- await ctx.context.internalAdapter.createVerificationValue({
270
- identifier,
271
- value: domainVerificationToken,
272
- expiresAt: new Date(Date.now() + 3600 * 24 * 7 * 1e3)
273
- });
274
- ctx.setStatus(201);
275
- return ctx.json({ domainVerificationToken });
276
- });
277
- };
278
- const verifyDomain = (options) => {
279
- return createAuthEndpoint("/sso/verify-domain", {
280
- method: "POST",
281
- body: domainVerificationBodySchema,
282
- metadata: { openapi: {
283
- summary: "Verify the provider domain ownership",
284
- description: "Verify the provider domain ownership via DNS records",
285
- responses: {
286
- "404": { description: "Provider not found" },
287
- "409": { description: "Domain has already been verified or no pending verification exists" },
288
- "502": { description: "Unable to verify domain ownership due to upstream validator error" },
289
- "204": { description: "Domain ownership was verified" }
290
- }
291
- } },
292
- use: [sessionMiddleware]
293
- }, async (ctx) => {
294
- const body = ctx.body;
295
- const provider = await ctx.context.adapter.findOne({
296
- model: "ssoProvider",
297
- where: [{
298
- field: "providerId",
299
- value: body.providerId
300
- }]
301
- });
302
- if (!provider) throw new APIError("NOT_FOUND", {
303
- message: "Provider not found",
304
- code: "PROVIDER_NOT_FOUND"
305
- });
306
- const userId = ctx.context.session.user.id;
307
- let isOrgMember = true;
308
- if (provider.organizationId) isOrgMember = await ctx.context.adapter.count({
309
- model: "member",
310
- where: [{
311
- field: "userId",
312
- value: userId
313
- }, {
314
- field: "organizationId",
315
- value: provider.organizationId
316
- }]
317
- }) > 0;
318
- if (provider.userId !== userId || !isOrgMember) throw new APIError("FORBIDDEN", {
319
- message: "User must be owner of or belong to the SSO provider organization",
320
- code: "INSUFICCIENT_ACCESS"
321
- });
322
- if ("domainVerified" in provider && provider.domainVerified) throw new APIError("CONFLICT", {
323
- message: "Domain has already been verified",
324
- code: "DOMAIN_VERIFIED"
325
- });
326
- const identifier = getVerificationIdentifier(options, provider.providerId);
327
- if (identifier.length > DNS_LABEL_MAX_LENGTH) throw new APIError("BAD_REQUEST", {
328
- message: `Verification identifier exceeds the DNS label limit of ${DNS_LABEL_MAX_LENGTH} characters`,
329
- code: "IDENTIFIER_TOO_LONG"
330
- });
331
- const activeVerification = await ctx.context.internalAdapter.findVerificationValue(identifier);
332
- if (!activeVerification || new Date(activeVerification.expiresAt) <= /* @__PURE__ */ new Date()) throw new APIError("NOT_FOUND", {
333
- message: "No pending domain verification exists",
334
- code: "NO_PENDING_VERIFICATION"
335
- });
336
- let records = [];
337
- let dns;
338
- try {
339
- dns = await import("node:dns/promises");
340
- } catch (error) {
341
- ctx.context.logger.error("The core node:dns module is required for the domain verification feature", error);
342
- throw new APIError("INTERNAL_SERVER_ERROR", {
343
- message: "Unable to verify domain ownership due to server error",
344
- code: "DOMAIN_VERIFICATION_FAILED"
345
- });
346
- }
347
- const hostname = getHostnameFromDomain(provider.domain);
348
- if (!hostname) throw new APIError("BAD_REQUEST", {
349
- message: "Invalid domain",
350
- code: "INVALID_DOMAIN"
351
- });
352
- try {
353
- records = (await dns.resolveTxt(`${identifier}.${hostname}`)).flat();
354
- } catch (error) {
355
- ctx.context.logger.warn("DNS resolution failure while validating domain ownership", error);
356
- }
357
- if (!records.find((record) => record.includes(`${activeVerification.identifier}=${activeVerification.value}`))) throw new APIError("BAD_GATEWAY", {
358
- message: "Unable to verify domain ownership. Try again later",
359
- code: "DOMAIN_VERIFICATION_FAILED"
360
- });
361
- await ctx.context.adapter.update({
362
- model: "ssoProvider",
363
- where: [{
364
- field: "providerId",
365
- value: provider.providerId
366
- }],
367
- update: { domainVerified: true }
368
- });
369
- ctx.setStatus(204);
370
- });
245
+ //#region src/oidc/types.ts
246
+ /**
247
+ * Custom error class for OIDC discovery failures.
248
+ * Can be caught and mapped to APIError at the edge.
249
+ */
250
+ var DiscoveryError = class DiscoveryError extends Error {
251
+ code;
252
+ details;
253
+ constructor(code, message, details, options) {
254
+ super(message, options);
255
+ this.name = "DiscoveryError";
256
+ this.code = code;
257
+ this.details = details;
258
+ if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
259
+ }
371
260
  };
261
+ /**
262
+ * Required fields that must be present in a valid discovery document.
263
+ */
264
+ const REQUIRED_DISCOVERY_FIELDS = [
265
+ "issuer",
266
+ "authorization_endpoint",
267
+ "token_endpoint",
268
+ "jwks_uri"
269
+ ];
372
270
  //#endregion
373
- //#region src/saml/parser.ts
374
- const xmlParser = new XMLParser({
375
- ignoreAttributes: false,
376
- attributeNamePrefix: "@_",
377
- removeNSPrefix: true,
378
- processEntities: false
379
- });
380
- function findNode(obj, nodeName) {
381
- if (!obj || typeof obj !== "object") return null;
382
- const record = obj;
383
- if (nodeName in record) return record[nodeName];
384
- for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
385
- const found = findNode(item, nodeName);
386
- if (found) return found;
387
- }
388
- else if (typeof value === "object" && value !== null) {
389
- const found = findNode(value, nodeName);
390
- if (found) return found;
391
- }
392
- return null;
271
+ //#region src/oidc/discovery.ts
272
+ /**
273
+ * OIDC Discovery Pipeline
274
+ *
275
+ * Implements OIDC discovery document fetching, validation, and hydration.
276
+ * This module is used both at provider registration time (to persist validated config)
277
+ * and at runtime (to hydrate legacy providers that are missing metadata).
278
+ *
279
+ * @see https://openid.net/specs/openid-connect-discovery-1_0.html
280
+ */
281
+ /** Default timeout for discovery requests (10 seconds) */
282
+ const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
283
+ function isHttpRedirectStatus(status) {
284
+ return status >= 300 && status < 400;
393
285
  }
394
- function countAllNodes(obj, nodeName) {
395
- if (!obj || typeof obj !== "object") return 0;
396
- let count = 0;
397
- const record = obj;
398
- if (nodeName in record) {
399
- const node = record[nodeName];
400
- count += Array.isArray(node) ? node.length : 1;
401
- }
402
- for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) count += countAllNodes(item, nodeName);
403
- else if (typeof value === "object" && value !== null) count += countAllNodes(value, nodeName);
404
- return count;
286
+ /**
287
+ * Main entry point: Discover and hydrate OIDC configuration from an issuer.
288
+ *
289
+ * This function:
290
+ * 1. Computes the discovery URL from the issuer
291
+ * 2. Validates the discovery URL
292
+ * 3. Fetches the discovery document
293
+ * 4. Validates the discovery document (issuer match + required fields)
294
+ * 5. Normalizes URLs
295
+ * 6. Selects token endpoint auth method
296
+ * 7. Merges with existing config (existing values take precedence)
297
+ *
298
+ * @param params - Discovery parameters
299
+ * @param isTrustedOrigin - Origin verification tester function
300
+ * @returns Hydrated OIDC configuration ready for persistence
301
+ * @throws DiscoveryError on any failure
302
+ */
303
+ async function discoverOIDCConfig(params) {
304
+ const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
305
+ const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
306
+ validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
307
+ const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout, params.isTrustedOrigin);
308
+ validateDiscoveryDocument(discoveryDoc, issuer);
309
+ const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
310
+ const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
311
+ return {
312
+ issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
313
+ discoveryEndpoint: existingConfig?.discoveryEndpoint ?? discoveryUrl,
314
+ authorizationEndpoint: existingConfig?.authorizationEndpoint ?? normalizedDoc.authorization_endpoint,
315
+ tokenEndpoint: existingConfig?.tokenEndpoint ?? normalizedDoc.token_endpoint,
316
+ jwksEndpoint: existingConfig?.jwksEndpoint ?? normalizedDoc.jwks_uri,
317
+ userInfoEndpoint: existingConfig?.userInfoEndpoint ?? normalizedDoc.userinfo_endpoint,
318
+ tokenEndpointAuthentication: existingConfig?.tokenEndpointAuthentication ?? tokenEndpointAuth,
319
+ scopesSupported: existingConfig?.scopesSupported ?? normalizedDoc.scopes_supported
320
+ };
405
321
  }
406
- //#endregion
407
- //#region src/saml/algorithms.ts
408
- const SignatureAlgorithm = {
409
- RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
410
- RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
411
- RSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
412
- RSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
413
- ECDSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
414
- ECDSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
415
- ECDSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"
416
- };
417
- const DigestAlgorithm = {
418
- SHA1: "http://www.w3.org/2000/09/xmldsig#sha1",
419
- SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
420
- SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
421
- SHA512: "http://www.w3.org/2001/04/xmlenc#sha512"
422
- };
423
- const KeyEncryptionAlgorithm = {
424
- RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
425
- RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
426
- RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep"
427
- };
428
- const DataEncryptionAlgorithm = {
429
- TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
430
- AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
431
- AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
432
- AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
433
- AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
434
- AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
435
- AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
436
- };
437
- const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
438
- const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS = [KeyEncryptionAlgorithm.RSA_1_5];
439
- const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS = [DataEncryptionAlgorithm.TRIPLEDES_CBC];
440
- const DEPRECATED_DIGEST_ALGORITHMS = [DigestAlgorithm.SHA1];
441
- const SECURE_SIGNATURE_ALGORITHMS = [
442
- SignatureAlgorithm.RSA_SHA256,
443
- SignatureAlgorithm.RSA_SHA384,
444
- SignatureAlgorithm.RSA_SHA512,
445
- SignatureAlgorithm.ECDSA_SHA256,
446
- SignatureAlgorithm.ECDSA_SHA384,
447
- SignatureAlgorithm.ECDSA_SHA512
448
- ];
449
- const SECURE_DIGEST_ALGORITHMS = [
450
- DigestAlgorithm.SHA256,
451
- DigestAlgorithm.SHA384,
452
- DigestAlgorithm.SHA512
453
- ];
454
- const SHORT_FORM_SIGNATURE_TO_URI = {
455
- sha1: SignatureAlgorithm.RSA_SHA1,
456
- sha256: SignatureAlgorithm.RSA_SHA256,
457
- sha384: SignatureAlgorithm.RSA_SHA384,
458
- sha512: SignatureAlgorithm.RSA_SHA512,
459
- "rsa-sha1": SignatureAlgorithm.RSA_SHA1,
460
- "rsa-sha256": SignatureAlgorithm.RSA_SHA256,
461
- "rsa-sha384": SignatureAlgorithm.RSA_SHA384,
462
- "rsa-sha512": SignatureAlgorithm.RSA_SHA512,
463
- "ecdsa-sha256": SignatureAlgorithm.ECDSA_SHA256,
464
- "ecdsa-sha384": SignatureAlgorithm.ECDSA_SHA384,
465
- "ecdsa-sha512": SignatureAlgorithm.ECDSA_SHA512
466
- };
467
- const SHORT_FORM_DIGEST_TO_URI = {
468
- sha1: DigestAlgorithm.SHA1,
469
- sha256: DigestAlgorithm.SHA256,
470
- sha384: DigestAlgorithm.SHA384,
471
- sha512: DigestAlgorithm.SHA512
472
- };
473
- function normalizeSignatureAlgorithm(alg) {
474
- return SHORT_FORM_SIGNATURE_TO_URI[alg.toLowerCase()] ?? alg;
322
+ /**
323
+ * Compute the discovery URL from an issuer URL.
324
+ *
325
+ * Per OIDC Discovery spec, the discovery document is located at:
326
+ * <issuer>/.well-known/openid-configuration
327
+ *
328
+ * Handles trailing slashes correctly.
329
+ */
330
+ function computeDiscoveryUrl(issuer) {
331
+ return `${issuer.endsWith("/") ? issuer.slice(0, -1) : issuer}/.well-known/openid-configuration`;
475
332
  }
476
- function normalizeDigestAlgorithm(alg) {
477
- return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
333
+ /**
334
+ * Validate a discovery URL before fetching.
335
+ *
336
+ * @param url - The discovery URL to validate
337
+ * @param isTrustedOrigin - Origin verification tester function
338
+ * @throws DiscoveryError if URL is invalid
339
+ */
340
+ function validateDiscoveryUrl(url, isTrustedOrigin) {
341
+ const discoveryEndpoint = parseURL("discoveryEndpoint", url).toString();
342
+ if (!isTrustedOrigin(discoveryEndpoint)) throw new DiscoveryError("discovery_untrusted_origin", `The main discovery endpoint "${discoveryEndpoint}" is not trusted by your trusted origins configuration.`, { url: discoveryEndpoint });
478
343
  }
479
- function extractEncryptionAlgorithms(xml) {
344
+ /**
345
+ * Validate that a user-supplied OIDC endpoint URL is safe to fetch.
346
+ *
347
+ * Layered checks (in order):
348
+ * 1. URL parsing + http(s) scheme → discovery_invalid_url
349
+ * 2. Public-routable host (RFC 6890) → allowed
350
+ * 3. Operator-allowlisted via trustedOrigins → allowed (opt-in for internal IdPs)
351
+ * 4. Otherwise → discovery_private_host
352
+ *
353
+ * Step 2 rejects loopback, RFC 1918, link-local, ULA, shared-address,
354
+ * cloud-metadata FQDNs (e.g. `169.254.169.254`, `metadata.google.internal`),
355
+ * multicast, and reserved ranges. See `isPublicRoutableHost` in
356
+ * `@better-auth/core/utils/host`.
357
+ *
358
+ * Step 3 is the documented escape hatch for customers whose IdP runs on a
359
+ * private network or behind a corporate VPN: they add the IdP origin to their
360
+ * `trustedOrigins` configuration.
361
+ *
362
+ * @param name - The endpoint field name, used in error messages
363
+ * @param endpoint - The URL to validate
364
+ * @param isTrustedOrigin - Predicate matching the configured `trustedOrigins`
365
+ * @throws DiscoveryError(discovery_invalid_url) — malformed URL or non-http(s) scheme
366
+ * @throws DiscoveryError(discovery_private_host) — host is not publicly routable and not allowlisted
367
+ */
368
+ function validateOIDCEndpointUrl(name, endpoint, isTrustedOrigin) {
369
+ const parsed = parseURL(name, endpoint);
370
+ if (isPublicRoutableHost(parsed.hostname)) return;
371
+ if (isTrustedOrigin(parsed.toString())) return;
372
+ 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.`, {
373
+ endpoint: name,
374
+ url: endpoint,
375
+ hostname: parsed.hostname
376
+ });
377
+ }
378
+ /**
379
+ * Validate every present OIDC endpoint URL in a registration or update body.
380
+ *
381
+ * Each provided URL is checked with {@link validateOIDCEndpointUrl}.
382
+ * Omitted (undefined / null / empty) fields are skipped.
383
+ *
384
+ * @param config - OIDC endpoint URLs from the request body
385
+ * @param isTrustedOrigin - Predicate matching the configured `trustedOrigins`
386
+ * @throws DiscoveryError on the first invalid endpoint
387
+ */
388
+ function validateOIDCEndpointUrls(config, isTrustedOrigin) {
389
+ const fields = [
390
+ ["authorizationEndpoint", config.authorizationEndpoint],
391
+ ["tokenEndpoint", config.tokenEndpoint],
392
+ ["userInfoEndpoint", config.userInfoEndpoint],
393
+ ["jwksEndpoint", config.jwksEndpoint],
394
+ ["discoveryEndpoint", config.discoveryEndpoint]
395
+ ];
396
+ for (const [name, url] of fields) if (url) validateOIDCEndpointUrl(name, url, isTrustedOrigin);
397
+ }
398
+ /**
399
+ * Re-validate an endpoint by resolving its hostname and rejecting any resolved
400
+ * address that is not publicly routable.
401
+ *
402
+ * {@link validateOIDCEndpointUrl} only classifies the literal hostname, so
403
+ * a host like `idp.example` whose DNS record points at `127.0.0.1`,
404
+ * `169.254.169.254`, or an RFC 1918 address passes that check unchanged. This
405
+ * function closes that gap by performing the same RFC 6890 classification on the
406
+ * addresses the host actually resolves to, right before the server-side fetch.
407
+ *
408
+ * Best-effort by design:
409
+ * - Operator-allowlisted origins (trustedOrigins) are skipped — this is the
410
+ * documented escape hatch for internal IdPs.
411
+ * - IP-literal hosts are already fully covered by the synchronous check.
412
+ * - On runtimes without `node:dns` (e.g. Cloudflare Workers / edge), DNS
413
+ * resolution is unavailable; we fall back to the synchronous host check and
414
+ * the platform's own egress controls.
415
+ *
416
+ * Note: this resolves once and validates the result; it does not pin the address
417
+ * for the subsequent connection, so a change in the resolved address between
418
+ * this lookup and the fetch remains theoretically possible. It nonetheless
419
+ * rejects the common case of a DNS record that statically points at an internal
420
+ * address.
421
+ *
422
+ * @throws DiscoveryError(discovery_private_host) if any resolved address is not public
423
+ */
424
+ async function assertEndpointResolvesPublic(name, endpoint, isTrustedOrigin) {
425
+ const parsed = parseURL(name, endpoint);
426
+ if (isTrustedOrigin(parsed.toString())) return;
427
+ const host = parsed.hostname;
428
+ if (classifyHost(host).literal !== "fqdn") return;
429
+ let dns;
480
430
  try {
481
- const parsed = xmlParser.parse(xml);
482
- const keyAlg = (findNode(parsed, "EncryptedKey")?.EncryptionMethod)?.["@_Algorithm"];
483
- const dataAlg = (findNode(parsed, "EncryptedData")?.EncryptionMethod)?.["@_Algorithm"];
484
- return {
485
- keyEncryption: keyAlg || null,
486
- dataEncryption: dataAlg || null
487
- };
431
+ dns = await import("node:dns/promises");
488
432
  } catch {
489
- return {
490
- keyEncryption: null,
491
- dataEncryption: null
492
- };
433
+ return;
493
434
  }
494
- }
495
- function hasEncryptedAssertion(xml) {
435
+ let resolved;
496
436
  try {
497
- return findNode(xmlParser.parse(xml), "EncryptedAssertion") !== null;
437
+ resolved = await dns.lookup(host, { all: true });
498
438
  } catch {
499
- return false;
439
+ return;
500
440
  }
441
+ for (const { address } of resolved) if (!isPublicRoutableHost(address)) throw new DiscoveryError("discovery_private_host", `The ${name} host "${host}" resolves to a non-publicly-routable address (${address}). If this is an internal IdP, add its origin to trustedOrigins.`, {
442
+ endpoint: name,
443
+ url: endpoint,
444
+ hostname: host,
445
+ resolved: address
446
+ });
501
447
  }
502
- function handleDeprecatedAlgorithm(message, behavior, errorCode) {
503
- switch (behavior) {
504
- case "reject": throw new APIError("BAD_REQUEST", {
505
- message,
506
- code: errorCode
507
- });
508
- case "warn":
509
- console.warn(`[SAML Security Warning] ${message}`);
510
- break;
511
- case "allow": break;
512
- }
448
+ /**
449
+ * Validate an OIDC endpoint immediately before a server-side fetch.
450
+ */
451
+ async function assertOIDCEndpointAllowed(name, endpoint, isTrustedOrigin) {
452
+ validateOIDCEndpointUrl(name, endpoint, isTrustedOrigin);
453
+ await assertEndpointResolvesPublic(name, endpoint, isTrustedOrigin);
513
454
  }
514
- function validateSignatureAlgorithm(algorithm, options = {}) {
515
- if (!algorithm) return;
516
- const { onDeprecated = "warn", allowedSignatureAlgorithms } = options;
517
- if (allowedSignatureAlgorithms) {
518
- if (!allowedSignatureAlgorithms.includes(algorithm)) throw new APIError("BAD_REQUEST", {
519
- message: `SAML signature algorithm not in allow-list: ${algorithm}`,
520
- code: "SAML_ALGORITHM_NOT_ALLOWED"
521
- });
522
- return;
523
- }
524
- if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(algorithm)) {
525
- handleDeprecatedAlgorithm(`SAML response uses deprecated signature algorithm: ${algorithm}. Please configure your IdP to use SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
526
- return;
455
+ /**
456
+ * Re-validate, at fetch time, every OIDC endpoint that is fetched server-side
457
+ * (token, userinfo, jwks). `authorizationEndpoint` is intentionally excluded
458
+ * because it is a browser redirect target, not a server-side fetch.
459
+ */
460
+ async function assertServerFetchedOIDCEndpointsAllowed(config, isTrustedOrigin) {
461
+ const fields = [
462
+ ["tokenEndpoint", config.tokenEndpoint],
463
+ ["userInfoEndpoint", config.userInfoEndpoint],
464
+ ["jwksEndpoint", config.jwksEndpoint]
465
+ ];
466
+ for (const [name, url] of fields) {
467
+ if (!url) continue;
468
+ await assertOIDCEndpointAllowed(name, url, isTrustedOrigin);
527
469
  }
528
- if (!SECURE_SIGNATURE_ALGORITHMS.includes(algorithm)) throw new APIError("BAD_REQUEST", {
529
- message: `SAML signature algorithm not recognized: ${algorithm}`,
530
- code: "SAML_UNKNOWN_ALGORITHM"
470
+ }
471
+ /**
472
+ * Convert an explicit HTTP redirect response into the OIDC configuration error
473
+ * used by all server-side endpoint fetches.
474
+ */
475
+ function throwRedirectError(name, endpoint, status, location) {
476
+ throw new DiscoveryError("oidc_endpoint_redirect", `The ${name} (${endpoint}) returned an HTTP ${status} redirect. Configure the final OIDC endpoint URL instead of a redirecting URL.`, {
477
+ endpoint: name,
478
+ url: endpoint,
479
+ status,
480
+ location
531
481
  });
532
482
  }
533
- function validateEncryptionAlgorithms(algorithms, options = {}) {
534
- const { onDeprecated = "warn", allowedKeyEncryptionAlgorithms, allowedDataEncryptionAlgorithms } = options;
535
- const { keyEncryption, dataEncryption } = algorithms;
536
- if (keyEncryption) {
537
- if (allowedKeyEncryptionAlgorithms) {
538
- if (!allowedKeyEncryptionAlgorithms.includes(keyEncryption)) throw new APIError("BAD_REQUEST", {
539
- message: `SAML key encryption algorithm not in allow-list: ${keyEncryption}`,
540
- code: "SAML_ALGORITHM_NOT_ALLOWED"
541
- });
542
- } 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");
543
- }
544
- if (dataEncryption) {
545
- if (allowedDataEncryptionAlgorithms) {
546
- if (!allowedDataEncryptionAlgorithms.includes(dataEncryption)) throw new APIError("BAD_REQUEST", {
547
- message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
548
- code: "SAML_ALGORITHM_NOT_ALLOWED"
549
- });
550
- } 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");
551
- }
483
+ /**
484
+ * Fetch a configured OIDC endpoint without following redirects.
485
+ *
486
+ * Every server-side OIDC request goes through this helper so private-host
487
+ * checks and redirect handling stay consistent across discovery, token, and
488
+ * userinfo calls.
489
+ */
490
+ async function fetchOIDCEndpoint(name, endpoint, options, isTrustedOrigin) {
491
+ await assertOIDCEndpointAllowed(name, endpoint, isTrustedOrigin);
492
+ let redirectLocation = null;
493
+ const { onError, ...fetchOptions } = options;
494
+ const response = await betterFetch(endpoint, {
495
+ ...fetchOptions,
496
+ redirect: "manual",
497
+ onError: async (context) => {
498
+ if (isHttpRedirectStatus(context.response.status)) redirectLocation = context.response.headers.get("location");
499
+ await onError?.(context);
500
+ }
501
+ });
502
+ if (response.error && isHttpRedirectStatus(response.error.status)) throwRedirectError(name, endpoint, response.error.status, redirectLocation);
503
+ return response;
552
504
  }
553
- function validateSAMLAlgorithms(response, options) {
554
- validateSignatureAlgorithm(response.sigAlg, options);
555
- if (hasEncryptedAssertion(response.samlContent)) validateEncryptionAlgorithms(extractEncryptionAlgorithms(response.samlContent), options);
505
+ /**
506
+ * Native-fetch variant for libraries that require a `fetch` implementation,
507
+ * such as jose's remote JWKS loader.
508
+ */
509
+ async function fetchOIDCEndpointResponse(name, endpoint, init, isTrustedOrigin) {
510
+ await assertOIDCEndpointAllowed(name, endpoint, isTrustedOrigin);
511
+ const response = await fetch(endpoint, {
512
+ ...init,
513
+ redirect: "manual"
514
+ });
515
+ if (isHttpRedirectStatus(response.status)) throwRedirectError(name, endpoint, response.status, response.headers.get("location"));
516
+ return response;
556
517
  }
557
- function validateConfigAlgorithms(config, options = {}) {
558
- const { onDeprecated = "warn", allowedSignatureAlgorithms, allowedDigestAlgorithms } = options;
559
- if (config.signatureAlgorithm) {
560
- const normalized = normalizeSignatureAlgorithm(config.signatureAlgorithm);
561
- if (allowedSignatureAlgorithms) {
562
- if (!allowedSignatureAlgorithms.map(normalizeSignatureAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
563
- message: `SAML signature algorithm not in allow-list: ${config.signatureAlgorithm}`,
564
- code: "SAML_ALGORITHM_NOT_ALLOWED"
518
+ /**
519
+ * Validate an OIDC ID token using the same endpoint fetch policy as the rest of
520
+ * the SSO OIDC flow.
521
+ */
522
+ async function validateOIDCIdToken(token, jwksEndpoint, options, isTrustedOrigin) {
523
+ return jwtVerify(token, createRemoteJWKSet(new URL(jwksEndpoint), { [customFetch]: (url, init) => fetchOIDCEndpointResponse("jwksEndpoint", url, init, isTrustedOrigin) }), {
524
+ audience: options.audience,
525
+ issuer: options.issuer
526
+ });
527
+ }
528
+ /**
529
+ * Fetch the OIDC discovery document from the IdP.
530
+ *
531
+ * @param url - The discovery endpoint URL
532
+ * @param timeout - Request timeout in milliseconds
533
+ * @returns The parsed discovery document
534
+ * @throws DiscoveryError on network errors, timeouts, or invalid responses
535
+ */
536
+ async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT, isTrustedOrigin = () => false) {
537
+ try {
538
+ const response = await fetchOIDCEndpoint("discoveryEndpoint", url, {
539
+ method: "GET",
540
+ timeout
541
+ }, isTrustedOrigin);
542
+ if (response.error) {
543
+ const { status } = response.error;
544
+ if (status === 404) throw new DiscoveryError("discovery_not_found", "Discovery endpoint not found", {
545
+ url,
546
+ status
565
547
  });
566
- } 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");
567
- else if (!SECURE_SIGNATURE_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
568
- message: `SAML signature algorithm not recognized: ${config.signatureAlgorithm}`,
569
- code: "SAML_UNKNOWN_ALGORITHM"
570
- });
571
- }
572
- if (config.digestAlgorithm) {
573
- const normalized = normalizeDigestAlgorithm(config.digestAlgorithm);
574
- if (allowedDigestAlgorithms) {
575
- if (!allowedDigestAlgorithms.map(normalizeDigestAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
576
- message: `SAML digest algorithm not in allow-list: ${config.digestAlgorithm}`,
577
- code: "SAML_ALGORITHM_NOT_ALLOWED"
548
+ if (status === 408) throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
549
+ url,
550
+ timeout
578
551
  });
579
- } 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");
580
- else if (!SECURE_DIGEST_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
581
- message: `SAML digest algorithm not recognized: ${config.digestAlgorithm}`,
582
- code: "SAML_UNKNOWN_ALGORITHM"
552
+ throw new DiscoveryError("discovery_unexpected_error", `Unexpected discovery error: ${response.error.statusText}`, {
553
+ url,
554
+ ...response.error
555
+ });
556
+ }
557
+ if (!response.data) throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned an empty response", { url });
558
+ const data = response.data;
559
+ if (typeof data === "string") throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned invalid JSON", {
560
+ url,
561
+ bodyPreview: data.slice(0, 200)
583
562
  });
584
- }
585
- }
586
- //#endregion
587
- //#region src/saml/assertions.ts
588
- function countAssertions(xml) {
589
- let parsed;
590
- try {
591
- parsed = xmlParser.parse(xml);
592
- } catch {
593
- throw new APIError("BAD_REQUEST", {
594
- message: "Failed to parse SAML response XML",
595
- code: "SAML_INVALID_XML"
563
+ return data;
564
+ } catch (error) {
565
+ if (error instanceof DiscoveryError) throw error;
566
+ if (error instanceof Error && error.name === "AbortError") throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
567
+ url,
568
+ timeout
596
569
  });
570
+ throw new DiscoveryError("discovery_unexpected_error", `Unexpected error during discovery: ${error instanceof Error ? error.message : String(error)}`, { url }, { cause: error });
597
571
  }
598
- const assertions = countAllNodes(parsed, "Assertion");
599
- const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
600
- return {
601
- assertions,
602
- encryptedAssertions,
603
- total: assertions + encryptedAssertions
604
- };
605
572
  }
606
- function validateSingleAssertion(samlResponse) {
607
- let xml;
608
- try {
609
- xml = new TextDecoder().decode(base64.decode(samlResponse.replace(/\s+/g, "")));
610
- if (!xml.includes("<")) throw new Error("Not XML");
611
- } catch {
612
- throw new APIError("BAD_REQUEST", {
613
- message: "Invalid base64-encoded SAML response",
614
- code: "SAML_INVALID_ENCODING"
615
- });
616
- }
617
- const counts = countAssertions(xml);
618
- if (counts.total === 0) throw new APIError("BAD_REQUEST", {
619
- message: "SAML response contains no assertions",
620
- code: "SAML_NO_ASSERTION"
573
+ /**
574
+ * Validate a discovery document.
575
+ *
576
+ * Checks:
577
+ * 1. All required fields are present
578
+ * 2. Issuer matches the configured issuer (case-sensitive, exact match)
579
+ *
580
+ * Invariant: If this function returns without throwing, the document is safe
581
+ * to use for hydrating OIDC config (required fields present, issuer matches
582
+ * configured value, basic structural sanity verified).
583
+ *
584
+ * @param doc - The discovery document to validate
585
+ * @param configuredIssuer - The expected issuer value
586
+ * @throws DiscoveryError if validation fails
587
+ */
588
+ function validateDiscoveryDocument(doc, configuredIssuer) {
589
+ const missingFields = [];
590
+ for (const field of REQUIRED_DISCOVERY_FIELDS) if (!doc[field]) missingFields.push(field);
591
+ if (missingFields.length > 0) throw new DiscoveryError("discovery_incomplete", `Discovery document is missing required fields: ${missingFields.join(", ")}`, { missingFields });
592
+ 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}"`, {
593
+ discovered: doc.issuer,
594
+ configured: configuredIssuer
621
595
  });
622
- if (counts.total > 1) throw new APIError("BAD_REQUEST", {
623
- message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
624
- code: "SAML_MULTIPLE_ASSERTIONS"
596
+ }
597
+ /**
598
+ * Normalize URLs in the discovery document.
599
+ *
600
+ * @param document - The discovery document
601
+ * @param issuer - The base issuer URL
602
+ * @param isTrustedOrigin - Origin verification tester function
603
+ * @returns The normalized discovery document
604
+ */
605
+ function normalizeDiscoveryUrls(document, issuer, isTrustedOrigin) {
606
+ const doc = { ...document };
607
+ doc.token_endpoint = normalizeAndValidateUrl("token_endpoint", doc.token_endpoint, issuer, isTrustedOrigin);
608
+ doc.authorization_endpoint = normalizeAndValidateUrl("authorization_endpoint", doc.authorization_endpoint, issuer, isTrustedOrigin);
609
+ doc.jwks_uri = normalizeAndValidateUrl("jwks_uri", doc.jwks_uri, issuer, isTrustedOrigin);
610
+ if (doc.userinfo_endpoint) doc.userinfo_endpoint = normalizeAndValidateUrl("userinfo_endpoint", doc.userinfo_endpoint, issuer, isTrustedOrigin);
611
+ if (doc.revocation_endpoint) doc.revocation_endpoint = normalizeAndValidateUrl("revocation_endpoint", doc.revocation_endpoint, issuer, isTrustedOrigin);
612
+ if (doc.end_session_endpoint) doc.end_session_endpoint = normalizeAndValidateUrl("end_session_endpoint", doc.end_session_endpoint, issuer, isTrustedOrigin);
613
+ if (doc.introspection_endpoint) doc.introspection_endpoint = normalizeAndValidateUrl("introspection_endpoint", doc.introspection_endpoint, issuer, isTrustedOrigin);
614
+ return doc;
615
+ }
616
+ /**
617
+ * Normalizes and validates a single URL endpoint
618
+ * @param name The url name
619
+ * @param endpoint The url to validate
620
+ * @param issuer The issuer base url
621
+ * @param isTrustedOrigin - Origin verification tester function
622
+ * @returns
623
+ */
624
+ function normalizeAndValidateUrl(name, endpoint, issuer, isTrustedOrigin) {
625
+ const url = normalizeUrl(name, endpoint, issuer);
626
+ if (!isTrustedOrigin(url)) throw new DiscoveryError("discovery_untrusted_origin", `The ${name} "${url}" is not trusted by your trusted origins configuration.`, {
627
+ endpoint: name,
628
+ url
625
629
  });
630
+ return url;
626
631
  }
627
- //#endregion
628
- //#region src/saml/response-validation.ts
629
- function errorRedirectUrl(base, error, description) {
632
+ /**
633
+ * Normalize a single URL endpoint.
634
+ *
635
+ * @param name - The endpoint name (e.g token_endpoint)
636
+ * @param endpoint - The endpoint URL to normalize
637
+ * @param issuer - The base issuer URL
638
+ * @returns The normalized endpoint URL
639
+ */
640
+ function normalizeUrl(name, endpoint, issuer) {
630
641
  try {
631
- const url = new URL(base);
632
- url.searchParams.set("error", error);
633
- url.searchParams.set("error_description", description);
634
- return url.toString();
642
+ return parseURL(name, endpoint).toString();
635
643
  } catch {
636
- const hashIdx = base.indexOf("#");
637
- const path = hashIdx >= 0 ? base.slice(0, hashIdx) : base;
638
- const hash = hashIdx >= 0 ? base.slice(hashIdx + 1) : void 0;
639
- return `${path}${path.includes("?") ? "&" : "?"}${`error=${encodeURIComponent(error)}&error_description=${encodeURIComponent(description)}`}${hash ? `#${hash}` : ""}`;
644
+ const issuerURL = parseURL(name, issuer);
645
+ const basePath = issuerURL.pathname.replace(/\/+$/, "");
646
+ const endpointPath = endpoint.replace(/^\/+/, "");
647
+ return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
640
648
  }
641
649
  }
642
650
  /**
643
- * Validates the InResponseTo attribute of a SAML Response.
644
- *
645
- * This binds the IdP's Response to a specific SP-initiated AuthnRequest,
646
- * preventing replay attacks, unsolicited response injection, and
647
- * cross-provider assertion swaps.
651
+ * Parses the given URL or throws in case of invalid or unsupported protocols
648
652
  *
649
- * The InResponseTo value lives at `extract.response.inResponseTo` in
650
- * samlify's parsed output (not at the top level).
653
+ * @param name the url name
654
+ * @param endpoint the endpoint url
655
+ * @param [base] optional base path
656
+ * @returns
651
657
  */
652
- async function validateInResponseTo(c, ctx) {
653
- if (ctx.options.enableInResponseToValidation === false) return;
654
- const inResponseTo = ctx.extract.response?.inResponseTo;
655
- const allowIdpInitiated = ctx.options.allowIdpInitiated ?? false;
656
- if (inResponseTo) {
657
- let storedRequest = null;
658
- const verification = await c.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
659
- if (verification) try {
660
- storedRequest = JSON.parse(verification.value);
661
- if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
662
- } catch {
663
- storedRequest = null;
664
- }
665
- if (!storedRequest) {
666
- c.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
667
- inResponseTo,
668
- providerId: ctx.providerId
669
- });
670
- throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Unknown or expired request ID"));
671
- }
672
- if (storedRequest.providerId !== ctx.providerId) {
673
- c.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
674
- inResponseTo,
675
- expectedProvider: storedRequest.providerId,
676
- actualProvider: ctx.providerId
677
- });
678
- await c.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
679
- throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Provider mismatch"));
680
- }
681
- await c.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
682
- } else if (!allowIdpInitiated) {
683
- c.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: ctx.providerId });
684
- throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "unsolicited_response", "IdP-initiated SSO not allowed"));
658
+ function parseURL(name, endpoint, base) {
659
+ let endpointURL;
660
+ try {
661
+ endpointURL = new URL(endpoint, base);
662
+ if (endpointURL.protocol === "http:" || endpointURL.protocol === "https:") return endpointURL;
663
+ } catch (error) {
664
+ throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
685
665
  }
666
+ throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must use the http or https supported protocols: ${endpoint}`, {
667
+ url: endpoint,
668
+ protocol: endpointURL.protocol
669
+ });
686
670
  }
687
671
  /**
688
- * Validates the AudienceRestriction of a SAML assertion.
672
+ * Select the token endpoint authentication method.
689
673
  *
690
- * Per SAML 2.0 Core §2.5.1, an assertion's Audience element specifies
691
- * the intended recipient SP. Without this check, an assertion issued
692
- * for a different SP (e.g., another application sharing the same IdP)
693
- * could be accepted.
674
+ * @param doc - The discovery document
675
+ * @param existing - Existing authentication method from config
676
+ * @returns The selected authentication method
694
677
  */
695
- function validateAudience(c, ctx) {
696
- if (!ctx.expectedAudience) {
697
- c.context.logger.warn("Could not determine SP entity ID for audience validation; skipping", { providerId: ctx.providerId });
698
- return;
699
- }
700
- const audience = ctx.extract.audience;
701
- if (!audience) {
702
- c.context.logger.error("SAML assertion missing AudienceRestriction but audience is configured — rejecting", { providerId: ctx.providerId });
703
- throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Audience restriction missing"));
704
- }
705
- const audiences = Array.isArray(audience) ? audience : [audience];
706
- if (!audiences.includes(ctx.expectedAudience)) {
707
- c.context.logger.error("SAML audience mismatch: assertion was issued for a different service provider", {
708
- expected: ctx.expectedAudience,
709
- received: audiences,
710
- providerId: ctx.providerId
678
+ function selectTokenEndpointAuthMethod(doc, existing) {
679
+ if (existing === "private_key_jwt") return existing;
680
+ if (existing) return existing;
681
+ const supported = doc.token_endpoint_auth_methods_supported;
682
+ if (!supported || supported.length === 0) return "client_secret_basic";
683
+ if (supported.includes("client_secret_basic")) return "client_secret_basic";
684
+ if (supported.includes("client_secret_post")) return "client_secret_post";
685
+ if (supported.includes("private_key_jwt")) return "private_key_jwt";
686
+ return "client_secret_basic";
687
+ }
688
+ /**
689
+ * Check if a provider configuration needs runtime discovery.
690
+ *
691
+ * Returns true if we need discovery at runtime to complete the token exchange
692
+ * and validation. Specifically checks for:
693
+ * - `tokenEndpoint` - required for exchanging authorization code for tokens
694
+ * - `jwksEndpoint` - required for validating ID token signatures
695
+ * - `authorizationEndpoint` - required for redirecting users to the IdP for login
696
+ *
697
+ * @param config - Partial OIDC config from the provider
698
+ * @returns true if runtime discovery should be performed
699
+ */
700
+ function needsRuntimeDiscovery(config) {
701
+ if (!config) return true;
702
+ return !config.tokenEndpoint || !config.jwksEndpoint || !config.authorizationEndpoint;
703
+ }
704
+ /**
705
+ * Runs runtime OIDC discovery when the stored config is missing required
706
+ * endpoints, and merges the hydrated fields back into the config.
707
+ * Throws if discovery fails.
708
+ */
709
+ async function ensureRuntimeDiscovery(config, issuer, isTrustedOrigin) {
710
+ let resolved = config;
711
+ if (needsRuntimeDiscovery(config)) {
712
+ const hydrated = await discoverOIDCConfig({
713
+ issuer,
714
+ existingConfig: config,
715
+ isTrustedOrigin
711
716
  });
712
- throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Audience mismatch"));
717
+ resolved = {
718
+ ...config,
719
+ authorizationEndpoint: hydrated.authorizationEndpoint,
720
+ tokenEndpoint: hydrated.tokenEndpoint,
721
+ tokenEndpointAuthentication: hydrated.tokenEndpointAuthentication,
722
+ userInfoEndpoint: hydrated.userInfoEndpoint,
723
+ jwksEndpoint: hydrated.jwksEndpoint
724
+ };
713
725
  }
726
+ await assertServerFetchedOIDCEndpointsAllowed(resolved, isTrustedOrigin);
727
+ return resolved;
714
728
  }
715
729
  //#endregion
716
- //#region src/routes/schemas.ts
717
- const oidcMappingSchema = z.object({
718
- id: z.string().optional(),
719
- email: z.string().optional(),
720
- emailVerified: z.string().optional(),
721
- name: z.string().optional(),
722
- image: z.string().optional(),
723
- extraFields: z.record(z.string(), z.any()).optional()
724
- }).optional();
725
- const samlMappingSchema = z.object({
726
- id: z.string().optional(),
727
- email: z.string().optional(),
728
- emailVerified: z.string().optional(),
729
- name: z.string().optional(),
730
- firstName: z.string().optional(),
731
- lastName: z.string().optional(),
732
- extraFields: z.record(z.string(), z.any()).optional()
733
- }).optional();
734
- const oidcConfigSchema = z.object({
735
- clientId: z.string().optional(),
736
- clientSecret: z.string().optional(),
737
- authorizationEndpoint: z.string().url().optional(),
738
- tokenEndpoint: z.string().url().optional(),
739
- userInfoEndpoint: z.string().url().optional(),
740
- tokenEndpointAuthentication: z.enum([
741
- "client_secret_post",
742
- "client_secret_basic",
743
- "private_key_jwt"
744
- ]).optional(),
745
- privateKeyId: z.string().optional(),
746
- privateKeyAlgorithm: z.string().optional(),
747
- jwksEndpoint: z.string().url().optional(),
748
- discoveryEndpoint: z.string().url().optional(),
749
- scopes: z.array(z.string()).optional(),
750
- pkce: z.boolean().optional(),
751
- overrideUserInfo: z.boolean().optional(),
752
- mapping: oidcMappingSchema
753
- });
754
- const samlConfigSchema = z.object({
755
- entryPoint: z.string().url().optional(),
756
- cert: z.string().optional(),
757
- audience: z.string().optional(),
758
- idpMetadata: z.object({
759
- metadata: z.string().optional(),
760
- entityID: z.string().optional(),
761
- cert: z.string().optional(),
762
- privateKey: z.string().optional(),
763
- privateKeyPass: z.string().optional(),
764
- isAssertionEncrypted: z.boolean().optional(),
765
- encPrivateKey: z.string().optional(),
766
- encPrivateKeyPass: z.string().optional(),
767
- singleSignOnService: z.array(z.object({
768
- Binding: z.string(),
769
- Location: z.string().url()
770
- })).optional()
771
- }).optional(),
772
- spMetadata: z.object({
773
- metadata: z.string().optional(),
774
- entityID: z.string().optional(),
775
- binding: z.string().optional(),
776
- privateKey: z.string().optional(),
777
- privateKeyPass: z.string().optional(),
778
- isAssertionEncrypted: z.boolean().optional(),
779
- encPrivateKey: z.string().optional(),
780
- encPrivateKeyPass: z.string().optional()
781
- }).optional(),
782
- wantAssertionsSigned: z.boolean().optional(),
783
- authnRequestsSigned: z.boolean().optional(),
784
- signatureAlgorithm: z.string().optional(),
785
- digestAlgorithm: z.string().optional(),
786
- identifierFormat: z.string().optional(),
787
- privateKey: z.string().optional(),
788
- mapping: samlMappingSchema
789
- });
790
- const updateSSOProviderBodySchema = z.object({
791
- issuer: z.string().url().optional(),
792
- domain: z.string().optional(),
793
- oidcConfig: oidcConfigSchema.optional(),
794
- samlConfig: samlConfigSchema.optional()
730
+ //#region src/oidc/errors.ts
731
+ /**
732
+ * OIDC Discovery Error Mapping
733
+ *
734
+ * Maps DiscoveryError codes to appropriate APIError responses.
735
+ * Used at the boundary between the discovery pipeline and HTTP handlers.
736
+ */
737
+ /**
738
+ * Maps a DiscoveryError to an appropriate APIError for HTTP responses.
739
+ *
740
+ * Error code mapping:
741
+ * - discovery_invalid_url → 400 BAD_REQUEST
742
+ * - discovery_not_found → 400 BAD_REQUEST
743
+ * - discovery_untrusted_origin → 400 BAD_REQUEST
744
+ * - discovery_private_host → 400 BAD_REQUEST
745
+ * - oidc_endpoint_redirect → 400 BAD_REQUEST
746
+ * - discovery_invalid_json → 400 BAD_REQUEST
747
+ * - discovery_incomplete → 400 BAD_REQUEST
748
+ * - issuer_mismatch → 400 BAD_REQUEST
749
+ * - unsupported_token_auth_method → 400 BAD_REQUEST
750
+ * - discovery_timeout → 502 BAD_GATEWAY
751
+ * - discovery_unexpected_error → 502 BAD_GATEWAY
752
+ *
753
+ * @param error - The DiscoveryError to map
754
+ * @returns An APIError with appropriate status and message
755
+ */
756
+ function mapDiscoveryErrorToAPIError(error) {
757
+ switch (error.code) {
758
+ case "discovery_timeout": return new APIError("BAD_GATEWAY", {
759
+ message: `OIDC discovery timed out: ${error.message}`,
760
+ code: error.code
761
+ });
762
+ case "discovery_unexpected_error": return new APIError("BAD_GATEWAY", {
763
+ message: `OIDC discovery failed: ${error.message}`,
764
+ code: error.code
765
+ });
766
+ case "discovery_not_found": return new APIError("BAD_REQUEST", {
767
+ message: `OIDC discovery endpoint not found. The issuer may not support OIDC discovery, or the URL is incorrect. ${error.message}`,
768
+ code: error.code
769
+ });
770
+ case "discovery_invalid_url": return new APIError("BAD_REQUEST", {
771
+ message: `Invalid OIDC endpoint URL: ${error.message}`,
772
+ code: error.code
773
+ });
774
+ case "discovery_untrusted_origin": return new APIError("BAD_REQUEST", {
775
+ message: `Untrusted OIDC discovery URL: ${error.message}`,
776
+ code: error.code
777
+ });
778
+ case "discovery_private_host": return new APIError("BAD_REQUEST", {
779
+ message: error.message,
780
+ code: error.code
781
+ });
782
+ case "oidc_endpoint_redirect": return new APIError("BAD_REQUEST", {
783
+ message: error.message,
784
+ code: error.code
785
+ });
786
+ case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
787
+ message: `OIDC discovery returned invalid data: ${error.message}`,
788
+ code: error.code
789
+ });
790
+ case "discovery_incomplete": return new APIError("BAD_REQUEST", {
791
+ message: `OIDC discovery document is missing required fields: ${error.message}`,
792
+ code: error.code
793
+ });
794
+ case "issuer_mismatch": return new APIError("BAD_REQUEST", {
795
+ message: `OIDC issuer mismatch: ${error.message}`,
796
+ code: error.code
797
+ });
798
+ case "unsupported_token_auth_method": return new APIError("BAD_REQUEST", {
799
+ message: `Incompatible OIDC provider: ${error.message}`,
800
+ code: error.code
801
+ });
802
+ default:
803
+ error.code;
804
+ return new APIError("INTERNAL_SERVER_ERROR", {
805
+ message: `Unexpected discovery error: ${error.message}`,
806
+ code: "discovery_unexpected_error"
807
+ });
808
+ }
809
+ }
810
+ //#endregion
811
+ //#region src/saml/parser.ts
812
+ const xmlParser = new XMLParser({
813
+ ignoreAttributes: false,
814
+ attributeNamePrefix: "@_",
815
+ removeNSPrefix: true,
816
+ processEntities: false
795
817
  });
818
+ function findNode(obj, nodeName) {
819
+ if (!obj || typeof obj !== "object") return null;
820
+ const record = obj;
821
+ if (nodeName in record) return record[nodeName];
822
+ for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
823
+ const found = findNode(item, nodeName);
824
+ if (found) return found;
825
+ }
826
+ else if (typeof value === "object" && value !== null) {
827
+ const found = findNode(value, nodeName);
828
+ if (found) return found;
829
+ }
830
+ return null;
831
+ }
832
+ function countAllNodes(obj, nodeName) {
833
+ if (!obj || typeof obj !== "object") return 0;
834
+ let count = 0;
835
+ const record = obj;
836
+ if (nodeName in record) {
837
+ const node = record[nodeName];
838
+ count += Array.isArray(node) ? node.length : 1;
839
+ }
840
+ for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) count += countAllNodes(item, nodeName);
841
+ else if (typeof value === "object" && value !== null) count += countAllNodes(value, nodeName);
842
+ return count;
843
+ }
796
844
  //#endregion
797
- //#region src/routes/providers.ts
798
- const ADMIN_ROLES = ["owner", "admin"];
799
- async function isOrgAdmin(ctx, userId, organizationId) {
800
- const member = await ctx.context.adapter.findOne({
801
- model: "member",
802
- where: [{
803
- field: "userId",
804
- value: userId
805
- }, {
806
- field: "organizationId",
807
- value: organizationId
808
- }]
809
- });
810
- if (!member) return false;
811
- return member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()));
845
+ //#region src/saml/algorithms.ts
846
+ const SignatureAlgorithm = {
847
+ RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
848
+ RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
849
+ RSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
850
+ RSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
851
+ ECDSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
852
+ ECDSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
853
+ ECDSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"
854
+ };
855
+ const DigestAlgorithm = {
856
+ SHA1: "http://www.w3.org/2000/09/xmldsig#sha1",
857
+ SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
858
+ SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
859
+ SHA512: "http://www.w3.org/2001/04/xmlenc#sha512"
860
+ };
861
+ const KeyEncryptionAlgorithm = {
862
+ RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
863
+ RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
864
+ RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep"
865
+ };
866
+ const DataEncryptionAlgorithm = {
867
+ TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
868
+ AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
869
+ AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
870
+ AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
871
+ AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
872
+ AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
873
+ AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
874
+ };
875
+ const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
876
+ const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS = [KeyEncryptionAlgorithm.RSA_1_5];
877
+ const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS = [DataEncryptionAlgorithm.TRIPLEDES_CBC];
878
+ const DEPRECATED_DIGEST_ALGORITHMS = [DigestAlgorithm.SHA1];
879
+ const SECURE_SIGNATURE_ALGORITHMS = [
880
+ SignatureAlgorithm.RSA_SHA256,
881
+ SignatureAlgorithm.RSA_SHA384,
882
+ SignatureAlgorithm.RSA_SHA512,
883
+ SignatureAlgorithm.ECDSA_SHA256,
884
+ SignatureAlgorithm.ECDSA_SHA384,
885
+ SignatureAlgorithm.ECDSA_SHA512
886
+ ];
887
+ const SECURE_DIGEST_ALGORITHMS = [
888
+ DigestAlgorithm.SHA256,
889
+ DigestAlgorithm.SHA384,
890
+ DigestAlgorithm.SHA512
891
+ ];
892
+ const SHORT_FORM_SIGNATURE_TO_URI = {
893
+ sha1: SignatureAlgorithm.RSA_SHA1,
894
+ sha256: SignatureAlgorithm.RSA_SHA256,
895
+ sha384: SignatureAlgorithm.RSA_SHA384,
896
+ sha512: SignatureAlgorithm.RSA_SHA512,
897
+ "rsa-sha1": SignatureAlgorithm.RSA_SHA1,
898
+ "rsa-sha256": SignatureAlgorithm.RSA_SHA256,
899
+ "rsa-sha384": SignatureAlgorithm.RSA_SHA384,
900
+ "rsa-sha512": SignatureAlgorithm.RSA_SHA512,
901
+ "ecdsa-sha256": SignatureAlgorithm.ECDSA_SHA256,
902
+ "ecdsa-sha384": SignatureAlgorithm.ECDSA_SHA384,
903
+ "ecdsa-sha512": SignatureAlgorithm.ECDSA_SHA512
904
+ };
905
+ const SHORT_FORM_DIGEST_TO_URI = {
906
+ sha1: DigestAlgorithm.SHA1,
907
+ sha256: DigestAlgorithm.SHA256,
908
+ sha384: DigestAlgorithm.SHA384,
909
+ sha512: DigestAlgorithm.SHA512
910
+ };
911
+ function normalizeSignatureAlgorithm(alg) {
912
+ return SHORT_FORM_SIGNATURE_TO_URI[alg.toLowerCase()] ?? alg;
812
913
  }
813
- async function batchCheckOrgAdmin(ctx, userId, organizationIds) {
814
- if (organizationIds.length === 0) return /* @__PURE__ */ new Set();
815
- const members = await ctx.context.adapter.findMany({
816
- model: "member",
817
- where: [{
818
- field: "userId",
819
- value: userId
820
- }, {
821
- field: "organizationId",
822
- value: organizationIds,
823
- operator: "in"
824
- }]
825
- });
826
- const adminOrgIds = /* @__PURE__ */ new Set();
827
- for (const member of members) if (member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()))) adminOrgIds.add(member.organizationId);
828
- return adminOrgIds;
914
+ function normalizeDigestAlgorithm(alg) {
915
+ return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
829
916
  }
830
- function sanitizeProvider(provider, baseURL) {
831
- let oidcConfig = null;
832
- let samlConfig = null;
917
+ function extractEncryptionAlgorithms(xml) {
833
918
  try {
834
- oidcConfig = safeJsonParse(provider.oidcConfig);
919
+ const parsed = xmlParser.parse(xml);
920
+ const keyAlg = (findNode(parsed, "EncryptedKey")?.EncryptionMethod)?.["@_Algorithm"];
921
+ const dataAlg = (findNode(parsed, "EncryptedData")?.EncryptionMethod)?.["@_Algorithm"];
922
+ return {
923
+ keyEncryption: keyAlg || null,
924
+ dataEncryption: dataAlg || null
925
+ };
835
926
  } catch {
836
- oidcConfig = null;
927
+ return {
928
+ keyEncryption: null,
929
+ dataEncryption: null
930
+ };
837
931
  }
932
+ }
933
+ function hasEncryptedAssertion(xml) {
838
934
  try {
839
- samlConfig = safeJsonParse(provider.samlConfig);
935
+ return findNode(xmlParser.parse(xml), "EncryptedAssertion") !== null;
840
936
  } catch {
841
- samlConfig = null;
937
+ return false;
842
938
  }
843
- const type = samlConfig ? "saml" : "oidc";
844
- return {
845
- providerId: provider.providerId,
846
- type,
847
- issuer: provider.issuer,
848
- domain: provider.domain,
849
- organizationId: provider.organizationId || null,
850
- domainVerified: provider.domainVerified ?? false,
851
- oidcConfig: oidcConfig ? {
852
- discoveryEndpoint: oidcConfig.discoveryEndpoint,
853
- clientIdLastFour: maskClientId(oidcConfig.clientId),
854
- pkce: oidcConfig.pkce,
855
- authorizationEndpoint: oidcConfig.authorizationEndpoint,
856
- tokenEndpoint: oidcConfig.tokenEndpoint,
857
- userInfoEndpoint: oidcConfig.userInfoEndpoint,
858
- jwksEndpoint: oidcConfig.jwksEndpoint,
859
- scopes: oidcConfig.scopes,
860
- tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication
861
- } : void 0,
862
- samlConfig: samlConfig ? {
863
- entryPoint: samlConfig.entryPoint,
864
- audience: samlConfig.audience,
865
- wantAssertionsSigned: samlConfig.wantAssertionsSigned,
866
- authnRequestsSigned: samlConfig.authnRequestsSigned,
867
- identifierFormat: samlConfig.identifierFormat,
868
- signatureAlgorithm: samlConfig.signatureAlgorithm,
869
- digestAlgorithm: samlConfig.digestAlgorithm,
870
- certificate: (() => {
871
- try {
872
- return parseCertificate(samlConfig.cert);
873
- } catch {
874
- return { error: "Failed to parse certificate" };
875
- }
876
- })()
877
- } : void 0,
878
- spMetadataUrl: `${baseURL}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(provider.providerId)}`
879
- };
880
939
  }
881
- const listSSOProviders = () => {
882
- return createAuthEndpoint("/sso/providers", {
883
- method: "GET",
884
- use: [sessionMiddleware],
885
- metadata: { openapi: {
886
- operationId: "listSSOProviders",
887
- summary: "List SSO providers",
888
- description: "Returns a list of SSO providers the user has access to",
889
- responses: { "200": { description: "List of SSO providers" } }
890
- } }
891
- }, async (ctx) => {
892
- const userId = ctx.context.session.user.id;
893
- const allProviders = await ctx.context.adapter.findMany({ model: "ssoProvider" });
894
- const userOwnedProviders = allProviders.filter((p) => p.userId === userId && !p.organizationId);
895
- const orgProviders = allProviders.filter((p) => p.organizationId !== null && p.organizationId !== void 0);
896
- const orgPluginEnabled = ctx.context.hasPlugin("organization");
897
- let accessibleProviders = [...userOwnedProviders];
898
- if (orgPluginEnabled && orgProviders.length > 0) {
899
- const adminOrgIds = await batchCheckOrgAdmin(ctx, userId, [...new Set(orgProviders.map((p) => p.organizationId).filter((id) => id !== null && id !== void 0))]);
900
- const orgAccessibleProviders = orgProviders.filter((provider) => provider.organizationId && adminOrgIds.has(provider.organizationId));
901
- accessibleProviders = [...accessibleProviders, ...orgAccessibleProviders];
902
- } else if (!orgPluginEnabled) {
903
- const userOwnedOrgProviders = orgProviders.filter((p) => p.userId === userId);
904
- accessibleProviders = [...accessibleProviders, ...userOwnedOrgProviders];
905
- }
906
- const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL));
907
- return ctx.json({ providers });
908
- });
909
- };
910
- const getSSOProviderQuerySchema = z.object({ providerId: z.string() });
911
- async function checkProviderAccess(ctx, providerId) {
912
- const userId = ctx.context.session.user.id;
913
- const provider = await ctx.context.adapter.findOne({
914
- model: "ssoProvider",
915
- where: [{
916
- field: "providerId",
917
- value: providerId
918
- }]
940
+ function handleDeprecatedAlgorithm(message, behavior, errorCode) {
941
+ switch (behavior) {
942
+ case "reject": throw new APIError("BAD_REQUEST", {
943
+ message,
944
+ code: errorCode
945
+ });
946
+ case "warn":
947
+ console.warn(`[SAML Security Warning] ${message}`);
948
+ break;
949
+ case "allow": break;
950
+ }
951
+ }
952
+ function validateSignatureAlgorithm(algorithm, options = {}) {
953
+ if (!algorithm) return;
954
+ const { onDeprecated = "warn", allowedSignatureAlgorithms } = options;
955
+ if (allowedSignatureAlgorithms) {
956
+ if (!allowedSignatureAlgorithms.includes(algorithm)) throw new APIError("BAD_REQUEST", {
957
+ message: `SAML signature algorithm not in allow-list: ${algorithm}`,
958
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
959
+ });
960
+ return;
961
+ }
962
+ if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(algorithm)) {
963
+ handleDeprecatedAlgorithm(`SAML response uses deprecated signature algorithm: ${algorithm}. Please configure your IdP to use SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
964
+ return;
965
+ }
966
+ if (!SECURE_SIGNATURE_ALGORITHMS.includes(algorithm)) throw new APIError("BAD_REQUEST", {
967
+ message: `SAML signature algorithm not recognized: ${algorithm}`,
968
+ code: "SAML_UNKNOWN_ALGORITHM"
919
969
  });
920
- if (!provider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
921
- let hasAccess = false;
922
- if (provider.organizationId) if (ctx.context.hasPlugin("organization")) hasAccess = await isOrgAdmin(ctx, userId, provider.organizationId);
923
- else hasAccess = provider.userId === userId;
924
- else hasAccess = provider.userId === userId;
925
- if (!hasAccess) throw new APIError("FORBIDDEN", { message: "You don't have access to this provider" });
926
- return provider;
927
970
  }
928
- const getSSOProvider = () => {
929
- return createAuthEndpoint("/sso/get-provider", {
930
- method: "GET",
931
- use: [sessionMiddleware],
932
- query: getSSOProviderQuerySchema,
933
- metadata: { openapi: {
934
- operationId: "getSSOProvider",
935
- summary: "Get SSO provider details",
936
- description: "Returns sanitized details for a specific SSO provider",
937
- responses: {
938
- "200": { description: "SSO provider details" },
939
- "404": { description: "Provider not found" },
940
- "403": { description: "Access denied" }
941
- }
942
- } }
943
- }, async (ctx) => {
944
- const { providerId } = ctx.query;
945
- const provider = await checkProviderAccess(ctx, providerId);
946
- return ctx.json(sanitizeProvider(provider, ctx.context.baseURL));
947
- });
948
- };
949
- function parseAndValidateConfig(configString, configType) {
950
- let config = null;
971
+ function validateEncryptionAlgorithms(algorithms, options = {}) {
972
+ const { onDeprecated = "warn", allowedKeyEncryptionAlgorithms, allowedDataEncryptionAlgorithms } = options;
973
+ const { keyEncryption, dataEncryption } = algorithms;
974
+ if (keyEncryption) {
975
+ if (allowedKeyEncryptionAlgorithms) {
976
+ if (!allowedKeyEncryptionAlgorithms.includes(keyEncryption)) throw new APIError("BAD_REQUEST", {
977
+ message: `SAML key encryption algorithm not in allow-list: ${keyEncryption}`,
978
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
979
+ });
980
+ } 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");
981
+ }
982
+ if (dataEncryption) {
983
+ if (allowedDataEncryptionAlgorithms) {
984
+ if (!allowedDataEncryptionAlgorithms.includes(dataEncryption)) throw new APIError("BAD_REQUEST", {
985
+ message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
986
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
987
+ });
988
+ } 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");
989
+ }
990
+ }
991
+ function validateSAMLAlgorithms(response, options) {
992
+ validateSignatureAlgorithm(response.sigAlg, options);
993
+ if (hasEncryptedAssertion(response.samlContent)) validateEncryptionAlgorithms(extractEncryptionAlgorithms(response.samlContent), options);
994
+ }
995
+ function validateConfigAlgorithms(config, options = {}) {
996
+ const { onDeprecated = "warn", allowedSignatureAlgorithms, allowedDigestAlgorithms } = options;
997
+ if (config.signatureAlgorithm) {
998
+ const normalized = normalizeSignatureAlgorithm(config.signatureAlgorithm);
999
+ if (allowedSignatureAlgorithms) {
1000
+ if (!allowedSignatureAlgorithms.map(normalizeSignatureAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
1001
+ message: `SAML signature algorithm not in allow-list: ${config.signatureAlgorithm}`,
1002
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
1003
+ });
1004
+ } 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");
1005
+ else if (!SECURE_SIGNATURE_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
1006
+ message: `SAML signature algorithm not recognized: ${config.signatureAlgorithm}`,
1007
+ code: "SAML_UNKNOWN_ALGORITHM"
1008
+ });
1009
+ }
1010
+ if (config.digestAlgorithm) {
1011
+ const normalized = normalizeDigestAlgorithm(config.digestAlgorithm);
1012
+ if (allowedDigestAlgorithms) {
1013
+ if (!allowedDigestAlgorithms.map(normalizeDigestAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
1014
+ message: `SAML digest algorithm not in allow-list: ${config.digestAlgorithm}`,
1015
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
1016
+ });
1017
+ } 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");
1018
+ else if (!SECURE_DIGEST_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
1019
+ message: `SAML digest algorithm not recognized: ${config.digestAlgorithm}`,
1020
+ code: "SAML_UNKNOWN_ALGORITHM"
1021
+ });
1022
+ }
1023
+ }
1024
+ //#endregion
1025
+ //#region src/saml/assertions.ts
1026
+ function countAssertions(xml) {
1027
+ let parsed;
951
1028
  try {
952
- config = safeJsonParse(configString);
1029
+ parsed = xmlParser.parse(xml);
953
1030
  } catch {
954
- config = null;
1031
+ throw new APIError("BAD_REQUEST", {
1032
+ message: "Failed to parse SAML response XML",
1033
+ code: "SAML_INVALID_XML"
1034
+ });
955
1035
  }
956
- if (!config) throw new APIError("BAD_REQUEST", { message: `Cannot update ${configType} config for a provider that doesn't have ${configType} configured` });
957
- return config;
958
- }
959
- function mergeSAMLConfig(current, updates, issuer) {
960
- return {
961
- ...current,
962
- ...updates,
963
- issuer,
964
- entryPoint: updates.entryPoint ?? current.entryPoint,
965
- cert: updates.cert ?? current.cert,
966
- spMetadata: updates.spMetadata ?? current.spMetadata,
967
- idpMetadata: updates.idpMetadata ?? current.idpMetadata,
968
- mapping: updates.mapping ?? current.mapping,
969
- audience: updates.audience ?? current.audience,
970
- wantAssertionsSigned: updates.wantAssertionsSigned ?? current.wantAssertionsSigned,
971
- authnRequestsSigned: updates.authnRequestsSigned ?? current.authnRequestsSigned,
972
- identifierFormat: updates.identifierFormat ?? current.identifierFormat,
973
- signatureAlgorithm: updates.signatureAlgorithm ?? current.signatureAlgorithm,
974
- digestAlgorithm: updates.digestAlgorithm ?? current.digestAlgorithm
975
- };
976
- }
977
- function mergeOIDCConfig(current, updates, issuer) {
1036
+ const assertions = countAllNodes(parsed, "Assertion");
1037
+ const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
978
1038
  return {
979
- ...current,
980
- ...updates,
981
- issuer,
982
- pkce: updates.pkce ?? current.pkce ?? true,
983
- clientId: updates.clientId ?? current.clientId,
984
- clientSecret: updates.clientSecret ?? current.clientSecret,
985
- discoveryEndpoint: updates.discoveryEndpoint ?? current.discoveryEndpoint,
986
- mapping: updates.mapping ?? current.mapping,
987
- scopes: updates.scopes ?? current.scopes,
988
- authorizationEndpoint: updates.authorizationEndpoint ?? current.authorizationEndpoint,
989
- tokenEndpoint: updates.tokenEndpoint ?? current.tokenEndpoint,
990
- userInfoEndpoint: updates.userInfoEndpoint ?? current.userInfoEndpoint,
991
- jwksEndpoint: updates.jwksEndpoint ?? current.jwksEndpoint,
992
- tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication,
993
- privateKeyId: updates.privateKeyId ?? current.privateKeyId,
994
- privateKeyAlgorithm: updates.privateKeyAlgorithm ?? current.privateKeyAlgorithm
1039
+ assertions,
1040
+ encryptedAssertions,
1041
+ total: assertions + encryptedAssertions
995
1042
  };
996
1043
  }
997
- const updateSSOProvider = (options) => {
998
- return createAuthEndpoint("/sso/update-provider", {
999
- method: "POST",
1000
- use: [sessionMiddleware],
1001
- body: updateSSOProviderBodySchema.extend({ providerId: z.string() }),
1002
- metadata: { openapi: {
1003
- operationId: "updateSSOProvider",
1004
- summary: "Update SSO provider",
1005
- description: "Partially update an SSO provider. Only provided fields are updated. If domain changes, domainVerified is reset to false.",
1006
- responses: {
1007
- "200": { description: "SSO provider updated successfully" },
1008
- "404": { description: "Provider not found" },
1009
- "403": { description: "Access denied" }
1010
- }
1011
- } }
1012
- }, async (ctx) => {
1013
- const { providerId, ...body } = ctx.body;
1014
- const { issuer, domain, samlConfig, oidcConfig } = body;
1015
- if (!issuer && !domain && !samlConfig && !oidcConfig) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
1016
- const existingProvider = await checkProviderAccess(ctx, providerId);
1017
- const updateData = {};
1018
- if (body.issuer !== void 0) updateData.issuer = body.issuer;
1019
- if (body.domain !== void 0) {
1020
- updateData.domain = body.domain;
1021
- if (body.domain !== existingProvider.domain) updateData.domainVerified = false;
1022
- }
1023
- if (body.samlConfig) {
1024
- if (body.samlConfig.idpMetadata?.metadata) {
1025
- const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
1026
- if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
1027
- }
1028
- if (body.samlConfig.signatureAlgorithm !== void 0 || body.samlConfig.digestAlgorithm !== void 0) validateConfigAlgorithms({
1029
- signatureAlgorithm: body.samlConfig.signatureAlgorithm,
1030
- digestAlgorithm: body.samlConfig.digestAlgorithm
1031
- }, options?.saml?.algorithms);
1032
- const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
1033
- const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
1034
- updateData.samlConfig = JSON.stringify(updatedSamlConfig);
1035
- }
1036
- if (body.oidcConfig) {
1037
- const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
1038
- const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
1039
- 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" });
1040
- 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" });
1041
- updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
1042
- }
1043
- await ctx.context.adapter.update({
1044
- model: "ssoProvider",
1045
- where: [{
1046
- field: "providerId",
1047
- value: providerId
1048
- }],
1049
- update: updateData
1050
- });
1051
- const fullProvider = await ctx.context.adapter.findOne({
1052
- model: "ssoProvider",
1053
- where: [{
1054
- field: "providerId",
1055
- value: providerId
1056
- }]
1044
+ function validateSingleAssertion(samlResponse) {
1045
+ let xml;
1046
+ try {
1047
+ xml = new TextDecoder().decode(base64.decode(samlResponse.replace(/\s+/g, "")));
1048
+ if (!xml.includes("<")) throw new Error("Not XML");
1049
+ } catch {
1050
+ throw new APIError("BAD_REQUEST", {
1051
+ message: "Invalid base64-encoded SAML response",
1052
+ code: "SAML_INVALID_ENCODING"
1057
1053
  });
1058
- if (!fullProvider) throw new APIError("NOT_FOUND", { message: "Provider not found after update" });
1059
- return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL));
1054
+ }
1055
+ const counts = countAssertions(xml);
1056
+ if (counts.total === 0) throw new APIError("BAD_REQUEST", {
1057
+ message: "SAML response contains no assertions",
1058
+ code: "SAML_NO_ASSERTION"
1060
1059
  });
1061
- };
1062
- const deleteSSOProvider = () => {
1063
- return createAuthEndpoint("/sso/delete-provider", {
1064
- method: "POST",
1065
- use: [sessionMiddleware],
1066
- body: z.object({ providerId: z.string() }),
1067
- metadata: { openapi: {
1068
- operationId: "deleteSSOProvider",
1069
- summary: "Delete SSO provider",
1070
- description: "Deletes an SSO provider",
1071
- responses: {
1072
- "200": { description: "SSO provider deleted successfully" },
1073
- "404": { description: "Provider not found" },
1074
- "403": { description: "Access denied" }
1075
- }
1076
- } }
1077
- }, async (ctx) => {
1078
- const { providerId } = ctx.body;
1079
- await checkProviderAccess(ctx, providerId);
1080
- await ctx.context.adapter.delete({
1081
- model: "ssoProvider",
1082
- where: [{
1083
- field: "providerId",
1084
- value: providerId
1085
- }]
1086
- });
1087
- return ctx.json({ success: true });
1060
+ if (counts.total > 1) throw new APIError("BAD_REQUEST", {
1061
+ message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
1062
+ code: "SAML_MULTIPLE_ASSERTIONS"
1088
1063
  });
1089
- };
1090
- //#endregion
1091
- //#region src/oidc/types.ts
1092
- /**
1093
- * Custom error class for OIDC discovery failures.
1094
- * Can be caught and mapped to APIError at the edge.
1095
- */
1096
- var DiscoveryError = class DiscoveryError extends Error {
1097
- code;
1098
- details;
1099
- constructor(code, message, details, options) {
1100
- super(message, options);
1101
- this.name = "DiscoveryError";
1102
- this.code = code;
1103
- this.details = details;
1104
- if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
1105
- }
1106
- };
1107
- /**
1108
- * Required fields that must be present in a valid discovery document.
1109
- */
1110
- const REQUIRED_DISCOVERY_FIELDS = [
1111
- "issuer",
1112
- "authorization_endpoint",
1113
- "token_endpoint",
1114
- "jwks_uri"
1115
- ];
1116
- //#endregion
1117
- //#region src/oidc/discovery.ts
1118
- /**
1119
- * OIDC Discovery Pipeline
1120
- *
1121
- * Implements OIDC discovery document fetching, validation, and hydration.
1122
- * This module is used both at provider registration time (to persist validated config)
1123
- * and at runtime (to hydrate legacy providers that are missing metadata).
1124
- *
1125
- * @see https://openid.net/specs/openid-connect-discovery-1_0.html
1126
- */
1127
- /** Default timeout for discovery requests (10 seconds) */
1128
- const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
1129
- /**
1130
- * Main entry point: Discover and hydrate OIDC configuration from an issuer.
1131
- *
1132
- * This function:
1133
- * 1. Computes the discovery URL from the issuer
1134
- * 2. Validates the discovery URL
1135
- * 3. Fetches the discovery document
1136
- * 4. Validates the discovery document (issuer match + required fields)
1137
- * 5. Normalizes URLs
1138
- * 6. Selects token endpoint auth method
1139
- * 7. Merges with existing config (existing values take precedence)
1140
- *
1141
- * @param params - Discovery parameters
1142
- * @param isTrustedOrigin - Origin verification tester function
1143
- * @returns Hydrated OIDC configuration ready for persistence
1144
- * @throws DiscoveryError on any failure
1145
- */
1146
- async function discoverOIDCConfig(params) {
1147
- const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
1148
- const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
1149
- validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
1150
- const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
1151
- validateDiscoveryDocument(discoveryDoc, issuer);
1152
- const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
1153
- const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
1154
- return {
1155
- issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
1156
- discoveryEndpoint: existingConfig?.discoveryEndpoint ?? discoveryUrl,
1157
- authorizationEndpoint: existingConfig?.authorizationEndpoint ?? normalizedDoc.authorization_endpoint,
1158
- tokenEndpoint: existingConfig?.tokenEndpoint ?? normalizedDoc.token_endpoint,
1159
- jwksEndpoint: existingConfig?.jwksEndpoint ?? normalizedDoc.jwks_uri,
1160
- userInfoEndpoint: existingConfig?.userInfoEndpoint ?? normalizedDoc.userinfo_endpoint,
1161
- tokenEndpointAuthentication: existingConfig?.tokenEndpointAuthentication ?? tokenEndpointAuth,
1162
- scopesSupported: existingConfig?.scopesSupported ?? normalizedDoc.scopes_supported
1163
- };
1164
1064
  }
1165
- /**
1166
- * Compute the discovery URL from an issuer URL.
1167
- *
1168
- * Per OIDC Discovery spec, the discovery document is located at:
1169
- * <issuer>/.well-known/openid-configuration
1170
- *
1171
- * Handles trailing slashes correctly.
1065
+ //#endregion
1066
+ //#region src/saml/error-codes.ts
1067
+ const SAML_ERROR_CODES = defineErrorCodes({
1068
+ SINGLE_LOGOUT_NOT_ENABLED: "Single Logout is not enabled",
1069
+ INVALID_LOGOUT_RESPONSE: "Invalid LogoutResponse",
1070
+ INVALID_LOGOUT_REQUEST: "Invalid LogoutRequest",
1071
+ LOGOUT_FAILED_AT_IDP: "Logout failed at IdP",
1072
+ IDP_SLO_NOT_SUPPORTED: "IdP does not support Single Logout Service",
1073
+ SAML_PROVIDER_NOT_FOUND: "SAML provider not found",
1074
+ CERT_SOURCE_MISSING: "samlConfig requires either a signing certificate (cert or idpMetadata.cert) or an idpMetadata.metadata XML document."
1075
+ });
1076
+ //#endregion
1077
+ //#region src/saml/cert.ts
1078
+ /**
1079
+ * IdP signing-certificate rules for SAML configs. Centralized so the runtime
1080
+ * verification path (`createIdP`), the sanitizer (`getSSOProvider` and
1081
+ * friends), and the registration validator agree on precedence and the
1082
+ * "exactly one cert source" contract.
1172
1083
  */
1173
- function computeDiscoveryUrl(issuer) {
1174
- return `${issuer.endsWith("/") ? issuer.slice(0, -1) : issuer}/.well-known/openid-configuration`;
1084
+ /**
1085
+ * Returns the IdP signing certificates Better Auth trusts for this provider
1086
+ * as a list. `idpMetadata.cert` wins when both are set; the top-level `cert`
1087
+ * is the fallback. Returns `undefined` when neither is set (the certs come
1088
+ * from `idpMetadata.metadata` XML instead).
1089
+ */
1090
+ function resolveSigningCerts(config) {
1091
+ const cert = config.idpMetadata?.cert ?? config.cert;
1092
+ if (cert === void 0) return void 0;
1093
+ return Array.isArray(cert) ? cert : [cert];
1175
1094
  }
1176
1095
  /**
1177
- * Validate a discovery URL before fetching.
1178
- *
1179
- * @param url - The discovery URL to validate
1180
- * @param isTrustedOrigin - Origin verification tester function
1181
- * @throws DiscoveryError if URL is invalid
1096
+ * Reject SAML configs with no signing-cert source. samlify needs either an
1097
+ * `idpMetadata.metadata` XML document (which embeds the certs) or an explicit
1098
+ * PEM under `cert` or `idpMetadata.cert`; without one of those it has nothing
1099
+ * to verify responses against.
1182
1100
  */
1183
- function validateDiscoveryUrl(url, isTrustedOrigin) {
1184
- const discoveryEndpoint = parseURL("discoveryEndpoint", url).toString();
1185
- if (!isTrustedOrigin(discoveryEndpoint)) throw new DiscoveryError("discovery_untrusted_origin", `The main discovery endpoint "${discoveryEndpoint}" is not trusted by your trusted origins configuration.`, { url: discoveryEndpoint });
1101
+ function validateCertSources(config) {
1102
+ const hasMetadataXml = !!config.idpMetadata?.metadata;
1103
+ const hasExplicitCert = config.idpMetadata?.cert !== void 0 || config.cert !== void 0;
1104
+ if (!hasMetadataXml && !hasExplicitCert) throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.CERT_SOURCE_MISSING);
1105
+ }
1106
+ //#endregion
1107
+ //#region src/saml/response-binding.ts
1108
+ const SAML_HTTP_POST_BINDING = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
1109
+ const SAML_BEARER_CONFIRMATION_METHOD = "urn:oasis:names:tc:SAML:2.0:cm:bearer";
1110
+ function toNode(value) {
1111
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
1112
+ return value;
1113
+ }
1114
+ function toNodeArray(value) {
1115
+ if (Array.isArray(value)) return value.map(toNode).filter((node) => !!node);
1116
+ const node = toNode(value);
1117
+ return node ? [node] : [];
1118
+ }
1119
+ function firstNode(value) {
1120
+ return toNodeArray(value)[0] ?? null;
1121
+ }
1122
+ function toStringArray(value) {
1123
+ if (typeof value === "string") return [value];
1124
+ if (Array.isArray(value)) return value.flatMap(toStringArray);
1125
+ const text = toNode(value)?.["#text"];
1126
+ return typeof text === "string" ? [text] : [];
1127
+ }
1128
+ function toStringSet(values) {
1129
+ return new Set(values.filter((value) => !!value));
1130
+ }
1131
+ function parseSAMLContent(samlContent) {
1132
+ try {
1133
+ const parsed = toNode(xmlParser.parse(samlContent));
1134
+ if (parsed) return parsed;
1135
+ } catch {}
1136
+ throw new APIError("BAD_REQUEST", {
1137
+ message: "SAML response XML could not be parsed",
1138
+ code: "SAML_RESPONSE_INVALID_XML"
1139
+ });
1140
+ }
1141
+ function getSAMLPostAssertionConsumerServiceUrls(metadata) {
1142
+ if (!metadata) return [];
1143
+ try {
1144
+ const locations = toNodeArray(findNode(toNode(xmlParser.parse(metadata)), "SPSSODescriptor")).flatMap((descriptor) => toNodeArray(descriptor.AssertionConsumerService).filter((service) => service["@_Binding"] === SAML_HTTP_POST_BINDING).map((service) => service["@_Location"]).filter((location) => typeof location === "string" && !!location));
1145
+ return [...new Set(locations)];
1146
+ } catch {
1147
+ return [];
1148
+ }
1149
+ }
1150
+ function hasSAMLEncryptedAssertion(samlContent) {
1151
+ try {
1152
+ return countAllNodes(xmlParser.parse(samlContent), "EncryptedAssertion") > 0;
1153
+ } catch {
1154
+ return false;
1155
+ }
1156
+ }
1157
+ function getResponseNode(parsed) {
1158
+ return firstNode(parsed.Response);
1159
+ }
1160
+ function getAssertionNode(parsed, response) {
1161
+ const assertion = firstNode(response?.Assertion) ?? firstNode(parsed.Assertion);
1162
+ if (assertion) return assertion;
1163
+ throw new APIError("BAD_REQUEST", {
1164
+ message: "SAML response is missing an assertion",
1165
+ code: "SAML_ASSERTION_MISSING"
1166
+ });
1167
+ }
1168
+ function getAudienceRestrictionGroups(assertion) {
1169
+ return toNodeArray(firstNode(assertion.Conditions)?.AudienceRestriction).map((restriction) => toStringArray(restriction.Audience).filter(Boolean));
1170
+ }
1171
+ function validateAudienceRestrictions(assertion, expectedAudiences) {
1172
+ const audienceGroups = getAudienceRestrictionGroups(assertion);
1173
+ if (audienceGroups.length === 0 || audienceGroups.every((group) => !group.length)) throw new APIError("BAD_REQUEST", {
1174
+ message: "SAML assertion is missing an AudienceRestriction",
1175
+ code: "SAML_AUDIENCE_MISSING"
1176
+ });
1177
+ if (audienceGroups.some((group) => !group.some((audience) => expectedAudiences.has(audience)))) throw new APIError("BAD_REQUEST", {
1178
+ message: "SAML assertion audience does not match this Service Provider",
1179
+ code: "SAML_AUDIENCE_MISMATCH"
1180
+ });
1181
+ }
1182
+ function getBearerSubjectConfirmationData(assertion) {
1183
+ return toNodeArray(firstNode(assertion.Subject)?.SubjectConfirmation).filter((confirmation) => confirmation["@_Method"] === SAML_BEARER_CONFIRMATION_METHOD).map((confirmation) => firstNode(confirmation.SubjectConfirmationData)).filter((data) => !!data);
1184
+ }
1185
+ function validateBearerRecipient(assertion, expectedRecipients) {
1186
+ const confirmationData = getBearerSubjectConfirmationData(assertion);
1187
+ if (!confirmationData.length) throw new APIError("BAD_REQUEST", {
1188
+ message: "SAML assertion is missing bearer SubjectConfirmationData",
1189
+ code: "SAML_BEARER_CONFIRMATION_MISSING"
1190
+ });
1191
+ const recipients = confirmationData.map((data) => data["@_Recipient"]).filter((recipient) => typeof recipient === "string");
1192
+ if (!recipients.length) throw new APIError("BAD_REQUEST", {
1193
+ message: "SAML bearer SubjectConfirmationData is missing a Recipient",
1194
+ code: "SAML_RECIPIENT_MISSING"
1195
+ });
1196
+ if (!recipients.some((recipient) => expectedRecipients.has(recipient))) throw new APIError("BAD_REQUEST", {
1197
+ message: "SAML bearer SubjectConfirmationData Recipient does not match this Service Provider",
1198
+ code: "SAML_RECIPIENT_MISMATCH"
1199
+ });
1200
+ }
1201
+ function validateResponseDestination(response, expectedRecipients) {
1202
+ const destination = response?.["@_Destination"];
1203
+ if (typeof destination !== "string" || !destination) return;
1204
+ if (!expectedRecipients.has(destination)) throw new APIError("BAD_REQUEST", {
1205
+ message: "SAML response Destination does not match this Service Provider",
1206
+ code: "SAML_DESTINATION_MISMATCH"
1207
+ });
1208
+ }
1209
+ function validateSAMLResponseBinding(samlContent, options) {
1210
+ const expectedAudiences = toStringSet(options.expectedAudiences);
1211
+ const expectedRecipients = toStringSet(options.expectedRecipients);
1212
+ const parsed = parseSAMLContent(samlContent);
1213
+ const response = getResponseNode(parsed);
1214
+ const assertion = getAssertionNode(parsed, response);
1215
+ validateAudienceRestrictions(assertion, expectedAudiences);
1216
+ validateBearerRecipient(assertion, expectedRecipients);
1217
+ validateResponseDestination(response, expectedRecipients);
1218
+ }
1219
+ //#endregion
1220
+ //#region src/saml/response-validation.ts
1221
+ function errorRedirectUrl(base, error, description) {
1222
+ try {
1223
+ const url = new URL(base);
1224
+ url.searchParams.set("error", error);
1225
+ url.searchParams.set("error_description", description);
1226
+ return url.toString();
1227
+ } catch {
1228
+ const hashIdx = base.indexOf("#");
1229
+ const path = hashIdx >= 0 ? base.slice(0, hashIdx) : base;
1230
+ const hash = hashIdx >= 0 ? base.slice(hashIdx + 1) : void 0;
1231
+ return `${path}${path.includes("?") ? "&" : "?"}${`error=${encodeURIComponent(error)}&error_description=${encodeURIComponent(description)}`}${hash ? `#${hash}` : ""}`;
1232
+ }
1186
1233
  }
1187
1234
  /**
1188
- * Fetch the OIDC discovery document from the IdP.
1235
+ * Validates the InResponseTo attribute of a SAML Response.
1189
1236
  *
1190
- * @param url - The discovery endpoint URL
1191
- * @param timeout - Request timeout in milliseconds
1192
- * @returns The parsed discovery document
1193
- * @throws DiscoveryError on network errors, timeouts, or invalid responses
1237
+ * This binds the IdP's Response to a specific SP-initiated AuthnRequest,
1238
+ * preventing replay attacks, unsolicited response injection, and
1239
+ * cross-provider assertion swaps.
1240
+ *
1241
+ * The InResponseTo value lives at `extract.response.inResponseTo` in
1242
+ * samlify's parsed output (not at the top level).
1194
1243
  */
1195
- async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
1196
- try {
1197
- const response = await betterFetch(url, {
1198
- method: "GET",
1199
- timeout
1200
- });
1201
- if (response.error) {
1202
- const { status } = response.error;
1203
- if (status === 404) throw new DiscoveryError("discovery_not_found", "Discovery endpoint not found", {
1204
- url,
1205
- status
1206
- });
1207
- if (status === 408) throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
1208
- url,
1209
- timeout
1244
+ async function validateInResponseTo(c, ctx) {
1245
+ if (ctx.options.enableInResponseToValidation === false) return;
1246
+ const inResponseTo = ctx.extract.response?.inResponseTo;
1247
+ const allowIdpInitiated = ctx.options.allowIdpInitiated ?? false;
1248
+ if (inResponseTo) {
1249
+ const consumed = await c.context.internalAdapter.consumeVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1250
+ let storedRequest = null;
1251
+ if (consumed) try {
1252
+ storedRequest = JSON.parse(consumed.value);
1253
+ } catch {
1254
+ storedRequest = null;
1255
+ }
1256
+ if (!storedRequest) {
1257
+ c.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
1258
+ inResponseTo,
1259
+ providerId: ctx.providerId
1210
1260
  });
1211
- throw new DiscoveryError("discovery_unexpected_error", `Unexpected discovery error: ${response.error.statusText}`, {
1212
- url,
1213
- ...response.error
1261
+ throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Unknown or expired request ID"));
1262
+ }
1263
+ if (storedRequest.providerId !== ctx.providerId) {
1264
+ c.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
1265
+ inResponseTo,
1266
+ expectedProvider: storedRequest.providerId,
1267
+ actualProvider: ctx.providerId
1214
1268
  });
1269
+ throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Provider mismatch"));
1215
1270
  }
1216
- if (!response.data) throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned an empty response", { url });
1217
- const data = response.data;
1218
- if (typeof data === "string") throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned invalid JSON", {
1219
- url,
1220
- bodyPreview: data.slice(0, 200)
1221
- });
1222
- return data;
1223
- } catch (error) {
1224
- if (error instanceof DiscoveryError) throw error;
1225
- if (error instanceof Error && error.name === "AbortError") throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
1226
- url,
1227
- timeout
1228
- });
1229
- throw new DiscoveryError("discovery_unexpected_error", `Unexpected error during discovery: ${error instanceof Error ? error.message : String(error)}`, { url }, { cause: error });
1271
+ } else if (!allowIdpInitiated) {
1272
+ c.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: ctx.providerId });
1273
+ throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "unsolicited_response", "IdP-initiated SSO not allowed"));
1230
1274
  }
1231
1275
  }
1232
1276
  /**
1233
- * Validate a discovery document.
1234
- *
1235
- * Checks:
1236
- * 1. All required fields are present
1237
- * 2. Issuer matches the configured issuer (case-sensitive, exact match)
1238
- *
1239
- * Invariant: If this function returns without throwing, the document is safe
1240
- * to use for hydrating OIDC config (required fields present, issuer matches
1241
- * configured value, basic structural sanity verified).
1277
+ * Validates the AudienceRestriction of a SAML assertion.
1242
1278
  *
1243
- * @param doc - The discovery document to validate
1244
- * @param configuredIssuer - The expected issuer value
1245
- * @throws DiscoveryError if validation fails
1279
+ * Per SAML 2.0 Core §2.5.1, an assertion's Audience element specifies
1280
+ * the intended recipient SP. Without this check, an assertion issued
1281
+ * for a different SP (e.g., another application sharing the same IdP)
1282
+ * could be accepted.
1246
1283
  */
1247
- function validateDiscoveryDocument(doc, configuredIssuer) {
1248
- const missingFields = [];
1249
- for (const field of REQUIRED_DISCOVERY_FIELDS) if (!doc[field]) missingFields.push(field);
1250
- if (missingFields.length > 0) throw new DiscoveryError("discovery_incomplete", `Discovery document is missing required fields: ${missingFields.join(", ")}`, { missingFields });
1251
- 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}"`, {
1252
- discovered: doc.issuer,
1253
- configured: configuredIssuer
1284
+ function validateAudience(c, ctx) {
1285
+ if (!ctx.expectedAudience) {
1286
+ c.context.logger.warn("Could not determine SP entity ID for audience validation; skipping", { providerId: ctx.providerId });
1287
+ return;
1288
+ }
1289
+ const audience = ctx.extract.audience;
1290
+ if (!audience) {
1291
+ c.context.logger.error("SAML assertion missing AudienceRestriction but audience is configured — rejecting", { providerId: ctx.providerId });
1292
+ throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Audience restriction missing"));
1293
+ }
1294
+ const audiences = Array.isArray(audience) ? audience : [audience];
1295
+ if (!audiences.includes(ctx.expectedAudience)) {
1296
+ c.context.logger.error("SAML audience mismatch: assertion was issued for a different service provider", {
1297
+ expected: ctx.expectedAudience,
1298
+ received: audiences,
1299
+ providerId: ctx.providerId
1300
+ });
1301
+ throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Audience mismatch"));
1302
+ }
1303
+ }
1304
+ //#endregion
1305
+ //#region src/routes/schemas.ts
1306
+ function getSSOProviderAdditionalFields$1(options) {
1307
+ return options?.schema?.ssoProvider?.additionalFields ?? {};
1308
+ }
1309
+ function getSSOProviderAdditionalFieldsSchema(options) {
1310
+ const additionalFields = getSSOProviderAdditionalFields$1(options);
1311
+ const schema = toZodSchema({
1312
+ fields: additionalFields,
1313
+ isClientSide: true
1314
+ });
1315
+ const blockedInputFields = {};
1316
+ for (const key in additionalFields) if (additionalFields[key]?.input === false) blockedInputFields[key] = z.any().optional();
1317
+ return schema.extend(blockedInputFields);
1318
+ }
1319
+ function assertNoBlockedAdditionalFieldInput(fields, data) {
1320
+ for (const key in fields) if (fields[key]?.input === false && key in data) throw new APIError("BAD_REQUEST", { message: `${key} is not allowed to be set` });
1321
+ }
1322
+ function parseSSOProviderAdditionalFields(options, data, action) {
1323
+ const fields = getSSOProviderAdditionalFields$1(options);
1324
+ assertNoBlockedAdditionalFieldInput(fields, data);
1325
+ return parseInputData(data, {
1326
+ fields,
1327
+ action
1328
+ });
1329
+ }
1330
+ const oidcMappingSchema = z.object({
1331
+ id: z.string().meta({ description: "Field mapping for user ID (defaults to 'sub')" }),
1332
+ email: z.string().meta({ description: "Field mapping for email (defaults to 'email')" }),
1333
+ emailVerified: z.string().meta({ description: "Field mapping for email verification (defaults to 'email_verified')" }).optional(),
1334
+ name: z.string().meta({ description: "Field mapping for name (defaults to 'name')" }),
1335
+ image: z.string().meta({ description: "Field mapping for image (defaults to 'picture')" }).optional(),
1336
+ extraFields: z.record(z.string(), z.any()).optional()
1337
+ }).optional();
1338
+ const samlMappingSchema = z.object({
1339
+ id: z.string().meta({ description: "Field mapping for user ID (defaults to 'nameID')" }),
1340
+ email: z.string().meta({ description: "Field mapping for email (defaults to 'email')" }),
1341
+ emailVerified: z.string().meta({ description: "Field mapping for email verification" }).optional(),
1342
+ name: z.string().meta({ description: "Field mapping for name (defaults to 'displayName')" }),
1343
+ firstName: z.string().meta({ description: "Field mapping for first name (defaults to 'givenName')" }).optional(),
1344
+ lastName: z.string().meta({ description: "Field mapping for last name (defaults to 'surname')" }).optional(),
1345
+ extraFields: z.record(z.string(), z.any()).optional()
1346
+ }).optional();
1347
+ 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." });
1348
+ const oidcConfigSchema = z.object({
1349
+ clientId: z.string().meta({ description: "The client ID" }),
1350
+ clientSecret: z.string().meta({ description: "The client secret. Required for client_secret_basic/client_secret_post. Optional for private_key_jwt." }).optional(),
1351
+ authorizationEndpoint: z.string().url().meta({ description: "The authorization endpoint" }).optional(),
1352
+ tokenEndpoint: z.string().url().meta({ description: "The token endpoint" }).optional(),
1353
+ userInfoEndpoint: z.string().url().meta({ description: "The user info endpoint" }).optional(),
1354
+ tokenEndpointAuthentication: z.enum([
1355
+ "client_secret_post",
1356
+ "client_secret_basic",
1357
+ "private_key_jwt"
1358
+ ]).optional(),
1359
+ privateKeyId: z.string().optional(),
1360
+ privateKeyAlgorithm: z.string().optional(),
1361
+ jwksEndpoint: z.string().url().meta({ description: "The JWKS endpoint" }).optional(),
1362
+ discoveryEndpoint: z.string().url().optional(),
1363
+ skipDiscovery: z.boolean().meta({ description: "Skip OIDC discovery during registration. When true, you must provide authorizationEndpoint, tokenEndpoint, and jwksEndpoint manually." }).optional(),
1364
+ scopes: z.array(z.string()).meta({ description: "The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']" }).optional(),
1365
+ pkce: z.boolean().meta({ description: "Whether to use PKCE for the authorization flow" }).default(true).optional(),
1366
+ overrideUserInfo: z.boolean().optional(),
1367
+ mapping: oidcMappingSchema
1368
+ });
1369
+ const samlConfigSchema = z.object({
1370
+ entryPoint: z.string().url().meta({ description: "The IdP SSO URL (entry point)" }),
1371
+ 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(),
1372
+ audience: z.string().optional(),
1373
+ callbackUrl: z.string().refine((url) => !url.includes("#"), { message: "callbackUrl must not contain a fragment" }).optional(),
1374
+ idpMetadata: z.object({
1375
+ metadata: z.string().optional(),
1376
+ entityID: z.string().optional(),
1377
+ 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(),
1378
+ privateKey: z.string().optional(),
1379
+ privateKeyPass: z.string().optional(),
1380
+ isAssertionEncrypted: z.boolean().optional(),
1381
+ encPrivateKey: z.string().optional(),
1382
+ encPrivateKeyPass: z.string().optional(),
1383
+ singleSignOnService: z.array(z.object({
1384
+ Binding: z.string().meta({ description: "The binding type for the SSO service" }),
1385
+ Location: z.string().url().meta({ description: "The URL for the SSO service" })
1386
+ })).meta({ description: "Single Sign-On service configuration" }).optional(),
1387
+ singleLogoutService: z.array(z.object({
1388
+ Binding: z.string(),
1389
+ Location: z.string().url()
1390
+ })).optional()
1391
+ }).optional(),
1392
+ spMetadata: z.object({
1393
+ metadata: z.string().optional(),
1394
+ entityID: z.string().optional(),
1395
+ binding: z.string().optional(),
1396
+ privateKey: z.string().optional(),
1397
+ privateKeyPass: z.string().optional(),
1398
+ isAssertionEncrypted: z.boolean().optional(),
1399
+ encPrivateKey: z.string().optional(),
1400
+ encPrivateKeyPass: z.string().optional()
1401
+ }).optional(),
1402
+ wantAssertionsSigned: z.boolean().optional(),
1403
+ authnRequestsSigned: z.boolean().optional(),
1404
+ signatureAlgorithm: z.string().optional(),
1405
+ digestAlgorithm: z.string().optional(),
1406
+ identifierFormat: z.string().optional(),
1407
+ privateKey: z.string().optional(),
1408
+ mapping: samlMappingSchema
1409
+ });
1410
+ const registerSSOProviderBodySchema = z.object({
1411
+ providerId: z.string().meta({ description: "The ID of the provider. This is used to identify the provider during login and callback" }),
1412
+ issuer: z.string().url().meta({ description: "The issuer URL of the provider" }),
1413
+ 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')" }),
1414
+ oidcConfig: oidcConfigSchema.optional(),
1415
+ samlConfig: samlConfigSchema.optional(),
1416
+ organizationId: z.string().meta({ description: "If organization plugin is enabled, the organization id to link the provider to" }).optional(),
1417
+ overrideUserInfo: z.boolean().meta({ description: "Override user info with the provider info. Defaults to false" }).default(false).optional()
1418
+ });
1419
+ function getRegisterSSOProviderBodySchema(options) {
1420
+ return registerSSOProviderBodySchema.extend({ ...getSSOProviderAdditionalFieldsSchema(options).shape });
1421
+ }
1422
+ const updateSSOProviderBodySchema = z.object({
1423
+ issuer: z.string().url().optional(),
1424
+ domain: z.string().optional(),
1425
+ oidcConfig: oidcConfigSchema.partial().optional(),
1426
+ samlConfig: samlConfigSchema.partial().optional()
1427
+ });
1428
+ function getUpdateSSOProviderBodySchema(options) {
1429
+ return updateSSOProviderBodySchema.extend({
1430
+ providerId: z.string(),
1431
+ ...getSSOProviderAdditionalFieldsSchema(options).partial().shape
1432
+ });
1433
+ }
1434
+ //#endregion
1435
+ //#region src/routes/providers.ts
1436
+ const ADMIN_ROLES = ["owner", "admin"];
1437
+ const OIDC_IDENTITY_BOUNDARY_FIELDS = [
1438
+ "authorizationEndpoint",
1439
+ "clientId",
1440
+ "discoveryEndpoint",
1441
+ "jwksEndpoint",
1442
+ "tokenEndpoint",
1443
+ "userInfoEndpoint"
1444
+ ];
1445
+ const SAML_IDENTITY_BOUNDARY_FIELDS = [
1446
+ "audience",
1447
+ "callbackUrl",
1448
+ "entryPoint",
1449
+ "identifierFormat"
1450
+ ];
1451
+ const SAML_IDP_BOUNDARY_FIELDS = [
1452
+ "metadata",
1453
+ "entityID",
1454
+ "singleSignOnService"
1455
+ ];
1456
+ const SAML_SP_BOUNDARY_FIELDS = ["metadata", "entityID"];
1457
+ function isRecord(value) {
1458
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1459
+ }
1460
+ function stableStringify(value) {
1461
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
1462
+ if (isRecord(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
1463
+ return JSON.stringify(value) ?? String(value);
1464
+ }
1465
+ function identityValueChanged(current, updated) {
1466
+ return stableStringify(current) !== stableStringify(updated);
1467
+ }
1468
+ function hasChangedField(current, updated, fields) {
1469
+ return fields.some((field) => identityValueChanged(current?.[field], updated?.[field]));
1470
+ }
1471
+ function oidcIdentityBoundaryChanged(current, updated) {
1472
+ return hasChangedField(current, updated, OIDC_IDENTITY_BOUNDARY_FIELDS) || identityValueChanged(current.mapping?.id, updated.mapping?.id);
1473
+ }
1474
+ function samlIdentityBoundaryChanged(current, updated) {
1475
+ return hasChangedField(current, updated, SAML_IDENTITY_BOUNDARY_FIELDS) || identityValueChanged(current.mapping?.id, updated.mapping?.id) || hasChangedField(current.idpMetadata, updated.idpMetadata, SAML_IDP_BOUNDARY_FIELDS) || hasChangedField(current.spMetadata, updated.spMetadata, SAML_SP_BOUNDARY_FIELDS);
1476
+ }
1477
+ function parseConfigSnapshot(config, configType) {
1478
+ if (!config) return;
1479
+ if (typeof config === "string") return parseAndValidateConfig(config, configType);
1480
+ return config;
1481
+ }
1482
+ function ssoProviderIdentityBoundaryChanged(current, updated) {
1483
+ if (identityValueChanged(current.issuer, updated.issuer)) return true;
1484
+ const currentSamlConfig = parseConfigSnapshot(current.samlConfig, "SAML");
1485
+ const updatedSamlConfig = parseConfigSnapshot(updated.samlConfig, "SAML");
1486
+ if (currentSamlConfig && (!updatedSamlConfig || samlIdentityBoundaryChanged(currentSamlConfig, updatedSamlConfig))) return true;
1487
+ const currentOidcConfig = parseConfigSnapshot(current.oidcConfig, "OIDC");
1488
+ const updatedOidcConfig = parseConfigSnapshot(updated.oidcConfig, "OIDC");
1489
+ return Boolean(currentOidcConfig && (!updatedOidcConfig || oidcIdentityBoundaryChanged(currentOidcConfig, updatedOidcConfig)));
1490
+ }
1491
+ async function lockSSOProviderRow(adapter, providerId) {
1492
+ return (await getCurrentAdapter(adapter)).update({
1493
+ model: "ssoProvider",
1494
+ where: [{
1495
+ field: "providerId",
1496
+ value: providerId
1497
+ }],
1498
+ update: { providerId }
1254
1499
  });
1255
1500
  }
1256
- /**
1257
- * Normalize URLs in the discovery document.
1258
- *
1259
- * @param document - The discovery document
1260
- * @param issuer - The base issuer URL
1261
- * @param isTrustedOrigin - Origin verification tester function
1262
- * @returns The normalized discovery document
1263
- */
1264
- function normalizeDiscoveryUrls(document, issuer, isTrustedOrigin) {
1265
- const doc = { ...document };
1266
- doc.token_endpoint = normalizeAndValidateUrl("token_endpoint", doc.token_endpoint, issuer, isTrustedOrigin);
1267
- doc.authorization_endpoint = normalizeAndValidateUrl("authorization_endpoint", doc.authorization_endpoint, issuer, isTrustedOrigin);
1268
- doc.jwks_uri = normalizeAndValidateUrl("jwks_uri", doc.jwks_uri, issuer, isTrustedOrigin);
1269
- if (doc.userinfo_endpoint) doc.userinfo_endpoint = normalizeAndValidateUrl("userinfo_endpoint", doc.userinfo_endpoint, issuer, isTrustedOrigin);
1270
- if (doc.revocation_endpoint) doc.revocation_endpoint = normalizeAndValidateUrl("revocation_endpoint", doc.revocation_endpoint, issuer, isTrustedOrigin);
1271
- if (doc.end_session_endpoint) doc.end_session_endpoint = normalizeAndValidateUrl("end_session_endpoint", doc.end_session_endpoint, issuer, isTrustedOrigin);
1272
- if (doc.introspection_endpoint) doc.introspection_endpoint = normalizeAndValidateUrl("introspection_endpoint", doc.introspection_endpoint, issuer, isTrustedOrigin);
1273
- return doc;
1274
- }
1275
- /**
1276
- * Normalizes and validates a single URL endpoint
1277
- * @param name The url name
1278
- * @param endpoint The url to validate
1279
- * @param issuer The issuer base url
1280
- * @param isTrustedOrigin - Origin verification tester function
1281
- * @returns
1282
- */
1283
- function normalizeAndValidateUrl(name, endpoint, issuer, isTrustedOrigin) {
1284
- const url = normalizeUrl(name, endpoint, issuer);
1285
- if (!isTrustedOrigin(url)) throw new DiscoveryError("discovery_untrusted_origin", `The ${name} "${url}" is not trusted by your trusted origins configuration.`, {
1286
- endpoint: name,
1287
- url
1501
+ async function lockSSOProviderForAccountLink(ctx, provider) {
1502
+ const lockedProvider = await lockSSOProviderRow(ctx.context.adapter, provider.providerId);
1503
+ if (!lockedProvider) {
1504
+ if (provider.userId === "default") return;
1505
+ throw new APIError("CONFLICT", {
1506
+ code: "SSO_PROVIDER_CHANGED",
1507
+ message: "SSO provider changed while account linking was in progress"
1508
+ });
1509
+ }
1510
+ if (ssoProviderIdentityBoundaryChanged(provider, lockedProvider)) throw new APIError("CONFLICT", {
1511
+ code: "SSO_PROVIDER_CHANGED",
1512
+ message: "SSO provider changed while account linking was in progress"
1288
1513
  });
1289
- return url;
1290
1514
  }
1291
- /**
1292
- * Normalize a single URL endpoint.
1293
- *
1294
- * @param name - The endpoint name (e.g token_endpoint)
1295
- * @param endpoint - The endpoint URL to normalize
1296
- * @param issuer - The base issuer URL
1297
- * @returns The normalized endpoint URL
1298
- */
1299
- function normalizeUrl(name, endpoint, issuer) {
1515
+ function getSSOProviderAdditionalFields(options) {
1516
+ return options?.schema?.ssoProvider?.additionalFields ?? {};
1517
+ }
1518
+ function filterSSOProviderAdditionalFields(provider, options) {
1519
+ return filterOutputFields(provider, getSSOProviderAdditionalFields(options));
1520
+ }
1521
+ function getReturnedSSOProviderAdditionalFields(provider, options) {
1522
+ const additionalFields = getSSOProviderAdditionalFields(options);
1523
+ const result = {};
1524
+ for (const key in additionalFields) {
1525
+ if (additionalFields[key]?.returned === false) continue;
1526
+ if (key in provider) result[key] = provider[key];
1527
+ }
1528
+ return result;
1529
+ }
1530
+ function hasOrgAdminRole(member) {
1531
+ return member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()));
1532
+ }
1533
+ function parseCertOrError(cert) {
1300
1534
  try {
1301
- return parseURL(name, endpoint).toString();
1535
+ return parseCertificate(cert);
1302
1536
  } catch {
1303
- const issuerURL = parseURL(name, issuer);
1304
- const basePath = issuerURL.pathname.replace(/\/+$/, "");
1305
- const endpointPath = endpoint.replace(/^\/+/, "");
1306
- return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
1537
+ return { error: "Failed to parse certificate" };
1307
1538
  }
1308
1539
  }
1309
- /**
1310
- * Parses the given URL or throws in case of invalid or unsupported protocols
1311
- *
1312
- * @param name the url name
1313
- * @param endpoint the endpoint url
1314
- * @param [base] optional base path
1315
- * @returns
1316
- */
1317
- function parseURL(name, endpoint, base) {
1318
- let endpointURL;
1540
+ function sanitizeSigningCerts(config) {
1541
+ const certs = resolveSigningCerts(config);
1542
+ if (certs === void 0) return void 0;
1543
+ return certs.map(parseCertOrError);
1544
+ }
1545
+ async function isOrgAdmin(ctx, userId, organizationId) {
1546
+ const member = await ctx.context.adapter.findOne({
1547
+ model: "member",
1548
+ where: [{
1549
+ field: "userId",
1550
+ value: userId
1551
+ }, {
1552
+ field: "organizationId",
1553
+ value: organizationId
1554
+ }]
1555
+ });
1556
+ return member ? hasOrgAdminRole(member) : false;
1557
+ }
1558
+ async function batchCheckOrgAdmin(ctx, userId, organizationIds) {
1559
+ if (organizationIds.length === 0) return /* @__PURE__ */ new Set();
1560
+ const members = await ctx.context.adapter.findMany({
1561
+ model: "member",
1562
+ where: [{
1563
+ field: "userId",
1564
+ value: userId
1565
+ }, {
1566
+ field: "organizationId",
1567
+ value: organizationIds,
1568
+ operator: "in"
1569
+ }]
1570
+ });
1571
+ const adminOrgIds = /* @__PURE__ */ new Set();
1572
+ for (const member of members) if (hasOrgAdminRole(member)) adminOrgIds.add(member.organizationId);
1573
+ return adminOrgIds;
1574
+ }
1575
+ function sanitizeProvider(provider, baseURL, options) {
1576
+ let oidcConfig = null;
1577
+ let samlConfig = null;
1319
1578
  try {
1320
- endpointURL = new URL(endpoint, base);
1321
- if (endpointURL.protocol === "http:" || endpointURL.protocol === "https:") return endpointURL;
1322
- } catch (error) {
1323
- throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
1579
+ oidcConfig = safeJsonParse(provider.oidcConfig);
1580
+ } catch {
1581
+ oidcConfig = null;
1324
1582
  }
1325
- throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must use the http or https supported protocols: ${endpoint}`, {
1326
- url: endpoint,
1327
- protocol: endpointURL.protocol
1328
- });
1583
+ try {
1584
+ samlConfig = safeJsonParse(provider.samlConfig);
1585
+ } catch {
1586
+ samlConfig = null;
1587
+ }
1588
+ const type = samlConfig ? "saml" : "oidc";
1589
+ return {
1590
+ ...getReturnedSSOProviderAdditionalFields(provider, options),
1591
+ providerId: provider.providerId,
1592
+ type,
1593
+ issuer: provider.issuer,
1594
+ domain: provider.domain,
1595
+ organizationId: provider.organizationId || null,
1596
+ domainVerified: provider.domainVerified ?? false,
1597
+ oidcConfig: oidcConfig ? {
1598
+ discoveryEndpoint: oidcConfig.discoveryEndpoint,
1599
+ clientIdLastFour: maskClientId(oidcConfig.clientId),
1600
+ pkce: oidcConfig.pkce,
1601
+ authorizationEndpoint: oidcConfig.authorizationEndpoint,
1602
+ tokenEndpoint: oidcConfig.tokenEndpoint,
1603
+ userInfoEndpoint: oidcConfig.userInfoEndpoint,
1604
+ jwksEndpoint: oidcConfig.jwksEndpoint,
1605
+ scopes: oidcConfig.scopes,
1606
+ tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication
1607
+ } : void 0,
1608
+ samlConfig: samlConfig ? {
1609
+ entryPoint: samlConfig.entryPoint,
1610
+ audience: samlConfig.audience,
1611
+ wantAssertionsSigned: samlConfig.wantAssertionsSigned,
1612
+ authnRequestsSigned: samlConfig.authnRequestsSigned,
1613
+ identifierFormat: samlConfig.identifierFormat,
1614
+ signatureAlgorithm: samlConfig.signatureAlgorithm,
1615
+ digestAlgorithm: samlConfig.digestAlgorithm,
1616
+ certificate: sanitizeSigningCerts(samlConfig)
1617
+ } : void 0,
1618
+ spMetadataUrl: `${baseURL}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(provider.providerId)}`
1619
+ };
1329
1620
  }
1330
- /**
1331
- * Select the token endpoint authentication method.
1332
- *
1333
- * @param doc - The discovery document
1334
- * @param existing - Existing authentication method from config
1335
- * @returns The selected authentication method
1336
- */
1337
- function selectTokenEndpointAuthMethod(doc, existing) {
1338
- if (existing === "private_key_jwt") return existing;
1339
- if (existing) return existing;
1340
- const supported = doc.token_endpoint_auth_methods_supported;
1341
- if (!supported || supported.length === 0) return "client_secret_basic";
1342
- if (supported.includes("client_secret_basic")) return "client_secret_basic";
1343
- if (supported.includes("client_secret_post")) return "client_secret_post";
1344
- if (supported.includes("private_key_jwt")) return "private_key_jwt";
1345
- return "client_secret_basic";
1621
+ const listSSOProviders = (options) => {
1622
+ return createAuthEndpoint("/sso/providers", {
1623
+ method: "GET",
1624
+ use: [sessionMiddleware],
1625
+ metadata: { openapi: {
1626
+ operationId: "listSSOProviders",
1627
+ summary: "List SSO providers",
1628
+ description: "Returns a list of SSO providers the user has access to",
1629
+ 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`." } }
1630
+ } }
1631
+ }, async (ctx) => {
1632
+ const userId = ctx.context.session.user.id;
1633
+ const allProviders = await ctx.context.adapter.findMany({ model: "ssoProvider" });
1634
+ const userOwnedProviders = allProviders.filter((p) => p.userId === userId && !p.organizationId);
1635
+ const orgProviders = allProviders.filter((p) => p.organizationId !== null && p.organizationId !== void 0);
1636
+ const orgPluginEnabled = ctx.context.hasPlugin("organization");
1637
+ let accessibleProviders = [...userOwnedProviders];
1638
+ if (orgPluginEnabled && orgProviders.length > 0) {
1639
+ const adminOrgIds = await batchCheckOrgAdmin(ctx, userId, [...new Set(orgProviders.map((p) => p.organizationId).filter((id) => id !== null && id !== void 0))]);
1640
+ const orgAccessibleProviders = orgProviders.filter((provider) => provider.organizationId && adminOrgIds.has(provider.organizationId));
1641
+ accessibleProviders = [...accessibleProviders, ...orgAccessibleProviders];
1642
+ } else if (!orgPluginEnabled) {
1643
+ const userOwnedOrgProviders = orgProviders.filter((p) => p.userId === userId);
1644
+ accessibleProviders = [...accessibleProviders, ...userOwnedOrgProviders];
1645
+ }
1646
+ const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL, options));
1647
+ return ctx.json({ providers });
1648
+ });
1649
+ };
1650
+ const getSSOProviderQuerySchema = z.object({ providerId: z.string() });
1651
+ async function checkProviderAccess(ctx, providerId) {
1652
+ const userId = ctx.context.session.user.id;
1653
+ const provider = await ctx.context.adapter.findOne({
1654
+ model: "ssoProvider",
1655
+ where: [{
1656
+ field: "providerId",
1657
+ value: providerId
1658
+ }]
1659
+ });
1660
+ if (!provider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
1661
+ let hasAccess = false;
1662
+ if (provider.organizationId) if (ctx.context.hasPlugin("organization")) hasAccess = await isOrgAdmin(ctx, userId, provider.organizationId);
1663
+ else hasAccess = provider.userId === userId;
1664
+ else hasAccess = provider.userId === userId;
1665
+ if (!hasAccess) throw new APIError("FORBIDDEN", { message: "You don't have access to this provider" });
1666
+ return provider;
1346
1667
  }
1347
- /**
1348
- * Check if a provider configuration needs runtime discovery.
1349
- *
1350
- * Returns true if we need discovery at runtime to complete the token exchange
1351
- * and validation. Specifically checks for:
1352
- * - `tokenEndpoint` - required for exchanging authorization code for tokens
1353
- * - `jwksEndpoint` - required for validating ID token signatures
1354
- * - `authorizationEndpoint` - required for redirecting users to the IdP for login
1355
- *
1356
- * @param config - Partial OIDC config from the provider
1357
- * @returns true if runtime discovery should be performed
1358
- */
1359
- function needsRuntimeDiscovery(config) {
1360
- if (!config) return true;
1361
- return !config.tokenEndpoint || !config.jwksEndpoint || !config.authorizationEndpoint;
1668
+ const getSSOProvider = (options) => {
1669
+ return createAuthEndpoint("/sso/get-provider", {
1670
+ method: "GET",
1671
+ use: [sessionMiddleware],
1672
+ query: getSSOProviderQuerySchema,
1673
+ metadata: { openapi: {
1674
+ operationId: "getSSOProvider",
1675
+ summary: "Get SSO provider details",
1676
+ description: "Returns sanitized details for a specific SSO provider",
1677
+ responses: {
1678
+ "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`." },
1679
+ "404": { description: "Provider not found" },
1680
+ "403": { description: "Access denied" }
1681
+ }
1682
+ } }
1683
+ }, async (ctx) => {
1684
+ const { providerId } = ctx.query;
1685
+ const provider = await checkProviderAccess(ctx, providerId);
1686
+ return ctx.json(sanitizeProvider(provider, ctx.context.baseURL, options));
1687
+ });
1688
+ };
1689
+ function parseAndValidateConfig(configString, configType) {
1690
+ let config = null;
1691
+ try {
1692
+ config = safeJsonParse(configString);
1693
+ } catch {
1694
+ config = null;
1695
+ }
1696
+ if (!config) throw new APIError("BAD_REQUEST", { message: `Cannot update ${configType} config for a provider that doesn't have ${configType} configured` });
1697
+ return config;
1362
1698
  }
1363
- /**
1364
- * Runs runtime OIDC discovery when the stored config is missing required
1365
- * endpoints, and merges the hydrated fields back into the config.
1366
- * Throws if discovery fails.
1367
- */
1368
- async function ensureRuntimeDiscovery(config, issuer, isTrustedOrigin) {
1369
- if (!needsRuntimeDiscovery(config)) return config;
1370
- const hydrated = await discoverOIDCConfig({
1699
+ function mergeSAMLConfig(current, updates, issuer) {
1700
+ return {
1701
+ ...current,
1702
+ ...updates,
1371
1703
  issuer,
1372
- existingConfig: config,
1373
- isTrustedOrigin
1374
- });
1704
+ entryPoint: updates.entryPoint ?? current.entryPoint,
1705
+ cert: updates.cert ?? current.cert,
1706
+ spMetadata: updates.spMetadata ?? current.spMetadata,
1707
+ idpMetadata: updates.idpMetadata ?? current.idpMetadata,
1708
+ mapping: updates.mapping ?? current.mapping,
1709
+ audience: updates.audience ?? current.audience,
1710
+ callbackUrl: updates.callbackUrl ?? current.callbackUrl,
1711
+ wantAssertionsSigned: updates.wantAssertionsSigned ?? current.wantAssertionsSigned,
1712
+ authnRequestsSigned: updates.authnRequestsSigned ?? current.authnRequestsSigned,
1713
+ identifierFormat: updates.identifierFormat ?? current.identifierFormat,
1714
+ signatureAlgorithm: updates.signatureAlgorithm ?? current.signatureAlgorithm,
1715
+ digestAlgorithm: updates.digestAlgorithm ?? current.digestAlgorithm
1716
+ };
1717
+ }
1718
+ function mergeOIDCConfig(current, updates, issuer) {
1375
1719
  return {
1376
- ...config,
1377
- authorizationEndpoint: hydrated.authorizationEndpoint,
1378
- tokenEndpoint: hydrated.tokenEndpoint,
1379
- tokenEndpointAuthentication: hydrated.tokenEndpointAuthentication,
1380
- userInfoEndpoint: hydrated.userInfoEndpoint,
1381
- jwksEndpoint: hydrated.jwksEndpoint
1720
+ ...current,
1721
+ ...updates,
1722
+ issuer,
1723
+ pkce: updates.pkce ?? current.pkce ?? true,
1724
+ clientId: updates.clientId ?? current.clientId,
1725
+ clientSecret: updates.clientSecret ?? current.clientSecret,
1726
+ discoveryEndpoint: updates.discoveryEndpoint ?? current.discoveryEndpoint,
1727
+ mapping: updates.mapping ?? current.mapping,
1728
+ scopes: updates.scopes ?? current.scopes,
1729
+ authorizationEndpoint: updates.authorizationEndpoint ?? current.authorizationEndpoint,
1730
+ tokenEndpoint: updates.tokenEndpoint ?? current.tokenEndpoint,
1731
+ userInfoEndpoint: updates.userInfoEndpoint ?? current.userInfoEndpoint,
1732
+ jwksEndpoint: updates.jwksEndpoint ?? current.jwksEndpoint,
1733
+ tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication,
1734
+ privateKeyId: updates.privateKeyId ?? current.privateKeyId,
1735
+ privateKeyAlgorithm: updates.privateKeyAlgorithm ?? current.privateKeyAlgorithm
1382
1736
  };
1383
1737
  }
1384
- //#endregion
1385
- //#region src/oidc/errors.ts
1386
- /**
1387
- * OIDC Discovery Error Mapping
1388
- *
1389
- * Maps DiscoveryError codes to appropriate APIError responses.
1390
- * Used at the boundary between the discovery pipeline and HTTP handlers.
1391
- */
1392
- /**
1393
- * Maps a DiscoveryError to an appropriate APIError for HTTP responses.
1394
- *
1395
- * Error code mapping:
1396
- * - discovery_invalid_url → 400 BAD_REQUEST
1397
- * - discovery_not_found → 400 BAD_REQUEST
1398
- * - discovery_invalid_json → 400 BAD_REQUEST
1399
- * - discovery_incomplete → 400 BAD_REQUEST
1400
- * - issuer_mismatch → 400 BAD_REQUEST
1401
- * - unsupported_token_auth_method 400 BAD_REQUEST
1402
- * - discovery_timeout → 502 BAD_GATEWAY
1403
- * - discovery_unexpected_error → 502 BAD_GATEWAY
1404
- *
1405
- * @param error - The DiscoveryError to map
1406
- * @returns An APIError with appropriate status and message
1407
- */
1408
- function mapDiscoveryErrorToAPIError(error) {
1409
- switch (error.code) {
1410
- case "discovery_timeout": return new APIError("BAD_GATEWAY", {
1411
- message: `OIDC discovery timed out: ${error.message}`,
1412
- code: error.code
1413
- });
1414
- case "discovery_unexpected_error": return new APIError("BAD_GATEWAY", {
1415
- message: `OIDC discovery failed: ${error.message}`,
1416
- code: error.code
1738
+ const updateSSOProvider = (options) => {
1739
+ const updateBodySchema = getUpdateSSOProviderBodySchema(options);
1740
+ return createAuthEndpoint("/sso/update-provider", {
1741
+ method: "POST",
1742
+ use: [sessionMiddleware],
1743
+ body: updateBodySchema,
1744
+ metadata: { openapi: {
1745
+ operationId: "updateSSOProvider",
1746
+ summary: "Update SSO provider",
1747
+ description: "Partially update an SSO provider. Only provided fields are updated. If domain changes, domainVerified is reset to false.",
1748
+ responses: {
1749
+ "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`." },
1750
+ "404": { description: "Provider not found" },
1751
+ "403": { description: "Access denied" }
1752
+ }
1753
+ } }
1754
+ }, async (ctx) => {
1755
+ const { providerId, ...body } = ctx.body;
1756
+ const { issuer, domain, samlConfig, oidcConfig } = body;
1757
+ const additionalFields = parseSSOProviderAdditionalFields(options, body, "update");
1758
+ if (!issuer && !domain && !samlConfig && !oidcConfig && Object.keys(additionalFields).length === 0) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
1759
+ await checkProviderAccess(ctx, providerId);
1760
+ const fullProvider = await runWithTransaction(ctx.context.adapter, async () => {
1761
+ const trx = await getCurrentAdapter(ctx.context.adapter);
1762
+ const existingProvider = await lockSSOProviderRow(ctx.context.adapter, providerId);
1763
+ if (!existingProvider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
1764
+ const updateData = { ...additionalFields };
1765
+ let providerIdentityBoundaryChanged = body.issuer !== void 0 && body.issuer !== existingProvider.issuer;
1766
+ if (body.issuer !== void 0) updateData.issuer = body.issuer;
1767
+ if (body.domain !== void 0) {
1768
+ updateData.domain = body.domain;
1769
+ if (body.domain !== existingProvider.domain) updateData.domainVerified = false;
1770
+ }
1771
+ if (body.samlConfig) {
1772
+ if (body.samlConfig.idpMetadata?.metadata) {
1773
+ const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
1774
+ if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
1775
+ }
1776
+ if (body.samlConfig.signatureAlgorithm !== void 0 || body.samlConfig.digestAlgorithm !== void 0) validateConfigAlgorithms({
1777
+ signatureAlgorithm: body.samlConfig.signatureAlgorithm,
1778
+ digestAlgorithm: body.samlConfig.digestAlgorithm
1779
+ }, options?.saml?.algorithms);
1780
+ const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
1781
+ const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
1782
+ validateCertSources(updatedSamlConfig);
1783
+ if (samlIdentityBoundaryChanged(currentSamlConfig, updatedSamlConfig)) providerIdentityBoundaryChanged = true;
1784
+ updateData.samlConfig = JSON.stringify(updatedSamlConfig);
1785
+ }
1786
+ if (body.oidcConfig) {
1787
+ try {
1788
+ validateOIDCEndpointUrls(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
1789
+ } catch (error) {
1790
+ if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
1791
+ throw error;
1792
+ }
1793
+ const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
1794
+ const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
1795
+ 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" });
1796
+ 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" });
1797
+ if (oidcIdentityBoundaryChanged(currentOidcConfig, updatedOidcConfig)) providerIdentityBoundaryChanged = true;
1798
+ updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
1799
+ }
1800
+ if (providerIdentityBoundaryChanged) {
1801
+ if (await trx.findOne({
1802
+ model: "account",
1803
+ where: [{
1804
+ field: "providerId",
1805
+ value: providerId
1806
+ }]
1807
+ })) throw new APIError("CONFLICT", { message: "Cannot change SSO provider identity fields while linked accounts exist" });
1808
+ }
1809
+ await trx.update({
1810
+ model: "ssoProvider",
1811
+ where: [{
1812
+ field: "providerId",
1813
+ value: providerId
1814
+ }],
1815
+ update: updateData
1816
+ });
1817
+ const updatedProvider = await trx.findOne({
1818
+ model: "ssoProvider",
1819
+ where: [{
1820
+ field: "providerId",
1821
+ value: providerId
1822
+ }]
1823
+ });
1824
+ if (!updatedProvider) throw new APIError("NOT_FOUND", { message: "Provider not found after update" });
1825
+ return updatedProvider;
1417
1826
  });
1418
- case "discovery_not_found": return new APIError("BAD_REQUEST", {
1419
- message: `OIDC discovery endpoint not found. The issuer may not support OIDC discovery, or the URL is incorrect. ${error.message}`,
1420
- code: error.code
1827
+ return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL, options));
1828
+ });
1829
+ };
1830
+ const deleteSSOProvider = () => {
1831
+ return createAuthEndpoint("/sso/delete-provider", {
1832
+ method: "POST",
1833
+ use: [sessionMiddleware],
1834
+ body: z.object({ providerId: z.string() }),
1835
+ metadata: { openapi: {
1836
+ operationId: "deleteSSOProvider",
1837
+ summary: "Delete SSO provider",
1838
+ description: "Deletes an SSO provider",
1839
+ responses: {
1840
+ "200": { description: "SSO provider deleted successfully" },
1841
+ "404": { description: "Provider not found" },
1842
+ "403": { description: "Access denied" }
1843
+ }
1844
+ } }
1845
+ }, async (ctx) => {
1846
+ const { providerId } = ctx.body;
1847
+ await checkProviderAccess(ctx, providerId);
1848
+ await runWithTransaction(ctx.context.adapter, async () => {
1849
+ const trx = await getCurrentAdapter(ctx.context.adapter);
1850
+ await trx.deleteMany({
1851
+ model: "account",
1852
+ where: [{
1853
+ field: "providerId",
1854
+ value: providerId
1855
+ }]
1856
+ });
1857
+ await trx.delete({
1858
+ model: "ssoProvider",
1859
+ where: [{
1860
+ field: "providerId",
1861
+ value: providerId
1862
+ }]
1863
+ });
1421
1864
  });
1422
- case "discovery_invalid_url": return new APIError("BAD_REQUEST", {
1423
- message: `Invalid OIDC discovery URL: ${error.message}`,
1424
- code: error.code
1865
+ return ctx.json({ success: true });
1866
+ });
1867
+ };
1868
+ //#endregion
1869
+ //#region src/routes/domain-verification.ts
1870
+ const DNS_LABEL_MAX_LENGTH = 63;
1871
+ const DEFAULT_TOKEN_PREFIX = "better-auth-token";
1872
+ const domainVerificationBodySchema = z.object({ providerId: z.string() });
1873
+ function getVerificationIdentifier(options, providerId) {
1874
+ return `_${options.domainVerification?.tokenPrefix || DEFAULT_TOKEN_PREFIX}-${providerId}`;
1875
+ }
1876
+ const requestDomainVerification = (options) => {
1877
+ return createAuthEndpoint("/sso/request-domain-verification", {
1878
+ method: "POST",
1879
+ body: domainVerificationBodySchema,
1880
+ metadata: { openapi: {
1881
+ summary: "Request a domain verification",
1882
+ description: "Request a domain verification for the given SSO provider",
1883
+ responses: {
1884
+ "404": { description: "Provider not found" },
1885
+ "409": { description: "Domain has already been verified" },
1886
+ "201": { description: "Domain submitted for verification" }
1887
+ }
1888
+ } },
1889
+ use: [sessionMiddleware]
1890
+ }, async (ctx) => {
1891
+ const body = ctx.body;
1892
+ const provider = await checkProviderAccess(ctx, body.providerId);
1893
+ if (provider.domainVerified) throw new APIError("CONFLICT", {
1894
+ message: "Domain has already been verified",
1895
+ code: "DOMAIN_VERIFIED"
1425
1896
  });
1426
- case "discovery_untrusted_origin": return new APIError("BAD_REQUEST", {
1427
- message: `Untrusted OIDC discovery URL: ${error.message}`,
1428
- code: error.code
1897
+ const identifier = getVerificationIdentifier(options, provider.providerId);
1898
+ const activeVerification = await ctx.context.internalAdapter.findVerificationValue(identifier);
1899
+ if (activeVerification && new Date(activeVerification.expiresAt) > /* @__PURE__ */ new Date()) {
1900
+ ctx.setStatus(201);
1901
+ return ctx.json({ domainVerificationToken: activeVerification.value });
1902
+ }
1903
+ const domainVerificationToken = generateRandomString(24);
1904
+ await ctx.context.internalAdapter.createVerificationValue({
1905
+ identifier,
1906
+ value: domainVerificationToken,
1907
+ expiresAt: new Date(Date.now() + 3600 * 24 * 7 * 1e3)
1429
1908
  });
1430
- case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
1431
- message: `OIDC discovery returned invalid data: ${error.message}`,
1432
- code: error.code
1909
+ ctx.setStatus(201);
1910
+ return ctx.json({ domainVerificationToken });
1911
+ });
1912
+ };
1913
+ const verifyDomain = (options) => {
1914
+ return createAuthEndpoint("/sso/verify-domain", {
1915
+ method: "POST",
1916
+ body: domainVerificationBodySchema,
1917
+ metadata: { openapi: {
1918
+ summary: "Verify the provider domain ownership",
1919
+ description: "Verify the provider domain ownership via DNS records",
1920
+ responses: {
1921
+ "404": { description: "Provider not found" },
1922
+ "409": { description: "Domain has already been verified or no pending verification exists" },
1923
+ "502": { description: "Unable to verify domain ownership due to upstream validator error" },
1924
+ "204": { description: "Domain ownership was verified" }
1925
+ }
1926
+ } },
1927
+ use: [sessionMiddleware]
1928
+ }, async (ctx) => {
1929
+ const body = ctx.body;
1930
+ const provider = await checkProviderAccess(ctx, body.providerId);
1931
+ if (provider.domainVerified) throw new APIError("CONFLICT", {
1932
+ message: "Domain has already been verified",
1933
+ code: "DOMAIN_VERIFIED"
1433
1934
  });
1434
- case "discovery_incomplete": return new APIError("BAD_REQUEST", {
1435
- message: `OIDC discovery document is missing required fields: ${error.message}`,
1436
- code: error.code
1935
+ const identifier = getVerificationIdentifier(options, provider.providerId);
1936
+ if (identifier.length > DNS_LABEL_MAX_LENGTH) throw new APIError("BAD_REQUEST", {
1937
+ message: `Verification identifier exceeds the DNS label limit of ${DNS_LABEL_MAX_LENGTH} characters`,
1938
+ code: "IDENTIFIER_TOO_LONG"
1437
1939
  });
1438
- case "issuer_mismatch": return new APIError("BAD_REQUEST", {
1439
- message: `OIDC issuer mismatch: ${error.message}`,
1440
- code: error.code
1940
+ const activeVerification = await ctx.context.internalAdapter.findVerificationValue(identifier);
1941
+ if (!activeVerification || new Date(activeVerification.expiresAt) <= /* @__PURE__ */ new Date()) throw new APIError("NOT_FOUND", {
1942
+ message: "No pending domain verification exists",
1943
+ code: "NO_PENDING_VERIFICATION"
1441
1944
  });
1442
- case "unsupported_token_auth_method": return new APIError("BAD_REQUEST", {
1443
- message: `Incompatible OIDC provider: ${error.message}`,
1444
- code: error.code
1945
+ let dns;
1946
+ try {
1947
+ dns = await import("node:dns/promises");
1948
+ } catch (error) {
1949
+ ctx.context.logger.error("The core node:dns module is required for the domain verification feature", error);
1950
+ throw new APIError("INTERNAL_SERVER_ERROR", {
1951
+ message: "Unable to verify domain ownership due to server error",
1952
+ code: "DOMAIN_VERIFICATION_FAILED"
1953
+ });
1954
+ }
1955
+ const domains = parseProviderDomains(provider.domain);
1956
+ if (!domains) throw new APIError("BAD_REQUEST", {
1957
+ message: "Invalid domain",
1958
+ code: "INVALID_DOMAIN"
1445
1959
  });
1446
- default:
1447
- error.code;
1448
- return new APIError("INTERNAL_SERVER_ERROR", {
1449
- message: `Unexpected discovery error: ${error.message}`,
1450
- code: "discovery_unexpected_error"
1960
+ for (const domain of domains) {
1961
+ let records = [];
1962
+ try {
1963
+ records = (await dns.resolveTxt(`${identifier}.${domain}`)).map((record) => record.join(""));
1964
+ } catch (error) {
1965
+ ctx.context.logger.warn(`DNS resolution failure while validating domain ownership for ${domain}`, error);
1966
+ }
1967
+ const verificationValue = activeVerification.value;
1968
+ const verificationRecord = `${activeVerification.identifier}=${verificationValue}`;
1969
+ if (!records.find((record) => {
1970
+ const normalizedRecord = record.trim();
1971
+ return normalizedRecord === verificationRecord || normalizedRecord === verificationValue;
1972
+ })) throw new APIError("BAD_GATEWAY", {
1973
+ message: `Unable to verify domain ownership for ${domain}. Try again later`,
1974
+ code: "DOMAIN_VERIFICATION_FAILED"
1451
1975
  });
1452
- }
1453
- }
1454
- //#endregion
1455
- //#region src/saml/error-codes.ts
1456
- const SAML_ERROR_CODES = defineErrorCodes({
1457
- SINGLE_LOGOUT_NOT_ENABLED: "Single Logout is not enabled",
1458
- INVALID_LOGOUT_RESPONSE: "Invalid LogoutResponse",
1459
- INVALID_LOGOUT_REQUEST: "Invalid LogoutRequest",
1460
- LOGOUT_FAILED_AT_IDP: "Logout failed at IdP",
1461
- IDP_SLO_NOT_SUPPORTED: "IdP does not support Single Logout Service",
1462
- SAML_PROVIDER_NOT_FOUND: "SAML provider not found"
1463
- });
1976
+ }
1977
+ await ctx.context.adapter.update({
1978
+ model: "ssoProvider",
1979
+ where: [{
1980
+ field: "providerId",
1981
+ value: provider.providerId
1982
+ }],
1983
+ update: { domainVerified: true }
1984
+ });
1985
+ ctx.setStatus(204);
1986
+ });
1987
+ };
1464
1988
  //#endregion
1465
1989
  //#region src/saml-state.ts
1466
- async function generateRelayState(c, link, additionalData) {
1990
+ async function generateRelayState(c, link) {
1467
1991
  const callbackURL = c.body.callbackURL;
1468
1992
  if (!callbackURL) throw new APIError("BAD_REQUEST", { message: "callbackURL is required" });
1469
- const codeVerifier = generateRandomString(128);
1470
1993
  const stateData = {
1471
- ...additionalData ? additionalData : {},
1472
1994
  callbackURL,
1473
- codeVerifier,
1995
+ codeVerifier: generateRandomString(128),
1474
1996
  errorURL: c.body.errorCallbackURL,
1475
1997
  newUserURL: c.body.newUserCallbackURL,
1476
1998
  link,
@@ -1506,17 +2028,16 @@ async function parseRelayState(c) {
1506
2028
  if (!parsedData.errorURL) parsedData.errorURL = errorURL;
1507
2029
  return parsedData;
1508
2030
  }
2031
+ const saml = typeof samlifyNamespace.SPMetadata === "function" && typeof samlifyNamespace.setSchemaValidator === "function" ? samlifyNamespace : samlifyDefault ?? samlifyNamespace;
1509
2032
  //#endregion
1510
2033
  //#region src/routes/helpers.ts
1511
2034
  /**
1512
- * Normalizes a PEM string by trimming leading/trailing whitespace from each
1513
- * line. Native `crypto.createPrivateKey` (used by samlify 2.12+) rejects PEM
1514
- * blocks with leading whitespace, which is common when keys are stored in
1515
- * indented config files, environment variables, or JSON.
2035
+ * Same as `normalizePem`, but applied across the resolved list of IdP signing
2036
+ * certificates so multi-cert rotation configs survive the line-trim step.
1516
2037
  */
1517
- function normalizePem(pem) {
1518
- if (!pem) return pem;
1519
- return pem.split("\n").map((line) => line.trim()).join("\n");
2038
+ function normalizePemList(certs) {
2039
+ if (!certs) return certs;
2040
+ return certs.map((pem) => normalizePem(pem) ?? pem);
1520
2041
  }
1521
2042
  async function findSAMLProvider(providerId, options, adapter) {
1522
2043
  if (options?.defaultSSO?.length) {
@@ -1573,7 +2094,8 @@ function createSP(config, baseURL, providerId, opts) {
1573
2094
  isAssertionEncrypted: spData?.isAssertionEncrypted || false,
1574
2095
  encPrivateKey: normalizePem(spData?.encPrivateKey),
1575
2096
  encPrivateKeyPass: spData?.encPrivateKeyPass,
1576
- relayState: opts?.relayState
2097
+ relayState: opts?.relayState,
2098
+ clockDrifts: opts?.clockSkew && opts?.clockSkew !== 0 ? [-opts.clockSkew, opts.clockSkew] : void 0
1577
2099
  });
1578
2100
  }
1579
2101
  function createIdP(config) {
@@ -1593,7 +2115,7 @@ function createIdP(config) {
1593
2115
  Location: config.entryPoint
1594
2116
  }],
1595
2117
  singleLogoutService: idpData?.singleLogoutService,
1596
- signingCert: normalizePem(idpData?.cert || config.cert),
2118
+ signingCert: normalizePemList(resolveSigningCerts(config)),
1597
2119
  wantAuthnRequestsSigned: config.authnRequestsSigned || false,
1598
2120
  isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
1599
2121
  encPrivateKey: normalizePem(idpData?.encPrivateKey),
@@ -1604,7 +2126,17 @@ function escapeHtml(str) {
1604
2126
  if (!str) return "";
1605
2127
  return String(str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1606
2128
  }
2129
+ function isSAMLPostBindingLocation(value) {
2130
+ let url;
2131
+ try {
2132
+ url = new URL(value);
2133
+ } catch {
2134
+ return false;
2135
+ }
2136
+ return url.protocol === "http:" || url.protocol === "https:";
2137
+ }
1607
2138
  function createSAMLPostForm(action, samlParam, samlValue, relayState) {
2139
+ if (!isSAMLPostBindingLocation(action)) throw new APIError("BAD_REQUEST", { message: "SAML POST binding location must be an absolute http or https URL" });
1608
2140
  const safeAction = escapeHtml(action);
1609
2141
  const safeSamlParam = escapeHtml(samlParam);
1610
2142
  const safeSamlValue = escapeHtml(samlValue);
@@ -1674,6 +2206,14 @@ function getSafeRedirectUrl(url, callbackPath, appOrigin, isTrustedOrigin) {
1674
2206
  }
1675
2207
  return url;
1676
2208
  }
2209
+ try {
2210
+ const absoluteUrl = new URL(url);
2211
+ if (absoluteUrl.origin === appOrigin) {
2212
+ const callbackPathname = new URL(callbackPath).pathname;
2213
+ if (absoluteUrl.pathname === callbackPathname) return appOrigin;
2214
+ return url;
2215
+ }
2216
+ } catch {}
1677
2217
  if (!isTrustedOrigin(url, { allowRelativePaths: false })) return appOrigin;
1678
2218
  try {
1679
2219
  const callbackPathname = new URL(callbackPath).pathname;
@@ -1683,6 +2223,39 @@ function getSafeRedirectUrl(url, callbackPath, appOrigin, isTrustedOrigin) {
1683
2223
  }
1684
2224
  return url;
1685
2225
  }
2226
+ function buildSAMLRedirectUrl(url, params) {
2227
+ const searchParams = new URLSearchParams(params);
2228
+ try {
2229
+ const isRelativePath = url.startsWith("/") && !url.startsWith("//");
2230
+ const parsedUrl = new URL(url, "http://better-auth.local");
2231
+ for (const [key, value] of searchParams) parsedUrl.searchParams.set(key, value);
2232
+ if (isRelativePath) return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
2233
+ return parsedUrl.toString();
2234
+ } catch {
2235
+ const hashIndex = url.indexOf("#");
2236
+ const urlWithoutFragment = hashIndex === -1 ? url : url.slice(0, hashIndex);
2237
+ const fragment = hashIndex === -1 ? "" : url.slice(hashIndex);
2238
+ return `${urlWithoutFragment}${urlWithoutFragment.includes("?") ? "&" : "?"}${searchParams.toString()}${fragment}`;
2239
+ }
2240
+ }
2241
+ function toArray(value) {
2242
+ if (Array.isArray(value)) return value;
2243
+ return value ? [value] : [];
2244
+ }
2245
+ function getExpectedSAMLRecipients(config, baseURL, providerId, currentCallbackPath, assertionConsumerServiceUrl) {
2246
+ const configuredPostAssertionConsumerServiceUrls = getSAMLPostAssertionConsumerServiceUrls(config.spMetadata?.metadata);
2247
+ return [
2248
+ currentCallbackPath,
2249
+ `${baseURL}/sso/saml2/sp/acs/${providerId}`,
2250
+ ...configuredPostAssertionConsumerServiceUrls,
2251
+ ...toArray(assertionConsumerServiceUrl)
2252
+ ];
2253
+ }
2254
+ async function getSAMLResponseBindingContent(sp, samlContent) {
2255
+ if (!hasSAMLEncryptedAssertion(samlContent)) return samlContent;
2256
+ const [decryptedContent] = await saml.SamlLib.decryptAssertion(sp, samlContent);
2257
+ return decryptedContent;
2258
+ }
1686
2259
  /**
1687
2260
  * Extracts the Assertion ID from a SAML response XML.
1688
2261
  * Used for replay protection per SAML 2.0 Core section 2.3.3.
@@ -1729,9 +2302,9 @@ async function processSAMLResponse(ctx, params, options) {
1729
2302
  if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
1730
2303
  const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
1731
2304
  if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
1732
- const sp = createSP(parsedSamlConfig, ctx.context.baseURL, providerId);
2305
+ const sp = createSP(parsedSamlConfig, ctx.context.baseURL, providerId, { clockSkew: options?.saml?.clockSkew });
1733
2306
  const idp = createIdP(parsedSamlConfig);
1734
- const samlRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL, params.currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
2307
+ const samlRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, params.currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
1735
2308
  validateSingleAssertion(SAMLResponse);
1736
2309
  let parsedResponse;
1737
2310
  try {
@@ -1751,12 +2324,47 @@ async function processSAMLResponse(ctx, params, options) {
1751
2324
  });
1752
2325
  }
1753
2326
  const { extract } = parsedResponse;
2327
+ const samlContent = parsedResponse.samlContent;
1754
2328
  validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
1755
2329
  validateSAMLTimestamp(extract.conditions, {
1756
2330
  clockSkew: options?.saml?.clockSkew,
1757
2331
  requireTimestamps: options?.saml?.requireTimestamps,
1758
2332
  logger: ctx.context.logger
1759
2333
  });
2334
+ const expectedAudiences = [sp.entityMeta.getEntityID(), parsedSamlConfig.audience];
2335
+ const assertionConsumerServiceUrl = sp.entityMeta.getAssertionConsumerService(SAML_HTTP_POST_BINDING);
2336
+ const expectedRecipients = getExpectedSAMLRecipients(parsedSamlConfig, ctx.context.baseURL, providerId, currentCallbackPath, assertionConsumerServiceUrl);
2337
+ let samlBindingContent;
2338
+ try {
2339
+ samlBindingContent = await getSAMLResponseBindingContent(sp, samlContent);
2340
+ validateSAMLResponseBinding(samlBindingContent, {
2341
+ expectedAudiences,
2342
+ expectedRecipients
2343
+ });
2344
+ } catch (error) {
2345
+ if (isAPIError(error)) {
2346
+ ctx.context.logger.error("SAML response binding validation failed", {
2347
+ providerId,
2348
+ code: error.body?.code,
2349
+ expectedAudiences: expectedAudiences.filter(Boolean),
2350
+ expectedRecipients: expectedRecipients.filter(Boolean)
2351
+ });
2352
+ throw ctx.redirect(buildSAMLRedirectUrl(samlRedirectUrl, {
2353
+ error: "invalid_saml_response",
2354
+ error_description: error.body?.message || error.message || "Invalid SAML response"
2355
+ }));
2356
+ }
2357
+ ctx.context.logger.error("SAML response binding validation failed", {
2358
+ providerId,
2359
+ error,
2360
+ expectedAudiences: expectedAudiences.filter(Boolean),
2361
+ expectedRecipients: expectedRecipients.filter(Boolean)
2362
+ });
2363
+ throw ctx.redirect(buildSAMLRedirectUrl(samlRedirectUrl, {
2364
+ error: "invalid_saml_response",
2365
+ error_description: "SAML response binding could not be validated"
2366
+ }));
2367
+ }
1760
2368
  await validateInResponseTo(ctx, {
1761
2369
  extract,
1762
2370
  providerId,
@@ -1772,33 +2380,14 @@ async function processSAMLResponse(ctx, params, options) {
1772
2380
  providerId,
1773
2381
  redirectUrl: samlRedirectUrl
1774
2382
  });
1775
- const samlContent = parsedResponse.samlContent;
1776
- const assertionId = samlContent ? extractAssertionId(samlContent) : null;
2383
+ const assertionId = extractAssertionId(samlBindingContent);
1777
2384
  if (assertionId) {
1778
2385
  const issuer = idp.entityMeta.getEntityID();
1779
2386
  const conditions = extract.conditions;
1780
2387
  const clockSkew = options?.saml?.clockSkew ?? 3e5;
1781
2388
  const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
1782
- const existingAssertion = await ctx.context.internalAdapter.findVerificationValue(`${USED_ASSERTION_KEY_PREFIX}${assertionId}`);
1783
- let isReplay = false;
1784
- if (existingAssertion) try {
1785
- if (JSON.parse(existingAssertion.value).expiresAt >= Date.now()) isReplay = true;
1786
- } catch (error) {
1787
- ctx.context.logger.warn("Failed to parse stored assertion record", {
1788
- assertionId,
1789
- error
1790
- });
1791
- }
1792
- if (isReplay) {
1793
- ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
1794
- assertionId,
1795
- issuer,
1796
- providerId
1797
- });
1798
- throw ctx.redirect(`${samlRedirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
1799
- }
1800
- await ctx.context.internalAdapter.createVerificationValue({
1801
- identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
2389
+ if (!await ctx.context.internalAdapter.reserveVerificationValue({
2390
+ identifier: `saml-used-assertion:${assertionId}`,
1802
2391
  value: JSON.stringify({
1803
2392
  assertionId,
1804
2393
  issuer,
@@ -1807,16 +2396,30 @@ async function processSAMLResponse(ctx, params, options) {
1807
2396
  expiresAt
1808
2397
  }),
1809
2398
  expiresAt: new Date(expiresAt)
1810
- });
2399
+ })) {
2400
+ ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
2401
+ assertionId,
2402
+ issuer,
2403
+ providerId
2404
+ });
2405
+ throw ctx.redirect(buildSAMLRedirectUrl(samlRedirectUrl, {
2406
+ error: "replay_detected",
2407
+ error_description: "SAML assertion has already been used"
2408
+ }));
2409
+ }
1811
2410
  } else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
1812
2411
  const attributes = extract.attributes || {};
1813
2412
  const mapping = parsedSamlConfig.mapping ?? {};
2413
+ const attr = (key) => {
2414
+ const value = attributes[key];
2415
+ return Array.isArray(value) ? value[0] : value;
2416
+ };
1814
2417
  const userInfo = {
1815
2418
  ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
1816
- id: attributes[mapping.id || "nameID"] || extract.nameID,
1817
- email: (attributes[mapping.email || "email"] || extract.nameID || "").toLowerCase(),
1818
- name: [attributes[mapping.firstName || "givenName"], attributes[mapping.lastName || "surname"]].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
1819
- emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
2419
+ id: attr(mapping.id || "nameID") || extract.nameID,
2420
+ email: (attr(mapping.email || "email") || extract.nameID || "").toLowerCase(),
2421
+ name: [attr(mapping.firstName || "givenName"), attr(mapping.lastName || "surname")].filter(Boolean).join(" ") || attr(mapping.name || "displayName") || extract.nameID,
2422
+ emailVerified: options?.trustEmailVerified && mapping.emailVerified ? parseProviderEmailVerified(attr(mapping.emailVerified)) : false
1820
2423
  };
1821
2424
  if (!userInfo.id || !userInfo.email) {
1822
2425
  ctx.context.logger.error("Missing essential user info from SAML response", {
@@ -1827,26 +2430,47 @@ async function processSAMLResponse(ctx, params, options) {
1827
2430
  });
1828
2431
  throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
1829
2432
  }
1830
- const isTrustedProvider = ctx.context.trustedProviders.includes(providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
1831
- const postAuthRedirect = relayState?.callbackURL || ctx.context.baseURL;
1832
- const result = await handleOAuthUserInfo(ctx, {
1833
- userInfo: {
1834
- email: userInfo.email,
1835
- name: userInfo.name || userInfo.email,
1836
- id: userInfo.id,
1837
- emailVerified: Boolean(userInfo.emailVerified)
1838
- },
1839
- account: {
1840
- providerId,
1841
- accountId: userInfo.id,
1842
- accessToken: "",
1843
- refreshToken: ""
1844
- },
1845
- callbackURL: postAuthRedirect,
1846
- disableSignUp: options?.disableImplicitSignUp,
1847
- isTrustedProvider
1848
- });
1849
- if (result.error) throw ctx.redirect(`${samlRedirectUrl}?error=${result.error.split(" ").join("_")}`);
2433
+ const isTrustedProvider = "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
2434
+ const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2435
+ const errorUrl = relayState?.errorURL || samlRedirectUrl;
2436
+ let result;
2437
+ try {
2438
+ result = await runWithTransaction(ctx.context.adapter, async () => {
2439
+ await lockSSOProviderForAccountLink(ctx, provider);
2440
+ return handleOAuthUserInfo(ctx, {
2441
+ userInfo: {
2442
+ email: userInfo.email,
2443
+ name: userInfo.name || userInfo.email,
2444
+ id: userInfo.id,
2445
+ emailVerified: userInfo.emailVerified
2446
+ },
2447
+ account: {
2448
+ providerId,
2449
+ accountId: userInfo.id,
2450
+ accessToken: "",
2451
+ refreshToken: ""
2452
+ },
2453
+ callbackURL: callbackUrl,
2454
+ disableSignUp: options?.disableImplicitSignUp,
2455
+ source: {
2456
+ method: "sso-saml",
2457
+ sso: {
2458
+ providerId,
2459
+ profile: attributes
2460
+ }
2461
+ },
2462
+ isTrustedProvider,
2463
+ trustProviderByName: false
2464
+ });
2465
+ });
2466
+ } catch (e) {
2467
+ if (isAPIError(e) && e.body?.code) throw ctx.redirect(buildSAMLRedirectUrl(errorUrl, {
2468
+ error: e.body.code,
2469
+ ...e.body.message ? { error_description: e.body.message } : {}
2470
+ }));
2471
+ throw e;
2472
+ }
2473
+ if (result.error) throw ctx.redirect(buildSAMLRedirectUrl(callbackUrl, { error: result.error.split(" ").join("_") }));
1850
2474
  const { session, user } = result.data;
1851
2475
  if (options?.provisionUser && (result.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
1852
2476
  user,
@@ -1860,7 +2484,7 @@ async function processSAMLResponse(ctx, params, options) {
1860
2484
  providerId,
1861
2485
  accountId: userInfo.id,
1862
2486
  email: userInfo.email,
1863
- emailVerified: Boolean(userInfo.emailVerified),
2487
+ emailVerified: userInfo.emailVerified,
1864
2488
  rawAttributes: attributes
1865
2489
  },
1866
2490
  provider,
@@ -1874,6 +2498,7 @@ async function processSAMLResponse(ctx, params, options) {
1874
2498
  const samlSessionKey = `${SAML_SESSION_KEY_PREFIX}${providerId}:${extract.nameID}`;
1875
2499
  const samlSessionData = {
1876
2500
  sessionId: session.id,
2501
+ sessionToken: session.token,
1877
2502
  providerId,
1878
2503
  nameID: extract.nameID,
1879
2504
  sessionIndex: extract.sessionIndex?.sessionIndex
@@ -1889,10 +2514,18 @@ async function processSAMLResponse(ctx, params, options) {
1889
2514
  expiresAt: session.expiresAt
1890
2515
  }).catch((e) => ctx.context.logger.warn("Failed to create SAML session lookup record", e));
1891
2516
  }
1892
- return getSafeRedirectUrl(relayState?.callbackURL, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
2517
+ return getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
1893
2518
  }
1894
2519
  //#endregion
1895
2520
  //#region src/routes/sso.ts
2521
+ const BUILT_IN_ACCOUNT_PROVIDER_IDS = [
2522
+ "credential",
2523
+ "email-otp",
2524
+ "magic-link",
2525
+ "phone-number",
2526
+ "anonymous",
2527
+ "siwe"
2528
+ ];
1896
2529
  /**
1897
2530
  * Builds the OIDC redirect URI. Uses the shared `redirectURI` option
1898
2531
  * when set, otherwise falls back to `/sso/callback/:providerId`.
@@ -1918,15 +2551,9 @@ const spMetadata = (options) => {
1918
2551
  responses: { "200": { description: "SAML metadata in XML format" } }
1919
2552
  } }
1920
2553
  }, async (ctx) => {
1921
- const provider = await ctx.context.adapter.findOne({
1922
- model: "ssoProvider",
1923
- where: [{
1924
- field: "providerId",
1925
- value: ctx.query.providerId
1926
- }]
1927
- });
2554
+ const provider = await findSAMLProvider(ctx.query.providerId, options, ctx.context.adapter);
1928
2555
  if (!provider) throw new APIError("NOT_FOUND", { message: "No provider found for the given providerId" });
1929
- const parsedSamlConfig = safeJsonParse(provider.samlConfig);
2556
+ const parsedSamlConfig = provider.samlConfig;
1930
2557
  if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
1931
2558
  const sp = createSP(parsedSamlConfig, ctx.context.baseURL, ctx.query.providerId, options?.saml?.enableSingleLogout ? { sloOptions: {
1932
2559
  wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned,
@@ -1935,255 +2562,180 @@ const spMetadata = (options) => {
1935
2562
  return new Response(sp.getMetadata(), { headers: { "Content-Type": "application/xml" } });
1936
2563
  });
1937
2564
  };
1938
- const ssoProviderBodySchema = z.object({
1939
- providerId: z.string({}).meta({ description: "The ID of the provider. This is used to identify the provider during login and callback" }),
1940
- issuer: z.string({}).meta({ description: "The issuer of the provider" }),
1941
- 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')" }),
1942
- oidcConfig: z.object({
1943
- clientId: z.string({}).meta({ description: "The client ID" }),
1944
- clientSecret: z.string({}).optional().meta({ description: "The client secret. Required for client_secret_basic/client_secret_post. Optional for private_key_jwt." }),
1945
- authorizationEndpoint: z.string({}).meta({ description: "The authorization endpoint" }).optional(),
1946
- tokenEndpoint: z.string({}).meta({ description: "The token endpoint" }).optional(),
1947
- userInfoEndpoint: z.string({}).meta({ description: "The user info endpoint" }).optional(),
1948
- tokenEndpointAuthentication: z.enum([
1949
- "client_secret_post",
1950
- "client_secret_basic",
1951
- "private_key_jwt"
1952
- ]).optional(),
1953
- privateKeyId: z.string().optional(),
1954
- privateKeyAlgorithm: z.string().optional(),
1955
- jwksEndpoint: z.string({}).meta({ description: "The JWKS endpoint" }).optional(),
1956
- discoveryEndpoint: z.string().optional(),
1957
- skipDiscovery: z.boolean().meta({ description: "Skip OIDC discovery during registration. When true, you must provide authorizationEndpoint, tokenEndpoint, and jwksEndpoint manually." }).optional(),
1958
- scopes: z.array(z.string(), {}).meta({ description: "The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']" }).optional(),
1959
- pkce: z.boolean({}).meta({ description: "Whether to use PKCE for the authorization flow" }).default(true).optional(),
1960
- mapping: z.object({
1961
- id: z.string({}).meta({ description: "Field mapping for user ID (defaults to 'sub')" }),
1962
- email: z.string({}).meta({ description: "Field mapping for email (defaults to 'email')" }),
1963
- emailVerified: z.string({}).meta({ description: "Field mapping for email verification (defaults to 'email_verified')" }).optional(),
1964
- name: z.string({}).meta({ description: "Field mapping for name (defaults to 'name')" }),
1965
- image: z.string({}).meta({ description: "Field mapping for image (defaults to 'picture')" }).optional(),
1966
- extraFields: z.record(z.string(), z.any()).optional()
1967
- }).optional()
1968
- }).optional(),
1969
- samlConfig: z.object({
1970
- entryPoint: z.string({}).meta({ description: "The entry point of the provider" }),
1971
- cert: z.string({}).meta({ description: "The certificate of the provider" }),
1972
- audience: z.string().optional(),
1973
- idpMetadata: z.object({
1974
- metadata: z.string().optional(),
1975
- entityID: z.string().optional(),
1976
- cert: z.string().optional(),
1977
- privateKey: z.string().optional(),
1978
- privateKeyPass: z.string().optional(),
1979
- isAssertionEncrypted: z.boolean().optional(),
1980
- encPrivateKey: z.string().optional(),
1981
- encPrivateKeyPass: z.string().optional(),
1982
- singleSignOnService: z.array(z.object({
1983
- Binding: z.string().meta({ description: "The binding type for the SSO service" }),
1984
- Location: z.string().meta({ description: "The URL for the SSO service" })
1985
- })).optional().meta({ description: "Single Sign-On service configuration" })
1986
- }).optional(),
1987
- spMetadata: z.object({
1988
- metadata: z.string().optional(),
1989
- entityID: z.string().optional(),
1990
- binding: z.string().optional(),
1991
- privateKey: z.string().optional(),
1992
- privateKeyPass: z.string().optional(),
1993
- isAssertionEncrypted: z.boolean().optional(),
1994
- encPrivateKey: z.string().optional(),
1995
- encPrivateKeyPass: z.string().optional()
1996
- }).optional(),
1997
- wantAssertionsSigned: z.boolean().optional(),
1998
- authnRequestsSigned: z.boolean().optional(),
1999
- signatureAlgorithm: z.string().optional(),
2000
- digestAlgorithm: z.string().optional(),
2001
- identifierFormat: z.string().optional(),
2002
- privateKey: z.string().optional(),
2003
- mapping: z.object({
2004
- id: z.string({}).meta({ description: "Field mapping for user ID (defaults to 'nameID')" }),
2005
- email: z.string({}).meta({ description: "Field mapping for email (defaults to 'email')" }),
2006
- emailVerified: z.string({}).meta({ description: "Field mapping for email verification" }).optional(),
2007
- name: z.string({}).meta({ description: "Field mapping for name (defaults to 'displayName')" }),
2008
- firstName: z.string({}).meta({ description: "Field mapping for first name (defaults to 'givenName')" }).optional(),
2009
- lastName: z.string({}).meta({ description: "Field mapping for last name (defaults to 'surname')" }).optional(),
2010
- extraFields: z.record(z.string(), z.any()).optional()
2011
- }).optional()
2012
- }).optional(),
2013
- organizationId: z.string({}).meta({ description: "If organization plugin is enabled, the organization id to link the provider to" }).optional(),
2014
- overrideUserInfo: z.boolean({}).meta({ description: "Override user info with the provider info. Defaults to false" }).default(false).optional()
2015
- });
2016
2565
  const registerSSOProvider = (options) => {
2017
2566
  return createAuthEndpoint("/sso/register", {
2018
2567
  method: "POST",
2019
- body: ssoProviderBodySchema,
2568
+ body: getRegisterSSOProviderBodySchema(options),
2020
2569
  use: [sessionMiddleware],
2021
- metadata: { openapi: {
2022
- operationId: "registerSSOProvider",
2023
- summary: "Register an OIDC provider",
2024
- description: "This endpoint is used to register an OIDC provider. This is used to configure the provider and link it to an organization",
2025
- responses: { "200": {
2026
- description: "OIDC provider created successfully",
2027
- content: { "application/json": { schema: {
2028
- type: "object",
2029
- properties: {
2030
- issuer: {
2031
- type: "string",
2032
- format: "uri",
2033
- description: "The issuer URL of the provider"
2034
- },
2035
- domain: {
2036
- type: "string",
2037
- description: "The domain of the provider, used for email matching"
2038
- },
2039
- domainVerified: {
2040
- type: "boolean",
2041
- description: "A boolean indicating whether the domain has been verified or not"
2042
- },
2043
- domainVerificationToken: {
2044
- type: "string",
2045
- description: "Domain verification token. It can be used to prove ownership over the SSO domain"
2046
- },
2047
- oidcConfig: {
2048
- type: "object",
2049
- properties: {
2050
- issuer: {
2051
- type: "string",
2052
- format: "uri",
2053
- description: "The issuer URL of the provider"
2054
- },
2055
- pkce: {
2056
- type: "boolean",
2057
- description: "Whether PKCE is enabled for the authorization flow"
2058
- },
2059
- clientId: {
2060
- type: "string",
2061
- description: "The client ID for the provider"
2062
- },
2063
- clientSecret: {
2064
- type: "string",
2065
- description: "The client secret for the provider"
2066
- },
2067
- authorizationEndpoint: {
2068
- type: "string",
2069
- format: "uri",
2070
- nullable: true,
2071
- description: "The authorization endpoint URL"
2072
- },
2073
- discoveryEndpoint: {
2074
- type: "string",
2075
- format: "uri",
2076
- description: "The discovery endpoint URL"
2077
- },
2078
- userInfoEndpoint: {
2079
- type: "string",
2080
- format: "uri",
2081
- nullable: true,
2082
- description: "The user info endpoint URL"
2083
- },
2084
- scopes: {
2085
- type: "array",
2086
- items: { type: "string" },
2087
- nullable: true,
2088
- description: "The scopes requested from the provider"
2089
- },
2090
- tokenEndpoint: {
2091
- type: "string",
2092
- format: "uri",
2093
- nullable: true,
2094
- description: "The token endpoint URL"
2095
- },
2096
- tokenEndpointAuthentication: {
2097
- type: "string",
2098
- enum: ["client_secret_post", "client_secret_basic"],
2099
- nullable: true,
2100
- description: "Authentication method for the token endpoint"
2101
- },
2102
- jwksEndpoint: {
2103
- type: "string",
2104
- format: "uri",
2105
- nullable: true,
2106
- description: "The JWKS endpoint URL"
2107
- },
2108
- mapping: {
2109
- type: "object",
2110
- nullable: true,
2111
- properties: {
2112
- id: {
2113
- type: "string",
2114
- description: "Field mapping for user ID (defaults to 'sub')"
2115
- },
2116
- email: {
2117
- type: "string",
2118
- description: "Field mapping for email (defaults to 'email')"
2119
- },
2120
- emailVerified: {
2121
- type: "string",
2122
- nullable: true,
2123
- description: "Field mapping for email verification (defaults to 'email_verified')"
2124
- },
2125
- name: {
2126
- type: "string",
2127
- description: "Field mapping for name (defaults to 'name')"
2128
- },
2129
- image: {
2130
- type: "string",
2131
- nullable: true,
2132
- description: "Field mapping for image (defaults to 'picture')"
2133
- },
2134
- extraFields: {
2135
- type: "object",
2136
- additionalProperties: { type: "string" },
2137
- nullable: true,
2138
- description: "Additional field mappings"
2139
- }
2570
+ metadata: {
2571
+ $Infer: { body: {} },
2572
+ openapi: {
2573
+ operationId: "registerSSOProvider",
2574
+ summary: "Register an OIDC provider",
2575
+ description: "This endpoint is used to register an OIDC provider. This is used to configure the provider and link it to an organization",
2576
+ responses: { "200": {
2577
+ description: "OIDC provider created successfully",
2578
+ content: { "application/json": { schema: {
2579
+ type: "object",
2580
+ properties: {
2581
+ issuer: {
2582
+ type: "string",
2583
+ format: "uri",
2584
+ description: "The issuer URL of the provider"
2585
+ },
2586
+ domain: {
2587
+ type: "string",
2588
+ description: "The domain of the provider, used for email matching"
2589
+ },
2590
+ domainVerified: {
2591
+ type: "boolean",
2592
+ description: "A boolean indicating whether the domain has been verified or not"
2593
+ },
2594
+ domainVerificationToken: {
2595
+ type: "string",
2596
+ description: "Domain verification token. It can be used to prove ownership over the SSO domain"
2597
+ },
2598
+ oidcConfig: {
2599
+ type: "object",
2600
+ properties: {
2601
+ issuer: {
2602
+ type: "string",
2603
+ format: "uri",
2604
+ description: "The issuer URL of the provider"
2605
+ },
2606
+ pkce: {
2607
+ type: "boolean",
2608
+ description: "Whether PKCE is enabled for the authorization flow"
2609
+ },
2610
+ clientId: {
2611
+ type: "string",
2612
+ description: "The client ID for the provider"
2140
2613
  },
2141
- required: [
2142
- "id",
2143
- "email",
2144
- "name"
2145
- ]
2146
- }
2614
+ clientSecret: {
2615
+ type: "string",
2616
+ description: "The client secret for the provider"
2617
+ },
2618
+ authorizationEndpoint: {
2619
+ type: "string",
2620
+ format: "uri",
2621
+ nullable: true,
2622
+ description: "The authorization endpoint URL"
2623
+ },
2624
+ discoveryEndpoint: {
2625
+ type: "string",
2626
+ format: "uri",
2627
+ description: "The discovery endpoint URL"
2628
+ },
2629
+ userInfoEndpoint: {
2630
+ type: "string",
2631
+ format: "uri",
2632
+ nullable: true,
2633
+ description: "The user info endpoint URL"
2634
+ },
2635
+ scopes: {
2636
+ type: "array",
2637
+ items: { type: "string" },
2638
+ nullable: true,
2639
+ description: "The scopes requested from the provider"
2640
+ },
2641
+ tokenEndpoint: {
2642
+ type: "string",
2643
+ format: "uri",
2644
+ nullable: true,
2645
+ description: "The token endpoint URL"
2646
+ },
2647
+ tokenEndpointAuthentication: {
2648
+ type: "string",
2649
+ enum: ["client_secret_post", "client_secret_basic"],
2650
+ nullable: true,
2651
+ description: "Authentication method for the token endpoint"
2652
+ },
2653
+ jwksEndpoint: {
2654
+ type: "string",
2655
+ format: "uri",
2656
+ nullable: true,
2657
+ description: "The JWKS endpoint URL"
2658
+ },
2659
+ mapping: {
2660
+ type: "object",
2661
+ nullable: true,
2662
+ properties: {
2663
+ id: {
2664
+ type: "string",
2665
+ description: "Field mapping for user ID (defaults to 'sub')"
2666
+ },
2667
+ email: {
2668
+ type: "string",
2669
+ description: "Field mapping for email (defaults to 'email')"
2670
+ },
2671
+ emailVerified: {
2672
+ type: "string",
2673
+ nullable: true,
2674
+ description: "Field mapping for email verification (defaults to 'email_verified')"
2675
+ },
2676
+ name: {
2677
+ type: "string",
2678
+ description: "Field mapping for name (defaults to 'name')"
2679
+ },
2680
+ image: {
2681
+ type: "string",
2682
+ nullable: true,
2683
+ description: "Field mapping for image (defaults to 'picture')"
2684
+ },
2685
+ extraFields: {
2686
+ type: "object",
2687
+ additionalProperties: { type: "string" },
2688
+ nullable: true,
2689
+ description: "Additional field mappings"
2690
+ }
2691
+ },
2692
+ required: [
2693
+ "id",
2694
+ "email",
2695
+ "name"
2696
+ ]
2697
+ }
2698
+ },
2699
+ required: [
2700
+ "issuer",
2701
+ "pkce",
2702
+ "clientId",
2703
+ "clientSecret",
2704
+ "discoveryEndpoint"
2705
+ ],
2706
+ description: "OIDC configuration for the provider"
2147
2707
  },
2148
- required: [
2149
- "issuer",
2150
- "pkce",
2151
- "clientId",
2152
- "clientSecret",
2153
- "discoveryEndpoint"
2154
- ],
2155
- description: "OIDC configuration for the provider"
2156
- },
2157
- organizationId: {
2158
- type: "string",
2159
- nullable: true,
2160
- description: "ID of the linked organization, if any"
2161
- },
2162
- userId: {
2163
- type: "string",
2164
- description: "ID of the user who registered the provider"
2165
- },
2166
- providerId: {
2167
- type: "string",
2168
- description: "Unique identifier for the provider"
2708
+ organizationId: {
2709
+ type: "string",
2710
+ nullable: true,
2711
+ description: "ID of the linked organization, if any"
2712
+ },
2713
+ userId: {
2714
+ type: "string",
2715
+ description: "ID of the user who registered the provider"
2716
+ },
2717
+ providerId: {
2718
+ type: "string",
2719
+ description: "Unique identifier for the provider"
2720
+ },
2721
+ redirectURI: {
2722
+ type: "string",
2723
+ format: "uri",
2724
+ description: "The redirect URI for the provider callback"
2725
+ }
2169
2726
  },
2170
- redirectURI: {
2171
- type: "string",
2172
- format: "uri",
2173
- description: "The redirect URI for the provider callback"
2174
- }
2175
- },
2176
- required: [
2177
- "issuer",
2178
- "domain",
2179
- "oidcConfig",
2180
- "userId",
2181
- "providerId",
2182
- "redirectURI"
2183
- ]
2184
- } } }
2185
- } }
2186
- } }
2727
+ required: [
2728
+ "issuer",
2729
+ "domain",
2730
+ "oidcConfig",
2731
+ "userId",
2732
+ "providerId",
2733
+ "redirectURI"
2734
+ ]
2735
+ } } }
2736
+ } }
2737
+ }
2738
+ }
2187
2739
  }, async (ctx) => {
2188
2740
  const user = ctx.context.session?.user;
2189
2741
  if (!user) throw new APIError("UNAUTHORIZED");
@@ -2197,13 +2749,13 @@ const registerSSOProvider = (options) => {
2197
2749
  }]
2198
2750
  })).length >= limit) throw new APIError("FORBIDDEN", { message: "You have reached the maximum number of SSO providers" });
2199
2751
  const body = ctx.body;
2200
- if (z.string().url().safeParse(body.issuer).error) throw new APIError("BAD_REQUEST", { message: "Invalid issuer. Must be a valid URL" });
2752
+ const additionalFields = parseSSOProviderAdditionalFields(options, body, "create");
2201
2753
  if (body.samlConfig?.idpMetadata?.metadata) {
2202
2754
  const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
2203
2755
  if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
2204
2756
  }
2205
2757
  if (ctx.body.organizationId) {
2206
- if (!await ctx.context.adapter.findOne({
2758
+ const member = await ctx.context.adapter.findOne({
2207
2759
  model: "member",
2208
2760
  where: [{
2209
2761
  field: "userId",
@@ -2212,7 +2764,31 @@ const registerSSOProvider = (options) => {
2212
2764
  field: "organizationId",
2213
2765
  value: ctx.body.organizationId
2214
2766
  }]
2215
- })) throw new APIError("BAD_REQUEST", { message: "You are not a member of the organization" });
2767
+ });
2768
+ if (!member) throw new APIError("BAD_REQUEST", { message: "You are not a member of the organization" });
2769
+ if (ctx.context.hasPlugin("organization") && !hasOrgAdminRole(member)) throw new APIError("FORBIDDEN", { message: "You must be an organization owner or admin to register SSO providers" });
2770
+ }
2771
+ if (new Set([
2772
+ ...BUILT_IN_ACCOUNT_PROVIDER_IDS,
2773
+ ...Object.keys(ctx.context.options.socialProviders ?? {}),
2774
+ ...ctx.context.socialProviders.map((p) => p.id),
2775
+ ...ctx.context.trustedProviders,
2776
+ ...options?.defaultSSO?.map((p) => p.providerId) ?? []
2777
+ ]).has(body.providerId)) {
2778
+ ctx.context.logger.warn(`SSO provider registration rejected for reserved providerId: ${body.providerId}`);
2779
+ throw new APIError("UNPROCESSABLE_ENTITY", { message: "This providerId is reserved and cannot be used for an SSO provider" });
2780
+ }
2781
+ if (ctx.context.hasPlugin("scim")) {
2782
+ if (await ctx.context.adapter.findOne({
2783
+ model: "scimProvider",
2784
+ where: [{
2785
+ field: "providerId",
2786
+ value: body.providerId
2787
+ }]
2788
+ })) {
2789
+ ctx.context.logger.warn(`SSO provider registration rejected for SCIM providerId: ${body.providerId}`);
2790
+ throw new APIError("UNPROCESSABLE_ENTITY", { message: "This providerId is already used by a SCIM provider and cannot be used for an SSO provider" });
2791
+ }
2216
2792
  }
2217
2793
  if (await ctx.context.adapter.findOne({
2218
2794
  model: "ssoProvider",
@@ -2224,6 +2800,12 @@ const registerSSOProvider = (options) => {
2224
2800
  ctx.context.logger.info(`SSO provider creation attempt with existing providerId: ${body.providerId}`);
2225
2801
  throw new APIError("UNPROCESSABLE_ENTITY", { message: "SSO provider with this providerId already exists" });
2226
2802
  }
2803
+ if (body.oidcConfig) try {
2804
+ validateOIDCEndpointUrls(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
2805
+ } catch (error) {
2806
+ if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
2807
+ throw error;
2808
+ }
2227
2809
  let hydratedOIDCConfig = null;
2228
2810
  if (body.oidcConfig && !body.oidcConfig.skipDiscovery) try {
2229
2811
  hydratedOIDCConfig = await discoverOIDCConfig({
@@ -2285,6 +2867,7 @@ const registerSSOProvider = (options) => {
2285
2867
  signatureAlgorithm: body.samlConfig.signatureAlgorithm,
2286
2868
  digestAlgorithm: body.samlConfig.digestAlgorithm
2287
2869
  }, options?.saml?.algorithms);
2870
+ validateCertSources(body.samlConfig);
2288
2871
  const hasIdpMetadata = body.samlConfig.idpMetadata?.metadata;
2289
2872
  let hasEntryPoint = false;
2290
2873
  if (body.samlConfig.entryPoint) try {
@@ -2300,6 +2883,7 @@ const registerSSOProvider = (options) => {
2300
2883
  issuer: body.issuer,
2301
2884
  domain: body.domain,
2302
2885
  domainVerified: false,
2886
+ ...additionalFields,
2303
2887
  oidcConfig: (() => {
2304
2888
  const config = buildOIDCConfig();
2305
2889
  if (config) {
@@ -2314,6 +2898,7 @@ const registerSSOProvider = (options) => {
2314
2898
  entryPoint: body.samlConfig.entryPoint,
2315
2899
  cert: body.samlConfig.cert,
2316
2900
  audience: body.samlConfig.audience,
2901
+ callbackUrl: body.samlConfig.callbackUrl,
2317
2902
  idpMetadata: body.samlConfig.idpMetadata,
2318
2903
  spMetadata: body.samlConfig.spMetadata,
2319
2904
  wantAssertionsSigned: body.samlConfig.wantAssertionsSigned,
@@ -2341,7 +2926,7 @@ const registerSSOProvider = (options) => {
2341
2926
  });
2342
2927
  }
2343
2928
  const result = {
2344
- ...provider,
2929
+ ...filterSSOProviderAdditionalFields(provider, options),
2345
2930
  oidcConfig: safeJsonParse(provider.oidcConfig),
2346
2931
  samlConfig: safeJsonParse(provider.samlConfig),
2347
2932
  redirectURI: getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options),
@@ -2352,17 +2937,18 @@ const registerSSOProvider = (options) => {
2352
2937
  });
2353
2938
  };
2354
2939
  const signInSSOBodySchema = z.object({
2355
- email: z.string({}).meta({ description: "The email address to sign in with. This is used to identify the issuer to sign in with. It's optional if the issuer is provided" }).optional(),
2356
- organizationSlug: z.string({}).meta({ description: "The slug of the organization to sign in with" }).optional(),
2357
- providerId: z.string({}).meta({ description: "The ID of the provider to sign in with. This can be provided instead of email or issuer" }).optional(),
2358
- domain: z.string({}).meta({ description: "The domain of the provider." }).optional(),
2359
- callbackURL: z.string({}).meta({ description: "The URL to redirect to after login" }),
2360
- errorCallbackURL: z.string({}).meta({ description: "The URL to redirect to after login" }).optional(),
2361
- newUserCallbackURL: z.string({}).meta({ description: "The URL to redirect to after login if the user is new" }).optional(),
2940
+ 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(),
2941
+ organizationSlug: z.string({}).meta({ description: "The slug of the organization to sign in with." }).optional(),
2942
+ providerId: z.string({}).meta({ description: "The ID of the provider to sign in with. Can be provided instead of email." }).optional(),
2943
+ domain: z.string({}).meta({ description: "The email domain of the provider. Can be provided instead of email." }).optional(),
2944
+ callbackURL: z.string({}).meta({ description: "The URL to redirect to after successful sign-in." }),
2945
+ errorCallbackURL: z.string({}).meta({ description: "The URL to redirect to if the sign-in flow fails." }).optional(),
2946
+ newUserCallbackURL: z.string({}).meta({ description: "The URL to redirect to after sign-in if the user is newly registered." }).optional(),
2362
2947
  scopes: z.array(z.string(), {}).meta({ description: "Scopes to request from the provider." }).optional(),
2363
- loginHint: z.string({}).meta({ description: "Login hint to send to the identity provider (e.g., email or identifier). If supported, will be sent as 'login_hint'." }).optional(),
2364
- requestSignUp: z.boolean({}).meta({ description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider" }).optional(),
2365
- providerType: z.enum(["oidc", "saml"]).optional()
2948
+ 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(),
2949
+ additionalParams: additionalAuthorizationParamsSchema,
2950
+ requestSignUp: z.boolean({}).meta({ description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider." }).optional(),
2951
+ providerType: z.enum(["oidc", "saml"]).meta({ description: "The provider protocol to sign in with." }).optional()
2366
2952
  });
2367
2953
  const signInSSO = (options) => {
2368
2954
  return createAuthEndpoint("/sign-in/sso", {
@@ -2377,31 +2963,54 @@ const signInSSO = (options) => {
2377
2963
  properties: {
2378
2964
  email: {
2379
2965
  type: "string",
2380
- description: "The email address to sign in with. This is used to identify the issuer to sign in with. It's optional if the issuer is provided"
2966
+ 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."
2381
2967
  },
2382
- issuer: {
2968
+ organizationSlug: {
2383
2969
  type: "string",
2384
- description: "The issuer identifier, this is the URL of the provider and can be used to verify the provider and identify the provider during login. It's optional if the email is provided"
2970
+ description: "The slug of the organization to sign in with."
2385
2971
  },
2386
2972
  providerId: {
2387
2973
  type: "string",
2388
- description: "The ID of the provider to sign in with. This can be provided instead of email or issuer"
2974
+ description: "The ID of the provider to sign in with. Can be provided instead of email."
2975
+ },
2976
+ domain: {
2977
+ type: "string",
2978
+ description: "The email domain of the provider. Can be provided instead of email."
2389
2979
  },
2390
2980
  callbackURL: {
2391
2981
  type: "string",
2392
- description: "The URL to redirect to after login"
2982
+ description: "The URL to redirect to after successful sign-in."
2393
2983
  },
2394
2984
  errorCallbackURL: {
2395
2985
  type: "string",
2396
- description: "The URL to redirect to after login"
2986
+ description: "The URL to redirect to if the sign-in flow fails."
2397
2987
  },
2398
2988
  newUserCallbackURL: {
2399
2989
  type: "string",
2400
- description: "The URL to redirect to after login if the user is new"
2990
+ description: "The URL to redirect to after sign-in if the user is newly registered."
2991
+ },
2992
+ scopes: {
2993
+ type: "array",
2994
+ items: { type: "string" },
2995
+ description: "Scopes to request from the provider."
2401
2996
  },
2402
2997
  loginHint: {
2403
2998
  type: "string",
2404
2999
  description: "Login hint to send to the identity provider (e.g., email or identifier). If supported, sent as 'login_hint'."
3000
+ },
3001
+ additionalParams: {
3002
+ type: "object",
3003
+ additionalProperties: { type: "string" },
3004
+ 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."
3005
+ },
3006
+ requestSignUp: {
3007
+ type: "boolean",
3008
+ description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider."
3009
+ },
3010
+ providerType: {
3011
+ type: "string",
3012
+ enum: ["oidc", "saml"],
3013
+ description: "The provider protocol to sign in with."
2405
3014
  }
2406
3015
  },
2407
3016
  required: ["callbackURL"]
@@ -2444,7 +3053,7 @@ const signInSSO = (options) => {
2444
3053
  });
2445
3054
  let provider = null;
2446
3055
  if (options?.defaultSSO?.length) {
2447
- const matchingDefault = providerId ? options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId) : options.defaultSSO.find((defaultProvider) => defaultProvider.domain === domain);
3056
+ const matchingDefault = providerId ? options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId) : options.defaultSSO.find((defaultProvider) => domain && domainMatches(domain, defaultProvider.domain));
2448
3057
  if (matchingDefault) provider = {
2449
3058
  issuer: matchingDefault.samlConfig?.issuer || matchingDefault.oidcConfig?.issuer || "",
2450
3059
  providerId: matchingDefault.providerId,
@@ -2498,7 +3107,8 @@ const signInSSO = (options) => {
2498
3107
  throw error;
2499
3108
  }
2500
3109
  if (!config.authorizationEndpoint) throw new APIError("BAD_REQUEST", { message: "Invalid OIDC configuration. Authorization URL not found." });
2501
- const state = await generateState(ctx, void 0, options?.redirectURI?.trim() ? { ssoProviderId: provider.providerId } : false);
3110
+ if (options?.redirectURI?.trim()) await addOAuthServerContext({ ssoProviderId: provider.providerId });
3111
+ const state = await generateState(ctx);
2502
3112
  const redirectURI = getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options);
2503
3113
  const authorizationURL = await createAuthorizationURL({
2504
3114
  id: provider.issuer,
@@ -2516,7 +3126,8 @@ const signInSSO = (options) => {
2516
3126
  "offline_access"
2517
3127
  ],
2518
3128
  loginHint: ctx.body.loginHint || email,
2519
- authorizationEndpoint: config.authorizationEndpoint
3129
+ authorizationEndpoint: config.authorizationEndpoint,
3130
+ additionalParams: ctx.body.additionalParams
2520
3131
  });
2521
3132
  return ctx.json({
2522
3133
  url: authorizationURL.toString(),
@@ -2524,10 +3135,11 @@ const signInSSO = (options) => {
2524
3135
  });
2525
3136
  }
2526
3137
  if (provider.samlConfig) {
3138
+ 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." });
2527
3139
  const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
2528
3140
  if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
2529
3141
  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" });
2530
- const { state: relayState } = await generateRelayState(ctx, void 0, false);
3142
+ const { state: relayState } = await generateRelayState(ctx, void 0);
2531
3143
  const sp = createSP(parsedSamlConfig, ctx.context.baseURL, provider.providerId, { relayState });
2532
3144
  const idp = createIdP(parsedSamlConfig);
2533
3145
  const loginRequest = sp.createLoginRequest(idp, "redirect");
@@ -2556,10 +3168,26 @@ const signInSSO = (options) => {
2556
3168
  };
2557
3169
  const callbackSSOQuerySchema = z.object({
2558
3170
  code: z.string().optional(),
2559
- state: z.string(),
3171
+ state: z.string().optional(),
2560
3172
  error: z.string().optional(),
2561
3173
  error_description: z.string().optional()
2562
3174
  });
3175
+ function getStringErrorField(value, field) {
3176
+ if (!value || typeof value !== "object") return;
3177
+ const fieldValue = value[field];
3178
+ return typeof fieldValue === "string" && fieldValue.length > 0 ? fieldValue : void 0;
3179
+ }
3180
+ function getOIDCErrorDescription(error, fallback) {
3181
+ const nestedError = error && typeof error === "object" ? error.error : void 0;
3182
+ const description = getStringErrorField(nestedError, "error_description") || getStringErrorField(error, "error_description") || getStringErrorField(nestedError, "message") || getStringErrorField(error, "message") || getStringErrorField(error, "statusText") || getStringErrorField(nestedError, "error") || getStringErrorField(error, "error");
3183
+ if (description) return description;
3184
+ if (error && typeof error === "object") {
3185
+ const status = error.status;
3186
+ if (typeof status === "number") return `HTTP ${status}`;
3187
+ if (typeof status === "string" && status.length > 0) return status;
3188
+ }
3189
+ return fallback;
3190
+ }
2563
3191
  /**
2564
3192
  * Core OIDC callback handler logic, shared between the per-provider and
2565
3193
  * shared callback endpoints. Resolves the provider, exchanges the
@@ -2576,30 +3204,17 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2576
3204
  throw ctx.redirect(`${errorURL}?error=invalid_state`);
2577
3205
  }
2578
3206
  const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
2579
- if (!code || error) throw ctx.redirect(`${errorURL || callbackURL}?error=${error}&error_description=${error_description}`);
2580
- let provider = null;
2581
- if (options?.defaultSSO?.length) {
2582
- const matchingDefault = options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId);
2583
- if (matchingDefault) provider = {
2584
- ...matchingDefault,
2585
- issuer: matchingDefault.oidcConfig?.issuer || "",
2586
- userId: "default",
2587
- ...options.domainVerification?.enabled ? { domainVerified: true } : {}
2588
- };
2589
- }
2590
- if (!provider) provider = await ctx.context.adapter.findOne({
2591
- model: "ssoProvider",
2592
- where: [{
2593
- field: "providerId",
2594
- value: providerId
2595
- }]
2596
- }).then((res) => {
2597
- if (!res) return null;
2598
- return {
2599
- ...res,
2600
- oidcConfig: safeJsonParse(res.oidcConfig) || void 0
2601
- };
2602
- });
3207
+ const redirectOIDCError = (error, description) => {
3208
+ const baseURL = errorURL || callbackURL;
3209
+ const params = new URLSearchParams({
3210
+ error,
3211
+ error_description: description
3212
+ });
3213
+ const separator = baseURL.includes("?") ? "&" : "?";
3214
+ throw ctx.redirect(`${baseURL}${separator}${params.toString()}`);
3215
+ };
3216
+ if (!code || error) redirectOIDCError(error || "invalid_request", error_description || (error ? error : "authorization_code_not_found"));
3217
+ const provider = await resolveOIDCProvider(ctx, options, providerId);
2603
3218
  if (!provider) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
2604
3219
  if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
2605
3220
  let config = provider.oidcConfig;
@@ -2620,11 +3235,9 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2620
3235
  ]
2621
3236
  };
2622
3237
  if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_endpoint_not_found`);
2623
- let authMethod = "basic";
2624
- if (config.tokenEndpointAuthentication === "client_secret_post") authMethod = "post";
2625
- else if (config.tokenEndpointAuthentication === "private_key_jwt") authMethod = "private_key_jwt";
2626
- let clientAssertionConfig;
2627
- if (authMethod === "private_key_jwt") {
3238
+ const tokenEndpoint = config.tokenEndpoint;
3239
+ let tokenEndpointAuth = config.tokenEndpointAuthentication === "client_secret_post" ? { method: "client_secret_post" } : { method: "client_secret_basic" };
3240
+ if (config.tokenEndpointAuthentication === "private_key_jwt") {
2628
3241
  let resolved;
2629
3242
  const matchingDefault = options?.defaultSSO?.find((p) => p.providerId === provider.providerId && "privateKey" in p && p.privateKey);
2630
3243
  if (matchingDefault && "privateKey" in matchingDefault) resolved = matchingDefault.privateKey;
@@ -2633,55 +3246,73 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2633
3246
  keyId: config.privateKeyId,
2634
3247
  issuer: config.issuer
2635
3248
  });
2636
- if (!resolved) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=no_private_key_available`);
3249
+ if (!resolved || !resolved.privateKeyJwk && !resolved.privateKeyPem) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=no_private_key_available`);
2637
3250
  const rawAlg = config.privateKeyAlgorithm ?? resolved.algorithm;
2638
- const algorithm = rawAlg && ASSERTION_SIGNING_ALGORITHMS.includes(rawAlg) ? rawAlg : void 0;
2639
- clientAssertionConfig = {
2640
- privateKeyJwk: resolved.privateKeyJwk,
2641
- privateKeyPem: resolved.privateKeyPem,
2642
- kid: config.privateKeyId ?? resolved.kid,
2643
- algorithm,
2644
- tokenEndpoint: config.tokenEndpoint
3251
+ const algorithm = rawAlg && PRIVATE_KEY_JWT_SIGNING_ALGORITHMS.includes(rawAlg) ? rawAlg : void 0;
3252
+ tokenEndpointAuth = {
3253
+ method: "private_key_jwt",
3254
+ getClientAssertion: createPrivateKeyJwtClientAssertionGetter({
3255
+ privateKeyJwk: resolved.privateKeyJwk,
3256
+ privateKeyPem: resolved.privateKeyPem,
3257
+ kid: config.privateKeyId ?? resolved.kid,
3258
+ algorithm
3259
+ })
2645
3260
  };
2646
3261
  }
2647
- const tokenResponse = await validateAuthorizationCode({
2648
- code,
2649
- codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
2650
- redirectURI: getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options),
2651
- options: {
2652
- clientId: config.clientId,
2653
- clientSecret: config.clientSecret
2654
- },
2655
- tokenEndpoint: config.tokenEndpoint,
2656
- authentication: authMethod,
2657
- clientAssertion: clientAssertionConfig
2658
- }).catch((e) => {
3262
+ const tokenRequestOptions = { clientId: config.clientId };
3263
+ if (tokenEndpointAuth.method !== "private_key_jwt") tokenRequestOptions.clientSecret = config.clientSecret;
3264
+ const tokenResponse = await (async () => {
3265
+ const { body, headers } = await authorizationCodeRequest({
3266
+ code,
3267
+ codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
3268
+ redirectURI: getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options),
3269
+ options: tokenRequestOptions,
3270
+ tokenEndpoint,
3271
+ tokenEndpointAuth
3272
+ });
3273
+ const { data, error } = await fetchOIDCEndpoint("tokenEndpoint", tokenEndpoint, {
3274
+ method: "POST",
3275
+ body,
3276
+ headers
3277
+ }, (url) => ctx.context.isTrustedOrigin(url));
3278
+ if (error) redirectOIDCError("invalid_provider", getOIDCErrorDescription(error, "token_response_error"));
3279
+ if (!data) throw new Error("Token endpoint returned an empty response");
3280
+ return getOAuth2Tokens(data);
3281
+ })().catch((e) => {
3282
+ if (isAPIError(e)) throw e;
2659
3283
  ctx.context.logger.error("Error validating authorization code", e);
2660
- if (e instanceof BetterFetchError) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${e.message}`);
2661
- return null;
3284
+ if (e instanceof DiscoveryError) redirectOIDCError("invalid_provider", e.message);
3285
+ redirectOIDCError("invalid_provider", getOIDCErrorDescription(e, "token_response_error"));
2662
3286
  });
2663
3287
  if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_response_not_found`);
2664
3288
  let userInfo = null;
2665
3289
  const mapping = config.mapping || {};
3290
+ let rawProfile;
2666
3291
  if (config.userInfoEndpoint) {
2667
- const userInfoResponse = await betterFetch(config.userInfoEndpoint, { headers: { Authorization: `Bearer ${tokenResponse.accessToken}` } });
2668
- if (userInfoResponse.error) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${userInfoResponse.error.message}`);
2669
- const rawUserInfo = userInfoResponse.data;
3292
+ const userInfoResponse = await fetchOIDCEndpoint("userInfoEndpoint", config.userInfoEndpoint, { headers: { Authorization: `Bearer ${tokenResponse.accessToken}` } }, (url) => ctx.context.isTrustedOrigin(url)).catch((e) => {
3293
+ if (e instanceof DiscoveryError) redirectOIDCError("invalid_provider", e.message);
3294
+ throw e;
3295
+ });
3296
+ if (userInfoResponse.error) redirectOIDCError("invalid_provider", userInfoResponse.error.message || userInfoResponse.error.statusText || "userinfo_response_error");
3297
+ const rawUserInfo = userInfoResponse.data ?? redirectOIDCError("invalid_provider", "userinfo_response_not_found");
3298
+ rawProfile = rawUserInfo;
2670
3299
  userInfo = {
2671
3300
  ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, rawUserInfo[value]])),
2672
3301
  id: rawUserInfo[mapping.id || "sub"],
2673
3302
  email: rawUserInfo[mapping.email || "email"],
2674
- emailVerified: options?.trustEmailVerified ? rawUserInfo[mapping.emailVerified || "email_verified"] : false,
3303
+ emailVerified: options?.trustEmailVerified ? parseProviderEmailVerified(rawUserInfo[mapping.emailVerified || "email_verified"]) : false,
2675
3304
  name: rawUserInfo[mapping.name || "name"],
2676
3305
  image: rawUserInfo[mapping.image || "picture"]
2677
3306
  };
2678
3307
  } else if (tokenResponse.idToken) {
2679
3308
  const idToken = decodeJwt(tokenResponse.idToken);
3309
+ rawProfile = idToken;
2680
3310
  if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=jwks_endpoint_not_found`);
2681
- const verified = await validateToken(tokenResponse.idToken, config.jwksEndpoint, {
3311
+ const verified = await validateOIDCIdToken(tokenResponse.idToken, config.jwksEndpoint, {
2682
3312
  audience: config.clientId,
2683
3313
  issuer: provider.issuer
2684
- }).catch((e) => {
3314
+ }, (url) => ctx.context.isTrustedOrigin(url)).catch((e) => {
3315
+ if (e instanceof DiscoveryError) redirectOIDCError("invalid_provider", e.message);
2685
3316
  ctx.context.logger.error(e);
2686
3317
  return null;
2687
3318
  });
@@ -2690,37 +3321,67 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2690
3321
  ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, verified.payload[value]])),
2691
3322
  id: idToken[mapping.id || "sub"],
2692
3323
  email: idToken[mapping.email || "email"],
2693
- emailVerified: options?.trustEmailVerified ? idToken[mapping.emailVerified || "email_verified"] : false,
3324
+ emailVerified: options?.trustEmailVerified ? parseProviderEmailVerified(idToken[mapping.emailVerified || "email_verified"]) : false,
2694
3325
  name: idToken[mapping.name || "name"],
2695
3326
  image: idToken[mapping.image || "picture"]
2696
3327
  };
2697
3328
  } else throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=user_info_endpoint_not_found`);
2698
3329
  if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=missing_user_info`);
2699
- const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfo.email, provider.domain);
2700
- const linked = await handleOAuthUserInfo(ctx, {
2701
- userInfo: {
2702
- email: userInfo.email,
2703
- name: userInfo.name || "",
2704
- id: userInfo.id,
2705
- image: userInfo.image,
2706
- emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
2707
- },
2708
- account: {
2709
- idToken: tokenResponse.idToken,
2710
- accessToken: tokenResponse.accessToken,
2711
- refreshToken: tokenResponse.refreshToken,
2712
- accountId: userInfo.id,
2713
- providerId: provider.providerId,
2714
- accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
2715
- refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
2716
- scope: tokenResponse.scopes?.join(",")
2717
- },
2718
- callbackURL,
2719
- disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
2720
- overrideUserInfo: config.overrideUserInfo,
2721
- isTrustedProvider
2722
- });
2723
- if (linked.error) throw ctx.redirect(`${errorURL || callbackURL}?error=${linked.error}`);
3330
+ const userInfoEmail = userInfo.email;
3331
+ const userInfoId = userInfo.id;
3332
+ const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfoEmail, provider.domain);
3333
+ let linked;
3334
+ try {
3335
+ linked = await runWithTransaction(ctx.context.adapter, async () => {
3336
+ await lockSSOProviderForAccountLink(ctx, provider);
3337
+ return handleOAuthUserInfo(ctx, {
3338
+ userInfo: {
3339
+ email: userInfoEmail,
3340
+ name: userInfo.name || "",
3341
+ id: userInfoId,
3342
+ image: userInfo.image,
3343
+ emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
3344
+ },
3345
+ account: {
3346
+ idToken: tokenResponse.idToken,
3347
+ accessToken: tokenResponse.accessToken,
3348
+ refreshToken: tokenResponse.refreshToken,
3349
+ accountId: userInfoId,
3350
+ providerId: provider.providerId,
3351
+ accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
3352
+ refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
3353
+ scope: tokenResponse.scopes?.join(",")
3354
+ },
3355
+ callbackURL,
3356
+ disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
3357
+ overrideUserInfo: config.overrideUserInfo,
3358
+ source: {
3359
+ method: "sso-oidc",
3360
+ sso: {
3361
+ providerId: provider.providerId,
3362
+ profile: rawProfile
3363
+ }
3364
+ },
3365
+ isTrustedProvider,
3366
+ trustProviderByName: false
3367
+ });
3368
+ });
3369
+ } catch (e) {
3370
+ if (isAPIError(e) && e.body?.code) {
3371
+ const baseURL = errorURL || callbackURL;
3372
+ const params = new URLSearchParams({ error: e.body.code });
3373
+ if (e.body.message) params.set("error_description", e.body.message);
3374
+ const sep = baseURL.includes("?") ? "&" : "?";
3375
+ throw ctx.redirect(`${baseURL}${sep}${params.toString()}`);
3376
+ }
3377
+ throw e;
3378
+ }
3379
+ if (linked.error) {
3380
+ const baseURL = errorURL || callbackURL;
3381
+ const params = new URLSearchParams({ error: linked.error });
3382
+ const sep = baseURL.includes("?") ? "&" : "?";
3383
+ throw ctx.redirect(`${baseURL}${sep}${params.toString()}`);
3384
+ }
2724
3385
  const { session, user } = linked.data;
2725
3386
  if (options?.provisionUser && (linked.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
2726
3387
  user,
@@ -2733,8 +3394,8 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2733
3394
  profile: {
2734
3395
  providerType: "oidc",
2735
3396
  providerId: provider.providerId,
2736
- accountId: userInfo.id,
2737
- email: userInfo.email,
3397
+ accountId: userInfoId,
3398
+ email: userInfoEmail,
2738
3399
  emailVerified: Boolean(userInfo.emailVerified),
2739
3400
  rawAttributes: userInfo
2740
3401
  },
@@ -2768,9 +3429,86 @@ const callbackSSOEndpointConfig = {
2768
3429
  }
2769
3430
  }
2770
3431
  };
3432
+ /**
3433
+ * Resolves an SSO provider by `providerId`, first checking `options.defaultSSO`
3434
+ * and falling back to the `ssoProvider` table. Returns `null` when no match is
3435
+ * found so the caller can decide how to react (redirect, silently skip, etc.).
3436
+ */
3437
+ async function resolveOIDCProvider(ctx, options, providerId) {
3438
+ const matchingDefault = options?.defaultSSO?.find((defaultProvider) => defaultProvider.providerId === providerId);
3439
+ if (matchingDefault) return {
3440
+ ...matchingDefault,
3441
+ issuer: matchingDefault.oidcConfig?.issuer || "",
3442
+ userId: "default",
3443
+ ...options?.domainVerification?.enabled ? { domainVerified: true } : {}
3444
+ };
3445
+ return ctx.context.adapter.findOne({
3446
+ model: "ssoProvider",
3447
+ where: [{
3448
+ field: "providerId",
3449
+ value: providerId
3450
+ }]
3451
+ }).then((res) => {
3452
+ if (!res) return null;
3453
+ return {
3454
+ ...res,
3455
+ oidcConfig: safeJsonParse(res.oidcConfig) || void 0
3456
+ };
3457
+ });
3458
+ }
3459
+ /**
3460
+ * Restarts the OAuth flow server-side when a stateless callback arrives for
3461
+ * an OIDC provider that opted into IDP-initiated flows. Silently returns
3462
+ * otherwise, letting the normal handler produce its error redirect.
3463
+ */
3464
+ async function bounceIfIdpInitiated(ctx, options, providerId) {
3465
+ const provider = await resolveOIDCProvider(ctx, options, providerId);
3466
+ if (!provider?.oidcConfig?.allowIdpInitiated) return;
3467
+ let config = provider.oidcConfig;
3468
+ try {
3469
+ config = await ensureRuntimeDiscovery(config, provider.issuer, (url) => ctx.context.isTrustedOrigin(url));
3470
+ } catch (error) {
3471
+ ctx.context.logger.error("IDP-initiated bounce skipped: OIDC discovery failed", {
3472
+ providerId: provider.providerId,
3473
+ issuer: provider.issuer,
3474
+ error
3475
+ });
3476
+ return;
3477
+ }
3478
+ if (!config.authorizationEndpoint) {
3479
+ ctx.context.logger.error("IDP-initiated bounce skipped: authorizationEndpoint missing after discovery", {
3480
+ providerId: provider.providerId,
3481
+ issuer: provider.issuer
3482
+ });
3483
+ return;
3484
+ }
3485
+ if (options?.redirectURI?.trim()) await addOAuthServerContext({ ssoProviderId: provider.providerId });
3486
+ const state = await generateState(ctx);
3487
+ const redirectURI = getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options);
3488
+ const authorizationURL = await createAuthorizationURL({
3489
+ id: provider.issuer,
3490
+ options: {
3491
+ clientId: config.clientId,
3492
+ clientSecret: config.clientSecret
3493
+ },
3494
+ redirectURI,
3495
+ state: state.state,
3496
+ codeVerifier: config.pkce ? state.codeVerifier : void 0,
3497
+ scopes: config.scopes || [
3498
+ "openid",
3499
+ "email",
3500
+ "profile",
3501
+ "offline_access"
3502
+ ],
3503
+ authorizationEndpoint: config.authorizationEndpoint
3504
+ });
3505
+ throw ctx.redirect(authorizationURL.toString());
3506
+ }
2771
3507
  const callbackSSO = (options) => {
2772
3508
  return createAuthEndpoint("/sso/callback/:providerId", callbackSSOEndpointConfig, async (ctx) => {
2773
- return handleOIDCCallback(ctx, options, ctx.params.providerId);
3509
+ const providerId = ctx.params.providerId;
3510
+ if (ctx.query.state === void 0 && ctx.query.code) await bounceIfIdpInitiated(ctx, options, providerId);
3511
+ return handleOIDCCallback(ctx, options, providerId);
2774
3512
  });
2775
3513
  };
2776
3514
  /**
@@ -2796,7 +3534,7 @@ const callbackSSOShared = (options) => {
2796
3534
  const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
2797
3535
  throw ctx.redirect(`${errorURL}?error=invalid_state`);
2798
3536
  }
2799
- const providerId = stateData.ssoProviderId;
3537
+ const providerId = stateData.serverContext?.ssoProviderId;
2800
3538
  if (!providerId) {
2801
3539
  const errorURL = stateData.errorURL || stateData.callbackURL;
2802
3540
  throw ctx.redirect(`${errorURL}?error=invalid_state&error_description=missing_provider_id`);
@@ -2945,7 +3683,7 @@ async function handleLogoutRequest(ctx, sp, idp, relayState, providerId) {
2945
3683
  if (stored) {
2946
3684
  const data = safeJsonParse(stored.value);
2947
3685
  if (data) if (!sessionIndex || !data.sessionIndex || sessionIndex === data.sessionIndex) {
2948
- await ctx.context.internalAdapter.deleteSession(data.sessionId).catch((e) => ctx.context.logger.warn("Failed to delete session during SLO", { error: e }));
3686
+ await ctx.context.internalAdapter.deleteSession(data.sessionToken).catch((e) => ctx.context.logger.warn("Failed to delete session during SLO", { error: e }));
2949
3687
  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));
2950
3688
  } else ctx.context.logger.warn("SessionIndex mismatch in LogoutRequest - skipping session deletion", {
2951
3689
  providerId,
@@ -2955,10 +3693,9 @@ async function handleLogoutRequest(ctx, sp, idp, relayState, providerId) {
2955
3693
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(key).catch((e) => ctx.context.logger.warn("Failed to delete SAML session key during SLO", e));
2956
3694
  }
2957
3695
  const currentSession = await getSessionFromCtx(ctx);
2958
- if (currentSession?.session) await ctx.context.internalAdapter.deleteSession(currentSession.session.id);
3696
+ if (currentSession?.session) await ctx.context.internalAdapter.deleteSession(currentSession.session.token);
2959
3697
  deleteSessionCookie(ctx);
2960
- const requestId = parsed.extract.request?.id || "";
2961
- const res = sp.createLogoutResponse(idp, null, binding, relayState || "", (template) => template.replace("{InResponseTo}", requestId).replace("{StatusCode}", SAML_STATUS_SUCCESS));
3698
+ const res = sp.createLogoutResponse(idp, parsed, binding, relayState || "");
2962
3699
  if (binding === "post" && res.entityEndpoint) return createSAMLPostForm(res.entityEndpoint, "SAMLResponse", res.context, relayState);
2963
3700
  throw ctx.redirect(res.context);
2964
3701
  }
@@ -3011,7 +3748,7 @@ const initiateSLO = (options) => {
3011
3748
  });
3012
3749
  if (samlSessionKey) await ctx.context.internalAdapter.deleteVerificationByIdentifier(samlSessionKey).catch((e) => ctx.context.logger.warn("Failed to delete SAML session key during logout", e));
3013
3750
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(sessionLookupKey).catch((e) => ctx.context.logger.warn("Failed to delete session lookup key during logout", e));
3014
- await ctx.context.internalAdapter.deleteSession(session.session.id);
3751
+ await ctx.context.internalAdapter.deleteSession(session.session.token);
3015
3752
  deleteSessionCookie(ctx);
3016
3753
  throw ctx.redirect(logoutRequest.context);
3017
3754
  });
@@ -3028,7 +3765,42 @@ saml.setSchemaValidator({ async validate(xml) {
3028
3765
  * which won't have a matching Origin header.
3029
3766
  */
3030
3767
  const SAML_SKIP_ORIGIN_CHECK_PATHS = ["/sso/saml2/sp/acs", "/sso/saml2/sp/slo"];
3768
+ const SSO_PROVIDER_BUILT_IN_FIELD_KEYS = [
3769
+ "id",
3770
+ "issuer",
3771
+ "oidcConfig",
3772
+ "samlConfig",
3773
+ "userId",
3774
+ "providerId",
3775
+ "organizationId",
3776
+ "domain",
3777
+ "domainVerified"
3778
+ ];
3779
+ const SSO_PROVIDER_RESPONSE_FIELD_KEYS = [
3780
+ "type",
3781
+ "spMetadataUrl",
3782
+ "redirectURI",
3783
+ "domainVerificationToken"
3784
+ ];
3785
+ const SSO_PROVIDER_BUILT_IN_FIELD_KEY_SET = new Set(SSO_PROVIDER_BUILT_IN_FIELD_KEYS);
3786
+ const SSO_PROVIDER_RESPONSE_FIELD_KEY_SET = new Set(SSO_PROVIDER_RESPONSE_FIELD_KEYS);
3787
+ function getSSOProviderBuiltInFieldName(options, key) {
3788
+ const fieldNames = options?.fields;
3789
+ const schemaFieldNames = options?.schema?.ssoProvider?.fields;
3790
+ return fieldNames?.[key] ?? schemaFieldNames?.[key] ?? key;
3791
+ }
3792
+ function assertNoAdditionalFieldCollisions(options) {
3793
+ const additionalFields = options?.schema?.ssoProvider?.additionalFields ?? {};
3794
+ const builtInFieldNames = new Set(SSO_PROVIDER_BUILT_IN_FIELD_KEYS.map((key) => getSSOProviderBuiltInFieldName(options, key)));
3795
+ for (const [key, field] of Object.entries(additionalFields)) {
3796
+ if (SSO_PROVIDER_BUILT_IN_FIELD_KEY_SET.has(key)) throw new Error(`ssoProvider additional field "${key}" conflicts with a built-in field`);
3797
+ if (SSO_PROVIDER_RESPONSE_FIELD_KEY_SET.has(key)) throw new Error(`ssoProvider additional field "${key}" conflicts with a returned provider field`);
3798
+ const fieldName = field.fieldName ?? key;
3799
+ if (builtInFieldNames.has(fieldName)) throw new Error(`ssoProvider additional field "${key}" maps to built-in field "${fieldName}"`);
3800
+ }
3801
+ }
3031
3802
  function sso(options) {
3803
+ assertNoAdditionalFieldCollisions(options);
3032
3804
  const optionsWithStore = options;
3033
3805
  let endpoints = {
3034
3806
  spMetadata: spMetadata(optionsWithStore),
@@ -3039,8 +3811,8 @@ function sso(options) {
3039
3811
  acsEndpoint: acsEndpoint(optionsWithStore),
3040
3812
  sloEndpoint: sloEndpoint(optionsWithStore),
3041
3813
  initiateSLO: initiateSLO(optionsWithStore),
3042
- listSSOProviders: listSSOProviders(),
3043
- getSSOProvider: getSSOProvider(),
3814
+ listSSOProviders: listSSOProviders(optionsWithStore),
3815
+ getSSOProvider: getSSOProvider(optionsWithStore),
3044
3816
  updateSSOProvider: updateSSOProvider(optionsWithStore),
3045
3817
  deleteSSOProvider: deleteSSOProvider()
3046
3818
  };
@@ -3097,22 +3869,22 @@ function sso(options) {
3097
3869
  }]
3098
3870
  },
3099
3871
  schema: { ssoProvider: {
3100
- modelName: options?.modelName ?? "ssoProvider",
3872
+ modelName: options?.modelName ?? options?.schema?.ssoProvider?.modelName ?? "ssoProvider",
3101
3873
  fields: {
3102
3874
  issuer: {
3103
3875
  type: "string",
3104
3876
  required: true,
3105
- fieldName: options?.fields?.issuer ?? "issuer"
3877
+ fieldName: options?.fields?.issuer ?? options?.schema?.ssoProvider?.fields?.issuer ?? "issuer"
3106
3878
  },
3107
3879
  oidcConfig: {
3108
3880
  type: "string",
3109
3881
  required: false,
3110
- fieldName: options?.fields?.oidcConfig ?? "oidcConfig"
3882
+ fieldName: options?.fields?.oidcConfig ?? options?.schema?.ssoProvider?.fields?.oidcConfig ?? "oidcConfig"
3111
3883
  },
3112
3884
  samlConfig: {
3113
3885
  type: "string",
3114
3886
  required: false,
3115
- fieldName: options?.fields?.samlConfig ?? "samlConfig"
3887
+ fieldName: options?.fields?.samlConfig ?? options?.schema?.ssoProvider?.fields?.samlConfig ?? "samlConfig"
3116
3888
  },
3117
3889
  userId: {
3118
3890
  type: "string",
@@ -3120,30 +3892,33 @@ function sso(options) {
3120
3892
  model: "user",
3121
3893
  field: "id"
3122
3894
  },
3123
- fieldName: options?.fields?.userId ?? "userId"
3895
+ fieldName: options?.fields?.userId ?? options?.schema?.ssoProvider?.fields?.userId ?? "userId"
3124
3896
  },
3125
3897
  providerId: {
3126
3898
  type: "string",
3127
3899
  required: true,
3128
3900
  unique: true,
3129
- fieldName: options?.fields?.providerId ?? "providerId"
3901
+ fieldName: options?.fields?.providerId ?? options?.schema?.ssoProvider?.fields?.providerId ?? "providerId"
3130
3902
  },
3131
3903
  organizationId: {
3132
3904
  type: "string",
3133
3905
  required: false,
3134
- fieldName: options?.fields?.organizationId ?? "organizationId"
3906
+ fieldName: options?.fields?.organizationId ?? options?.schema?.ssoProvider?.fields?.organizationId ?? "organizationId"
3135
3907
  },
3136
3908
  domain: {
3137
3909
  type: "string",
3138
3910
  required: true,
3139
- fieldName: options?.fields?.domain ?? "domain"
3911
+ fieldName: options?.fields?.domain ?? options?.schema?.ssoProvider?.fields?.domain ?? "domain"
3140
3912
  },
3141
3913
  ...options?.domainVerification?.enabled ? { domainVerified: {
3142
3914
  type: "boolean",
3143
- required: false
3144
- } } : {}
3915
+ required: false,
3916
+ fieldName: options?.schema?.ssoProvider?.fields?.domainVerified ?? "domainVerified"
3917
+ } } : {},
3918
+ ...options?.schema?.ssoProvider?.additionalFields ?? {}
3145
3919
  }
3146
3920
  } },
3921
+ $Infer: { SSOProvider: {} },
3147
3922
  options
3148
3923
  };
3149
3924
  }