@better-auth/sso 1.7.0-beta.5 → 1.7.0-beta.7

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,19 +1,21 @@
1
- import { t as PACKAGE_VERSION } from "./version-DzWb5tB_.mjs";
1
+ import { t as PACKAGE_VERSION } from "./version-DQW8cveo.mjs";
2
2
  import { APIError, addOAuthServerContext, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
3
3
  import { XMLParser, XMLValidator } from "fast-xml-parser";
4
4
  import { X509Certificate } from "node:crypto";
5
5
  import { getHostname } from "tldts";
6
6
  import { generateRandomString } from "better-auth/crypto";
7
7
  import * as z from "zod";
8
+ import { filterOutputFields } from "@better-auth/core/utils/db";
8
9
  import { classifyHost, isPublicRoutableHost } from "@better-auth/core/utils/host";
9
- import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
10
+ import { betterFetch } from "@better-fetch/fetch";
11
+ import { createRemoteJWKSet, customFetch, decodeJwt, jwtVerify } from "jose";
10
12
  import { base64 } from "@better-auth/utils/base64";
11
13
  import { defineErrorCodes } from "@better-auth/core/utils/error-codes";
14
+ import { parseInputData, toZodSchema } from "better-auth/db";
12
15
  import { isAPIError } from "@better-auth/core/utils/is-api-error";
13
- import { HIDE_METADATA, PRIVATE_KEY_JWT_SIGNING_ALGORITHMS, createAuthorizationURL, createPrivateKeyJwtClientAssertionGetter, generateGenericState, generateState, parseGenericState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
16
+ import { HIDE_METADATA, PRIVATE_KEY_JWT_SIGNING_ALGORITHMS, authorizationCodeRequest, createAuthorizationURL, createPrivateKeyJwtClientAssertionGetter, generateGenericState, generateState, getOAuth2Tokens, parseGenericState, parseState } from "better-auth";
14
17
  import { deleteSessionCookie, setSessionCookie } from "better-auth/cookies";
15
18
  import { additionalAuthorizationParamsSchema, signInWithOAuthIdentity } from "better-auth/oauth2";
16
- import { decodeJwt } from "jose";
17
19
  import * as samlifyNamespace from "samlify";
18
20
  import samlifyDefault from "samlify";
19
21
  //#region src/constants.ts
@@ -24,8 +26,6 @@ import samlifyDefault from "samlify";
24
26
  */
25
27
  /** Prefix for AuthnRequest IDs used in InResponseTo validation */
26
28
  const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
27
- /** Prefix for used Assertion IDs used in replay protection */
28
- const USED_ASSERTION_KEY_PREFIX = "saml-used-assertion:";
29
29
  /** Prefix for SAML session data (NameID + SessionIndex) for SLO */
30
30
  const SAML_SESSION_KEY_PREFIX = "saml-session:";
31
31
  /** Prefix for reverse lookup of SAML session by Better Auth session ID */
@@ -86,6 +86,16 @@ const domainMatches = (searchDomain, domainList) => {
86
86
  return domainList.split(",").map((d) => d.trim().toLowerCase()).filter(Boolean).some((d) => search === d || search.endsWith(`.${d}`));
87
87
  };
88
88
  /**
89
+ * Strictly parse a provider-supplied email-verification claim.
90
+ *
91
+ * OIDC userInfo, OIDC id-token, and SAML attribute values are frequently
92
+ * strings, so a loose `Boolean(value)` or truthy fallback treats the string
93
+ * `"false"` as verified. Only a boolean `true` or the exact string `"true"`
94
+ * count as verified; every other value, including `"false"`, `"0"`, `""`,
95
+ * numbers, arrays, and objects, is unverified.
96
+ */
97
+ const parseProviderEmailVerified = (value) => value === true || value === "true";
98
+ /**
89
99
  * Validates email domain against allowed domain(s).
90
100
  * Supports comma-separated domains for multi-domain SSO.
91
101
  */
@@ -211,171 +221,6 @@ async function assignOrganizationByDomain(ctx, options) {
211
221
  });
212
222
  }
213
223
  //#endregion
214
- //#region src/routes/domain-verification.ts
215
- const DNS_LABEL_MAX_LENGTH = 63;
216
- const DEFAULT_TOKEN_PREFIX = "better-auth-token";
217
- const domainVerificationBodySchema = z.object({ providerId: z.string() });
218
- function getVerificationIdentifier(options, providerId) {
219
- return `_${options.domainVerification?.tokenPrefix || DEFAULT_TOKEN_PREFIX}-${providerId}`;
220
- }
221
- const requestDomainVerification = (options) => {
222
- return createAuthEndpoint("/sso/request-domain-verification", {
223
- method: "POST",
224
- body: domainVerificationBodySchema,
225
- metadata: { openapi: {
226
- summary: "Request a domain verification",
227
- description: "Request a domain verification for the given SSO provider",
228
- responses: {
229
- "404": { description: "Provider not found" },
230
- "409": { description: "Domain has already been verified" },
231
- "201": { description: "Domain submitted for verification" }
232
- }
233
- } },
234
- use: [sessionMiddleware]
235
- }, async (ctx) => {
236
- const body = ctx.body;
237
- const provider = await ctx.context.adapter.findOne({
238
- model: "ssoProvider",
239
- where: [{
240
- field: "providerId",
241
- value: body.providerId
242
- }]
243
- });
244
- if (!provider) throw new APIError("NOT_FOUND", {
245
- message: "Provider not found",
246
- code: "PROVIDER_NOT_FOUND"
247
- });
248
- const userId = ctx.context.session.user.id;
249
- let isOrgMember = true;
250
- if (provider.organizationId) isOrgMember = await ctx.context.adapter.count({
251
- model: "member",
252
- where: [{
253
- field: "userId",
254
- value: userId
255
- }, {
256
- field: "organizationId",
257
- value: provider.organizationId
258
- }]
259
- }) > 0;
260
- if (provider.userId !== userId || !isOrgMember) throw new APIError("FORBIDDEN", {
261
- message: "User must be owner of or belong to the SSO provider organization",
262
- code: "INSUFICCIENT_ACCESS"
263
- });
264
- if ("domainVerified" in provider && provider.domainVerified) throw new APIError("CONFLICT", {
265
- message: "Domain has already been verified",
266
- code: "DOMAIN_VERIFIED"
267
- });
268
- const identifier = getVerificationIdentifier(options, provider.providerId);
269
- const activeVerification = await ctx.context.internalAdapter.findVerificationValue(identifier);
270
- if (activeVerification && new Date(activeVerification.expiresAt) > /* @__PURE__ */ new Date()) {
271
- ctx.setStatus(201);
272
- return ctx.json({ domainVerificationToken: activeVerification.value });
273
- }
274
- const domainVerificationToken = generateRandomString(24);
275
- await ctx.context.internalAdapter.createVerificationValue({
276
- identifier,
277
- value: domainVerificationToken,
278
- expiresAt: new Date(Date.now() + 3600 * 24 * 7 * 1e3)
279
- });
280
- ctx.setStatus(201);
281
- return ctx.json({ domainVerificationToken });
282
- });
283
- };
284
- const verifyDomain = (options) => {
285
- return createAuthEndpoint("/sso/verify-domain", {
286
- method: "POST",
287
- body: domainVerificationBodySchema,
288
- metadata: { openapi: {
289
- summary: "Verify the provider domain ownership",
290
- description: "Verify the provider domain ownership via DNS records",
291
- responses: {
292
- "404": { description: "Provider not found" },
293
- "409": { description: "Domain has already been verified or no pending verification exists" },
294
- "502": { description: "Unable to verify domain ownership due to upstream validator error" },
295
- "204": { description: "Domain ownership was verified" }
296
- }
297
- } },
298
- use: [sessionMiddleware]
299
- }, async (ctx) => {
300
- const body = ctx.body;
301
- const provider = await ctx.context.adapter.findOne({
302
- model: "ssoProvider",
303
- where: [{
304
- field: "providerId",
305
- value: body.providerId
306
- }]
307
- });
308
- if (!provider) throw new APIError("NOT_FOUND", {
309
- message: "Provider not found",
310
- code: "PROVIDER_NOT_FOUND"
311
- });
312
- const userId = ctx.context.session.user.id;
313
- let isOrgMember = true;
314
- if (provider.organizationId) isOrgMember = await ctx.context.adapter.count({
315
- model: "member",
316
- where: [{
317
- field: "userId",
318
- value: userId
319
- }, {
320
- field: "organizationId",
321
- value: provider.organizationId
322
- }]
323
- }) > 0;
324
- if (provider.userId !== userId || !isOrgMember) throw new APIError("FORBIDDEN", {
325
- message: "User must be owner of or belong to the SSO provider organization",
326
- code: "INSUFICCIENT_ACCESS"
327
- });
328
- if ("domainVerified" in provider && provider.domainVerified) throw new APIError("CONFLICT", {
329
- message: "Domain has already been verified",
330
- code: "DOMAIN_VERIFIED"
331
- });
332
- const identifier = getVerificationIdentifier(options, provider.providerId);
333
- if (identifier.length > DNS_LABEL_MAX_LENGTH) throw new APIError("BAD_REQUEST", {
334
- message: `Verification identifier exceeds the DNS label limit of ${DNS_LABEL_MAX_LENGTH} characters`,
335
- code: "IDENTIFIER_TOO_LONG"
336
- });
337
- const activeVerification = await ctx.context.internalAdapter.findVerificationValue(identifier);
338
- if (!activeVerification || new Date(activeVerification.expiresAt) <= /* @__PURE__ */ new Date()) throw new APIError("NOT_FOUND", {
339
- message: "No pending domain verification exists",
340
- code: "NO_PENDING_VERIFICATION"
341
- });
342
- let records = [];
343
- let dns;
344
- try {
345
- dns = await import("node:dns/promises");
346
- } catch (error) {
347
- ctx.context.logger.error("The core node:dns module is required for the domain verification feature", error);
348
- throw new APIError("INTERNAL_SERVER_ERROR", {
349
- message: "Unable to verify domain ownership due to server error",
350
- code: "DOMAIN_VERIFICATION_FAILED"
351
- });
352
- }
353
- const hostname = getHostnameFromDomain(provider.domain);
354
- if (!hostname) throw new APIError("BAD_REQUEST", {
355
- message: "Invalid domain",
356
- code: "INVALID_DOMAIN"
357
- });
358
- try {
359
- records = (await dns.resolveTxt(`${identifier}.${hostname}`)).flat();
360
- } catch (error) {
361
- ctx.context.logger.warn("DNS resolution failure while validating domain ownership", error);
362
- }
363
- if (!records.find((record) => record.includes(`${activeVerification.identifier}=${activeVerification.value}`))) throw new APIError("BAD_GATEWAY", {
364
- message: "Unable to verify domain ownership. Try again later",
365
- code: "DOMAIN_VERIFICATION_FAILED"
366
- });
367
- await ctx.context.adapter.update({
368
- model: "ssoProvider",
369
- where: [{
370
- field: "providerId",
371
- value: provider.providerId
372
- }],
373
- update: { domainVerified: true }
374
- });
375
- ctx.setStatus(204);
376
- });
377
- };
378
- //#endregion
379
224
  //#region src/oidc/types.ts
380
225
  /**
381
226
  * Custom error class for OIDC discovery failures.
@@ -414,6 +259,9 @@ const REQUIRED_DISCOVERY_FIELDS = [
414
259
  */
415
260
  /** Default timeout for discovery requests (10 seconds) */
416
261
  const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
262
+ function isHttpRedirectStatus(status) {
263
+ return status >= 300 && status < 400;
264
+ }
417
265
  /**
418
266
  * Main entry point: Discover and hydrate OIDC configuration from an issuer.
419
267
  *
@@ -435,7 +283,7 @@ async function discoverOIDCConfig(params) {
435
283
  const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
436
284
  const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
437
285
  validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
438
- const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
286
+ const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout, params.isTrustedOrigin);
439
287
  validateDiscoveryDocument(discoveryDoc, issuer);
440
288
  const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
441
289
  const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
@@ -496,7 +344,7 @@ function validateDiscoveryUrl(url, isTrustedOrigin) {
496
344
  * @throws DiscoveryError(discovery_invalid_url) — malformed URL or non-http(s) scheme
497
345
  * @throws DiscoveryError(discovery_private_host) — host is not publicly routable and not allowlisted
498
346
  */
499
- function validateSkipDiscoveryEndpoint(name, endpoint, isTrustedOrigin) {
347
+ function validateOIDCEndpointUrl(name, endpoint, isTrustedOrigin) {
500
348
  const parsed = parseURL(name, endpoint);
501
349
  if (isPublicRoutableHost(parsed.hostname)) return;
502
350
  if (isTrustedOrigin(parsed.toString())) return;
@@ -509,14 +357,14 @@ function validateSkipDiscoveryEndpoint(name, endpoint, isTrustedOrigin) {
509
357
  /**
510
358
  * Validate every present OIDC endpoint URL in a registration or update body.
511
359
  *
512
- * Each provided URL is checked with {@link validateSkipDiscoveryEndpoint}.
360
+ * Each provided URL is checked with {@link validateOIDCEndpointUrl}.
513
361
  * Omitted (undefined / null / empty) fields are skipped.
514
362
  *
515
363
  * @param config - OIDC endpoint URLs from the request body
516
364
  * @param isTrustedOrigin - Predicate matching the configured `trustedOrigins`
517
365
  * @throws DiscoveryError on the first invalid endpoint
518
366
  */
519
- function validateSkipDiscoveryEndpoints(config, isTrustedOrigin) {
367
+ function validateOIDCEndpointUrls(config, isTrustedOrigin) {
520
368
  const fields = [
521
369
  ["authorizationEndpoint", config.authorizationEndpoint],
522
370
  ["tokenEndpoint", config.tokenEndpoint],
@@ -524,13 +372,13 @@ function validateSkipDiscoveryEndpoints(config, isTrustedOrigin) {
524
372
  ["jwksEndpoint", config.jwksEndpoint],
525
373
  ["discoveryEndpoint", config.discoveryEndpoint]
526
374
  ];
527
- for (const [name, url] of fields) if (url) validateSkipDiscoveryEndpoint(name, url, isTrustedOrigin);
375
+ for (const [name, url] of fields) if (url) validateOIDCEndpointUrl(name, url, isTrustedOrigin);
528
376
  }
529
377
  /**
530
378
  * Re-validate an endpoint by resolving its hostname and rejecting any resolved
531
379
  * address that is not publicly routable.
532
380
  *
533
- * {@link validateSkipDiscoveryEndpoint} only classifies the literal hostname, so
381
+ * {@link validateOIDCEndpointUrl} only classifies the literal hostname, so
534
382
  * a host like `idp.example` whose DNS record points at `127.0.0.1`,
535
383
  * `169.254.169.254`, or an RFC 1918 address passes that check unchanged. This
536
384
  * function closes that gap by performing the same RFC 6890 classification on the
@@ -577,13 +425,18 @@ async function assertEndpointResolvesPublic(name, endpoint, isTrustedOrigin) {
577
425
  });
578
426
  }
579
427
  /**
428
+ * Validate an OIDC endpoint immediately before a server-side fetch.
429
+ */
430
+ async function assertOIDCEndpointAllowed(name, endpoint, isTrustedOrigin) {
431
+ validateOIDCEndpointUrl(name, endpoint, isTrustedOrigin);
432
+ await assertEndpointResolvesPublic(name, endpoint, isTrustedOrigin);
433
+ }
434
+ /**
580
435
  * Re-validate, at fetch time, every OIDC endpoint that is fetched server-side
581
- * (token, userinfo, jwks). Runs the synchronous host classification plus the
582
- * best-effort DNS resolution check. `authorizationEndpoint` is intentionally
583
- * excluded — it is a browser redirect target, not a server-side fetch, so these
584
- * checks don't apply to it.
436
+ * (token, userinfo, jwks). `authorizationEndpoint` is intentionally excluded
437
+ * because it is a browser redirect target, not a server-side fetch.
585
438
  */
586
- async function assertOIDCEndpointsResolvePublic(config, isTrustedOrigin) {
439
+ async function assertServerFetchedOIDCEndpointsAllowed(config, isTrustedOrigin) {
587
440
  const fields = [
588
441
  ["tokenEndpoint", config.tokenEndpoint],
589
442
  ["userInfoEndpoint", config.userInfoEndpoint],
@@ -591,11 +444,67 @@ async function assertOIDCEndpointsResolvePublic(config, isTrustedOrigin) {
591
444
  ];
592
445
  for (const [name, url] of fields) {
593
446
  if (!url) continue;
594
- validateSkipDiscoveryEndpoint(name, url, isTrustedOrigin);
595
- await assertEndpointResolvesPublic(name, url, isTrustedOrigin);
447
+ await assertOIDCEndpointAllowed(name, url, isTrustedOrigin);
596
448
  }
597
449
  }
598
450
  /**
451
+ * Convert an explicit HTTP redirect response into the OIDC configuration error
452
+ * used by all server-side endpoint fetches.
453
+ */
454
+ function throwRedirectError(name, endpoint, status, location) {
455
+ 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.`, {
456
+ endpoint: name,
457
+ url: endpoint,
458
+ status,
459
+ location
460
+ });
461
+ }
462
+ /**
463
+ * Fetch a configured OIDC endpoint without following redirects.
464
+ *
465
+ * Every server-side OIDC request goes through this helper so private-host
466
+ * checks and redirect handling stay consistent across discovery, token, and
467
+ * userinfo calls.
468
+ */
469
+ async function fetchOIDCEndpoint(name, endpoint, options, isTrustedOrigin) {
470
+ await assertOIDCEndpointAllowed(name, endpoint, isTrustedOrigin);
471
+ let redirectLocation = null;
472
+ const { onError, ...fetchOptions } = options;
473
+ const response = await betterFetch(endpoint, {
474
+ ...fetchOptions,
475
+ redirect: "manual",
476
+ onError: async (context) => {
477
+ if (isHttpRedirectStatus(context.response.status)) redirectLocation = context.response.headers.get("location");
478
+ await onError?.(context);
479
+ }
480
+ });
481
+ if (response.error && isHttpRedirectStatus(response.error.status)) throwRedirectError(name, endpoint, response.error.status, redirectLocation);
482
+ return response;
483
+ }
484
+ /**
485
+ * Native-fetch variant for libraries that require a `fetch` implementation,
486
+ * such as jose's remote JWKS loader.
487
+ */
488
+ async function fetchOIDCEndpointResponse(name, endpoint, init, isTrustedOrigin) {
489
+ await assertOIDCEndpointAllowed(name, endpoint, isTrustedOrigin);
490
+ const response = await fetch(endpoint, {
491
+ ...init,
492
+ redirect: "manual"
493
+ });
494
+ if (isHttpRedirectStatus(response.status)) throwRedirectError(name, endpoint, response.status, response.headers.get("location"));
495
+ return response;
496
+ }
497
+ /**
498
+ * Validate an OIDC ID token using the same endpoint fetch policy as the rest of
499
+ * the SSO OIDC flow.
500
+ */
501
+ async function validateOIDCIdToken(token, jwksEndpoint, options, isTrustedOrigin) {
502
+ return jwtVerify(token, createRemoteJWKSet(new URL(jwksEndpoint), { [customFetch]: (url, init) => fetchOIDCEndpointResponse("jwksEndpoint", url, init, isTrustedOrigin) }), {
503
+ audience: options.audience,
504
+ issuer: options.issuer
505
+ });
506
+ }
507
+ /**
599
508
  * Fetch the OIDC discovery document from the IdP.
600
509
  *
601
510
  * @param url - The discovery endpoint URL
@@ -603,13 +512,12 @@ async function assertOIDCEndpointsResolvePublic(config, isTrustedOrigin) {
603
512
  * @returns The parsed discovery document
604
513
  * @throws DiscoveryError on network errors, timeouts, or invalid responses
605
514
  */
606
- async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
515
+ async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT, isTrustedOrigin = () => false) {
607
516
  try {
608
- const response = await betterFetch(url, {
517
+ const response = await fetchOIDCEndpoint("discoveryEndpoint", url, {
609
518
  method: "GET",
610
- timeout,
611
- redirect: "error"
612
- });
519
+ timeout
520
+ }, isTrustedOrigin);
613
521
  if (response.error) {
614
522
  const { status } = response.error;
615
523
  if (status === 404) throw new DiscoveryError("discovery_not_found", "Discovery endpoint not found", {
@@ -794,7 +702,7 @@ async function ensureRuntimeDiscovery(config, issuer, isTrustedOrigin) {
794
702
  jwksEndpoint: hydrated.jwksEndpoint
795
703
  };
796
704
  }
797
- await assertOIDCEndpointsResolvePublic(resolved, isTrustedOrigin);
705
+ await assertServerFetchedOIDCEndpointsAllowed(resolved, isTrustedOrigin);
798
706
  return resolved;
799
707
  }
800
708
  //#endregion
@@ -813,6 +721,7 @@ async function ensureRuntimeDiscovery(config, issuer, isTrustedOrigin) {
813
721
  * - discovery_not_found → 400 BAD_REQUEST
814
722
  * - discovery_untrusted_origin → 400 BAD_REQUEST
815
723
  * - discovery_private_host → 400 BAD_REQUEST
724
+ * - oidc_endpoint_redirect → 400 BAD_REQUEST
816
725
  * - discovery_invalid_json → 400 BAD_REQUEST
817
726
  * - discovery_incomplete → 400 BAD_REQUEST
818
727
  * - issuer_mismatch → 400 BAD_REQUEST
@@ -849,6 +758,10 @@ function mapDiscoveryErrorToAPIError(error) {
849
758
  message: error.message,
850
759
  code: error.code
851
760
  });
761
+ case "oidc_endpoint_redirect": return new APIError("BAD_REQUEST", {
762
+ message: error.message,
763
+ code: error.code
764
+ });
852
765
  case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
853
766
  message: `OIDC discovery returned invalid data: ${error.message}`,
854
767
  code: error.code
@@ -1256,6 +1169,30 @@ function validateAudience(c, ctx) {
1256
1169
  }
1257
1170
  //#endregion
1258
1171
  //#region src/routes/schemas.ts
1172
+ function getSSOProviderAdditionalFields$1(options) {
1173
+ return options?.schema?.ssoProvider?.additionalFields ?? {};
1174
+ }
1175
+ function getSSOProviderAdditionalFieldsSchema(options) {
1176
+ const additionalFields = getSSOProviderAdditionalFields$1(options);
1177
+ const schema = toZodSchema({
1178
+ fields: additionalFields,
1179
+ isClientSide: true
1180
+ });
1181
+ const blockedInputFields = {};
1182
+ for (const key in additionalFields) if (additionalFields[key]?.input === false) blockedInputFields[key] = z.any().optional();
1183
+ return schema.extend(blockedInputFields);
1184
+ }
1185
+ function assertNoBlockedAdditionalFieldInput(fields, data) {
1186
+ 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` });
1187
+ }
1188
+ function parseSSOProviderAdditionalFields(options, data, action) {
1189
+ const fields = getSSOProviderAdditionalFields$1(options);
1190
+ assertNoBlockedAdditionalFieldInput(fields, data);
1191
+ return parseInputData(data, {
1192
+ fields,
1193
+ action
1194
+ });
1195
+ }
1259
1196
  const oidcMappingSchema = z.object({
1260
1197
  id: z.string().meta({ description: "Field mapping for user ID (defaults to 'sub')" }),
1261
1198
  email: z.string().meta({ description: "Field mapping for email (defaults to 'email')" }),
@@ -1344,15 +1281,39 @@ const registerSSOProviderBodySchema = z.object({
1344
1281
  organizationId: z.string().meta({ description: "If organization plugin is enabled, the organization id to link the provider to" }).optional(),
1345
1282
  overrideUserInfo: z.boolean().meta({ description: "Override user info with the provider info. Defaults to false" }).default(false).optional()
1346
1283
  });
1284
+ function getRegisterSSOProviderBodySchema(options) {
1285
+ return registerSSOProviderBodySchema.extend({ ...getSSOProviderAdditionalFieldsSchema(options).shape });
1286
+ }
1347
1287
  const updateSSOProviderBodySchema = z.object({
1348
1288
  issuer: z.string().url().optional(),
1349
1289
  domain: z.string().optional(),
1350
1290
  oidcConfig: oidcConfigSchema.partial().optional(),
1351
1291
  samlConfig: samlConfigSchema.partial().optional()
1352
1292
  });
1293
+ function getUpdateSSOProviderBodySchema(options) {
1294
+ return updateSSOProviderBodySchema.extend({
1295
+ providerId: z.string(),
1296
+ ...getSSOProviderAdditionalFieldsSchema(options).partial().shape
1297
+ });
1298
+ }
1353
1299
  //#endregion
1354
1300
  //#region src/routes/providers.ts
1355
1301
  const ADMIN_ROLES = ["owner", "admin"];
1302
+ function getSSOProviderAdditionalFields(options) {
1303
+ return options?.schema?.ssoProvider?.additionalFields ?? {};
1304
+ }
1305
+ function filterSSOProviderAdditionalFields(provider, options) {
1306
+ return filterOutputFields(provider, getSSOProviderAdditionalFields(options));
1307
+ }
1308
+ function getReturnedSSOProviderAdditionalFields(provider, options) {
1309
+ const additionalFields = getSSOProviderAdditionalFields(options);
1310
+ const result = {};
1311
+ for (const key in additionalFields) {
1312
+ if (additionalFields[key]?.returned === false) continue;
1313
+ if (key in provider) result[key] = provider[key];
1314
+ }
1315
+ return result;
1316
+ }
1356
1317
  function hasOrgAdminRole(member) {
1357
1318
  return member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()));
1358
1319
  }
@@ -1398,7 +1359,7 @@ async function batchCheckOrgAdmin(ctx, userId, organizationIds) {
1398
1359
  for (const member of members) if (hasOrgAdminRole(member)) adminOrgIds.add(member.organizationId);
1399
1360
  return adminOrgIds;
1400
1361
  }
1401
- function sanitizeProvider(provider, baseURL) {
1362
+ function sanitizeProvider(provider, baseURL, options) {
1402
1363
  let oidcConfig = null;
1403
1364
  let samlConfig = null;
1404
1365
  try {
@@ -1413,6 +1374,7 @@ function sanitizeProvider(provider, baseURL) {
1413
1374
  }
1414
1375
  const type = samlConfig ? "saml" : "oidc";
1415
1376
  return {
1377
+ ...getReturnedSSOProviderAdditionalFields(provider, options),
1416
1378
  providerId: provider.providerId,
1417
1379
  type,
1418
1380
  issuer: provider.issuer,
@@ -1443,7 +1405,7 @@ function sanitizeProvider(provider, baseURL) {
1443
1405
  spMetadataUrl: `${baseURL}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(provider.providerId)}`
1444
1406
  };
1445
1407
  }
1446
- const listSSOProviders = () => {
1408
+ const listSSOProviders = (options) => {
1447
1409
  return createAuthEndpoint("/sso/providers", {
1448
1410
  method: "GET",
1449
1411
  use: [sessionMiddleware],
@@ -1468,7 +1430,7 @@ const listSSOProviders = () => {
1468
1430
  const userOwnedOrgProviders = orgProviders.filter((p) => p.userId === userId);
1469
1431
  accessibleProviders = [...accessibleProviders, ...userOwnedOrgProviders];
1470
1432
  }
1471
- const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL));
1433
+ const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL, options));
1472
1434
  return ctx.json({ providers });
1473
1435
  });
1474
1436
  };
@@ -1490,7 +1452,7 @@ async function checkProviderAccess(ctx, providerId) {
1490
1452
  if (!hasAccess) throw new APIError("FORBIDDEN", { message: "You don't have access to this provider" });
1491
1453
  return provider;
1492
1454
  }
1493
- const getSSOProvider = () => {
1455
+ const getSSOProvider = (options) => {
1494
1456
  return createAuthEndpoint("/sso/get-provider", {
1495
1457
  method: "GET",
1496
1458
  use: [sessionMiddleware],
@@ -1508,7 +1470,7 @@ const getSSOProvider = () => {
1508
1470
  }, async (ctx) => {
1509
1471
  const { providerId } = ctx.query;
1510
1472
  const provider = await checkProviderAccess(ctx, providerId);
1511
- return ctx.json(sanitizeProvider(provider, ctx.context.baseURL));
1473
+ return ctx.json(sanitizeProvider(provider, ctx.context.baseURL, options));
1512
1474
  });
1513
1475
  };
1514
1476
  function parseAndValidateConfig(configString, configType) {
@@ -1560,10 +1522,11 @@ function mergeOIDCConfig(current, updates, issuer) {
1560
1522
  };
1561
1523
  }
1562
1524
  const updateSSOProvider = (options) => {
1525
+ const updateBodySchema = getUpdateSSOProviderBodySchema(options);
1563
1526
  return createAuthEndpoint("/sso/update-provider", {
1564
1527
  method: "POST",
1565
1528
  use: [sessionMiddleware],
1566
- body: updateSSOProviderBodySchema.extend({ providerId: z.string() }),
1529
+ body: updateBodySchema,
1567
1530
  metadata: { openapi: {
1568
1531
  operationId: "updateSSOProvider",
1569
1532
  summary: "Update SSO provider",
@@ -1577,9 +1540,10 @@ const updateSSOProvider = (options) => {
1577
1540
  }, async (ctx) => {
1578
1541
  const { providerId, ...body } = ctx.body;
1579
1542
  const { issuer, domain, samlConfig, oidcConfig } = body;
1580
- if (!issuer && !domain && !samlConfig && !oidcConfig) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
1543
+ const additionalFields = parseSSOProviderAdditionalFields(options, body, "update");
1544
+ if (!issuer && !domain && !samlConfig && !oidcConfig && Object.keys(additionalFields).length === 0) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
1581
1545
  const existingProvider = await checkProviderAccess(ctx, providerId);
1582
- const updateData = {};
1546
+ const updateData = { ...additionalFields };
1583
1547
  if (body.issuer !== void 0) updateData.issuer = body.issuer;
1584
1548
  if (body.domain !== void 0) {
1585
1549
  updateData.domain = body.domain;
@@ -1601,7 +1565,7 @@ const updateSSOProvider = (options) => {
1601
1565
  }
1602
1566
  if (body.oidcConfig) {
1603
1567
  try {
1604
- validateSkipDiscoveryEndpoints(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
1568
+ validateOIDCEndpointUrls(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
1605
1569
  } catch (error) {
1606
1570
  if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
1607
1571
  throw error;
@@ -1628,7 +1592,7 @@ const updateSSOProvider = (options) => {
1628
1592
  }]
1629
1593
  });
1630
1594
  if (!fullProvider) throw new APIError("NOT_FOUND", { message: "Provider not found after update" });
1631
- return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL));
1595
+ return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL, options));
1632
1596
  });
1633
1597
  };
1634
1598
  const deleteSSOProvider = () => {
@@ -1660,6 +1624,119 @@ const deleteSSOProvider = () => {
1660
1624
  });
1661
1625
  };
1662
1626
  //#endregion
1627
+ //#region src/routes/domain-verification.ts
1628
+ const DNS_LABEL_MAX_LENGTH = 63;
1629
+ const DEFAULT_TOKEN_PREFIX = "better-auth-token";
1630
+ const domainVerificationBodySchema = z.object({ providerId: z.string() });
1631
+ function getVerificationIdentifier(options, providerId) {
1632
+ return `_${options.domainVerification?.tokenPrefix || DEFAULT_TOKEN_PREFIX}-${providerId}`;
1633
+ }
1634
+ const requestDomainVerification = (options) => {
1635
+ return createAuthEndpoint("/sso/request-domain-verification", {
1636
+ method: "POST",
1637
+ body: domainVerificationBodySchema,
1638
+ metadata: { openapi: {
1639
+ summary: "Request a domain verification",
1640
+ description: "Request a domain verification for the given SSO provider",
1641
+ responses: {
1642
+ "404": { description: "Provider not found" },
1643
+ "409": { description: "Domain has already been verified" },
1644
+ "201": { description: "Domain submitted for verification" }
1645
+ }
1646
+ } },
1647
+ use: [sessionMiddleware]
1648
+ }, async (ctx) => {
1649
+ const body = ctx.body;
1650
+ const provider = await checkProviderAccess(ctx, body.providerId);
1651
+ if (provider.domainVerified) throw new APIError("CONFLICT", {
1652
+ message: "Domain has already been verified",
1653
+ code: "DOMAIN_VERIFIED"
1654
+ });
1655
+ const identifier = getVerificationIdentifier(options, provider.providerId);
1656
+ const activeVerification = await ctx.context.internalAdapter.findVerificationValue(identifier);
1657
+ if (activeVerification && new Date(activeVerification.expiresAt) > /* @__PURE__ */ new Date()) {
1658
+ ctx.setStatus(201);
1659
+ return ctx.json({ domainVerificationToken: activeVerification.value });
1660
+ }
1661
+ const domainVerificationToken = generateRandomString(24);
1662
+ await ctx.context.internalAdapter.createVerificationValue({
1663
+ identifier,
1664
+ value: domainVerificationToken,
1665
+ expiresAt: new Date(Date.now() + 3600 * 24 * 7 * 1e3)
1666
+ });
1667
+ ctx.setStatus(201);
1668
+ return ctx.json({ domainVerificationToken });
1669
+ });
1670
+ };
1671
+ const verifyDomain = (options) => {
1672
+ return createAuthEndpoint("/sso/verify-domain", {
1673
+ method: "POST",
1674
+ body: domainVerificationBodySchema,
1675
+ metadata: { openapi: {
1676
+ summary: "Verify the provider domain ownership",
1677
+ description: "Verify the provider domain ownership via DNS records",
1678
+ responses: {
1679
+ "404": { description: "Provider not found" },
1680
+ "409": { description: "Domain has already been verified or no pending verification exists" },
1681
+ "502": { description: "Unable to verify domain ownership due to upstream validator error" },
1682
+ "204": { description: "Domain ownership was verified" }
1683
+ }
1684
+ } },
1685
+ use: [sessionMiddleware]
1686
+ }, async (ctx) => {
1687
+ const body = ctx.body;
1688
+ const provider = await checkProviderAccess(ctx, body.providerId);
1689
+ if (provider.domainVerified) throw new APIError("CONFLICT", {
1690
+ message: "Domain has already been verified",
1691
+ code: "DOMAIN_VERIFIED"
1692
+ });
1693
+ const identifier = getVerificationIdentifier(options, provider.providerId);
1694
+ if (identifier.length > DNS_LABEL_MAX_LENGTH) throw new APIError("BAD_REQUEST", {
1695
+ message: `Verification identifier exceeds the DNS label limit of ${DNS_LABEL_MAX_LENGTH} characters`,
1696
+ code: "IDENTIFIER_TOO_LONG"
1697
+ });
1698
+ const activeVerification = await ctx.context.internalAdapter.findVerificationValue(identifier);
1699
+ if (!activeVerification || new Date(activeVerification.expiresAt) <= /* @__PURE__ */ new Date()) throw new APIError("NOT_FOUND", {
1700
+ message: "No pending domain verification exists",
1701
+ code: "NO_PENDING_VERIFICATION"
1702
+ });
1703
+ let records = [];
1704
+ let dns;
1705
+ try {
1706
+ dns = await import("node:dns/promises");
1707
+ } catch (error) {
1708
+ ctx.context.logger.error("The core node:dns module is required for the domain verification feature", error);
1709
+ throw new APIError("INTERNAL_SERVER_ERROR", {
1710
+ message: "Unable to verify domain ownership due to server error",
1711
+ code: "DOMAIN_VERIFICATION_FAILED"
1712
+ });
1713
+ }
1714
+ const hostname = getHostnameFromDomain(provider.domain);
1715
+ if (!hostname) throw new APIError("BAD_REQUEST", {
1716
+ message: "Invalid domain",
1717
+ code: "INVALID_DOMAIN"
1718
+ });
1719
+ try {
1720
+ records = (await dns.resolveTxt(`${identifier}.${hostname}`)).flat();
1721
+ } catch (error) {
1722
+ ctx.context.logger.warn("DNS resolution failure while validating domain ownership", error);
1723
+ }
1724
+ if (!records.find((record) => record.includes(`${activeVerification.identifier}=${activeVerification.value}`))) throw new APIError("BAD_GATEWAY", {
1725
+ message: "Unable to verify domain ownership. Try again later",
1726
+ code: "DOMAIN_VERIFICATION_FAILED"
1727
+ });
1728
+ await ctx.context.adapter.update({
1729
+ model: "ssoProvider",
1730
+ where: [{
1731
+ field: "providerId",
1732
+ value: provider.providerId
1733
+ }],
1734
+ update: { domainVerified: true }
1735
+ });
1736
+ ctx.setStatus(204);
1737
+ });
1738
+ };
1739
+ //#endregion
1663
1740
  //#region src/saml-state.ts
1664
1741
  async function generateRelayState(c, link) {
1665
1742
  const callbackURL = c.body.callbackURL;
@@ -1975,26 +2052,8 @@ async function processSAMLResponse(ctx, params, options) {
1975
2052
  const conditions = extract.conditions;
1976
2053
  const clockSkew = options?.saml?.clockSkew ?? 3e5;
1977
2054
  const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
1978
- const existingAssertion = await ctx.context.internalAdapter.findVerificationValue(`${USED_ASSERTION_KEY_PREFIX}${assertionId}`);
1979
- let isReplay = false;
1980
- if (existingAssertion) try {
1981
- if (JSON.parse(existingAssertion.value).expiresAt >= Date.now()) isReplay = true;
1982
- } catch (error) {
1983
- ctx.context.logger.warn("Failed to parse stored assertion record", {
1984
- assertionId,
1985
- error
1986
- });
1987
- }
1988
- if (isReplay) {
1989
- ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
1990
- assertionId,
1991
- issuer,
1992
- providerId
1993
- });
1994
- throw ctx.redirect(`${samlRedirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
1995
- }
1996
- await ctx.context.internalAdapter.createVerificationValue({
1997
- identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
2055
+ if (!await ctx.context.internalAdapter.reserveVerificationValue({
2056
+ identifier: `saml-used-assertion:${assertionId}`,
1998
2057
  value: JSON.stringify({
1999
2058
  assertionId,
2000
2059
  issuer,
@@ -2003,7 +2062,14 @@ async function processSAMLResponse(ctx, params, options) {
2003
2062
  expiresAt
2004
2063
  }),
2005
2064
  expiresAt: new Date(expiresAt)
2006
- });
2065
+ })) {
2066
+ ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
2067
+ assertionId,
2068
+ issuer,
2069
+ providerId
2070
+ });
2071
+ throw ctx.redirect(`${samlRedirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
2072
+ }
2007
2073
  } else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
2008
2074
  const attributes = extract.attributes || {};
2009
2075
  const mapping = parsedSamlConfig.mapping ?? {};
@@ -2016,7 +2082,7 @@ async function processSAMLResponse(ctx, params, options) {
2016
2082
  id: attr(mapping.id || "nameID") || extract.nameID,
2017
2083
  email: (attr(mapping.email || "email") || extract.nameID || "").toLowerCase(),
2018
2084
  name: [attr(mapping.firstName || "givenName"), attr(mapping.lastName || "surname")].filter(Boolean).join(" ") || attr(mapping.name || "displayName") || extract.nameID,
2019
- emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attr(mapping.emailVerified) || false : false
2085
+ emailVerified: options?.trustEmailVerified && mapping.emailVerified ? parseProviderEmailVerified(attr(mapping.emailVerified)) : false
2020
2086
  };
2021
2087
  if (!userInfo.id || !userInfo.email) {
2022
2088
  ctx.context.logger.error("Missing essential user info from SAML response", {
@@ -2037,7 +2103,7 @@ async function processSAMLResponse(ctx, params, options) {
2037
2103
  email: userInfo.email,
2038
2104
  name: userInfo.name || userInfo.email,
2039
2105
  id: userInfo.id,
2040
- emailVerified: Boolean(userInfo.emailVerified)
2106
+ emailVerified: userInfo.emailVerified
2041
2107
  },
2042
2108
  providerId,
2043
2109
  accountId: userInfo.id,
@@ -2077,7 +2143,7 @@ async function processSAMLResponse(ctx, params, options) {
2077
2143
  providerId,
2078
2144
  accountId: userInfo.id,
2079
2145
  email: userInfo.email,
2080
- emailVerified: Boolean(userInfo.emailVerified),
2146
+ emailVerified: userInfo.emailVerified,
2081
2147
  rawAttributes: attributes
2082
2148
  },
2083
2149
  provider,
@@ -2150,174 +2216,177 @@ const spMetadata = (options) => {
2150
2216
  const registerSSOProvider = (options) => {
2151
2217
  return createAuthEndpoint("/sso/register", {
2152
2218
  method: "POST",
2153
- body: registerSSOProviderBodySchema,
2219
+ body: getRegisterSSOProviderBodySchema(options),
2154
2220
  use: [sessionMiddleware],
2155
- metadata: { openapi: {
2156
- operationId: "registerSSOProvider",
2157
- summary: "Register an OIDC provider",
2158
- description: "This endpoint is used to register an OIDC provider. This is used to configure the provider and link it to an organization",
2159
- responses: { "200": {
2160
- description: "OIDC provider created successfully",
2161
- content: { "application/json": { schema: {
2162
- type: "object",
2163
- properties: {
2164
- issuer: {
2165
- type: "string",
2166
- format: "uri",
2167
- description: "The issuer URL of the provider"
2168
- },
2169
- domain: {
2170
- type: "string",
2171
- description: "The domain of the provider, used for email matching"
2172
- },
2173
- domainVerified: {
2174
- type: "boolean",
2175
- description: "A boolean indicating whether the domain has been verified or not"
2176
- },
2177
- domainVerificationToken: {
2178
- type: "string",
2179
- description: "Domain verification token. It can be used to prove ownership over the SSO domain"
2180
- },
2181
- oidcConfig: {
2182
- type: "object",
2183
- properties: {
2184
- issuer: {
2185
- type: "string",
2186
- format: "uri",
2187
- description: "The issuer URL of the provider"
2188
- },
2189
- pkce: {
2190
- type: "boolean",
2191
- description: "Whether PKCE is enabled for the authorization flow"
2192
- },
2193
- clientId: {
2194
- type: "string",
2195
- description: "The client ID for the provider"
2196
- },
2197
- clientSecret: {
2198
- type: "string",
2199
- description: "The client secret for the provider"
2200
- },
2201
- authorizationEndpoint: {
2202
- type: "string",
2203
- format: "uri",
2204
- nullable: true,
2205
- description: "The authorization endpoint URL"
2206
- },
2207
- discoveryEndpoint: {
2208
- type: "string",
2209
- format: "uri",
2210
- description: "The discovery endpoint URL"
2211
- },
2212
- userInfoEndpoint: {
2213
- type: "string",
2214
- format: "uri",
2215
- nullable: true,
2216
- description: "The user info endpoint URL"
2217
- },
2218
- scopes: {
2219
- type: "array",
2220
- items: { type: "string" },
2221
- nullable: true,
2222
- description: "The scopes requested from the provider"
2223
- },
2224
- tokenEndpoint: {
2225
- type: "string",
2226
- format: "uri",
2227
- nullable: true,
2228
- description: "The token endpoint URL"
2229
- },
2230
- tokenEndpointAuthentication: {
2231
- type: "string",
2232
- enum: ["client_secret_post", "client_secret_basic"],
2233
- nullable: true,
2234
- description: "Authentication method for the token endpoint"
2235
- },
2236
- jwksEndpoint: {
2237
- type: "string",
2238
- format: "uri",
2239
- nullable: true,
2240
- description: "The JWKS endpoint URL"
2241
- },
2242
- mapping: {
2243
- type: "object",
2244
- nullable: true,
2245
- properties: {
2246
- id: {
2247
- type: "string",
2248
- description: "Field mapping for user ID (defaults to 'sub')"
2249
- },
2250
- email: {
2251
- type: "string",
2252
- description: "Field mapping for email (defaults to 'email')"
2253
- },
2254
- emailVerified: {
2255
- type: "string",
2256
- nullable: true,
2257
- description: "Field mapping for email verification (defaults to 'email_verified')"
2258
- },
2259
- name: {
2260
- type: "string",
2261
- description: "Field mapping for name (defaults to 'name')"
2262
- },
2263
- image: {
2264
- type: "string",
2265
- nullable: true,
2266
- description: "Field mapping for image (defaults to 'picture')"
2267
- },
2268
- extraFields: {
2269
- type: "object",
2270
- additionalProperties: { type: "string" },
2271
- nullable: true,
2272
- description: "Additional field mappings"
2273
- }
2221
+ metadata: {
2222
+ $Infer: { body: {} },
2223
+ openapi: {
2224
+ operationId: "registerSSOProvider",
2225
+ summary: "Register an OIDC provider",
2226
+ description: "This endpoint is used to register an OIDC provider. This is used to configure the provider and link it to an organization",
2227
+ responses: { "200": {
2228
+ description: "OIDC provider created successfully",
2229
+ content: { "application/json": { schema: {
2230
+ type: "object",
2231
+ properties: {
2232
+ issuer: {
2233
+ type: "string",
2234
+ format: "uri",
2235
+ description: "The issuer URL of the provider"
2236
+ },
2237
+ domain: {
2238
+ type: "string",
2239
+ description: "The domain of the provider, used for email matching"
2240
+ },
2241
+ domainVerified: {
2242
+ type: "boolean",
2243
+ description: "A boolean indicating whether the domain has been verified or not"
2244
+ },
2245
+ domainVerificationToken: {
2246
+ type: "string",
2247
+ description: "Domain verification token. It can be used to prove ownership over the SSO domain"
2248
+ },
2249
+ oidcConfig: {
2250
+ type: "object",
2251
+ properties: {
2252
+ issuer: {
2253
+ type: "string",
2254
+ format: "uri",
2255
+ description: "The issuer URL of the provider"
2256
+ },
2257
+ pkce: {
2258
+ type: "boolean",
2259
+ description: "Whether PKCE is enabled for the authorization flow"
2260
+ },
2261
+ clientId: {
2262
+ type: "string",
2263
+ description: "The client ID for the provider"
2264
+ },
2265
+ clientSecret: {
2266
+ type: "string",
2267
+ description: "The client secret for the provider"
2268
+ },
2269
+ authorizationEndpoint: {
2270
+ type: "string",
2271
+ format: "uri",
2272
+ nullable: true,
2273
+ description: "The authorization endpoint URL"
2274
+ },
2275
+ discoveryEndpoint: {
2276
+ type: "string",
2277
+ format: "uri",
2278
+ description: "The discovery endpoint URL"
2279
+ },
2280
+ userInfoEndpoint: {
2281
+ type: "string",
2282
+ format: "uri",
2283
+ nullable: true,
2284
+ description: "The user info endpoint URL"
2285
+ },
2286
+ scopes: {
2287
+ type: "array",
2288
+ items: { type: "string" },
2289
+ nullable: true,
2290
+ description: "The scopes requested from the provider"
2291
+ },
2292
+ tokenEndpoint: {
2293
+ type: "string",
2294
+ format: "uri",
2295
+ nullable: true,
2296
+ description: "The token endpoint URL"
2274
2297
  },
2275
- required: [
2276
- "id",
2277
- "email",
2278
- "name"
2279
- ]
2280
- }
2298
+ tokenEndpointAuthentication: {
2299
+ type: "string",
2300
+ enum: ["client_secret_post", "client_secret_basic"],
2301
+ nullable: true,
2302
+ description: "Authentication method for the token endpoint"
2303
+ },
2304
+ jwksEndpoint: {
2305
+ type: "string",
2306
+ format: "uri",
2307
+ nullable: true,
2308
+ description: "The JWKS endpoint URL"
2309
+ },
2310
+ mapping: {
2311
+ type: "object",
2312
+ nullable: true,
2313
+ properties: {
2314
+ id: {
2315
+ type: "string",
2316
+ description: "Field mapping for user ID (defaults to 'sub')"
2317
+ },
2318
+ email: {
2319
+ type: "string",
2320
+ description: "Field mapping for email (defaults to 'email')"
2321
+ },
2322
+ emailVerified: {
2323
+ type: "string",
2324
+ nullable: true,
2325
+ description: "Field mapping for email verification (defaults to 'email_verified')"
2326
+ },
2327
+ name: {
2328
+ type: "string",
2329
+ description: "Field mapping for name (defaults to 'name')"
2330
+ },
2331
+ image: {
2332
+ type: "string",
2333
+ nullable: true,
2334
+ description: "Field mapping for image (defaults to 'picture')"
2335
+ },
2336
+ extraFields: {
2337
+ type: "object",
2338
+ additionalProperties: { type: "string" },
2339
+ nullable: true,
2340
+ description: "Additional field mappings"
2341
+ }
2342
+ },
2343
+ required: [
2344
+ "id",
2345
+ "email",
2346
+ "name"
2347
+ ]
2348
+ }
2349
+ },
2350
+ required: [
2351
+ "issuer",
2352
+ "pkce",
2353
+ "clientId",
2354
+ "clientSecret",
2355
+ "discoveryEndpoint"
2356
+ ],
2357
+ description: "OIDC configuration for the provider"
2281
2358
  },
2282
- required: [
2283
- "issuer",
2284
- "pkce",
2285
- "clientId",
2286
- "clientSecret",
2287
- "discoveryEndpoint"
2288
- ],
2289
- description: "OIDC configuration for the provider"
2290
- },
2291
- organizationId: {
2292
- type: "string",
2293
- nullable: true,
2294
- description: "ID of the linked organization, if any"
2295
- },
2296
- userId: {
2297
- type: "string",
2298
- description: "ID of the user who registered the provider"
2299
- },
2300
- providerId: {
2301
- type: "string",
2302
- description: "Unique identifier for the provider"
2359
+ organizationId: {
2360
+ type: "string",
2361
+ nullable: true,
2362
+ description: "ID of the linked organization, if any"
2363
+ },
2364
+ userId: {
2365
+ type: "string",
2366
+ description: "ID of the user who registered the provider"
2367
+ },
2368
+ providerId: {
2369
+ type: "string",
2370
+ description: "Unique identifier for the provider"
2371
+ },
2372
+ redirectURI: {
2373
+ type: "string",
2374
+ format: "uri",
2375
+ description: "The redirect URI for the provider callback"
2376
+ }
2303
2377
  },
2304
- redirectURI: {
2305
- type: "string",
2306
- format: "uri",
2307
- description: "The redirect URI for the provider callback"
2308
- }
2309
- },
2310
- required: [
2311
- "issuer",
2312
- "domain",
2313
- "oidcConfig",
2314
- "userId",
2315
- "providerId",
2316
- "redirectURI"
2317
- ]
2318
- } } }
2319
- } }
2320
- } }
2378
+ required: [
2379
+ "issuer",
2380
+ "domain",
2381
+ "oidcConfig",
2382
+ "userId",
2383
+ "providerId",
2384
+ "redirectURI"
2385
+ ]
2386
+ } } }
2387
+ } }
2388
+ }
2389
+ }
2321
2390
  }, async (ctx) => {
2322
2391
  const user = ctx.context.session?.user;
2323
2392
  if (!user) throw new APIError("UNAUTHORIZED");
@@ -2331,6 +2400,7 @@ const registerSSOProvider = (options) => {
2331
2400
  }]
2332
2401
  })).length >= limit) throw new APIError("FORBIDDEN", { message: "You have reached the maximum number of SSO providers" });
2333
2402
  const body = ctx.body;
2403
+ const additionalFields = parseSSOProviderAdditionalFields(options, body, "create");
2334
2404
  if (body.samlConfig?.idpMetadata?.metadata) {
2335
2405
  const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
2336
2406
  if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
@@ -2368,7 +2438,7 @@ const registerSSOProvider = (options) => {
2368
2438
  throw new APIError("UNPROCESSABLE_ENTITY", { message: "SSO provider with this providerId already exists" });
2369
2439
  }
2370
2440
  if (body.oidcConfig) try {
2371
- validateSkipDiscoveryEndpoints(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
2441
+ validateOIDCEndpointUrls(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
2372
2442
  } catch (error) {
2373
2443
  if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
2374
2444
  throw error;
@@ -2450,6 +2520,7 @@ const registerSSOProvider = (options) => {
2450
2520
  issuer: body.issuer,
2451
2521
  domain: body.domain,
2452
2522
  domainVerified: false,
2523
+ ...additionalFields,
2453
2524
  oidcConfig: (() => {
2454
2525
  const config = buildOIDCConfig();
2455
2526
  if (config) {
@@ -2491,7 +2562,7 @@ const registerSSOProvider = (options) => {
2491
2562
  });
2492
2563
  }
2493
2564
  const result = {
2494
- ...provider,
2565
+ ...filterSSOProviderAdditionalFields(provider, options),
2495
2566
  oidcConfig: safeJsonParse(provider.oidcConfig),
2496
2567
  samlConfig: safeJsonParse(provider.samlConfig),
2497
2568
  redirectURI: getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options),
@@ -2738,6 +2809,22 @@ const callbackSSOQuerySchema = z.object({
2738
2809
  error: z.string().optional(),
2739
2810
  error_description: z.string().optional()
2740
2811
  });
2812
+ function getStringErrorField(value, field) {
2813
+ if (!value || typeof value !== "object") return;
2814
+ const fieldValue = value[field];
2815
+ return typeof fieldValue === "string" && fieldValue.length > 0 ? fieldValue : void 0;
2816
+ }
2817
+ function getOIDCErrorDescription(error, fallback) {
2818
+ const nestedError = error && typeof error === "object" ? error.error : void 0;
2819
+ 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");
2820
+ if (description) return description;
2821
+ if (error && typeof error === "object") {
2822
+ const status = error.status;
2823
+ if (typeof status === "number") return `HTTP ${status}`;
2824
+ if (typeof status === "string" && status.length > 0) return status;
2825
+ }
2826
+ return fallback;
2827
+ }
2741
2828
  /**
2742
2829
  * Core OIDC callback handler logic, shared between the per-provider and
2743
2830
  * shared callback endpoints. Resolves the provider, exchanges the
@@ -2754,7 +2841,16 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2754
2841
  throw ctx.redirect(`${errorURL}?error=invalid_state`);
2755
2842
  }
2756
2843
  const { callbackURL, errorURL, newUserURL, requestSignUp, requestedScopes } = stateData;
2757
- if (!code || error) throw ctx.redirect(`${errorURL || callbackURL}?error=${error}&error_description=${error_description}`);
2844
+ const redirectOIDCError = (error, description) => {
2845
+ const baseURL = errorURL || callbackURL;
2846
+ const params = new URLSearchParams({
2847
+ error,
2848
+ error_description: description
2849
+ });
2850
+ const separator = baseURL.includes("?") ? "&" : "?";
2851
+ throw ctx.redirect(`${baseURL}${separator}${params.toString()}`);
2852
+ };
2853
+ if (!code || error) redirectOIDCError(error || "invalid_request", error_description || (error ? error : "authorization_code_not_found"));
2758
2854
  const provider = await resolveOIDCProvider(ctx, options, providerId);
2759
2855
  if (!provider) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
2760
2856
  if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
@@ -2776,6 +2872,7 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2776
2872
  ]
2777
2873
  };
2778
2874
  if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_endpoint_not_found`);
2875
+ const tokenEndpoint = config.tokenEndpoint;
2779
2876
  let tokenEndpointAuth = config.tokenEndpointAuthentication === "client_secret_post" ? { method: "client_secret_post" } : { method: "client_secret_basic" };
2780
2877
  if (config.tokenEndpointAuthentication === "private_key_jwt") {
2781
2878
  let resolved;
@@ -2801,35 +2898,46 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2801
2898
  }
2802
2899
  const tokenRequestOptions = { clientId: config.clientId };
2803
2900
  if (tokenEndpointAuth.method !== "private_key_jwt") tokenRequestOptions.clientSecret = config.clientSecret;
2804
- const tokenResponse = await validateAuthorizationCode({
2805
- code,
2806
- codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
2807
- redirectURI: getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options),
2808
- options: tokenRequestOptions,
2809
- tokenEndpoint: config.tokenEndpoint,
2810
- tokenEndpointAuth
2811
- }).catch((e) => {
2901
+ const tokenResponse = await (async () => {
2902
+ const { body, headers } = await authorizationCodeRequest({
2903
+ code,
2904
+ codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
2905
+ redirectURI: getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options),
2906
+ options: tokenRequestOptions,
2907
+ tokenEndpoint,
2908
+ tokenEndpointAuth
2909
+ });
2910
+ const { data, error } = await fetchOIDCEndpoint("tokenEndpoint", tokenEndpoint, {
2911
+ method: "POST",
2912
+ body,
2913
+ headers
2914
+ }, (url) => ctx.context.isTrustedOrigin(url));
2915
+ if (error) redirectOIDCError("invalid_provider", getOIDCErrorDescription(error, "token_response_error"));
2916
+ if (!data) throw new Error("Token endpoint returned an empty response");
2917
+ return getOAuth2Tokens(data);
2918
+ })().catch((e) => {
2919
+ if (isAPIError(e)) throw e;
2812
2920
  ctx.context.logger.error("Error validating authorization code", e);
2813
- if (e instanceof BetterFetchError) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${e.message}`);
2814
- return null;
2921
+ if (e instanceof DiscoveryError) redirectOIDCError("invalid_provider", e.message);
2922
+ redirectOIDCError("invalid_provider", getOIDCErrorDescription(e, "token_response_error"));
2815
2923
  });
2816
2924
  if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_response_not_found`);
2817
2925
  let userInfo = null;
2818
2926
  const mapping = config.mapping || {};
2819
2927
  let rawProfile;
2820
2928
  if (config.userInfoEndpoint) {
2821
- const userInfoResponse = await betterFetch(config.userInfoEndpoint, {
2822
- headers: { Authorization: `Bearer ${tokenResponse.accessToken}` },
2823
- redirect: "error"
2929
+ const userInfoResponse = await fetchOIDCEndpoint("userInfoEndpoint", config.userInfoEndpoint, { headers: { Authorization: `Bearer ${tokenResponse.accessToken}` } }, (url) => ctx.context.isTrustedOrigin(url)).catch((e) => {
2930
+ if (e instanceof DiscoveryError) redirectOIDCError("invalid_provider", e.message);
2931
+ throw e;
2824
2932
  });
2825
- if (userInfoResponse.error) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${userInfoResponse.error.message}`);
2826
- const rawUserInfo = userInfoResponse.data;
2933
+ if (userInfoResponse.error) redirectOIDCError("invalid_provider", userInfoResponse.error.message || userInfoResponse.error.statusText || "userinfo_response_error");
2934
+ const rawUserInfo = userInfoResponse.data ?? redirectOIDCError("invalid_provider", "userinfo_response_not_found");
2827
2935
  rawProfile = rawUserInfo;
2828
2936
  userInfo = {
2829
2937
  ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, rawUserInfo[value]])),
2830
2938
  id: rawUserInfo[mapping.id || "sub"],
2831
2939
  email: rawUserInfo[mapping.email || "email"],
2832
- emailVerified: options?.trustEmailVerified ? rawUserInfo[mapping.emailVerified || "email_verified"] : false,
2940
+ emailVerified: options?.trustEmailVerified ? parseProviderEmailVerified(rawUserInfo[mapping.emailVerified || "email_verified"]) : false,
2833
2941
  name: rawUserInfo[mapping.name || "name"],
2834
2942
  image: rawUserInfo[mapping.image || "picture"]
2835
2943
  };
@@ -2837,10 +2945,11 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2837
2945
  const idToken = decodeJwt(tokenResponse.idToken);
2838
2946
  rawProfile = idToken;
2839
2947
  if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=jwks_endpoint_not_found`);
2840
- const verified = await validateToken(tokenResponse.idToken, config.jwksEndpoint, {
2948
+ const verified = await validateOIDCIdToken(tokenResponse.idToken, config.jwksEndpoint, {
2841
2949
  audience: config.clientId,
2842
2950
  issuer: provider.issuer
2843
- }).catch((e) => {
2951
+ }, (url) => ctx.context.isTrustedOrigin(url)).catch((e) => {
2952
+ if (e instanceof DiscoveryError) redirectOIDCError("invalid_provider", e.message);
2844
2953
  ctx.context.logger.error(e);
2845
2954
  return null;
2846
2955
  });
@@ -2849,7 +2958,7 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2849
2958
  ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, verified.payload[value]])),
2850
2959
  id: idToken[mapping.id || "sub"],
2851
2960
  email: idToken[mapping.email || "email"],
2852
- emailVerified: options?.trustEmailVerified ? idToken[mapping.emailVerified || "email_verified"] : false,
2961
+ emailVerified: options?.trustEmailVerified ? parseProviderEmailVerified(idToken[mapping.emailVerified || "email_verified"]) : false,
2853
2962
  name: idToken[mapping.name || "name"],
2854
2963
  image: idToken[mapping.image || "picture"]
2855
2964
  };
@@ -3287,7 +3396,42 @@ saml.setSchemaValidator({ async validate(xml) {
3287
3396
  * which won't have a matching Origin header.
3288
3397
  */
3289
3398
  const SAML_SKIP_ORIGIN_CHECK_PATHS = ["/sso/saml2/sp/acs", "/sso/saml2/sp/slo"];
3399
+ const SSO_PROVIDER_BUILT_IN_FIELD_KEYS = [
3400
+ "id",
3401
+ "issuer",
3402
+ "oidcConfig",
3403
+ "samlConfig",
3404
+ "userId",
3405
+ "providerId",
3406
+ "organizationId",
3407
+ "domain",
3408
+ "domainVerified"
3409
+ ];
3410
+ const SSO_PROVIDER_RESPONSE_FIELD_KEYS = [
3411
+ "type",
3412
+ "spMetadataUrl",
3413
+ "redirectURI",
3414
+ "domainVerificationToken"
3415
+ ];
3416
+ const SSO_PROVIDER_BUILT_IN_FIELD_KEY_SET = new Set(SSO_PROVIDER_BUILT_IN_FIELD_KEYS);
3417
+ const SSO_PROVIDER_RESPONSE_FIELD_KEY_SET = new Set(SSO_PROVIDER_RESPONSE_FIELD_KEYS);
3418
+ function getSSOProviderBuiltInFieldName(options, key) {
3419
+ const fieldNames = options?.fields;
3420
+ const schemaFieldNames = options?.schema?.ssoProvider?.fields;
3421
+ return fieldNames?.[key] ?? schemaFieldNames?.[key] ?? key;
3422
+ }
3423
+ function assertNoAdditionalFieldCollisions(options) {
3424
+ const additionalFields = options?.schema?.ssoProvider?.additionalFields ?? {};
3425
+ const builtInFieldNames = new Set(SSO_PROVIDER_BUILT_IN_FIELD_KEYS.map((key) => getSSOProviderBuiltInFieldName(options, key)));
3426
+ for (const [key, field] of Object.entries(additionalFields)) {
3427
+ if (SSO_PROVIDER_BUILT_IN_FIELD_KEY_SET.has(key)) throw new Error(`ssoProvider additional field "${key}" conflicts with a built-in field`);
3428
+ if (SSO_PROVIDER_RESPONSE_FIELD_KEY_SET.has(key)) throw new Error(`ssoProvider additional field "${key}" conflicts with a returned provider field`);
3429
+ const fieldName = field.fieldName ?? key;
3430
+ if (builtInFieldNames.has(fieldName)) throw new Error(`ssoProvider additional field "${key}" maps to built-in field "${fieldName}"`);
3431
+ }
3432
+ }
3290
3433
  function sso(options) {
3434
+ assertNoAdditionalFieldCollisions(options);
3291
3435
  const optionsWithStore = options;
3292
3436
  let endpoints = {
3293
3437
  spMetadata: spMetadata(optionsWithStore),
@@ -3298,8 +3442,8 @@ function sso(options) {
3298
3442
  acsEndpoint: acsEndpoint(optionsWithStore),
3299
3443
  sloEndpoint: sloEndpoint(optionsWithStore),
3300
3444
  initiateSLO: initiateSLO(optionsWithStore),
3301
- listSSOProviders: listSSOProviders(),
3302
- getSSOProvider: getSSOProvider(),
3445
+ listSSOProviders: listSSOProviders(optionsWithStore),
3446
+ getSSOProvider: getSSOProvider(optionsWithStore),
3303
3447
  updateSSOProvider: updateSSOProvider(optionsWithStore),
3304
3448
  deleteSSOProvider: deleteSSOProvider()
3305
3449
  };
@@ -3356,22 +3500,22 @@ function sso(options) {
3356
3500
  }]
3357
3501
  },
3358
3502
  schema: { ssoProvider: {
3359
- modelName: options?.modelName ?? "ssoProvider",
3503
+ modelName: options?.modelName ?? options?.schema?.ssoProvider?.modelName ?? "ssoProvider",
3360
3504
  fields: {
3361
3505
  issuer: {
3362
3506
  type: "string",
3363
3507
  required: true,
3364
- fieldName: options?.fields?.issuer ?? "issuer"
3508
+ fieldName: options?.fields?.issuer ?? options?.schema?.ssoProvider?.fields?.issuer ?? "issuer"
3365
3509
  },
3366
3510
  oidcConfig: {
3367
3511
  type: "string",
3368
3512
  required: false,
3369
- fieldName: options?.fields?.oidcConfig ?? "oidcConfig"
3513
+ fieldName: options?.fields?.oidcConfig ?? options?.schema?.ssoProvider?.fields?.oidcConfig ?? "oidcConfig"
3370
3514
  },
3371
3515
  samlConfig: {
3372
3516
  type: "string",
3373
3517
  required: false,
3374
- fieldName: options?.fields?.samlConfig ?? "samlConfig"
3518
+ fieldName: options?.fields?.samlConfig ?? options?.schema?.ssoProvider?.fields?.samlConfig ?? "samlConfig"
3375
3519
  },
3376
3520
  userId: {
3377
3521
  type: "string",
@@ -3379,30 +3523,33 @@ function sso(options) {
3379
3523
  model: "user",
3380
3524
  field: "id"
3381
3525
  },
3382
- fieldName: options?.fields?.userId ?? "userId"
3526
+ fieldName: options?.fields?.userId ?? options?.schema?.ssoProvider?.fields?.userId ?? "userId"
3383
3527
  },
3384
3528
  providerId: {
3385
3529
  type: "string",
3386
3530
  required: true,
3387
3531
  unique: true,
3388
- fieldName: options?.fields?.providerId ?? "providerId"
3532
+ fieldName: options?.fields?.providerId ?? options?.schema?.ssoProvider?.fields?.providerId ?? "providerId"
3389
3533
  },
3390
3534
  organizationId: {
3391
3535
  type: "string",
3392
3536
  required: false,
3393
- fieldName: options?.fields?.organizationId ?? "organizationId"
3537
+ fieldName: options?.fields?.organizationId ?? options?.schema?.ssoProvider?.fields?.organizationId ?? "organizationId"
3394
3538
  },
3395
3539
  domain: {
3396
3540
  type: "string",
3397
3541
  required: true,
3398
- fieldName: options?.fields?.domain ?? "domain"
3542
+ fieldName: options?.fields?.domain ?? options?.schema?.ssoProvider?.fields?.domain ?? "domain"
3399
3543
  },
3400
3544
  ...options?.domainVerification?.enabled ? { domainVerified: {
3401
3545
  type: "boolean",
3402
- required: false
3403
- } } : {}
3546
+ required: false,
3547
+ fieldName: options?.schema?.ssoProvider?.fields?.domainVerified ?? "domainVerified"
3548
+ } } : {},
3549
+ ...options?.schema?.ssoProvider?.additionalFields ?? {}
3404
3550
  }
3405
3551
  } },
3552
+ $Infer: { SSOProvider: {} },
3406
3553
  options
3407
3554
  };
3408
3555
  }