@better-auth/sso 1.7.0-beta.0 → 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-CzfTSPRz.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,1274 +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) return;
697
- const audience = ctx.extract.audience;
698
- if (!audience) {
699
- c.context.logger.error("SAML assertion missing AudienceRestriction but audience is configured — rejecting", { providerId: ctx.providerId });
700
- throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Audience restriction missing"));
701
- }
702
- const audiences = Array.isArray(audience) ? audience : [audience];
703
- if (!audiences.includes(ctx.expectedAudience)) {
704
- c.context.logger.error("SAML audience mismatch: assertion was issued for a different service provider", {
705
- expected: ctx.expectedAudience,
706
- received: audiences,
707
- 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
708
716
  });
709
- 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
+ };
710
725
  }
726
+ await assertServerFetchedOIDCEndpointsAllowed(resolved, isTrustedOrigin);
727
+ return resolved;
711
728
  }
712
729
  //#endregion
713
- //#region src/routes/schemas.ts
714
- const oidcMappingSchema = z.object({
715
- id: z.string().optional(),
716
- email: z.string().optional(),
717
- emailVerified: z.string().optional(),
718
- name: z.string().optional(),
719
- image: z.string().optional(),
720
- extraFields: z.record(z.string(), z.any()).optional()
721
- }).optional();
722
- const samlMappingSchema = z.object({
723
- id: z.string().optional(),
724
- email: z.string().optional(),
725
- emailVerified: z.string().optional(),
726
- name: z.string().optional(),
727
- firstName: z.string().optional(),
728
- lastName: z.string().optional(),
729
- extraFields: z.record(z.string(), z.any()).optional()
730
- }).optional();
731
- const oidcConfigSchema = z.object({
732
- clientId: z.string().optional(),
733
- clientSecret: z.string().optional(),
734
- authorizationEndpoint: z.string().url().optional(),
735
- tokenEndpoint: z.string().url().optional(),
736
- userInfoEndpoint: z.string().url().optional(),
737
- tokenEndpointAuthentication: z.enum([
738
- "client_secret_post",
739
- "client_secret_basic",
740
- "private_key_jwt"
741
- ]).optional(),
742
- privateKeyId: z.string().optional(),
743
- privateKeyAlgorithm: z.string().optional(),
744
- jwksEndpoint: z.string().url().optional(),
745
- discoveryEndpoint: z.string().url().optional(),
746
- scopes: z.array(z.string()).optional(),
747
- pkce: z.boolean().optional(),
748
- overrideUserInfo: z.boolean().optional(),
749
- mapping: oidcMappingSchema
750
- });
751
- const samlConfigSchema = z.object({
752
- entryPoint: z.string().url().optional(),
753
- cert: z.string().optional(),
754
- callbackUrl: z.string().url().optional(),
755
- audience: z.string().optional(),
756
- idpMetadata: z.object({
757
- metadata: z.string().optional(),
758
- entityID: z.string().optional(),
759
- cert: z.string().optional(),
760
- privateKey: z.string().optional(),
761
- privateKeyPass: z.string().optional(),
762
- isAssertionEncrypted: z.boolean().optional(),
763
- encPrivateKey: z.string().optional(),
764
- encPrivateKeyPass: z.string().optional(),
765
- singleSignOnService: z.array(z.object({
766
- Binding: z.string(),
767
- Location: z.string().url()
768
- })).optional()
769
- }).optional(),
770
- spMetadata: z.object({
771
- metadata: z.string().optional(),
772
- entityID: z.string().optional(),
773
- binding: z.string().optional(),
774
- privateKey: z.string().optional(),
775
- privateKeyPass: z.string().optional(),
776
- isAssertionEncrypted: z.boolean().optional(),
777
- encPrivateKey: z.string().optional(),
778
- encPrivateKeyPass: z.string().optional()
779
- }).optional(),
780
- wantAssertionsSigned: z.boolean().optional(),
781
- authnRequestsSigned: z.boolean().optional(),
782
- signatureAlgorithm: z.string().optional(),
783
- digestAlgorithm: z.string().optional(),
784
- identifierFormat: z.string().optional(),
785
- privateKey: z.string().optional(),
786
- decryptionPvk: z.string().optional(),
787
- additionalParams: z.record(z.string(), z.any()).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
- callbackUrl: samlConfig.callbackUrl,
865
- audience: samlConfig.audience,
866
- wantAssertionsSigned: samlConfig.wantAssertionsSigned,
867
- authnRequestsSigned: samlConfig.authnRequestsSigned,
868
- identifierFormat: samlConfig.identifierFormat,
869
- signatureAlgorithm: samlConfig.signatureAlgorithm,
870
- digestAlgorithm: samlConfig.digestAlgorithm,
871
- certificate: (() => {
872
- try {
873
- return parseCertificate(samlConfig.cert);
874
- } catch {
875
- return { error: "Failed to parse certificate" };
876
- }
877
- })()
878
- } : void 0,
879
- spMetadataUrl: `${baseURL}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(provider.providerId)}`
880
- };
881
939
  }
882
- const listSSOProviders = () => {
883
- return createAuthEndpoint("/sso/providers", {
884
- method: "GET",
885
- use: [sessionMiddleware],
886
- metadata: { openapi: {
887
- operationId: "listSSOProviders",
888
- summary: "List SSO providers",
889
- description: "Returns a list of SSO providers the user has access to",
890
- responses: { "200": { description: "List of SSO providers" } }
891
- } }
892
- }, async (ctx) => {
893
- const userId = ctx.context.session.user.id;
894
- const allProviders = await ctx.context.adapter.findMany({ model: "ssoProvider" });
895
- const userOwnedProviders = allProviders.filter((p) => p.userId === userId && !p.organizationId);
896
- const orgProviders = allProviders.filter((p) => p.organizationId !== null && p.organizationId !== void 0);
897
- const orgPluginEnabled = ctx.context.hasPlugin("organization");
898
- let accessibleProviders = [...userOwnedProviders];
899
- if (orgPluginEnabled && orgProviders.length > 0) {
900
- const adminOrgIds = await batchCheckOrgAdmin(ctx, userId, [...new Set(orgProviders.map((p) => p.organizationId).filter((id) => id !== null && id !== void 0))]);
901
- const orgAccessibleProviders = orgProviders.filter((provider) => provider.organizationId && adminOrgIds.has(provider.organizationId));
902
- accessibleProviders = [...accessibleProviders, ...orgAccessibleProviders];
903
- } else if (!orgPluginEnabled) {
904
- const userOwnedOrgProviders = orgProviders.filter((p) => p.userId === userId);
905
- accessibleProviders = [...accessibleProviders, ...userOwnedOrgProviders];
906
- }
907
- const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL));
908
- return ctx.json({ providers });
909
- });
910
- };
911
- const getSSOProviderQuerySchema = z.object({ providerId: z.string() });
912
- async function checkProviderAccess(ctx, providerId) {
913
- const userId = ctx.context.session.user.id;
914
- const provider = await ctx.context.adapter.findOne({
915
- model: "ssoProvider",
916
- where: [{
917
- field: "providerId",
918
- value: providerId
919
- }]
920
- });
921
- if (!provider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
922
- let hasAccess = false;
923
- if (provider.organizationId) if (ctx.context.hasPlugin("organization")) hasAccess = await isOrgAdmin(ctx, userId, provider.organizationId);
924
- else hasAccess = provider.userId === userId;
925
- else hasAccess = provider.userId === userId;
926
- if (!hasAccess) throw new APIError("FORBIDDEN", { message: "You don't have access to this provider" });
927
- return provider;
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
+ }
928
951
  }
929
- const getSSOProvider = () => {
930
- return createAuthEndpoint("/sso/get-provider", {
931
- method: "GET",
932
- use: [sessionMiddleware],
933
- query: getSSOProviderQuerySchema,
934
- metadata: { openapi: {
935
- operationId: "getSSOProvider",
936
- summary: "Get SSO provider details",
937
- description: "Returns sanitized details for a specific SSO provider",
938
- responses: {
939
- "200": { description: "SSO provider details" },
940
- "404": { description: "Provider not found" },
941
- "403": { description: "Access denied" }
942
- }
943
- } }
944
- }, async (ctx) => {
945
- const { providerId } = ctx.query;
946
- const provider = await checkProviderAccess(ctx, providerId);
947
- return ctx.json(sanitizeProvider(provider, ctx.context.baseURL));
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"
948
969
  });
949
- };
950
- function parseAndValidateConfig(configString, configType) {
951
- let config = null;
952
- try {
953
- config = safeJsonParse(configString);
954
- } catch {
955
- config = null;
970
+ }
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");
956
989
  }
957
- if (!config) throw new APIError("BAD_REQUEST", { message: `Cannot update ${configType} config for a provider that doesn't have ${configType} configured` });
958
- return config;
959
990
  }
960
- function mergeSAMLConfig(current, updates, issuer) {
961
- return {
962
- ...current,
963
- ...updates,
964
- issuer,
965
- entryPoint: updates.entryPoint ?? current.entryPoint,
966
- cert: updates.cert ?? current.cert,
967
- callbackUrl: updates.callbackUrl ?? current.callbackUrl,
968
- spMetadata: updates.spMetadata ?? current.spMetadata,
969
- idpMetadata: updates.idpMetadata ?? current.idpMetadata,
970
- mapping: updates.mapping ?? current.mapping,
971
- audience: updates.audience ?? current.audience,
972
- wantAssertionsSigned: updates.wantAssertionsSigned ?? current.wantAssertionsSigned,
973
- authnRequestsSigned: updates.authnRequestsSigned ?? current.authnRequestsSigned,
974
- identifierFormat: updates.identifierFormat ?? current.identifierFormat,
975
- signatureAlgorithm: updates.signatureAlgorithm ?? current.signatureAlgorithm,
976
- digestAlgorithm: updates.digestAlgorithm ?? current.digestAlgorithm
977
- };
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
+ }
978
1023
  }
979
- function mergeOIDCConfig(current, updates, issuer) {
1024
+ //#endregion
1025
+ //#region src/saml/assertions.ts
1026
+ function countAssertions(xml) {
1027
+ let parsed;
1028
+ try {
1029
+ parsed = xmlParser.parse(xml);
1030
+ } catch {
1031
+ throw new APIError("BAD_REQUEST", {
1032
+ message: "Failed to parse SAML response XML",
1033
+ code: "SAML_INVALID_XML"
1034
+ });
1035
+ }
1036
+ const assertions = countAllNodes(parsed, "Assertion");
1037
+ const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
980
1038
  return {
981
- ...current,
982
- ...updates,
983
- issuer,
984
- pkce: updates.pkce ?? current.pkce ?? true,
985
- clientId: updates.clientId ?? current.clientId,
986
- clientSecret: updates.clientSecret ?? current.clientSecret,
987
- discoveryEndpoint: updates.discoveryEndpoint ?? current.discoveryEndpoint,
988
- mapping: updates.mapping ?? current.mapping,
989
- scopes: updates.scopes ?? current.scopes,
990
- authorizationEndpoint: updates.authorizationEndpoint ?? current.authorizationEndpoint,
991
- tokenEndpoint: updates.tokenEndpoint ?? current.tokenEndpoint,
992
- userInfoEndpoint: updates.userInfoEndpoint ?? current.userInfoEndpoint,
993
- jwksEndpoint: updates.jwksEndpoint ?? current.jwksEndpoint,
994
- tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication,
995
- privateKeyId: updates.privateKeyId ?? current.privateKeyId,
996
- privateKeyAlgorithm: updates.privateKeyAlgorithm ?? current.privateKeyAlgorithm
1039
+ assertions,
1040
+ encryptedAssertions,
1041
+ total: assertions + encryptedAssertions
997
1042
  };
998
1043
  }
999
- const updateSSOProvider = (options) => {
1000
- return createAuthEndpoint("/sso/update-provider", {
1001
- method: "POST",
1002
- use: [sessionMiddleware],
1003
- body: updateSSOProviderBodySchema.extend({ providerId: z.string() }),
1004
- metadata: { openapi: {
1005
- operationId: "updateSSOProvider",
1006
- summary: "Update SSO provider",
1007
- description: "Partially update an SSO provider. Only provided fields are updated. If domain changes, domainVerified is reset to false.",
1008
- responses: {
1009
- "200": { description: "SSO provider updated successfully" },
1010
- "404": { description: "Provider not found" },
1011
- "403": { description: "Access denied" }
1012
- }
1013
- } }
1014
- }, async (ctx) => {
1015
- const { providerId, ...body } = ctx.body;
1016
- const { issuer, domain, samlConfig, oidcConfig } = body;
1017
- if (!issuer && !domain && !samlConfig && !oidcConfig) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
1018
- const existingProvider = await checkProviderAccess(ctx, providerId);
1019
- const updateData = {};
1020
- if (body.issuer !== void 0) updateData.issuer = body.issuer;
1021
- if (body.domain !== void 0) {
1022
- updateData.domain = body.domain;
1023
- if (body.domain !== existingProvider.domain) updateData.domainVerified = false;
1024
- }
1025
- if (body.samlConfig) {
1026
- if (body.samlConfig.idpMetadata?.metadata) {
1027
- const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
1028
- if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
1029
- }
1030
- if (body.samlConfig.signatureAlgorithm !== void 0 || body.samlConfig.digestAlgorithm !== void 0) validateConfigAlgorithms({
1031
- signatureAlgorithm: body.samlConfig.signatureAlgorithm,
1032
- digestAlgorithm: body.samlConfig.digestAlgorithm
1033
- }, options?.saml?.algorithms);
1034
- const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
1035
- const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
1036
- updateData.samlConfig = JSON.stringify(updatedSamlConfig);
1037
- }
1038
- if (body.oidcConfig) {
1039
- const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
1040
- const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
1041
- 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" });
1042
- 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" });
1043
- updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
1044
- }
1045
- await ctx.context.adapter.update({
1046
- model: "ssoProvider",
1047
- where: [{
1048
- field: "providerId",
1049
- value: providerId
1050
- }],
1051
- update: updateData
1052
- });
1053
- const fullProvider = await ctx.context.adapter.findOne({
1054
- model: "ssoProvider",
1055
- where: [{
1056
- field: "providerId",
1057
- value: providerId
1058
- }]
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"
1059
1053
  });
1060
- if (!fullProvider) throw new APIError("NOT_FOUND", { message: "Provider not found after update" });
1061
- 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"
1062
1059
  });
1063
- };
1064
- const deleteSSOProvider = () => {
1065
- return createAuthEndpoint("/sso/delete-provider", {
1066
- method: "POST",
1067
- use: [sessionMiddleware],
1068
- body: z.object({ providerId: z.string() }),
1069
- metadata: { openapi: {
1070
- operationId: "deleteSSOProvider",
1071
- summary: "Delete SSO provider",
1072
- description: "Deletes an SSO provider",
1073
- responses: {
1074
- "200": { description: "SSO provider deleted successfully" },
1075
- "404": { description: "Provider not found" },
1076
- "403": { description: "Access denied" }
1077
- }
1078
- } }
1079
- }, async (ctx) => {
1080
- const { providerId } = ctx.body;
1081
- await checkProviderAccess(ctx, providerId);
1082
- await ctx.context.adapter.delete({
1083
- model: "ssoProvider",
1084
- where: [{
1085
- field: "providerId",
1086
- value: providerId
1087
- }]
1088
- });
1089
- 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"
1090
1063
  });
1091
- };
1064
+ }
1092
1065
  //#endregion
1093
- //#region src/oidc/types.ts
1094
- /**
1095
- * Custom error class for OIDC discovery failures.
1096
- * Can be caught and mapped to APIError at the edge.
1097
- */
1098
- var DiscoveryError = class DiscoveryError extends Error {
1099
- code;
1100
- details;
1101
- constructor(code, message, details, options) {
1102
- super(message, options);
1103
- this.name = "DiscoveryError";
1104
- this.code = code;
1105
- this.details = details;
1106
- if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
1107
- }
1108
- };
1109
- /**
1110
- * Required fields that must be present in a valid discovery document.
1111
- */
1112
- const REQUIRED_DISCOVERY_FIELDS = [
1113
- "issuer",
1114
- "authorization_endpoint",
1115
- "token_endpoint",
1116
- "jwks_uri"
1117
- ];
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
+ });
1118
1076
  //#endregion
1119
- //#region src/oidc/discovery.ts
1077
+ //#region src/saml/cert.ts
1120
1078
  /**
1121
- * OIDC Discovery Pipeline
1122
- *
1123
- * Implements OIDC discovery document fetching, validation, and hydration.
1124
- * This module is used both at provider registration time (to persist validated config)
1125
- * and at runtime (to hydrate legacy providers that are missing metadata).
1126
- *
1127
- * @see https://openid.net/specs/openid-connect-discovery-1_0.html
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.
1128
1083
  */
1129
- /** Default timeout for discovery requests (10 seconds) */
1130
- const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
1131
1084
  /**
1132
- * Main entry point: Discover and hydrate OIDC configuration from an issuer.
1133
- *
1134
- * This function:
1135
- * 1. Computes the discovery URL from the issuer
1136
- * 2. Validates the discovery URL
1137
- * 3. Fetches the discovery document
1138
- * 4. Validates the discovery document (issuer match + required fields)
1139
- * 5. Normalizes URLs
1140
- * 6. Selects token endpoint auth method
1141
- * 7. Merges with existing config (existing values take precedence)
1142
- *
1143
- * @param params - Discovery parameters
1144
- * @param isTrustedOrigin - Origin verification tester function
1145
- * @returns Hydrated OIDC configuration ready for persistence
1146
- * @throws DiscoveryError on any failure
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).
1147
1089
  */
1148
- async function discoverOIDCConfig(params) {
1149
- const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
1150
- const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
1151
- validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
1152
- const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
1153
- validateDiscoveryDocument(discoveryDoc, issuer);
1154
- const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
1155
- const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
1156
- return {
1157
- issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
1158
- discoveryEndpoint: existingConfig?.discoveryEndpoint ?? discoveryUrl,
1159
- authorizationEndpoint: existingConfig?.authorizationEndpoint ?? normalizedDoc.authorization_endpoint,
1160
- tokenEndpoint: existingConfig?.tokenEndpoint ?? normalizedDoc.token_endpoint,
1161
- jwksEndpoint: existingConfig?.jwksEndpoint ?? normalizedDoc.jwks_uri,
1162
- userInfoEndpoint: existingConfig?.userInfoEndpoint ?? normalizedDoc.userinfo_endpoint,
1163
- tokenEndpointAuthentication: existingConfig?.tokenEndpointAuthentication ?? tokenEndpointAuth,
1164
- scopesSupported: existingConfig?.scopesSupported ?? normalizedDoc.scopes_supported
1165
- };
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];
1166
1094
  }
1167
1095
  /**
1168
- * Compute the discovery URL from an issuer URL.
1169
- *
1170
- * Per OIDC Discovery spec, the discovery document is located at:
1171
- * <issuer>/.well-known/openid-configuration
1172
- *
1173
- * Handles trailing slashes correctly.
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.
1174
1100
  */
1175
- function computeDiscoveryUrl(issuer) {
1176
- return `${issuer.endsWith("/") ? issuer.slice(0, -1) : issuer}/.well-known/openid-configuration`;
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);
1177
1105
  }
1178
- /**
1179
- * Validate a discovery URL before fetching.
1180
- *
1181
- * @param url - The discovery URL to validate
1182
- * @param isTrustedOrigin - Origin verification tester function
1183
- * @throws DiscoveryError if URL is invalid
1184
- */
1185
- function validateDiscoveryUrl(url, isTrustedOrigin) {
1186
- const discoveryEndpoint = parseURL("discoveryEndpoint", url).toString();
1187
- if (!isTrustedOrigin(discoveryEndpoint)) throw new DiscoveryError("discovery_untrusted_origin", `The main discovery endpoint "${discoveryEndpoint}" is not trusted by your trusted origins configuration.`, { url: discoveryEndpoint });
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
+ }
1188
1233
  }
1189
1234
  /**
1190
- * Fetch the OIDC discovery document from the IdP.
1235
+ * Validates the InResponseTo attribute of a SAML Response.
1191
1236
  *
1192
- * @param url - The discovery endpoint URL
1193
- * @param timeout - Request timeout in milliseconds
1194
- * @returns The parsed discovery document
1195
- * @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).
1196
1243
  */
1197
- async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
1198
- try {
1199
- const response = await betterFetch(url, {
1200
- method: "GET",
1201
- timeout
1202
- });
1203
- if (response.error) {
1204
- const { status } = response.error;
1205
- if (status === 404) throw new DiscoveryError("discovery_not_found", "Discovery endpoint not found", {
1206
- url,
1207
- status
1208
- });
1209
- if (status === 408) throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
1210
- url,
1211
- 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
1212
1260
  });
1213
- throw new DiscoveryError("discovery_unexpected_error", `Unexpected discovery error: ${response.error.statusText}`, {
1214
- url,
1215
- ...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
1216
1268
  });
1269
+ throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Provider mismatch"));
1217
1270
  }
1218
- if (!response.data) throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned an empty response", { url });
1219
- const data = response.data;
1220
- if (typeof data === "string") throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned invalid JSON", {
1221
- url,
1222
- bodyPreview: data.slice(0, 200)
1223
- });
1224
- return data;
1225
- } catch (error) {
1226
- if (error instanceof DiscoveryError) throw error;
1227
- if (error instanceof Error && error.name === "AbortError") throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
1228
- url,
1229
- timeout
1230
- });
1231
- 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"));
1232
1274
  }
1233
1275
  }
1234
1276
  /**
1235
- * Validate a discovery document.
1236
- *
1237
- * Checks:
1238
- * 1. All required fields are present
1239
- * 2. Issuer matches the configured issuer (case-sensitive, exact match)
1240
- *
1241
- * Invariant: If this function returns without throwing, the document is safe
1242
- * to use for hydrating OIDC config (required fields present, issuer matches
1243
- * configured value, basic structural sanity verified).
1277
+ * Validates the AudienceRestriction of a SAML assertion.
1244
1278
  *
1245
- * @param doc - The discovery document to validate
1246
- * @param configuredIssuer - The expected issuer value
1247
- * @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.
1248
1283
  */
1249
- function validateDiscoveryDocument(doc, configuredIssuer) {
1250
- const missingFields = [];
1251
- for (const field of REQUIRED_DISCOVERY_FIELDS) if (!doc[field]) missingFields.push(field);
1252
- if (missingFields.length > 0) throw new DiscoveryError("discovery_incomplete", `Discovery document is missing required fields: ${missingFields.join(", ")}`, { missingFields });
1253
- 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}"`, {
1254
- discovered: doc.issuer,
1255
- 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
1256
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);
1257
1318
  }
1258
- /**
1259
- * Normalize URLs in the discovery document.
1260
- *
1261
- * @param document - The discovery document
1262
- * @param issuer - The base issuer URL
1263
- * @param isTrustedOrigin - Origin verification tester function
1264
- * @returns The normalized discovery document
1265
- */
1266
- function normalizeDiscoveryUrls(document, issuer, isTrustedOrigin) {
1267
- const doc = { ...document };
1268
- doc.token_endpoint = normalizeAndValidateUrl("token_endpoint", doc.token_endpoint, issuer, isTrustedOrigin);
1269
- doc.authorization_endpoint = normalizeAndValidateUrl("authorization_endpoint", doc.authorization_endpoint, issuer, isTrustedOrigin);
1270
- doc.jwks_uri = normalizeAndValidateUrl("jwks_uri", doc.jwks_uri, issuer, isTrustedOrigin);
1271
- if (doc.userinfo_endpoint) doc.userinfo_endpoint = normalizeAndValidateUrl("userinfo_endpoint", doc.userinfo_endpoint, issuer, isTrustedOrigin);
1272
- if (doc.revocation_endpoint) doc.revocation_endpoint = normalizeAndValidateUrl("revocation_endpoint", doc.revocation_endpoint, issuer, isTrustedOrigin);
1273
- if (doc.end_session_endpoint) doc.end_session_endpoint = normalizeAndValidateUrl("end_session_endpoint", doc.end_session_endpoint, issuer, isTrustedOrigin);
1274
- if (doc.introspection_endpoint) doc.introspection_endpoint = normalizeAndValidateUrl("introspection_endpoint", doc.introspection_endpoint, issuer, isTrustedOrigin);
1275
- return doc;
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` });
1276
1321
  }
1277
- /**
1278
- * Normalizes and validates a single URL endpoint
1279
- * @param name The url name
1280
- * @param endpoint The url to validate
1281
- * @param issuer The issuer base url
1282
- * @param isTrustedOrigin - Origin verification tester function
1283
- * @returns
1284
- */
1285
- function normalizeAndValidateUrl(name, endpoint, issuer, isTrustedOrigin) {
1286
- const url = normalizeUrl(name, endpoint, issuer);
1287
- if (!isTrustedOrigin(url)) throw new DiscoveryError("discovery_untrusted_origin", `The ${name} "${url}" is not trusted by your trusted origins configuration.`, {
1288
- endpoint: name,
1289
- url
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 }
1499
+ });
1500
+ }
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"
1513
+ });
1514
+ }
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) {
1534
+ try {
1535
+ return parseCertificate(cert);
1536
+ } catch {
1537
+ return { error: "Failed to parse certificate" };
1538
+ }
1539
+ }
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
+ }]
1290
1555
  });
1291
- return url;
1556
+ return member ? hasOrgAdminRole(member) : false;
1292
1557
  }
1293
- /**
1294
- * Normalize a single URL endpoint.
1295
- *
1296
- * @param name - The endpoint name (e.g token_endpoint)
1297
- * @param endpoint - The endpoint URL to normalize
1298
- * @param issuer - The base issuer URL
1299
- * @returns The normalized endpoint URL
1300
- */
1301
- function normalizeUrl(name, endpoint, issuer) {
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;
1302
1578
  try {
1303
- return parseURL(name, endpoint).toString();
1579
+ oidcConfig = safeJsonParse(provider.oidcConfig);
1304
1580
  } catch {
1305
- const issuerURL = parseURL(name, issuer);
1306
- const basePath = issuerURL.pathname.replace(/\/+$/, "");
1307
- const endpointPath = endpoint.replace(/^\/+/, "");
1308
- return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
1581
+ oidcConfig = null;
1309
1582
  }
1310
- }
1311
- /**
1312
- * Parses the given URL or throws in case of invalid or unsupported protocols
1313
- *
1314
- * @param name the url name
1315
- * @param endpoint the endpoint url
1316
- * @param [base] optional base path
1317
- * @returns
1318
- */
1319
- function parseURL(name, endpoint, base) {
1320
- let endpointURL;
1321
1583
  try {
1322
- endpointURL = new URL(endpoint, base);
1323
- if (endpointURL.protocol === "http:" || endpointURL.protocol === "https:") return endpointURL;
1324
- } catch (error) {
1325
- throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
1584
+ samlConfig = safeJsonParse(provider.samlConfig);
1585
+ } catch {
1586
+ samlConfig = null;
1326
1587
  }
1327
- throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must use the http or https supported protocols: ${endpoint}`, {
1328
- url: endpoint,
1329
- protocol: endpointURL.protocol
1330
- });
1331
- }
1332
- /**
1333
- * Select the token endpoint authentication method.
1334
- *
1335
- * @param doc - The discovery document
1336
- * @param existing - Existing authentication method from config
1337
- * @returns The selected authentication method
1338
- */
1339
- function selectTokenEndpointAuthMethod(doc, existing) {
1340
- if (existing === "private_key_jwt") return existing;
1341
- if (existing) return existing;
1342
- const supported = doc.token_endpoint_auth_methods_supported;
1343
- if (!supported || supported.length === 0) return "client_secret_basic";
1344
- if (supported.includes("client_secret_basic")) return "client_secret_basic";
1345
- if (supported.includes("client_secret_post")) return "client_secret_post";
1346
- if (supported.includes("private_key_jwt")) return "private_key_jwt";
1347
- return "client_secret_basic";
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
+ };
1348
1620
  }
1349
- /**
1350
- * Check if a provider configuration needs runtime discovery.
1351
- *
1352
- * Returns true if we need discovery at runtime to complete the token exchange
1353
- * and validation. Specifically checks for:
1354
- * - `tokenEndpoint` - required for exchanging authorization code for tokens
1355
- * - `jwksEndpoint` - required for validating ID token signatures
1356
- * - `authorizationEndpoint` - required for redirecting users to the IdP for login
1357
- *
1358
- * @param config - Partial OIDC config from the provider
1359
- * @returns true if runtime discovery should be performed
1360
- */
1361
- function needsRuntimeDiscovery(config) {
1362
- if (!config) return true;
1363
- return !config.tokenEndpoint || !config.jwksEndpoint || !config.authorizationEndpoint;
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;
1364
1667
  }
1365
- /**
1366
- * Runs runtime OIDC discovery when the stored config is missing required
1367
- * endpoints, and merges the hydrated fields back into the config.
1368
- * Throws if discovery fails.
1369
- */
1370
- async function ensureRuntimeDiscovery(config, issuer, isTrustedOrigin) {
1371
- if (!needsRuntimeDiscovery(config)) return config;
1372
- const hydrated = await discoverOIDCConfig({
1373
- issuer,
1374
- existingConfig: config,
1375
- isTrustedOrigin
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));
1376
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;
1698
+ }
1699
+ function mergeSAMLConfig(current, updates, issuer) {
1377
1700
  return {
1378
- ...config,
1379
- authorizationEndpoint: hydrated.authorizationEndpoint,
1380
- tokenEndpoint: hydrated.tokenEndpoint,
1381
- tokenEndpointAuthentication: hydrated.tokenEndpointAuthentication,
1382
- userInfoEndpoint: hydrated.userInfoEndpoint,
1383
- jwksEndpoint: hydrated.jwksEndpoint
1701
+ ...current,
1702
+ ...updates,
1703
+ issuer,
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
1384
1716
  };
1385
1717
  }
1386
- //#endregion
1387
- //#region src/oidc/errors.ts
1388
- /**
1389
- * OIDC Discovery Error Mapping
1390
- *
1391
- * Maps DiscoveryError codes to appropriate APIError responses.
1392
- * Used at the boundary between the discovery pipeline and HTTP handlers.
1393
- */
1394
- /**
1395
- * Maps a DiscoveryError to an appropriate APIError for HTTP responses.
1396
- *
1397
- * Error code mapping:
1398
- * - discovery_invalid_url → 400 BAD_REQUEST
1399
- * - discovery_not_found → 400 BAD_REQUEST
1400
- * - discovery_invalid_json → 400 BAD_REQUEST
1401
- * - discovery_incomplete → 400 BAD_REQUEST
1402
- * - issuer_mismatch → 400 BAD_REQUEST
1403
- * - unsupported_token_auth_method → 400 BAD_REQUEST
1404
- * - discovery_timeout → 502 BAD_GATEWAY
1405
- * - discovery_unexpected_error → 502 BAD_GATEWAY
1406
- *
1407
- * @param error - The DiscoveryError to map
1408
- * @returns An APIError with appropriate status and message
1409
- */
1410
- function mapDiscoveryErrorToAPIError(error) {
1411
- switch (error.code) {
1412
- case "discovery_timeout": return new APIError("BAD_GATEWAY", {
1413
- message: `OIDC discovery timed out: ${error.message}`,
1414
- code: error.code
1415
- });
1416
- case "discovery_unexpected_error": return new APIError("BAD_GATEWAY", {
1417
- message: `OIDC discovery failed: ${error.message}`,
1418
- code: error.code
1718
+ function mergeOIDCConfig(current, updates, issuer) {
1719
+ return {
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
1736
+ };
1737
+ }
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;
1419
1826
  });
1420
- case "discovery_not_found": return new APIError("BAD_REQUEST", {
1421
- message: `OIDC discovery endpoint not found. The issuer may not support OIDC discovery, or the URL is incorrect. ${error.message}`,
1422
- 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
+ });
1423
1864
  });
1424
- case "discovery_invalid_url": return new APIError("BAD_REQUEST", {
1425
- message: `Invalid OIDC discovery URL: ${error.message}`,
1426
- 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"
1427
1896
  });
1428
- case "discovery_untrusted_origin": return new APIError("BAD_REQUEST", {
1429
- message: `Untrusted OIDC discovery URL: ${error.message}`,
1430
- 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)
1431
1908
  });
1432
- case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
1433
- message: `OIDC discovery returned invalid data: ${error.message}`,
1434
- 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"
1435
1934
  });
1436
- case "discovery_incomplete": return new APIError("BAD_REQUEST", {
1437
- message: `OIDC discovery document is missing required fields: ${error.message}`,
1438
- 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"
1439
1939
  });
1440
- case "issuer_mismatch": return new APIError("BAD_REQUEST", {
1441
- message: `OIDC issuer mismatch: ${error.message}`,
1442
- 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"
1443
1944
  });
1444
- case "unsupported_token_auth_method": return new APIError("BAD_REQUEST", {
1445
- message: `Incompatible OIDC provider: ${error.message}`,
1446
- 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"
1447
1959
  });
1448
- default:
1449
- error.code;
1450
- return new APIError("INTERNAL_SERVER_ERROR", {
1451
- message: `Unexpected discovery error: ${error.message}`,
1452
- 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"
1453
1975
  });
1454
- }
1455
- }
1456
- //#endregion
1457
- //#region src/saml/error-codes.ts
1458
- const SAML_ERROR_CODES = defineErrorCodes({
1459
- SINGLE_LOGOUT_NOT_ENABLED: "Single Logout is not enabled",
1460
- INVALID_LOGOUT_RESPONSE: "Invalid LogoutResponse",
1461
- INVALID_LOGOUT_REQUEST: "Invalid LogoutRequest",
1462
- LOGOUT_FAILED_AT_IDP: "Logout failed at IdP",
1463
- IDP_SLO_NOT_SUPPORTED: "IdP does not support Single Logout Service",
1464
- SAML_PROVIDER_NOT_FOUND: "SAML provider not found"
1465
- });
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
+ };
1466
1988
  //#endregion
1467
1989
  //#region src/saml-state.ts
1468
- async function generateRelayState(c, link, additionalData) {
1990
+ async function generateRelayState(c, link) {
1469
1991
  const callbackURL = c.body.callbackURL;
1470
1992
  if (!callbackURL) throw new APIError("BAD_REQUEST", { message: "callbackURL is required" });
1471
- const codeVerifier = generateRandomString(128);
1472
1993
  const stateData = {
1473
- ...additionalData ? additionalData : {},
1474
1994
  callbackURL,
1475
- codeVerifier,
1995
+ codeVerifier: generateRandomString(128),
1476
1996
  errorURL: c.body.errorCallbackURL,
1477
1997
  newUserURL: c.body.newUserCallbackURL,
1478
1998
  link,
@@ -1508,8 +2028,17 @@ async function parseRelayState(c) {
1508
2028
  if (!parsedData.errorURL) parsedData.errorURL = errorURL;
1509
2029
  return parsedData;
1510
2030
  }
2031
+ const saml = typeof samlifyNamespace.SPMetadata === "function" && typeof samlifyNamespace.setSchemaValidator === "function" ? samlifyNamespace : samlifyDefault ?? samlifyNamespace;
1511
2032
  //#endregion
1512
2033
  //#region src/routes/helpers.ts
2034
+ /**
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.
2037
+ */
2038
+ function normalizePemList(certs) {
2039
+ if (!certs) return certs;
2040
+ return certs.map((pem) => normalizePem(pem) ?? pem);
2041
+ }
1513
2042
  async function findSAMLProvider(providerId, options, adapter) {
1514
2043
  if (options?.defaultSSO?.length) {
1515
2044
  const match = options.defaultSSO.find((p) => p.providerId === providerId);
@@ -1536,41 +2065,47 @@ async function findSAMLProvider(providerId, options, adapter) {
1536
2065
  function createSP(config, baseURL, providerId, opts) {
1537
2066
  const spData = config.spMetadata;
1538
2067
  const sloLocation = `${baseURL}/sso/saml2/sp/slo/${providerId}`;
1539
- const acsUrl = config.callbackUrl || `${baseURL}/sso/saml2/sp/acs/${providerId}`;
1540
- return saml.ServiceProvider({
2068
+ const acsUrl = `${baseURL}/sso/saml2/sp/acs/${providerId}`;
2069
+ let metadata = spData?.metadata;
2070
+ if (!metadata) metadata = saml.SPMetadata({
1541
2071
  entityID: spData?.entityID || config.issuer,
1542
- assertionConsumerService: spData?.metadata ? void 0 : [{
2072
+ assertionConsumerService: [{
1543
2073
  Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1544
2074
  Location: acsUrl
1545
2075
  }],
1546
- singleLogoutService: [{
2076
+ singleLogoutService: opts?.sloOptions ? [{
1547
2077
  Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1548
2078
  Location: sloLocation
1549
2079
  }, {
1550
2080
  Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1551
2081
  Location: sloLocation
1552
- }],
2082
+ }] : void 0,
1553
2083
  wantMessageSigned: config.wantAssertionsSigned || false,
2084
+ authnRequestsSigned: config.authnRequestsSigned || false,
2085
+ nameIDFormat: config.identifierFormat ? [config.identifierFormat] : void 0
2086
+ }).getMetadata() || "";
2087
+ return saml.ServiceProvider({
2088
+ metadata,
2089
+ allowCreate: true,
1554
2090
  wantLogoutRequestSigned: opts?.sloOptions?.wantLogoutRequestSigned ?? false,
1555
2091
  wantLogoutResponseSigned: opts?.sloOptions?.wantLogoutResponseSigned ?? false,
1556
- metadata: spData?.metadata,
1557
- privateKey: spData?.privateKey || config.privateKey,
2092
+ privateKey: normalizePem(spData?.privateKey || config.privateKey),
1558
2093
  privateKeyPass: spData?.privateKeyPass,
1559
2094
  isAssertionEncrypted: spData?.isAssertionEncrypted || false,
1560
- encPrivateKey: spData?.encPrivateKey,
2095
+ encPrivateKey: normalizePem(spData?.encPrivateKey),
1561
2096
  encPrivateKeyPass: spData?.encPrivateKeyPass,
1562
- nameIDFormat: config.identifierFormat ? [config.identifierFormat] : void 0,
1563
- relayState: opts?.relayState
2097
+ relayState: opts?.relayState,
2098
+ clockDrifts: opts?.clockSkew && opts?.clockSkew !== 0 ? [-opts.clockSkew, opts.clockSkew] : void 0
1564
2099
  });
1565
2100
  }
1566
2101
  function createIdP(config) {
1567
2102
  const idpData = config.idpMetadata;
1568
2103
  if (idpData?.metadata) return saml.IdentityProvider({
1569
2104
  metadata: idpData.metadata,
1570
- privateKey: idpData.privateKey,
2105
+ privateKey: normalizePem(idpData.privateKey),
1571
2106
  privateKeyPass: idpData.privateKeyPass,
1572
2107
  isAssertionEncrypted: idpData.isAssertionEncrypted,
1573
- encPrivateKey: idpData.encPrivateKey,
2108
+ encPrivateKey: normalizePem(idpData.encPrivateKey),
1574
2109
  encPrivateKeyPass: idpData.encPrivateKeyPass
1575
2110
  });
1576
2111
  return saml.IdentityProvider({
@@ -1580,10 +2115,10 @@ function createIdP(config) {
1580
2115
  Location: config.entryPoint
1581
2116
  }],
1582
2117
  singleLogoutService: idpData?.singleLogoutService,
1583
- signingCert: idpData?.cert || config.cert,
2118
+ signingCert: normalizePemList(resolveSigningCerts(config)),
1584
2119
  wantAuthnRequestsSigned: config.authnRequestsSigned || false,
1585
2120
  isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
1586
- encPrivateKey: idpData?.encPrivateKey,
2121
+ encPrivateKey: normalizePem(idpData?.encPrivateKey),
1587
2122
  encPrivateKeyPass: idpData?.encPrivateKeyPass
1588
2123
  });
1589
2124
  }
@@ -1591,7 +2126,17 @@ function escapeHtml(str) {
1591
2126
  if (!str) return "";
1592
2127
  return String(str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1593
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
+ }
1594
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" });
1595
2140
  const safeAction = escapeHtml(action);
1596
2141
  const safeSamlParam = escapeHtml(samlParam);
1597
2142
  const safeSamlValue = escapeHtml(samlValue);
@@ -1661,6 +2206,14 @@ function getSafeRedirectUrl(url, callbackPath, appOrigin, isTrustedOrigin) {
1661
2206
  }
1662
2207
  return url;
1663
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 {}
1664
2217
  if (!isTrustedOrigin(url, { allowRelativePaths: false })) return appOrigin;
1665
2218
  try {
1666
2219
  const callbackPathname = new URL(callbackPath).pathname;
@@ -1670,6 +2223,39 @@ function getSafeRedirectUrl(url, callbackPath, appOrigin, isTrustedOrigin) {
1670
2223
  }
1671
2224
  return url;
1672
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
+ }
1673
2259
  /**
1674
2260
  * Extracts the Assertion ID from a SAML response XML.
1675
2261
  * Used for replay protection per SAML 2.0 Core section 2.3.3.
@@ -1694,8 +2280,8 @@ function extractAssertionId(samlContent) {
1694
2280
  /**
1695
2281
  * Unified SAML response processing pipeline.
1696
2282
  *
1697
- * Both `/sso/saml2/callback/:providerId` (POST) and `/sso/saml2/sp/acs/:providerId`
1698
- * delegate to this function. It handles the full lifecycle: provider lookup,
2283
+ * The `/sso/saml2/sp/acs/:providerId` endpoint delegates to this function.
2284
+ * It handles the full lifecycle: provider lookup,
1699
2285
  * SP/IdP construction, response validation, session creation, and redirect
1700
2286
  * URL computation.
1701
2287
  */
@@ -1716,7 +2302,7 @@ async function processSAMLResponse(ctx, params, options) {
1716
2302
  if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
1717
2303
  const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
1718
2304
  if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
1719
- const sp = createSP(parsedSamlConfig, ctx.context.baseURL, providerId);
2305
+ const sp = createSP(parsedSamlConfig, ctx.context.baseURL, providerId, { clockSkew: options?.saml?.clockSkew });
1720
2306
  const idp = createIdP(parsedSamlConfig);
1721
2307
  const samlRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, params.currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
1722
2308
  validateSingleAssertion(SAMLResponse);
@@ -1738,12 +2324,47 @@ async function processSAMLResponse(ctx, params, options) {
1738
2324
  });
1739
2325
  }
1740
2326
  const { extract } = parsedResponse;
2327
+ const samlContent = parsedResponse.samlContent;
1741
2328
  validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
1742
2329
  validateSAMLTimestamp(extract.conditions, {
1743
2330
  clockSkew: options?.saml?.clockSkew,
1744
2331
  requireTimestamps: options?.saml?.requireTimestamps,
1745
2332
  logger: ctx.context.logger
1746
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
+ }
1747
2368
  await validateInResponseTo(ctx, {
1748
2369
  extract,
1749
2370
  providerId,
@@ -1755,37 +2376,18 @@ async function processSAMLResponse(ctx, params, options) {
1755
2376
  });
1756
2377
  validateAudience(ctx, {
1757
2378
  extract,
1758
- expectedAudience: parsedSamlConfig.audience,
2379
+ expectedAudience: parsedSamlConfig.audience || sp.entityMeta.getEntityID(),
1759
2380
  providerId,
1760
2381
  redirectUrl: samlRedirectUrl
1761
2382
  });
1762
- const samlContent = parsedResponse.samlContent;
1763
- const assertionId = samlContent ? extractAssertionId(samlContent) : null;
2383
+ const assertionId = extractAssertionId(samlBindingContent);
1764
2384
  if (assertionId) {
1765
2385
  const issuer = idp.entityMeta.getEntityID();
1766
2386
  const conditions = extract.conditions;
1767
2387
  const clockSkew = options?.saml?.clockSkew ?? 3e5;
1768
2388
  const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
1769
- const existingAssertion = await ctx.context.internalAdapter.findVerificationValue(`${USED_ASSERTION_KEY_PREFIX}${assertionId}`);
1770
- let isReplay = false;
1771
- if (existingAssertion) try {
1772
- if (JSON.parse(existingAssertion.value).expiresAt >= Date.now()) isReplay = true;
1773
- } catch (error) {
1774
- ctx.context.logger.warn("Failed to parse stored assertion record", {
1775
- assertionId,
1776
- error
1777
- });
1778
- }
1779
- if (isReplay) {
1780
- ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
1781
- assertionId,
1782
- issuer,
1783
- providerId
1784
- });
1785
- throw ctx.redirect(`${samlRedirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
1786
- }
1787
- await ctx.context.internalAdapter.createVerificationValue({
1788
- identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
2389
+ if (!await ctx.context.internalAdapter.reserveVerificationValue({
2390
+ identifier: `saml-used-assertion:${assertionId}`,
1789
2391
  value: JSON.stringify({
1790
2392
  assertionId,
1791
2393
  issuer,
@@ -1794,16 +2396,30 @@ async function processSAMLResponse(ctx, params, options) {
1794
2396
  expiresAt
1795
2397
  }),
1796
2398
  expiresAt: new Date(expiresAt)
1797
- });
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
+ }
1798
2410
  } else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
1799
2411
  const attributes = extract.attributes || {};
1800
2412
  const mapping = parsedSamlConfig.mapping ?? {};
2413
+ const attr = (key) => {
2414
+ const value = attributes[key];
2415
+ return Array.isArray(value) ? value[0] : value;
2416
+ };
1801
2417
  const userInfo = {
1802
2418
  ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
1803
- id: attributes[mapping.id || "nameID"] || extract.nameID,
1804
- email: (attributes[mapping.email || "email"] || extract.nameID || "").toLowerCase(),
1805
- name: [attributes[mapping.firstName || "givenName"], attributes[mapping.lastName || "surname"]].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
1806
- 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
1807
2423
  };
1808
2424
  if (!userInfo.id || !userInfo.email) {
1809
2425
  ctx.context.logger.error("Missing essential user info from SAML response", {
@@ -1814,26 +2430,47 @@ async function processSAMLResponse(ctx, params, options) {
1814
2430
  });
1815
2431
  throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
1816
2432
  }
1817
- const isTrustedProvider = ctx.context.trustedProviders.includes(providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
2433
+ const isTrustedProvider = "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
1818
2434
  const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1819
- const result = await handleOAuthUserInfo(ctx, {
1820
- userInfo: {
1821
- email: userInfo.email,
1822
- name: userInfo.name || userInfo.email,
1823
- id: userInfo.id,
1824
- emailVerified: Boolean(userInfo.emailVerified)
1825
- },
1826
- account: {
1827
- providerId,
1828
- accountId: userInfo.id,
1829
- accessToken: "",
1830
- refreshToken: ""
1831
- },
1832
- callbackURL: callbackUrl,
1833
- disableSignUp: options?.disableImplicitSignUp,
1834
- isTrustedProvider
1835
- });
1836
- if (result.error) throw ctx.redirect(`${samlRedirectUrl}?error=${result.error.split(" ").join("_")}`);
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("_") }));
1837
2474
  const { session, user } = result.data;
1838
2475
  if (options?.provisionUser && (result.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
1839
2476
  user,
@@ -1847,7 +2484,7 @@ async function processSAMLResponse(ctx, params, options) {
1847
2484
  providerId,
1848
2485
  accountId: userInfo.id,
1849
2486
  email: userInfo.email,
1850
- emailVerified: Boolean(userInfo.emailVerified),
2487
+ emailVerified: userInfo.emailVerified,
1851
2488
  rawAttributes: attributes
1852
2489
  },
1853
2490
  provider,
@@ -1861,6 +2498,7 @@ async function processSAMLResponse(ctx, params, options) {
1861
2498
  const samlSessionKey = `${SAML_SESSION_KEY_PREFIX}${providerId}:${extract.nameID}`;
1862
2499
  const samlSessionData = {
1863
2500
  sessionId: session.id,
2501
+ sessionToken: session.token,
1864
2502
  providerId,
1865
2503
  nameID: extract.nameID,
1866
2504
  sessionIndex: extract.sessionIndex?.sessionIndex
@@ -1880,6 +2518,14 @@ async function processSAMLResponse(ctx, params, options) {
1880
2518
  }
1881
2519
  //#endregion
1882
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
+ ];
1883
2529
  /**
1884
2530
  * Builds the OIDC redirect URI. Uses the shared `redirectURI` option
1885
2531
  * when set, otherwise falls back to `/sso/callback/:providerId`.
@@ -1893,10 +2539,7 @@ function getOIDCRedirectURI(baseURL, providerId, options) {
1893
2539
  }
1894
2540
  return `${baseURL}/sso/callback/${providerId}`;
1895
2541
  }
1896
- const spMetadataQuerySchema = z.object({
1897
- providerId: z.string(),
1898
- format: z.enum(["xml", "json"]).default("xml")
1899
- });
2542
+ const spMetadataQuerySchema = z.object({ providerId: z.string() });
1900
2543
  const spMetadata = (options) => {
1901
2544
  return createAuthEndpoint("/sso/saml2/sp/metadata", {
1902
2545
  method: "GET",
@@ -1908,290 +2551,191 @@ const spMetadata = (options) => {
1908
2551
  responses: { "200": { description: "SAML metadata in XML format" } }
1909
2552
  } }
1910
2553
  }, async (ctx) => {
1911
- const provider = await ctx.context.adapter.findOne({
1912
- model: "ssoProvider",
1913
- where: [{
1914
- field: "providerId",
1915
- value: ctx.query.providerId
1916
- }]
1917
- });
2554
+ const provider = await findSAMLProvider(ctx.query.providerId, options, ctx.context.adapter);
1918
2555
  if (!provider) throw new APIError("NOT_FOUND", { message: "No provider found for the given providerId" });
1919
- const parsedSamlConfig = safeJsonParse(provider.samlConfig);
2556
+ const parsedSamlConfig = provider.samlConfig;
1920
2557
  if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
1921
- const sloLocation = `${ctx.context.baseURL}/sso/saml2/sp/slo/${ctx.query.providerId}`;
1922
- const singleLogoutService = options?.saml?.enableSingleLogout ? [{
1923
- Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1924
- Location: sloLocation
1925
- }, {
1926
- Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1927
- Location: sloLocation
1928
- }] : void 0;
1929
- const sp = parsedSamlConfig.spMetadata.metadata ? saml.ServiceProvider({ metadata: parsedSamlConfig.spMetadata.metadata }) : saml.SPMetadata({
1930
- entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
1931
- assertionConsumerService: [{
1932
- Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1933
- Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${ctx.query.providerId}`
1934
- }],
1935
- singleLogoutService,
1936
- wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1937
- authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
1938
- nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
1939
- });
2558
+ const sp = createSP(parsedSamlConfig, ctx.context.baseURL, ctx.query.providerId, options?.saml?.enableSingleLogout ? { sloOptions: {
2559
+ wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned,
2560
+ wantLogoutResponseSigned: options?.saml?.wantLogoutResponseSigned
2561
+ } } : void 0);
1940
2562
  return new Response(sp.getMetadata(), { headers: { "Content-Type": "application/xml" } });
1941
2563
  });
1942
2564
  };
1943
- const ssoProviderBodySchema = z.object({
1944
- providerId: z.string({}).meta({ description: "The ID of the provider. This is used to identify the provider during login and callback" }),
1945
- issuer: z.string({}).meta({ description: "The issuer of the provider" }),
1946
- 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')" }),
1947
- oidcConfig: z.object({
1948
- clientId: z.string({}).meta({ description: "The client ID" }),
1949
- clientSecret: z.string({}).optional().meta({ description: "The client secret. Required for client_secret_basic/client_secret_post. Optional for private_key_jwt." }),
1950
- authorizationEndpoint: z.string({}).meta({ description: "The authorization endpoint" }).optional(),
1951
- tokenEndpoint: z.string({}).meta({ description: "The token endpoint" }).optional(),
1952
- userInfoEndpoint: z.string({}).meta({ description: "The user info endpoint" }).optional(),
1953
- tokenEndpointAuthentication: z.enum([
1954
- "client_secret_post",
1955
- "client_secret_basic",
1956
- "private_key_jwt"
1957
- ]).optional(),
1958
- privateKeyId: z.string().optional(),
1959
- privateKeyAlgorithm: z.string().optional(),
1960
- jwksEndpoint: z.string({}).meta({ description: "The JWKS endpoint" }).optional(),
1961
- discoveryEndpoint: z.string().optional(),
1962
- skipDiscovery: z.boolean().meta({ description: "Skip OIDC discovery during registration. When true, you must provide authorizationEndpoint, tokenEndpoint, and jwksEndpoint manually." }).optional(),
1963
- scopes: z.array(z.string(), {}).meta({ description: "The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']" }).optional(),
1964
- pkce: z.boolean({}).meta({ description: "Whether to use PKCE for the authorization flow" }).default(true).optional(),
1965
- mapping: z.object({
1966
- id: z.string({}).meta({ description: "Field mapping for user ID (defaults to 'sub')" }),
1967
- email: z.string({}).meta({ description: "Field mapping for email (defaults to 'email')" }),
1968
- emailVerified: z.string({}).meta({ description: "Field mapping for email verification (defaults to 'email_verified')" }).optional(),
1969
- name: z.string({}).meta({ description: "Field mapping for name (defaults to 'name')" }),
1970
- image: z.string({}).meta({ description: "Field mapping for image (defaults to 'picture')" }).optional(),
1971
- extraFields: z.record(z.string(), z.any()).optional()
1972
- }).optional()
1973
- }).optional(),
1974
- samlConfig: z.object({
1975
- entryPoint: z.string({}).meta({ description: "The entry point of the provider" }),
1976
- cert: z.string({}).meta({ description: "The certificate of the provider" }),
1977
- callbackUrl: z.string({}).meta({ description: "The callback URL of the provider" }),
1978
- audience: z.string().optional(),
1979
- idpMetadata: z.object({
1980
- metadata: z.string().optional(),
1981
- entityID: z.string().optional(),
1982
- cert: z.string().optional(),
1983
- privateKey: z.string().optional(),
1984
- privateKeyPass: z.string().optional(),
1985
- isAssertionEncrypted: z.boolean().optional(),
1986
- encPrivateKey: z.string().optional(),
1987
- encPrivateKeyPass: z.string().optional(),
1988
- singleSignOnService: z.array(z.object({
1989
- Binding: z.string().meta({ description: "The binding type for the SSO service" }),
1990
- Location: z.string().meta({ description: "The URL for the SSO service" })
1991
- })).optional().meta({ description: "Single Sign-On service configuration" })
1992
- }).optional(),
1993
- spMetadata: z.object({
1994
- metadata: z.string().optional(),
1995
- entityID: z.string().optional(),
1996
- binding: z.string().optional(),
1997
- privateKey: z.string().optional(),
1998
- privateKeyPass: z.string().optional(),
1999
- isAssertionEncrypted: z.boolean().optional(),
2000
- encPrivateKey: z.string().optional(),
2001
- encPrivateKeyPass: z.string().optional()
2002
- }),
2003
- wantAssertionsSigned: z.boolean().optional(),
2004
- authnRequestsSigned: z.boolean().optional(),
2005
- signatureAlgorithm: z.string().optional(),
2006
- digestAlgorithm: z.string().optional(),
2007
- identifierFormat: z.string().optional(),
2008
- privateKey: z.string().optional(),
2009
- decryptionPvk: z.string().optional(),
2010
- additionalParams: z.record(z.string(), z.any()).optional(),
2011
- mapping: z.object({
2012
- id: z.string({}).meta({ description: "Field mapping for user ID (defaults to 'nameID')" }),
2013
- email: z.string({}).meta({ description: "Field mapping for email (defaults to 'email')" }),
2014
- emailVerified: z.string({}).meta({ description: "Field mapping for email verification" }).optional(),
2015
- name: z.string({}).meta({ description: "Field mapping for name (defaults to 'displayName')" }),
2016
- firstName: z.string({}).meta({ description: "Field mapping for first name (defaults to 'givenName')" }).optional(),
2017
- lastName: z.string({}).meta({ description: "Field mapping for last name (defaults to 'surname')" }).optional(),
2018
- extraFields: z.record(z.string(), z.any()).optional()
2019
- }).optional()
2020
- }).optional(),
2021
- organizationId: z.string({}).meta({ description: "If organization plugin is enabled, the organization id to link the provider to" }).optional(),
2022
- overrideUserInfo: z.boolean({}).meta({ description: "Override user info with the provider info. Defaults to false" }).default(false).optional()
2023
- });
2024
2565
  const registerSSOProvider = (options) => {
2025
2566
  return createAuthEndpoint("/sso/register", {
2026
2567
  method: "POST",
2027
- body: ssoProviderBodySchema,
2568
+ body: getRegisterSSOProviderBodySchema(options),
2028
2569
  use: [sessionMiddleware],
2029
- metadata: { openapi: {
2030
- operationId: "registerSSOProvider",
2031
- summary: "Register an OIDC provider",
2032
- description: "This endpoint is used to register an OIDC provider. This is used to configure the provider and link it to an organization",
2033
- responses: { "200": {
2034
- description: "OIDC provider created successfully",
2035
- content: { "application/json": { schema: {
2036
- type: "object",
2037
- properties: {
2038
- issuer: {
2039
- type: "string",
2040
- format: "uri",
2041
- description: "The issuer URL of the provider"
2042
- },
2043
- domain: {
2044
- type: "string",
2045
- description: "The domain of the provider, used for email matching"
2046
- },
2047
- domainVerified: {
2048
- type: "boolean",
2049
- description: "A boolean indicating whether the domain has been verified or not"
2050
- },
2051
- domainVerificationToken: {
2052
- type: "string",
2053
- description: "Domain verification token. It can be used to prove ownership over the SSO domain"
2054
- },
2055
- oidcConfig: {
2056
- type: "object",
2057
- properties: {
2058
- issuer: {
2059
- type: "string",
2060
- format: "uri",
2061
- description: "The issuer URL of the provider"
2062
- },
2063
- pkce: {
2064
- type: "boolean",
2065
- description: "Whether PKCE is enabled for the authorization flow"
2066
- },
2067
- clientId: {
2068
- type: "string",
2069
- description: "The client ID for the provider"
2070
- },
2071
- clientSecret: {
2072
- type: "string",
2073
- description: "The client secret for the provider"
2074
- },
2075
- authorizationEndpoint: {
2076
- type: "string",
2077
- format: "uri",
2078
- nullable: true,
2079
- description: "The authorization endpoint URL"
2080
- },
2081
- discoveryEndpoint: {
2082
- type: "string",
2083
- format: "uri",
2084
- description: "The discovery endpoint URL"
2085
- },
2086
- userInfoEndpoint: {
2087
- type: "string",
2088
- format: "uri",
2089
- nullable: true,
2090
- description: "The user info endpoint URL"
2091
- },
2092
- scopes: {
2093
- type: "array",
2094
- items: { type: "string" },
2095
- nullable: true,
2096
- description: "The scopes requested from the provider"
2097
- },
2098
- tokenEndpoint: {
2099
- type: "string",
2100
- format: "uri",
2101
- nullable: true,
2102
- description: "The token endpoint URL"
2103
- },
2104
- tokenEndpointAuthentication: {
2105
- type: "string",
2106
- enum: ["client_secret_post", "client_secret_basic"],
2107
- nullable: true,
2108
- description: "Authentication method for the token endpoint"
2109
- },
2110
- jwksEndpoint: {
2111
- type: "string",
2112
- format: "uri",
2113
- nullable: true,
2114
- description: "The JWKS endpoint URL"
2115
- },
2116
- mapping: {
2117
- type: "object",
2118
- nullable: true,
2119
- properties: {
2120
- id: {
2121
- type: "string",
2122
- description: "Field mapping for user ID (defaults to 'sub')"
2123
- },
2124
- email: {
2125
- type: "string",
2126
- description: "Field mapping for email (defaults to 'email')"
2127
- },
2128
- emailVerified: {
2129
- type: "string",
2130
- nullable: true,
2131
- description: "Field mapping for email verification (defaults to 'email_verified')"
2132
- },
2133
- name: {
2134
- type: "string",
2135
- description: "Field mapping for name (defaults to 'name')"
2136
- },
2137
- image: {
2138
- type: "string",
2139
- nullable: true,
2140
- description: "Field mapping for image (defaults to 'picture')"
2141
- },
2142
- extraFields: {
2143
- type: "object",
2144
- additionalProperties: { type: "string" },
2145
- nullable: true,
2146
- description: "Additional field mappings"
2147
- }
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"
2613
+ },
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"
2148
2634
  },
2149
- required: [
2150
- "id",
2151
- "email",
2152
- "name"
2153
- ]
2154
- }
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"
2155
2707
  },
2156
- required: [
2157
- "issuer",
2158
- "pkce",
2159
- "clientId",
2160
- "clientSecret",
2161
- "discoveryEndpoint"
2162
- ],
2163
- description: "OIDC configuration for the provider"
2164
- },
2165
- organizationId: {
2166
- type: "string",
2167
- nullable: true,
2168
- description: "ID of the linked organization, if any"
2169
- },
2170
- userId: {
2171
- type: "string",
2172
- description: "ID of the user who registered the provider"
2173
- },
2174
- providerId: {
2175
- type: "string",
2176
- 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
+ }
2177
2726
  },
2178
- redirectURI: {
2179
- type: "string",
2180
- format: "uri",
2181
- description: "The redirect URI for the provider callback"
2182
- }
2183
- },
2184
- required: [
2185
- "issuer",
2186
- "domain",
2187
- "oidcConfig",
2188
- "userId",
2189
- "providerId",
2190
- "redirectURI"
2191
- ]
2192
- } } }
2193
- } }
2194
- } }
2727
+ required: [
2728
+ "issuer",
2729
+ "domain",
2730
+ "oidcConfig",
2731
+ "userId",
2732
+ "providerId",
2733
+ "redirectURI"
2734
+ ]
2735
+ } } }
2736
+ } }
2737
+ }
2738
+ }
2195
2739
  }, async (ctx) => {
2196
2740
  const user = ctx.context.session?.user;
2197
2741
  if (!user) throw new APIError("UNAUTHORIZED");
@@ -2205,13 +2749,13 @@ const registerSSOProvider = (options) => {
2205
2749
  }]
2206
2750
  })).length >= limit) throw new APIError("FORBIDDEN", { message: "You have reached the maximum number of SSO providers" });
2207
2751
  const body = ctx.body;
2208
- 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");
2209
2753
  if (body.samlConfig?.idpMetadata?.metadata) {
2210
2754
  const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
2211
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)` });
2212
2756
  }
2213
2757
  if (ctx.body.organizationId) {
2214
- if (!await ctx.context.adapter.findOne({
2758
+ const member = await ctx.context.adapter.findOne({
2215
2759
  model: "member",
2216
2760
  where: [{
2217
2761
  field: "userId",
@@ -2220,7 +2764,31 @@ const registerSSOProvider = (options) => {
2220
2764
  field: "organizationId",
2221
2765
  value: ctx.body.organizationId
2222
2766
  }]
2223
- })) 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
+ }
2224
2792
  }
2225
2793
  if (await ctx.context.adapter.findOne({
2226
2794
  model: "ssoProvider",
@@ -2232,6 +2800,12 @@ const registerSSOProvider = (options) => {
2232
2800
  ctx.context.logger.info(`SSO provider creation attempt with existing providerId: ${body.providerId}`);
2233
2801
  throw new APIError("UNPROCESSABLE_ENTITY", { message: "SSO provider with this providerId already exists" });
2234
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
+ }
2235
2809
  let hydratedOIDCConfig = null;
2236
2810
  if (body.oidcConfig && !body.oidcConfig.skipDiscovery) try {
2237
2811
  hydratedOIDCConfig = await discoverOIDCConfig({
@@ -2293,6 +2867,7 @@ const registerSSOProvider = (options) => {
2293
2867
  signatureAlgorithm: body.samlConfig.signatureAlgorithm,
2294
2868
  digestAlgorithm: body.samlConfig.digestAlgorithm
2295
2869
  }, options?.saml?.algorithms);
2870
+ validateCertSources(body.samlConfig);
2296
2871
  const hasIdpMetadata = body.samlConfig.idpMetadata?.metadata;
2297
2872
  let hasEntryPoint = false;
2298
2873
  if (body.samlConfig.entryPoint) try {
@@ -2308,6 +2883,7 @@ const registerSSOProvider = (options) => {
2308
2883
  issuer: body.issuer,
2309
2884
  domain: body.domain,
2310
2885
  domainVerified: false,
2886
+ ...additionalFields,
2311
2887
  oidcConfig: (() => {
2312
2888
  const config = buildOIDCConfig();
2313
2889
  if (config) {
@@ -2321,8 +2897,8 @@ const registerSSOProvider = (options) => {
2321
2897
  issuer: body.issuer,
2322
2898
  entryPoint: body.samlConfig.entryPoint,
2323
2899
  cert: body.samlConfig.cert,
2324
- callbackUrl: body.samlConfig.callbackUrl,
2325
2900
  audience: body.samlConfig.audience,
2901
+ callbackUrl: body.samlConfig.callbackUrl,
2326
2902
  idpMetadata: body.samlConfig.idpMetadata,
2327
2903
  spMetadata: body.samlConfig.spMetadata,
2328
2904
  wantAssertionsSigned: body.samlConfig.wantAssertionsSigned,
@@ -2331,8 +2907,6 @@ const registerSSOProvider = (options) => {
2331
2907
  digestAlgorithm: body.samlConfig.digestAlgorithm,
2332
2908
  identifierFormat: body.samlConfig.identifierFormat,
2333
2909
  privateKey: body.samlConfig.privateKey,
2334
- decryptionPvk: body.samlConfig.decryptionPvk,
2335
- additionalParams: body.samlConfig.additionalParams,
2336
2910
  mapping: body.samlConfig.mapping
2337
2911
  }) : null,
2338
2912
  organizationId: body.organizationId,
@@ -2352,7 +2926,7 @@ const registerSSOProvider = (options) => {
2352
2926
  });
2353
2927
  }
2354
2928
  const result = {
2355
- ...provider,
2929
+ ...filterSSOProviderAdditionalFields(provider, options),
2356
2930
  oidcConfig: safeJsonParse(provider.oidcConfig),
2357
2931
  samlConfig: safeJsonParse(provider.samlConfig),
2358
2932
  redirectURI: getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options),
@@ -2363,17 +2937,18 @@ const registerSSOProvider = (options) => {
2363
2937
  });
2364
2938
  };
2365
2939
  const signInSSOBodySchema = z.object({
2366
- 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(),
2367
- organizationSlug: z.string({}).meta({ description: "The slug of the organization to sign in with" }).optional(),
2368
- providerId: z.string({}).meta({ description: "The ID of the provider to sign in with. This can be provided instead of email or issuer" }).optional(),
2369
- domain: z.string({}).meta({ description: "The domain of the provider." }).optional(),
2370
- callbackURL: z.string({}).meta({ description: "The URL to redirect to after login" }),
2371
- errorCallbackURL: z.string({}).meta({ description: "The URL to redirect to after login" }).optional(),
2372
- 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(),
2373
2947
  scopes: z.array(z.string(), {}).meta({ description: "Scopes to request from the provider." }).optional(),
2374
- 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(),
2375
- requestSignUp: z.boolean({}).meta({ description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider" }).optional(),
2376
- 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()
2377
2952
  });
2378
2953
  const signInSSO = (options) => {
2379
2954
  return createAuthEndpoint("/sign-in/sso", {
@@ -2388,31 +2963,54 @@ const signInSSO = (options) => {
2388
2963
  properties: {
2389
2964
  email: {
2390
2965
  type: "string",
2391
- 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."
2392
2967
  },
2393
- issuer: {
2968
+ organizationSlug: {
2394
2969
  type: "string",
2395
- 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."
2396
2971
  },
2397
2972
  providerId: {
2398
2973
  type: "string",
2399
- 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."
2400
2979
  },
2401
2980
  callbackURL: {
2402
2981
  type: "string",
2403
- description: "The URL to redirect to after login"
2982
+ description: "The URL to redirect to after successful sign-in."
2404
2983
  },
2405
2984
  errorCallbackURL: {
2406
2985
  type: "string",
2407
- description: "The URL to redirect to after login"
2986
+ description: "The URL to redirect to if the sign-in flow fails."
2408
2987
  },
2409
2988
  newUserCallbackURL: {
2410
2989
  type: "string",
2411
- 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."
2412
2996
  },
2413
2997
  loginHint: {
2414
2998
  type: "string",
2415
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."
2416
3014
  }
2417
3015
  },
2418
3016
  required: ["callbackURL"]
@@ -2455,7 +3053,7 @@ const signInSSO = (options) => {
2455
3053
  });
2456
3054
  let provider = null;
2457
3055
  if (options?.defaultSSO?.length) {
2458
- 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));
2459
3057
  if (matchingDefault) provider = {
2460
3058
  issuer: matchingDefault.samlConfig?.issuer || matchingDefault.oidcConfig?.issuer || "",
2461
3059
  providerId: matchingDefault.providerId,
@@ -2509,7 +3107,8 @@ const signInSSO = (options) => {
2509
3107
  throw error;
2510
3108
  }
2511
3109
  if (!config.authorizationEndpoint) throw new APIError("BAD_REQUEST", { message: "Invalid OIDC configuration. Authorization URL not found." });
2512
- 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);
2513
3112
  const redirectURI = getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options);
2514
3113
  const authorizationURL = await createAuthorizationURL({
2515
3114
  id: provider.issuer,
@@ -2527,7 +3126,8 @@ const signInSSO = (options) => {
2527
3126
  "offline_access"
2528
3127
  ],
2529
3128
  loginHint: ctx.body.loginHint || email,
2530
- authorizationEndpoint: config.authorizationEndpoint
3129
+ authorizationEndpoint: config.authorizationEndpoint,
3130
+ additionalParams: ctx.body.additionalParams
2531
3131
  });
2532
3132
  return ctx.json({
2533
3133
  url: authorizationURL.toString(),
@@ -2535,50 +3135,13 @@ const signInSSO = (options) => {
2535
3135
  });
2536
3136
  }
2537
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." });
2538
3139
  const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
2539
3140
  if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
2540
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" });
2541
- const { state: relayState } = await generateRelayState(ctx, void 0, false);
2542
- let metadata = parsedSamlConfig.spMetadata.metadata;
2543
- if (!metadata) metadata = saml.SPMetadata({
2544
- entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
2545
- assertionConsumerService: [{
2546
- Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
2547
- Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.providerId}`
2548
- }],
2549
- wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
2550
- authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
2551
- nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
2552
- }).getMetadata() || "";
2553
- const sp = saml.ServiceProvider({
2554
- metadata,
2555
- allowCreate: true,
2556
- privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
2557
- privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
2558
- relayState
2559
- });
2560
- const idpData = parsedSamlConfig.idpMetadata;
2561
- let idp;
2562
- if (!idpData?.metadata) idp = saml.IdentityProvider({
2563
- entityID: idpData?.entityID || parsedSamlConfig.issuer,
2564
- singleSignOnService: idpData?.singleSignOnService || [{
2565
- Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
2566
- Location: parsedSamlConfig.entryPoint
2567
- }],
2568
- signingCert: idpData?.cert || parsedSamlConfig.cert,
2569
- wantAuthnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
2570
- isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
2571
- encPrivateKey: idpData?.encPrivateKey,
2572
- encPrivateKeyPass: idpData?.encPrivateKeyPass
2573
- });
2574
- else idp = saml.IdentityProvider({
2575
- metadata: idpData.metadata,
2576
- privateKey: idpData.privateKey,
2577
- privateKeyPass: idpData.privateKeyPass,
2578
- isAssertionEncrypted: idpData.isAssertionEncrypted,
2579
- encPrivateKey: idpData.encPrivateKey,
2580
- encPrivateKeyPass: idpData.encPrivateKeyPass
2581
- });
3142
+ const { state: relayState } = await generateRelayState(ctx, void 0);
3143
+ const sp = createSP(parsedSamlConfig, ctx.context.baseURL, provider.providerId, { relayState });
3144
+ const idp = createIdP(parsedSamlConfig);
2582
3145
  const loginRequest = sp.createLoginRequest(idp, "redirect");
2583
3146
  if (!loginRequest) throw new APIError("BAD_REQUEST", { message: "Invalid SAML request" });
2584
3147
  if (loginRequest.id && options?.saml?.enableInResponseToValidation !== false) {
@@ -2605,10 +3168,26 @@ const signInSSO = (options) => {
2605
3168
  };
2606
3169
  const callbackSSOQuerySchema = z.object({
2607
3170
  code: z.string().optional(),
2608
- state: z.string(),
3171
+ state: z.string().optional(),
2609
3172
  error: z.string().optional(),
2610
3173
  error_description: z.string().optional()
2611
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
+ }
2612
3191
  /**
2613
3192
  * Core OIDC callback handler logic, shared between the per-provider and
2614
3193
  * shared callback endpoints. Resolves the provider, exchanges the
@@ -2625,30 +3204,17 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2625
3204
  throw ctx.redirect(`${errorURL}?error=invalid_state`);
2626
3205
  }
2627
3206
  const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
2628
- if (!code || error) throw ctx.redirect(`${errorURL || callbackURL}?error=${error}&error_description=${error_description}`);
2629
- let provider = null;
2630
- if (options?.defaultSSO?.length) {
2631
- const matchingDefault = options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId);
2632
- if (matchingDefault) provider = {
2633
- ...matchingDefault,
2634
- issuer: matchingDefault.oidcConfig?.issuer || "",
2635
- userId: "default",
2636
- ...options.domainVerification?.enabled ? { domainVerified: true } : {}
2637
- };
2638
- }
2639
- if (!provider) provider = await ctx.context.adapter.findOne({
2640
- model: "ssoProvider",
2641
- where: [{
2642
- field: "providerId",
2643
- value: providerId
2644
- }]
2645
- }).then((res) => {
2646
- if (!res) return null;
2647
- return {
2648
- ...res,
2649
- oidcConfig: safeJsonParse(res.oidcConfig) || void 0
2650
- };
2651
- });
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);
2652
3218
  if (!provider) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
2653
3219
  if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
2654
3220
  let config = provider.oidcConfig;
@@ -2669,11 +3235,9 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2669
3235
  ]
2670
3236
  };
2671
3237
  if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_endpoint_not_found`);
2672
- let authMethod = "basic";
2673
- if (config.tokenEndpointAuthentication === "client_secret_post") authMethod = "post";
2674
- else if (config.tokenEndpointAuthentication === "private_key_jwt") authMethod = "private_key_jwt";
2675
- let clientAssertionConfig;
2676
- 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") {
2677
3241
  let resolved;
2678
3242
  const matchingDefault = options?.defaultSSO?.find((p) => p.providerId === provider.providerId && "privateKey" in p && p.privateKey);
2679
3243
  if (matchingDefault && "privateKey" in matchingDefault) resolved = matchingDefault.privateKey;
@@ -2682,55 +3246,73 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2682
3246
  keyId: config.privateKeyId,
2683
3247
  issuer: config.issuer
2684
3248
  });
2685
- 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`);
2686
3250
  const rawAlg = config.privateKeyAlgorithm ?? resolved.algorithm;
2687
- const algorithm = rawAlg && ASSERTION_SIGNING_ALGORITHMS.includes(rawAlg) ? rawAlg : void 0;
2688
- clientAssertionConfig = {
2689
- privateKeyJwk: resolved.privateKeyJwk,
2690
- privateKeyPem: resolved.privateKeyPem,
2691
- kid: config.privateKeyId ?? resolved.kid,
2692
- algorithm,
2693
- 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
+ })
2694
3260
  };
2695
3261
  }
2696
- const tokenResponse = await validateAuthorizationCode({
2697
- code,
2698
- codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
2699
- redirectURI: getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options),
2700
- options: {
2701
- clientId: config.clientId,
2702
- clientSecret: config.clientSecret
2703
- },
2704
- tokenEndpoint: config.tokenEndpoint,
2705
- authentication: authMethod,
2706
- clientAssertion: clientAssertionConfig
2707
- }).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;
2708
3283
  ctx.context.logger.error("Error validating authorization code", e);
2709
- if (e instanceof BetterFetchError) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${e.message}`);
2710
- return null;
3284
+ if (e instanceof DiscoveryError) redirectOIDCError("invalid_provider", e.message);
3285
+ redirectOIDCError("invalid_provider", getOIDCErrorDescription(e, "token_response_error"));
2711
3286
  });
2712
3287
  if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_response_not_found`);
2713
3288
  let userInfo = null;
2714
3289
  const mapping = config.mapping || {};
3290
+ let rawProfile;
2715
3291
  if (config.userInfoEndpoint) {
2716
- const userInfoResponse = await betterFetch(config.userInfoEndpoint, { headers: { Authorization: `Bearer ${tokenResponse.accessToken}` } });
2717
- if (userInfoResponse.error) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${userInfoResponse.error.message}`);
2718
- 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;
2719
3299
  userInfo = {
2720
3300
  ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, rawUserInfo[value]])),
2721
3301
  id: rawUserInfo[mapping.id || "sub"],
2722
3302
  email: rawUserInfo[mapping.email || "email"],
2723
- emailVerified: options?.trustEmailVerified ? rawUserInfo[mapping.emailVerified || "email_verified"] : false,
3303
+ emailVerified: options?.trustEmailVerified ? parseProviderEmailVerified(rawUserInfo[mapping.emailVerified || "email_verified"]) : false,
2724
3304
  name: rawUserInfo[mapping.name || "name"],
2725
3305
  image: rawUserInfo[mapping.image || "picture"]
2726
3306
  };
2727
3307
  } else if (tokenResponse.idToken) {
2728
3308
  const idToken = decodeJwt(tokenResponse.idToken);
3309
+ rawProfile = idToken;
2729
3310
  if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=jwks_endpoint_not_found`);
2730
- const verified = await validateToken(tokenResponse.idToken, config.jwksEndpoint, {
3311
+ const verified = await validateOIDCIdToken(tokenResponse.idToken, config.jwksEndpoint, {
2731
3312
  audience: config.clientId,
2732
3313
  issuer: provider.issuer
2733
- }).catch((e) => {
3314
+ }, (url) => ctx.context.isTrustedOrigin(url)).catch((e) => {
3315
+ if (e instanceof DiscoveryError) redirectOIDCError("invalid_provider", e.message);
2734
3316
  ctx.context.logger.error(e);
2735
3317
  return null;
2736
3318
  });
@@ -2739,37 +3321,67 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2739
3321
  ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, verified.payload[value]])),
2740
3322
  id: idToken[mapping.id || "sub"],
2741
3323
  email: idToken[mapping.email || "email"],
2742
- emailVerified: options?.trustEmailVerified ? idToken[mapping.emailVerified || "email_verified"] : false,
3324
+ emailVerified: options?.trustEmailVerified ? parseProviderEmailVerified(idToken[mapping.emailVerified || "email_verified"]) : false,
2743
3325
  name: idToken[mapping.name || "name"],
2744
3326
  image: idToken[mapping.image || "picture"]
2745
3327
  };
2746
3328
  } else throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=user_info_endpoint_not_found`);
2747
3329
  if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=missing_user_info`);
2748
- const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfo.email, provider.domain);
2749
- const linked = await handleOAuthUserInfo(ctx, {
2750
- userInfo: {
2751
- email: userInfo.email,
2752
- name: userInfo.name || "",
2753
- id: userInfo.id,
2754
- image: userInfo.image,
2755
- emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
2756
- },
2757
- account: {
2758
- idToken: tokenResponse.idToken,
2759
- accessToken: tokenResponse.accessToken,
2760
- refreshToken: tokenResponse.refreshToken,
2761
- accountId: userInfo.id,
2762
- providerId: provider.providerId,
2763
- accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
2764
- refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
2765
- scope: tokenResponse.scopes?.join(",")
2766
- },
2767
- callbackURL,
2768
- disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
2769
- overrideUserInfo: config.overrideUserInfo,
2770
- isTrustedProvider
2771
- });
2772
- 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
+ }
2773
3385
  const { session, user } = linked.data;
2774
3386
  if (options?.provisionUser && (linked.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
2775
3387
  user,
@@ -2782,8 +3394,8 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2782
3394
  profile: {
2783
3395
  providerType: "oidc",
2784
3396
  providerId: provider.providerId,
2785
- accountId: userInfo.id,
2786
- email: userInfo.email,
3397
+ accountId: userInfoId,
3398
+ email: userInfoEmail,
2787
3399
  emailVerified: Boolean(userInfo.emailVerified),
2788
3400
  rawAttributes: userInfo
2789
3401
  },
@@ -2817,9 +3429,86 @@ const callbackSSOEndpointConfig = {
2817
3429
  }
2818
3430
  }
2819
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
+ }
2820
3507
  const callbackSSO = (options) => {
2821
3508
  return createAuthEndpoint("/sso/callback/:providerId", callbackSSOEndpointConfig, async (ctx) => {
2822
- 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);
2823
3512
  });
2824
3513
  };
2825
3514
  /**
@@ -2845,7 +3534,7 @@ const callbackSSOShared = (options) => {
2845
3534
  const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
2846
3535
  throw ctx.redirect(`${errorURL}?error=invalid_state`);
2847
3536
  }
2848
- const providerId = stateData.ssoProviderId;
3537
+ const providerId = stateData.serverContext?.ssoProviderId;
2849
3538
  if (!providerId) {
2850
3539
  const errorURL = stateData.errorURL || stateData.callbackURL;
2851
3540
  throw ctx.redirect(`${errorURL}?error=invalid_state&error_description=missing_provider_id`);
@@ -2853,72 +3542,42 @@ const callbackSSOShared = (options) => {
2853
3542
  return handleOIDCCallback(ctx, options, providerId, stateData);
2854
3543
  });
2855
3544
  };
2856
- const callbackSSOSAMLBodySchema = z.object({
3545
+ const acsEndpointBodySchema = z.object({
2857
3546
  SAMLResponse: z.string(),
2858
3547
  RelayState: z.string().optional()
2859
3548
  });
2860
- const callbackSSOSAML = (options) => {
2861
- return createAuthEndpoint("/sso/saml2/callback/:providerId", {
3549
+ const acsEndpoint = (options) => {
3550
+ return createAuthEndpoint("/sso/saml2/sp/acs/:providerId", {
2862
3551
  method: ["GET", "POST"],
2863
- body: callbackSSOSAMLBodySchema.optional(),
3552
+ body: acsEndpointBodySchema.optional(),
2864
3553
  query: z.object({ RelayState: z.string().optional() }).optional(),
2865
3554
  metadata: {
2866
3555
  ...HIDE_METADATA,
2867
3556
  allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
2868
3557
  openapi: {
2869
- operationId: "handleSAMLCallback",
2870
- summary: "Callback URL for SAML provider",
2871
- description: "This endpoint is used as the callback URL for SAML providers. Supports both GET and POST methods for IdP-initiated and SP-initiated flows.",
3558
+ operationId: "handleSAMLAssertionConsumerService",
3559
+ summary: "SAML Assertion Consumer Service",
3560
+ description: "Handles SAML responses from IdP after successful authentication. Supports GET for post-auth redirects and POST for SAML response processing.",
2872
3561
  responses: {
2873
- "302": { description: "Redirects to the callback URL" },
2874
- "400": { description: "Invalid SAML response" },
2875
- "401": { description: "Unauthorized - SAML authentication failed" }
3562
+ "302": { description: "Redirects after authentication (success or error with query params)" },
3563
+ "400": { description: "Missing SAMLResponse in POST body" },
3564
+ "404": { description: "SAML provider not found" }
2876
3565
  }
2877
3566
  }
2878
3567
  }
2879
3568
  }, async (ctx) => {
2880
3569
  const { providerId } = ctx.params;
3570
+ const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`;
2881
3571
  const appOrigin = new URL(ctx.context.baseURL).origin;
2882
- const errorURL = ctx.context.options.onAPIError?.errorURL || `${appOrigin}/error`;
2883
- const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/callback/${providerId}`;
2884
3572
  if (ctx.method === "GET" && !ctx.body?.SAMLResponse) {
2885
- if (!(await getSessionFromCtx(ctx))?.session) throw ctx.redirect(`${errorURL}?error=invalid_request`);
3573
+ if (!(await getSessionFromCtx(ctx))?.session) {
3574
+ const errorURL = ctx.context.options.onAPIError?.errorURL || `${appOrigin}/error`;
3575
+ throw ctx.redirect(`${errorURL}?error=invalid_request`);
3576
+ }
2886
3577
  const relayState = ctx.query?.RelayState;
2887
- const safeRedirectUrl = getSafeRedirectUrl(relayState, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
2888
- throw ctx.redirect(safeRedirectUrl);
3578
+ throw ctx.redirect(getSafeRedirectUrl(relayState, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings)));
2889
3579
  }
2890
3580
  if (!ctx.body?.SAMLResponse) throw new APIError("BAD_REQUEST", { message: "SAMLResponse is required for POST requests" });
2891
- const safeRedirectUrl = await processSAMLResponse(ctx, {
2892
- SAMLResponse: ctx.body.SAMLResponse,
2893
- RelayState: ctx.body.RelayState,
2894
- providerId,
2895
- currentCallbackPath
2896
- }, options);
2897
- throw ctx.redirect(safeRedirectUrl);
2898
- });
2899
- };
2900
- const acsEndpointBodySchema = z.object({
2901
- SAMLResponse: z.string(),
2902
- RelayState: z.string().optional()
2903
- });
2904
- const acsEndpoint = (options) => {
2905
- return createAuthEndpoint("/sso/saml2/sp/acs/:providerId", {
2906
- method: "POST",
2907
- body: acsEndpointBodySchema,
2908
- metadata: {
2909
- ...HIDE_METADATA,
2910
- allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
2911
- openapi: {
2912
- operationId: "handleSAMLAssertionConsumerService",
2913
- summary: "SAML Assertion Consumer Service",
2914
- description: "Handles SAML responses from IdP after successful authentication",
2915
- responses: { "302": { description: "Redirects to the callback URL after successful authentication" } }
2916
- }
2917
- }
2918
- }, async (ctx) => {
2919
- const { providerId } = ctx.params;
2920
- const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`;
2921
- const appOrigin = new URL(ctx.context.baseURL).origin;
2922
3581
  try {
2923
3582
  const safeRedirectUrl = await processSAMLResponse(ctx, {
2924
3583
  SAMLResponse: ctx.body.SAMLResponse,
@@ -2930,9 +3589,8 @@ const acsEndpoint = (options) => {
2930
3589
  } catch (error) {
2931
3590
  if (error instanceof Response || error && typeof error === "object" && "status" in error && error.status === 302) throw error;
2932
3591
  if (error instanceof APIError && error.statusCode === 400) {
2933
- const internalCode = error.body?.code || "";
2934
- const errorCode = internalCode === "SAML_MULTIPLE_ASSERTIONS" ? "multiple_assertions" : internalCode === "SAML_NO_ASSERTION" ? "no_assertion" : internalCode.toLowerCase() || "saml_error";
2935
- const redirectUrl = getSafeRedirectUrl(ctx.body.RelayState || void 0, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
3592
+ const errorCode = (error.body?.code || "saml_error").toLowerCase();
3593
+ const redirectUrl = getSafeRedirectUrl(ctx.body?.RelayState || void 0, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
2936
3594
  throw ctx.redirect(`${redirectUrl}${redirectUrl.includes("?") ? "&" : "?"}error=${encodeURIComponent(errorCode)}&error_description=${encodeURIComponent(error.message)}`);
2937
3595
  }
2938
3596
  throw error;
@@ -3025,7 +3683,7 @@ async function handleLogoutRequest(ctx, sp, idp, relayState, providerId) {
3025
3683
  if (stored) {
3026
3684
  const data = safeJsonParse(stored.value);
3027
3685
  if (data) if (!sessionIndex || !data.sessionIndex || sessionIndex === data.sessionIndex) {
3028
- 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 }));
3029
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));
3030
3688
  } else ctx.context.logger.warn("SessionIndex mismatch in LogoutRequest - skipping session deletion", {
3031
3689
  providerId,
@@ -3035,10 +3693,9 @@ async function handleLogoutRequest(ctx, sp, idp, relayState, providerId) {
3035
3693
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(key).catch((e) => ctx.context.logger.warn("Failed to delete SAML session key during SLO", e));
3036
3694
  }
3037
3695
  const currentSession = await getSessionFromCtx(ctx);
3038
- if (currentSession?.session) await ctx.context.internalAdapter.deleteSession(currentSession.session.id);
3696
+ if (currentSession?.session) await ctx.context.internalAdapter.deleteSession(currentSession.session.token);
3039
3697
  deleteSessionCookie(ctx);
3040
- const requestId = parsed.extract.request?.id || "";
3041
- 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 || "");
3042
3699
  if (binding === "post" && res.entityEndpoint) return createSAMLPostForm(res.entityEndpoint, "SAMLResponse", res.context, relayState);
3043
3700
  throw ctx.redirect(res.context);
3044
3701
  }
@@ -3091,7 +3748,7 @@ const initiateSLO = (options) => {
3091
3748
  });
3092
3749
  if (samlSessionKey) await ctx.context.internalAdapter.deleteVerificationByIdentifier(samlSessionKey).catch((e) => ctx.context.logger.warn("Failed to delete SAML session key during logout", e));
3093
3750
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(sessionLookupKey).catch((e) => ctx.context.logger.warn("Failed to delete session lookup key during logout", e));
3094
- await ctx.context.internalAdapter.deleteSession(session.session.id);
3751
+ await ctx.context.internalAdapter.deleteSession(session.session.token);
3095
3752
  deleteSessionCookie(ctx);
3096
3753
  throw ctx.redirect(logoutRequest.context);
3097
3754
  });
@@ -3107,12 +3764,43 @@ saml.setSchemaValidator({ async validate(xml) {
3107
3764
  * These endpoints receive POST requests from external Identity Providers,
3108
3765
  * which won't have a matching Origin header.
3109
3766
  */
3110
- const SAML_SKIP_ORIGIN_CHECK_PATHS = [
3111
- "/sso/saml2/callback",
3112
- "/sso/saml2/sp/acs",
3113
- "/sso/saml2/sp/slo"
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"
3114
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
+ }
3115
3802
  function sso(options) {
3803
+ assertNoAdditionalFieldCollisions(options);
3116
3804
  const optionsWithStore = options;
3117
3805
  let endpoints = {
3118
3806
  spMetadata: spMetadata(optionsWithStore),
@@ -3120,12 +3808,11 @@ function sso(options) {
3120
3808
  signInSSO: signInSSO(optionsWithStore),
3121
3809
  callbackSSO: callbackSSO(optionsWithStore),
3122
3810
  callbackSSOShared: callbackSSOShared(optionsWithStore),
3123
- callbackSSOSAML: callbackSSOSAML(optionsWithStore),
3124
3811
  acsEndpoint: acsEndpoint(optionsWithStore),
3125
3812
  sloEndpoint: sloEndpoint(optionsWithStore),
3126
3813
  initiateSLO: initiateSLO(optionsWithStore),
3127
- listSSOProviders: listSSOProviders(),
3128
- getSSOProvider: getSSOProvider(),
3814
+ listSSOProviders: listSSOProviders(optionsWithStore),
3815
+ getSSOProvider: getSSOProvider(optionsWithStore),
3129
3816
  updateSSOProvider: updateSSOProvider(optionsWithStore),
3130
3817
  deleteSSOProvider: deleteSSOProvider()
3131
3818
  };
@@ -3182,22 +3869,22 @@ function sso(options) {
3182
3869
  }]
3183
3870
  },
3184
3871
  schema: { ssoProvider: {
3185
- modelName: options?.modelName ?? "ssoProvider",
3872
+ modelName: options?.modelName ?? options?.schema?.ssoProvider?.modelName ?? "ssoProvider",
3186
3873
  fields: {
3187
3874
  issuer: {
3188
3875
  type: "string",
3189
3876
  required: true,
3190
- fieldName: options?.fields?.issuer ?? "issuer"
3877
+ fieldName: options?.fields?.issuer ?? options?.schema?.ssoProvider?.fields?.issuer ?? "issuer"
3191
3878
  },
3192
3879
  oidcConfig: {
3193
3880
  type: "string",
3194
3881
  required: false,
3195
- fieldName: options?.fields?.oidcConfig ?? "oidcConfig"
3882
+ fieldName: options?.fields?.oidcConfig ?? options?.schema?.ssoProvider?.fields?.oidcConfig ?? "oidcConfig"
3196
3883
  },
3197
3884
  samlConfig: {
3198
3885
  type: "string",
3199
3886
  required: false,
3200
- fieldName: options?.fields?.samlConfig ?? "samlConfig"
3887
+ fieldName: options?.fields?.samlConfig ?? options?.schema?.ssoProvider?.fields?.samlConfig ?? "samlConfig"
3201
3888
  },
3202
3889
  userId: {
3203
3890
  type: "string",
@@ -3205,30 +3892,33 @@ function sso(options) {
3205
3892
  model: "user",
3206
3893
  field: "id"
3207
3894
  },
3208
- fieldName: options?.fields?.userId ?? "userId"
3895
+ fieldName: options?.fields?.userId ?? options?.schema?.ssoProvider?.fields?.userId ?? "userId"
3209
3896
  },
3210
3897
  providerId: {
3211
3898
  type: "string",
3212
3899
  required: true,
3213
3900
  unique: true,
3214
- fieldName: options?.fields?.providerId ?? "providerId"
3901
+ fieldName: options?.fields?.providerId ?? options?.schema?.ssoProvider?.fields?.providerId ?? "providerId"
3215
3902
  },
3216
3903
  organizationId: {
3217
3904
  type: "string",
3218
3905
  required: false,
3219
- fieldName: options?.fields?.organizationId ?? "organizationId"
3906
+ fieldName: options?.fields?.organizationId ?? options?.schema?.ssoProvider?.fields?.organizationId ?? "organizationId"
3220
3907
  },
3221
3908
  domain: {
3222
3909
  type: "string",
3223
3910
  required: true,
3224
- fieldName: options?.fields?.domain ?? "domain"
3911
+ fieldName: options?.fields?.domain ?? options?.schema?.ssoProvider?.fields?.domain ?? "domain"
3225
3912
  },
3226
3913
  ...options?.domainVerification?.enabled ? { domainVerified: {
3227
3914
  type: "boolean",
3228
- required: false
3229
- } } : {}
3915
+ required: false,
3916
+ fieldName: options?.schema?.ssoProvider?.fields?.domainVerified ?? "domainVerified"
3917
+ } } : {},
3918
+ ...options?.schema?.ssoProvider?.additionalFields ?? {}
3230
3919
  }
3231
3920
  } },
3921
+ $Infer: { SSOProvider: {} },
3232
3922
  options
3233
3923
  };
3234
3924
  }