@better-auth/sso 1.7.0-beta.4 → 1.7.0-beta.6

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-5EiO_U3Z.mjs";
2
- import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
1
+ import { t as PACKAGE_VERSION } from "./version-BTlyLl-N.mjs";
2
+ import { APIError, addOAuthServerContext, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
3
3
  import { XMLParser, XMLValidator } from "fast-xml-parser";
4
4
  import { X509Certificate } from "node:crypto";
5
5
  import { getHostname } from "tldts";
6
6
  import { generateRandomString } from "better-auth/crypto";
7
7
  import * as z from "zod";
8
- import { isPublicRoutableHost } from "@better-auth/core/utils/host";
9
- import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
8
+ import { filterOutputFields } from "@better-auth/core/utils/db";
9
+ import { classifyHost, isPublicRoutableHost } from "@better-auth/core/utils/host";
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
- import { additionalAuthorizationParamsSchema, handleOAuthUserInfo } from "better-auth/oauth2";
16
- import { decodeJwt } from "jose";
18
+ import { additionalAuthorizationParamsSchema, signInWithOAuthIdentity } from "better-auth/oauth2";
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,7 +372,137 @@ 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);
376
+ }
377
+ /**
378
+ * Re-validate an endpoint by resolving its hostname and rejecting any resolved
379
+ * address that is not publicly routable.
380
+ *
381
+ * {@link validateOIDCEndpointUrl} only classifies the literal hostname, so
382
+ * a host like `idp.example` whose DNS record points at `127.0.0.1`,
383
+ * `169.254.169.254`, or an RFC 1918 address passes that check unchanged. This
384
+ * function closes that gap by performing the same RFC 6890 classification on the
385
+ * addresses the host actually resolves to, right before the server-side fetch.
386
+ *
387
+ * Best-effort by design:
388
+ * - Operator-allowlisted origins (trustedOrigins) are skipped — this is the
389
+ * documented escape hatch for internal IdPs.
390
+ * - IP-literal hosts are already fully covered by the synchronous check.
391
+ * - On runtimes without `node:dns` (e.g. Cloudflare Workers / edge), DNS
392
+ * resolution is unavailable; we fall back to the synchronous host check and
393
+ * the platform's own egress controls.
394
+ *
395
+ * Note: this resolves once and validates the result; it does not pin the address
396
+ * for the subsequent connection, so a change in the resolved address between
397
+ * this lookup and the fetch remains theoretically possible. It nonetheless
398
+ * rejects the common case of a DNS record that statically points at an internal
399
+ * address.
400
+ *
401
+ * @throws DiscoveryError(discovery_private_host) if any resolved address is not public
402
+ */
403
+ async function assertEndpointResolvesPublic(name, endpoint, isTrustedOrigin) {
404
+ const parsed = parseURL(name, endpoint);
405
+ if (isTrustedOrigin(parsed.toString())) return;
406
+ const host = parsed.hostname;
407
+ if (classifyHost(host).literal !== "fqdn") return;
408
+ let dns;
409
+ try {
410
+ dns = await import("node:dns/promises");
411
+ } catch {
412
+ return;
413
+ }
414
+ let resolved;
415
+ try {
416
+ resolved = await dns.lookup(host, { all: true });
417
+ } catch {
418
+ return;
419
+ }
420
+ 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.`, {
421
+ endpoint: name,
422
+ url: endpoint,
423
+ hostname: host,
424
+ resolved: address
425
+ });
426
+ }
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
+ /**
435
+ * Re-validate, at fetch time, every OIDC endpoint that is fetched server-side
436
+ * (token, userinfo, jwks). `authorizationEndpoint` is intentionally excluded
437
+ * because it is a browser redirect target, not a server-side fetch.
438
+ */
439
+ async function assertServerFetchedOIDCEndpointsAllowed(config, isTrustedOrigin) {
440
+ const fields = [
441
+ ["tokenEndpoint", config.tokenEndpoint],
442
+ ["userInfoEndpoint", config.userInfoEndpoint],
443
+ ["jwksEndpoint", config.jwksEndpoint]
444
+ ];
445
+ for (const [name, url] of fields) {
446
+ if (!url) continue;
447
+ await assertOIDCEndpointAllowed(name, url, isTrustedOrigin);
448
+ }
449
+ }
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
+ });
528
506
  }
529
507
  /**
530
508
  * Fetch the OIDC discovery document from the IdP.
@@ -534,12 +512,12 @@ function validateSkipDiscoveryEndpoints(config, isTrustedOrigin) {
534
512
  * @returns The parsed discovery document
535
513
  * @throws DiscoveryError on network errors, timeouts, or invalid responses
536
514
  */
537
- async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
515
+ async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT, isTrustedOrigin = () => false) {
538
516
  try {
539
- const response = await betterFetch(url, {
517
+ const response = await fetchOIDCEndpoint("discoveryEndpoint", url, {
540
518
  method: "GET",
541
519
  timeout
542
- });
520
+ }, isTrustedOrigin);
543
521
  if (response.error) {
544
522
  const { status } = response.error;
545
523
  if (status === 404) throw new DiscoveryError("discovery_not_found", "Discovery endpoint not found", {
@@ -708,20 +686,24 @@ function needsRuntimeDiscovery(config) {
708
686
  * Throws if discovery fails.
709
687
  */
710
688
  async function ensureRuntimeDiscovery(config, issuer, isTrustedOrigin) {
711
- if (!needsRuntimeDiscovery(config)) return config;
712
- const hydrated = await discoverOIDCConfig({
713
- issuer,
714
- existingConfig: config,
715
- isTrustedOrigin
716
- });
717
- return {
718
- ...config,
719
- authorizationEndpoint: hydrated.authorizationEndpoint,
720
- tokenEndpoint: hydrated.tokenEndpoint,
721
- tokenEndpointAuthentication: hydrated.tokenEndpointAuthentication,
722
- userInfoEndpoint: hydrated.userInfoEndpoint,
723
- jwksEndpoint: hydrated.jwksEndpoint
724
- };
689
+ let resolved = config;
690
+ if (needsRuntimeDiscovery(config)) {
691
+ const hydrated = await discoverOIDCConfig({
692
+ issuer,
693
+ existingConfig: config,
694
+ isTrustedOrigin
695
+ });
696
+ resolved = {
697
+ ...config,
698
+ authorizationEndpoint: hydrated.authorizationEndpoint,
699
+ tokenEndpoint: hydrated.tokenEndpoint,
700
+ tokenEndpointAuthentication: hydrated.tokenEndpointAuthentication,
701
+ userInfoEndpoint: hydrated.userInfoEndpoint,
702
+ jwksEndpoint: hydrated.jwksEndpoint
703
+ };
704
+ }
705
+ await assertServerFetchedOIDCEndpointsAllowed(resolved, isTrustedOrigin);
706
+ return resolved;
725
707
  }
726
708
  //#endregion
727
709
  //#region src/oidc/errors.ts
@@ -739,6 +721,7 @@ async function ensureRuntimeDiscovery(config, issuer, isTrustedOrigin) {
739
721
  * - discovery_not_found → 400 BAD_REQUEST
740
722
  * - discovery_untrusted_origin → 400 BAD_REQUEST
741
723
  * - discovery_private_host → 400 BAD_REQUEST
724
+ * - oidc_endpoint_redirect → 400 BAD_REQUEST
742
725
  * - discovery_invalid_json → 400 BAD_REQUEST
743
726
  * - discovery_incomplete → 400 BAD_REQUEST
744
727
  * - issuer_mismatch → 400 BAD_REQUEST
@@ -775,6 +758,10 @@ function mapDiscoveryErrorToAPIError(error) {
775
758
  message: error.message,
776
759
  code: error.code
777
760
  });
761
+ case "oidc_endpoint_redirect": return new APIError("BAD_REQUEST", {
762
+ message: error.message,
763
+ code: error.code
764
+ });
778
765
  case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
779
766
  message: `OIDC discovery returned invalid data: ${error.message}`,
780
767
  code: error.code
@@ -1125,11 +1112,10 @@ async function validateInResponseTo(c, ctx) {
1125
1112
  const inResponseTo = ctx.extract.response?.inResponseTo;
1126
1113
  const allowIdpInitiated = ctx.options.allowIdpInitiated ?? false;
1127
1114
  if (inResponseTo) {
1115
+ const consumed = await c.context.internalAdapter.consumeVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1128
1116
  let storedRequest = null;
1129
- const verification = await c.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1130
- if (verification) try {
1131
- storedRequest = JSON.parse(verification.value);
1132
- if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
1117
+ if (consumed) try {
1118
+ storedRequest = JSON.parse(consumed.value);
1133
1119
  } catch {
1134
1120
  storedRequest = null;
1135
1121
  }
@@ -1146,10 +1132,8 @@ async function validateInResponseTo(c, ctx) {
1146
1132
  expectedProvider: storedRequest.providerId,
1147
1133
  actualProvider: ctx.providerId
1148
1134
  });
1149
- await c.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1150
1135
  throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Provider mismatch"));
1151
1136
  }
1152
- await c.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1153
1137
  } else if (!allowIdpInitiated) {
1154
1138
  c.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: ctx.providerId });
1155
1139
  throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "unsolicited_response", "IdP-initiated SSO not allowed"));
@@ -1185,6 +1169,30 @@ function validateAudience(c, ctx) {
1185
1169
  }
1186
1170
  //#endregion
1187
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
+ }
1188
1196
  const oidcMappingSchema = z.object({
1189
1197
  id: z.string().meta({ description: "Field mapping for user ID (defaults to 'sub')" }),
1190
1198
  email: z.string().meta({ description: "Field mapping for email (defaults to 'email')" }),
@@ -1273,15 +1281,39 @@ const registerSSOProviderBodySchema = z.object({
1273
1281
  organizationId: z.string().meta({ description: "If organization plugin is enabled, the organization id to link the provider to" }).optional(),
1274
1282
  overrideUserInfo: z.boolean().meta({ description: "Override user info with the provider info. Defaults to false" }).default(false).optional()
1275
1283
  });
1284
+ function getRegisterSSOProviderBodySchema(options) {
1285
+ return registerSSOProviderBodySchema.extend({ ...getSSOProviderAdditionalFieldsSchema(options).shape });
1286
+ }
1276
1287
  const updateSSOProviderBodySchema = z.object({
1277
1288
  issuer: z.string().url().optional(),
1278
1289
  domain: z.string().optional(),
1279
1290
  oidcConfig: oidcConfigSchema.partial().optional(),
1280
1291
  samlConfig: samlConfigSchema.partial().optional()
1281
1292
  });
1293
+ function getUpdateSSOProviderBodySchema(options) {
1294
+ return updateSSOProviderBodySchema.extend({
1295
+ providerId: z.string(),
1296
+ ...getSSOProviderAdditionalFieldsSchema(options).partial().shape
1297
+ });
1298
+ }
1282
1299
  //#endregion
1283
1300
  //#region src/routes/providers.ts
1284
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
+ }
1285
1317
  function hasOrgAdminRole(member) {
1286
1318
  return member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()));
1287
1319
  }
@@ -1327,7 +1359,7 @@ async function batchCheckOrgAdmin(ctx, userId, organizationIds) {
1327
1359
  for (const member of members) if (hasOrgAdminRole(member)) adminOrgIds.add(member.organizationId);
1328
1360
  return adminOrgIds;
1329
1361
  }
1330
- function sanitizeProvider(provider, baseURL) {
1362
+ function sanitizeProvider(provider, baseURL, options) {
1331
1363
  let oidcConfig = null;
1332
1364
  let samlConfig = null;
1333
1365
  try {
@@ -1342,6 +1374,7 @@ function sanitizeProvider(provider, baseURL) {
1342
1374
  }
1343
1375
  const type = samlConfig ? "saml" : "oidc";
1344
1376
  return {
1377
+ ...getReturnedSSOProviderAdditionalFields(provider, options),
1345
1378
  providerId: provider.providerId,
1346
1379
  type,
1347
1380
  issuer: provider.issuer,
@@ -1372,7 +1405,7 @@ function sanitizeProvider(provider, baseURL) {
1372
1405
  spMetadataUrl: `${baseURL}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(provider.providerId)}`
1373
1406
  };
1374
1407
  }
1375
- const listSSOProviders = () => {
1408
+ const listSSOProviders = (options) => {
1376
1409
  return createAuthEndpoint("/sso/providers", {
1377
1410
  method: "GET",
1378
1411
  use: [sessionMiddleware],
@@ -1397,7 +1430,7 @@ const listSSOProviders = () => {
1397
1430
  const userOwnedOrgProviders = orgProviders.filter((p) => p.userId === userId);
1398
1431
  accessibleProviders = [...accessibleProviders, ...userOwnedOrgProviders];
1399
1432
  }
1400
- const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL));
1433
+ const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL, options));
1401
1434
  return ctx.json({ providers });
1402
1435
  });
1403
1436
  };
@@ -1419,7 +1452,7 @@ async function checkProviderAccess(ctx, providerId) {
1419
1452
  if (!hasAccess) throw new APIError("FORBIDDEN", { message: "You don't have access to this provider" });
1420
1453
  return provider;
1421
1454
  }
1422
- const getSSOProvider = () => {
1455
+ const getSSOProvider = (options) => {
1423
1456
  return createAuthEndpoint("/sso/get-provider", {
1424
1457
  method: "GET",
1425
1458
  use: [sessionMiddleware],
@@ -1437,7 +1470,7 @@ const getSSOProvider = () => {
1437
1470
  }, async (ctx) => {
1438
1471
  const { providerId } = ctx.query;
1439
1472
  const provider = await checkProviderAccess(ctx, providerId);
1440
- return ctx.json(sanitizeProvider(provider, ctx.context.baseURL));
1473
+ return ctx.json(sanitizeProvider(provider, ctx.context.baseURL, options));
1441
1474
  });
1442
1475
  };
1443
1476
  function parseAndValidateConfig(configString, configType) {
@@ -1489,10 +1522,11 @@ function mergeOIDCConfig(current, updates, issuer) {
1489
1522
  };
1490
1523
  }
1491
1524
  const updateSSOProvider = (options) => {
1525
+ const updateBodySchema = getUpdateSSOProviderBodySchema(options);
1492
1526
  return createAuthEndpoint("/sso/update-provider", {
1493
1527
  method: "POST",
1494
1528
  use: [sessionMiddleware],
1495
- body: updateSSOProviderBodySchema.extend({ providerId: z.string() }),
1529
+ body: updateBodySchema,
1496
1530
  metadata: { openapi: {
1497
1531
  operationId: "updateSSOProvider",
1498
1532
  summary: "Update SSO provider",
@@ -1506,9 +1540,10 @@ const updateSSOProvider = (options) => {
1506
1540
  }, async (ctx) => {
1507
1541
  const { providerId, ...body } = ctx.body;
1508
1542
  const { issuer, domain, samlConfig, oidcConfig } = body;
1509
- 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" });
1510
1545
  const existingProvider = await checkProviderAccess(ctx, providerId);
1511
- const updateData = {};
1546
+ const updateData = { ...additionalFields };
1512
1547
  if (body.issuer !== void 0) updateData.issuer = body.issuer;
1513
1548
  if (body.domain !== void 0) {
1514
1549
  updateData.domain = body.domain;
@@ -1530,7 +1565,7 @@ const updateSSOProvider = (options) => {
1530
1565
  }
1531
1566
  if (body.oidcConfig) {
1532
1567
  try {
1533
- validateSkipDiscoveryEndpoints(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
1568
+ validateOIDCEndpointUrls(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
1534
1569
  } catch (error) {
1535
1570
  if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
1536
1571
  throw error;
@@ -1557,7 +1592,7 @@ const updateSSOProvider = (options) => {
1557
1592
  }]
1558
1593
  });
1559
1594
  if (!fullProvider) throw new APIError("NOT_FOUND", { message: "Provider not found after update" });
1560
- return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL));
1595
+ return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL, options));
1561
1596
  });
1562
1597
  };
1563
1598
  const deleteSSOProvider = () => {
@@ -1589,15 +1624,126 @@ const deleteSSOProvider = () => {
1589
1624
  });
1590
1625
  };
1591
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
1592
1740
  //#region src/saml-state.ts
1593
- async function generateRelayState(c, link, additionalData) {
1741
+ async function generateRelayState(c, link) {
1594
1742
  const callbackURL = c.body.callbackURL;
1595
1743
  if (!callbackURL) throw new APIError("BAD_REQUEST", { message: "callbackURL is required" });
1596
- const codeVerifier = generateRandomString(128);
1597
1744
  const stateData = {
1598
- ...additionalData ? additionalData : {},
1599
1745
  callbackURL,
1600
- codeVerifier,
1746
+ codeVerifier: generateRandomString(128),
1601
1747
  errorURL: c.body.errorCallbackURL,
1602
1748
  newUserURL: c.body.newUserCallbackURL,
1603
1749
  link,
@@ -1699,7 +1845,8 @@ function createSP(config, baseURL, providerId, opts) {
1699
1845
  isAssertionEncrypted: spData?.isAssertionEncrypted || false,
1700
1846
  encPrivateKey: normalizePem(spData?.encPrivateKey),
1701
1847
  encPrivateKeyPass: spData?.encPrivateKeyPass,
1702
- relayState: opts?.relayState
1848
+ relayState: opts?.relayState,
1849
+ clockDrifts: opts?.clockSkew && opts?.clockSkew !== 0 ? [-opts.clockSkew, opts.clockSkew] : void 0
1703
1850
  });
1704
1851
  }
1705
1852
  function createIdP(config) {
@@ -1855,7 +2002,7 @@ async function processSAMLResponse(ctx, params, options) {
1855
2002
  if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
1856
2003
  const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
1857
2004
  if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
1858
- const sp = createSP(parsedSamlConfig, ctx.context.baseURL, providerId);
2005
+ const sp = createSP(parsedSamlConfig, ctx.context.baseURL, providerId, { clockSkew: options?.saml?.clockSkew });
1859
2006
  const idp = createIdP(parsedSamlConfig);
1860
2007
  const samlRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL, params.currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
1861
2008
  validateSingleAssertion(SAMLResponse);
@@ -1905,26 +2052,8 @@ async function processSAMLResponse(ctx, params, options) {
1905
2052
  const conditions = extract.conditions;
1906
2053
  const clockSkew = options?.saml?.clockSkew ?? 3e5;
1907
2054
  const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
1908
- const existingAssertion = await ctx.context.internalAdapter.findVerificationValue(`${USED_ASSERTION_KEY_PREFIX}${assertionId}`);
1909
- let isReplay = false;
1910
- if (existingAssertion) try {
1911
- if (JSON.parse(existingAssertion.value).expiresAt >= Date.now()) isReplay = true;
1912
- } catch (error) {
1913
- ctx.context.logger.warn("Failed to parse stored assertion record", {
1914
- assertionId,
1915
- error
1916
- });
1917
- }
1918
- if (isReplay) {
1919
- ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
1920
- assertionId,
1921
- issuer,
1922
- providerId
1923
- });
1924
- throw ctx.redirect(`${samlRedirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
1925
- }
1926
- await ctx.context.internalAdapter.createVerificationValue({
1927
- identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
2055
+ if (!await ctx.context.internalAdapter.reserveVerificationValue({
2056
+ identifier: `saml-used-assertion:${assertionId}`,
1928
2057
  value: JSON.stringify({
1929
2058
  assertionId,
1930
2059
  issuer,
@@ -1933,7 +2062,14 @@ async function processSAMLResponse(ctx, params, options) {
1933
2062
  expiresAt
1934
2063
  }),
1935
2064
  expiresAt: new Date(expiresAt)
1936
- });
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
+ }
1937
2073
  } else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
1938
2074
  const attributes = extract.attributes || {};
1939
2075
  const mapping = parsedSamlConfig.mapping ?? {};
@@ -1946,7 +2082,7 @@ async function processSAMLResponse(ctx, params, options) {
1946
2082
  id: attr(mapping.id || "nameID") || extract.nameID,
1947
2083
  email: (attr(mapping.email || "email") || extract.nameID || "").toLowerCase(),
1948
2084
  name: [attr(mapping.firstName || "givenName"), attr(mapping.lastName || "surname")].filter(Boolean).join(" ") || attr(mapping.name || "displayName") || extract.nameID,
1949
- emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attr(mapping.emailVerified) || false : false
2085
+ emailVerified: options?.trustEmailVerified && mapping.emailVerified ? parseProviderEmailVerified(attr(mapping.emailVerified)) : false
1950
2086
  };
1951
2087
  if (!userInfo.id || !userInfo.email) {
1952
2088
  ctx.context.logger.error("Missing essential user info from SAML response", {
@@ -1957,27 +2093,32 @@ async function processSAMLResponse(ctx, params, options) {
1957
2093
  });
1958
2094
  throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
1959
2095
  }
1960
- const isTrustedProvider = ctx.context.trustedProviders.includes(providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
2096
+ const isTrustedProvider = "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
1961
2097
  const postAuthRedirect = relayState?.callbackURL || ctx.context.baseURL;
1962
2098
  const errorUrl = relayState?.errorURL || samlRedirectUrl;
1963
2099
  let result;
1964
2100
  try {
1965
- result = await handleOAuthUserInfo(ctx, {
2101
+ result = await signInWithOAuthIdentity(ctx, {
1966
2102
  userInfo: {
1967
2103
  email: userInfo.email,
1968
2104
  name: userInfo.name || userInfo.email,
1969
2105
  id: userInfo.id,
1970
- emailVerified: Boolean(userInfo.emailVerified)
1971
- },
1972
- account: {
1973
- providerId,
1974
- accountId: userInfo.id,
1975
- accessToken: "",
1976
- refreshToken: ""
2106
+ emailVerified: userInfo.emailVerified
1977
2107
  },
2108
+ providerId,
2109
+ accountId: userInfo.id,
2110
+ tokens: {},
1978
2111
  callbackURL: postAuthRedirect,
1979
2112
  disableSignUp: options?.disableImplicitSignUp,
1980
- isTrustedProvider
2113
+ source: {
2114
+ method: "sso-saml",
2115
+ sso: {
2116
+ providerId,
2117
+ profile: attributes
2118
+ }
2119
+ },
2120
+ isTrustedProvider,
2121
+ trustProviderByName: false
1981
2122
  });
1982
2123
  } catch (e) {
1983
2124
  if (isAPIError(e) && e.body?.code) {
@@ -2002,7 +2143,7 @@ async function processSAMLResponse(ctx, params, options) {
2002
2143
  providerId,
2003
2144
  accountId: userInfo.id,
2004
2145
  email: userInfo.email,
2005
- emailVerified: Boolean(userInfo.emailVerified),
2146
+ emailVerified: userInfo.emailVerified,
2006
2147
  rawAttributes: attributes
2007
2148
  },
2008
2149
  provider,
@@ -2075,174 +2216,177 @@ const spMetadata = (options) => {
2075
2216
  const registerSSOProvider = (options) => {
2076
2217
  return createAuthEndpoint("/sso/register", {
2077
2218
  method: "POST",
2078
- body: registerSSOProviderBodySchema,
2219
+ body: getRegisterSSOProviderBodySchema(options),
2079
2220
  use: [sessionMiddleware],
2080
- metadata: { openapi: {
2081
- operationId: "registerSSOProvider",
2082
- summary: "Register an OIDC provider",
2083
- description: "This endpoint is used to register an OIDC provider. This is used to configure the provider and link it to an organization",
2084
- responses: { "200": {
2085
- description: "OIDC provider created successfully",
2086
- content: { "application/json": { schema: {
2087
- type: "object",
2088
- properties: {
2089
- issuer: {
2090
- type: "string",
2091
- format: "uri",
2092
- description: "The issuer URL of the provider"
2093
- },
2094
- domain: {
2095
- type: "string",
2096
- description: "The domain of the provider, used for email matching"
2097
- },
2098
- domainVerified: {
2099
- type: "boolean",
2100
- description: "A boolean indicating whether the domain has been verified or not"
2101
- },
2102
- domainVerificationToken: {
2103
- type: "string",
2104
- description: "Domain verification token. It can be used to prove ownership over the SSO domain"
2105
- },
2106
- oidcConfig: {
2107
- type: "object",
2108
- properties: {
2109
- issuer: {
2110
- type: "string",
2111
- format: "uri",
2112
- description: "The issuer URL of the provider"
2113
- },
2114
- pkce: {
2115
- type: "boolean",
2116
- description: "Whether PKCE is enabled for the authorization flow"
2117
- },
2118
- clientId: {
2119
- type: "string",
2120
- description: "The client ID for the provider"
2121
- },
2122
- clientSecret: {
2123
- type: "string",
2124
- description: "The client secret for the provider"
2125
- },
2126
- authorizationEndpoint: {
2127
- type: "string",
2128
- format: "uri",
2129
- nullable: true,
2130
- description: "The authorization endpoint URL"
2131
- },
2132
- discoveryEndpoint: {
2133
- type: "string",
2134
- format: "uri",
2135
- description: "The discovery endpoint URL"
2136
- },
2137
- userInfoEndpoint: {
2138
- type: "string",
2139
- format: "uri",
2140
- nullable: true,
2141
- description: "The user info endpoint URL"
2142
- },
2143
- scopes: {
2144
- type: "array",
2145
- items: { type: "string" },
2146
- nullable: true,
2147
- description: "The scopes requested from the provider"
2148
- },
2149
- tokenEndpoint: {
2150
- type: "string",
2151
- format: "uri",
2152
- nullable: true,
2153
- description: "The token endpoint URL"
2154
- },
2155
- tokenEndpointAuthentication: {
2156
- type: "string",
2157
- enum: ["client_secret_post", "client_secret_basic"],
2158
- nullable: true,
2159
- description: "Authentication method for the token endpoint"
2160
- },
2161
- jwksEndpoint: {
2162
- type: "string",
2163
- format: "uri",
2164
- nullable: true,
2165
- description: "The JWKS endpoint URL"
2166
- },
2167
- mapping: {
2168
- type: "object",
2169
- nullable: true,
2170
- properties: {
2171
- id: {
2172
- type: "string",
2173
- description: "Field mapping for user ID (defaults to 'sub')"
2174
- },
2175
- email: {
2176
- type: "string",
2177
- description: "Field mapping for email (defaults to 'email')"
2178
- },
2179
- emailVerified: {
2180
- type: "string",
2181
- nullable: true,
2182
- description: "Field mapping for email verification (defaults to 'email_verified')"
2183
- },
2184
- name: {
2185
- type: "string",
2186
- description: "Field mapping for name (defaults to 'name')"
2187
- },
2188
- image: {
2189
- type: "string",
2190
- nullable: true,
2191
- description: "Field mapping for image (defaults to 'picture')"
2192
- },
2193
- extraFields: {
2194
- type: "object",
2195
- additionalProperties: { type: "string" },
2196
- nullable: true,
2197
- description: "Additional field mappings"
2198
- }
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"
2199
2285
  },
2200
- required: [
2201
- "id",
2202
- "email",
2203
- "name"
2204
- ]
2205
- }
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"
2297
+ },
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"
2206
2358
  },
2207
- required: [
2208
- "issuer",
2209
- "pkce",
2210
- "clientId",
2211
- "clientSecret",
2212
- "discoveryEndpoint"
2213
- ],
2214
- description: "OIDC configuration for the provider"
2215
- },
2216
- organizationId: {
2217
- type: "string",
2218
- nullable: true,
2219
- description: "ID of the linked organization, if any"
2220
- },
2221
- userId: {
2222
- type: "string",
2223
- description: "ID of the user who registered the provider"
2224
- },
2225
- providerId: {
2226
- type: "string",
2227
- 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
+ }
2228
2377
  },
2229
- redirectURI: {
2230
- type: "string",
2231
- format: "uri",
2232
- description: "The redirect URI for the provider callback"
2233
- }
2234
- },
2235
- required: [
2236
- "issuer",
2237
- "domain",
2238
- "oidcConfig",
2239
- "userId",
2240
- "providerId",
2241
- "redirectURI"
2242
- ]
2243
- } } }
2244
- } }
2245
- } }
2378
+ required: [
2379
+ "issuer",
2380
+ "domain",
2381
+ "oidcConfig",
2382
+ "userId",
2383
+ "providerId",
2384
+ "redirectURI"
2385
+ ]
2386
+ } } }
2387
+ } }
2388
+ }
2389
+ }
2246
2390
  }, async (ctx) => {
2247
2391
  const user = ctx.context.session?.user;
2248
2392
  if (!user) throw new APIError("UNAUTHORIZED");
@@ -2256,6 +2400,7 @@ const registerSSOProvider = (options) => {
2256
2400
  }]
2257
2401
  })).length >= limit) throw new APIError("FORBIDDEN", { message: "You have reached the maximum number of SSO providers" });
2258
2402
  const body = ctx.body;
2403
+ const additionalFields = parseSSOProviderAdditionalFields(options, body, "create");
2259
2404
  if (body.samlConfig?.idpMetadata?.metadata) {
2260
2405
  const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
2261
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)` });
@@ -2274,6 +2419,14 @@ const registerSSOProvider = (options) => {
2274
2419
  if (!member) throw new APIError("BAD_REQUEST", { message: "You are not a member of the organization" });
2275
2420
  if (ctx.context.hasPlugin("organization") && !hasOrgAdminRole(member)) throw new APIError("FORBIDDEN", { message: "You must be an organization owner or admin to register SSO providers" });
2276
2421
  }
2422
+ if (new Set([
2423
+ "credential",
2424
+ ...ctx.context.socialProviders.map((p) => p.id),
2425
+ ...ctx.context.trustedProviders
2426
+ ]).has(body.providerId)) {
2427
+ ctx.context.logger.warn(`SSO provider registration rejected for reserved providerId: ${body.providerId}`);
2428
+ throw new APIError("UNPROCESSABLE_ENTITY", { message: "This providerId is reserved and cannot be used for an SSO provider" });
2429
+ }
2277
2430
  if (await ctx.context.adapter.findOne({
2278
2431
  model: "ssoProvider",
2279
2432
  where: [{
@@ -2285,7 +2438,7 @@ const registerSSOProvider = (options) => {
2285
2438
  throw new APIError("UNPROCESSABLE_ENTITY", { message: "SSO provider with this providerId already exists" });
2286
2439
  }
2287
2440
  if (body.oidcConfig) try {
2288
- validateSkipDiscoveryEndpoints(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
2441
+ validateOIDCEndpointUrls(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
2289
2442
  } catch (error) {
2290
2443
  if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
2291
2444
  throw error;
@@ -2367,6 +2520,7 @@ const registerSSOProvider = (options) => {
2367
2520
  issuer: body.issuer,
2368
2521
  domain: body.domain,
2369
2522
  domainVerified: false,
2523
+ ...additionalFields,
2370
2524
  oidcConfig: (() => {
2371
2525
  const config = buildOIDCConfig();
2372
2526
  if (config) {
@@ -2408,7 +2562,7 @@ const registerSSOProvider = (options) => {
2408
2562
  });
2409
2563
  }
2410
2564
  const result = {
2411
- ...provider,
2565
+ ...filterSSOProviderAdditionalFields(provider, options),
2412
2566
  oidcConfig: safeJsonParse(provider.oidcConfig),
2413
2567
  samlConfig: safeJsonParse(provider.samlConfig),
2414
2568
  redirectURI: getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options),
@@ -2589,9 +2743,16 @@ const signInSSO = (options) => {
2589
2743
  throw error;
2590
2744
  }
2591
2745
  if (!config.authorizationEndpoint) throw new APIError("BAD_REQUEST", { message: "Invalid OIDC configuration. Authorization URL not found." });
2592
- const state = await generateState(ctx, void 0, options?.redirectURI?.trim() ? { ssoProviderId: provider.providerId } : false);
2746
+ const requestedScopes = ctx.body.scopes || config.scopes || [
2747
+ "openid",
2748
+ "email",
2749
+ "profile",
2750
+ "offline_access"
2751
+ ];
2752
+ if (options?.redirectURI?.trim()) await addOAuthServerContext({ ssoProviderId: provider.providerId });
2753
+ const state = await generateState(ctx, { requestedScopes });
2593
2754
  const redirectURI = getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options);
2594
- const authorizationURL = await createAuthorizationURL({
2755
+ const { url: authorizationURL } = await createAuthorizationURL({
2595
2756
  id: provider.issuer,
2596
2757
  options: {
2597
2758
  clientId: config.clientId,
@@ -2600,12 +2761,7 @@ const signInSSO = (options) => {
2600
2761
  redirectURI,
2601
2762
  state: state.state,
2602
2763
  codeVerifier: config.pkce ? state.codeVerifier : void 0,
2603
- scopes: ctx.body.scopes || config.scopes || [
2604
- "openid",
2605
- "email",
2606
- "profile",
2607
- "offline_access"
2608
- ],
2764
+ scopes: requestedScopes,
2609
2765
  loginHint: ctx.body.loginHint || email,
2610
2766
  authorizationEndpoint: config.authorizationEndpoint,
2611
2767
  additionalParams: ctx.body.additionalParams
@@ -2620,7 +2776,7 @@ const signInSSO = (options) => {
2620
2776
  const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
2621
2777
  if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
2622
2778
  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" });
2623
- const { state: relayState } = await generateRelayState(ctx, void 0, false);
2779
+ const { state: relayState } = await generateRelayState(ctx, void 0);
2624
2780
  const sp = createSP(parsedSamlConfig, ctx.context.baseURL, provider.providerId, { relayState });
2625
2781
  const idp = createIdP(parsedSamlConfig);
2626
2782
  const loginRequest = sp.createLoginRequest(idp, "redirect");
@@ -2653,6 +2809,22 @@ const callbackSSOQuerySchema = z.object({
2653
2809
  error: z.string().optional(),
2654
2810
  error_description: z.string().optional()
2655
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
+ }
2656
2828
  /**
2657
2829
  * Core OIDC callback handler logic, shared between the per-provider and
2658
2830
  * shared callback endpoints. Resolves the provider, exchanges the
@@ -2668,8 +2840,17 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2668
2840
  const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
2669
2841
  throw ctx.redirect(`${errorURL}?error=invalid_state`);
2670
2842
  }
2671
- const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
2672
- if (!code || error) throw ctx.redirect(`${errorURL || callbackURL}?error=${error}&error_description=${error_description}`);
2843
+ const { callbackURL, errorURL, newUserURL, requestSignUp, requestedScopes } = stateData;
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"));
2673
2854
  const provider = await resolveOIDCProvider(ctx, options, providerId);
2674
2855
  if (!provider) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
2675
2856
  if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
@@ -2691,6 +2872,7 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2691
2872
  ]
2692
2873
  };
2693
2874
  if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_endpoint_not_found`);
2875
+ const tokenEndpoint = config.tokenEndpoint;
2694
2876
  let tokenEndpointAuth = config.tokenEndpointAuthentication === "client_secret_post" ? { method: "client_secret_post" } : { method: "client_secret_basic" };
2695
2877
  if (config.tokenEndpointAuthentication === "private_key_jwt") {
2696
2878
  let resolved;
@@ -2716,40 +2898,58 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2716
2898
  }
2717
2899
  const tokenRequestOptions = { clientId: config.clientId };
2718
2900
  if (tokenEndpointAuth.method !== "private_key_jwt") tokenRequestOptions.clientSecret = config.clientSecret;
2719
- const tokenResponse = await validateAuthorizationCode({
2720
- code,
2721
- codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
2722
- redirectURI: getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options),
2723
- options: tokenRequestOptions,
2724
- tokenEndpoint: config.tokenEndpoint,
2725
- tokenEndpointAuth
2726
- }).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;
2727
2920
  ctx.context.logger.error("Error validating authorization code", e);
2728
- if (e instanceof BetterFetchError) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${e.message}`);
2729
- return null;
2921
+ if (e instanceof DiscoveryError) redirectOIDCError("invalid_provider", e.message);
2922
+ redirectOIDCError("invalid_provider", getOIDCErrorDescription(e, "token_response_error"));
2730
2923
  });
2731
2924
  if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_response_not_found`);
2732
2925
  let userInfo = null;
2733
2926
  const mapping = config.mapping || {};
2927
+ let rawProfile;
2734
2928
  if (config.userInfoEndpoint) {
2735
- const userInfoResponse = await betterFetch(config.userInfoEndpoint, { headers: { Authorization: `Bearer ${tokenResponse.accessToken}` } });
2736
- if (userInfoResponse.error) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${userInfoResponse.error.message}`);
2737
- const rawUserInfo = userInfoResponse.data;
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;
2932
+ });
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");
2935
+ rawProfile = rawUserInfo;
2738
2936
  userInfo = {
2739
2937
  ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, rawUserInfo[value]])),
2740
2938
  id: rawUserInfo[mapping.id || "sub"],
2741
2939
  email: rawUserInfo[mapping.email || "email"],
2742
- emailVerified: options?.trustEmailVerified ? rawUserInfo[mapping.emailVerified || "email_verified"] : false,
2940
+ emailVerified: options?.trustEmailVerified ? parseProviderEmailVerified(rawUserInfo[mapping.emailVerified || "email_verified"]) : false,
2743
2941
  name: rawUserInfo[mapping.name || "name"],
2744
2942
  image: rawUserInfo[mapping.image || "picture"]
2745
2943
  };
2746
2944
  } else if (tokenResponse.idToken) {
2747
2945
  const idToken = decodeJwt(tokenResponse.idToken);
2946
+ rawProfile = idToken;
2748
2947
  if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=jwks_endpoint_not_found`);
2749
- const verified = await validateToken(tokenResponse.idToken, config.jwksEndpoint, {
2948
+ const verified = await validateOIDCIdToken(tokenResponse.idToken, config.jwksEndpoint, {
2750
2949
  audience: config.clientId,
2751
2950
  issuer: provider.issuer
2752
- }).catch((e) => {
2951
+ }, (url) => ctx.context.isTrustedOrigin(url)).catch((e) => {
2952
+ if (e instanceof DiscoveryError) redirectOIDCError("invalid_provider", e.message);
2753
2953
  ctx.context.logger.error(e);
2754
2954
  return null;
2755
2955
  });
@@ -2758,7 +2958,7 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2758
2958
  ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, verified.payload[value]])),
2759
2959
  id: idToken[mapping.id || "sub"],
2760
2960
  email: idToken[mapping.email || "email"],
2761
- emailVerified: options?.trustEmailVerified ? idToken[mapping.emailVerified || "email_verified"] : false,
2961
+ emailVerified: options?.trustEmailVerified ? parseProviderEmailVerified(idToken[mapping.emailVerified || "email_verified"]) : false,
2762
2962
  name: idToken[mapping.name || "name"],
2763
2963
  image: idToken[mapping.image || "picture"]
2764
2964
  };
@@ -2767,7 +2967,7 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2767
2967
  const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfo.email, provider.domain);
2768
2968
  let linked;
2769
2969
  try {
2770
- linked = await handleOAuthUserInfo(ctx, {
2970
+ linked = await signInWithOAuthIdentity(ctx, {
2771
2971
  userInfo: {
2772
2972
  email: userInfo.email,
2773
2973
  name: userInfo.name || "",
@@ -2775,20 +2975,22 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2775
2975
  image: userInfo.image,
2776
2976
  emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
2777
2977
  },
2778
- account: {
2779
- idToken: tokenResponse.idToken,
2780
- accessToken: tokenResponse.accessToken,
2781
- refreshToken: tokenResponse.refreshToken,
2782
- accountId: userInfo.id,
2783
- providerId: provider.providerId,
2784
- accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
2785
- refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
2786
- scope: tokenResponse.scopes?.join(",")
2787
- },
2978
+ providerId: provider.providerId,
2979
+ accountId: userInfo.id,
2980
+ tokens: tokenResponse,
2981
+ requestedScopes,
2788
2982
  callbackURL,
2789
2983
  disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
2790
2984
  overrideUserInfo: config.overrideUserInfo,
2791
- isTrustedProvider
2985
+ source: {
2986
+ method: "sso-oidc",
2987
+ sso: {
2988
+ providerId: provider.providerId,
2989
+ profile: rawProfile
2990
+ }
2991
+ },
2992
+ isTrustedProvider,
2993
+ trustProviderByName: false
2792
2994
  });
2793
2995
  } catch (e) {
2794
2996
  if (isAPIError(e) && e.body?.code) {
@@ -2906,9 +3108,15 @@ async function bounceIfIdpInitiated(ctx, options, providerId) {
2906
3108
  });
2907
3109
  return;
2908
3110
  }
2909
- const state = await generateState(ctx, void 0, options?.redirectURI?.trim() ? { ssoProviderId: provider.providerId } : false);
3111
+ if (options?.redirectURI?.trim()) await addOAuthServerContext({ ssoProviderId: provider.providerId });
3112
+ const state = await generateState(ctx, { requestedScopes: config.scopes || [
3113
+ "openid",
3114
+ "email",
3115
+ "profile",
3116
+ "offline_access"
3117
+ ] });
2910
3118
  const redirectURI = getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options);
2911
- const authorizationURL = await createAuthorizationURL({
3119
+ const { url: authorizationURL } = await createAuthorizationURL({
2912
3120
  id: provider.issuer,
2913
3121
  options: {
2914
3122
  clientId: config.clientId,
@@ -2957,7 +3165,7 @@ const callbackSSOShared = (options) => {
2957
3165
  const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
2958
3166
  throw ctx.redirect(`${errorURL}?error=invalid_state`);
2959
3167
  }
2960
- const providerId = stateData.ssoProviderId;
3168
+ const providerId = stateData.serverContext?.ssoProviderId;
2961
3169
  if (!providerId) {
2962
3170
  const errorURL = stateData.errorURL || stateData.callbackURL;
2963
3171
  throw ctx.redirect(`${errorURL}?error=invalid_state&error_description=missing_provider_id`);
@@ -3188,7 +3396,42 @@ saml.setSchemaValidator({ async validate(xml) {
3188
3396
  * which won't have a matching Origin header.
3189
3397
  */
3190
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
+ }
3191
3433
  function sso(options) {
3434
+ assertNoAdditionalFieldCollisions(options);
3192
3435
  const optionsWithStore = options;
3193
3436
  let endpoints = {
3194
3437
  spMetadata: spMetadata(optionsWithStore),
@@ -3199,8 +3442,8 @@ function sso(options) {
3199
3442
  acsEndpoint: acsEndpoint(optionsWithStore),
3200
3443
  sloEndpoint: sloEndpoint(optionsWithStore),
3201
3444
  initiateSLO: initiateSLO(optionsWithStore),
3202
- listSSOProviders: listSSOProviders(),
3203
- getSSOProvider: getSSOProvider(),
3445
+ listSSOProviders: listSSOProviders(optionsWithStore),
3446
+ getSSOProvider: getSSOProvider(optionsWithStore),
3204
3447
  updateSSOProvider: updateSSOProvider(optionsWithStore),
3205
3448
  deleteSSOProvider: deleteSSOProvider()
3206
3449
  };
@@ -3257,22 +3500,22 @@ function sso(options) {
3257
3500
  }]
3258
3501
  },
3259
3502
  schema: { ssoProvider: {
3260
- modelName: options?.modelName ?? "ssoProvider",
3503
+ modelName: options?.modelName ?? options?.schema?.ssoProvider?.modelName ?? "ssoProvider",
3261
3504
  fields: {
3262
3505
  issuer: {
3263
3506
  type: "string",
3264
3507
  required: true,
3265
- fieldName: options?.fields?.issuer ?? "issuer"
3508
+ fieldName: options?.fields?.issuer ?? options?.schema?.ssoProvider?.fields?.issuer ?? "issuer"
3266
3509
  },
3267
3510
  oidcConfig: {
3268
3511
  type: "string",
3269
3512
  required: false,
3270
- fieldName: options?.fields?.oidcConfig ?? "oidcConfig"
3513
+ fieldName: options?.fields?.oidcConfig ?? options?.schema?.ssoProvider?.fields?.oidcConfig ?? "oidcConfig"
3271
3514
  },
3272
3515
  samlConfig: {
3273
3516
  type: "string",
3274
3517
  required: false,
3275
- fieldName: options?.fields?.samlConfig ?? "samlConfig"
3518
+ fieldName: options?.fields?.samlConfig ?? options?.schema?.ssoProvider?.fields?.samlConfig ?? "samlConfig"
3276
3519
  },
3277
3520
  userId: {
3278
3521
  type: "string",
@@ -3280,30 +3523,33 @@ function sso(options) {
3280
3523
  model: "user",
3281
3524
  field: "id"
3282
3525
  },
3283
- fieldName: options?.fields?.userId ?? "userId"
3526
+ fieldName: options?.fields?.userId ?? options?.schema?.ssoProvider?.fields?.userId ?? "userId"
3284
3527
  },
3285
3528
  providerId: {
3286
3529
  type: "string",
3287
3530
  required: true,
3288
3531
  unique: true,
3289
- fieldName: options?.fields?.providerId ?? "providerId"
3532
+ fieldName: options?.fields?.providerId ?? options?.schema?.ssoProvider?.fields?.providerId ?? "providerId"
3290
3533
  },
3291
3534
  organizationId: {
3292
3535
  type: "string",
3293
3536
  required: false,
3294
- fieldName: options?.fields?.organizationId ?? "organizationId"
3537
+ fieldName: options?.fields?.organizationId ?? options?.schema?.ssoProvider?.fields?.organizationId ?? "organizationId"
3295
3538
  },
3296
3539
  domain: {
3297
3540
  type: "string",
3298
3541
  required: true,
3299
- fieldName: options?.fields?.domain ?? "domain"
3542
+ fieldName: options?.fields?.domain ?? options?.schema?.ssoProvider?.fields?.domain ?? "domain"
3300
3543
  },
3301
3544
  ...options?.domainVerification?.enabled ? { domainVerified: {
3302
3545
  type: "boolean",
3303
- required: false
3304
- } } : {}
3546
+ required: false,
3547
+ fieldName: options?.schema?.ssoProvider?.fields?.domainVerified ?? "domainVerified"
3548
+ } } : {},
3549
+ ...options?.schema?.ssoProvider?.additionalFields ?? {}
3305
3550
  }
3306
3551
  } },
3552
+ $Infer: { SSOProvider: {} },
3307
3553
  options
3308
3554
  };
3309
3555
  }