@better-auth/sso 1.6.15 → 1.6.17

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/client.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { t as SSOPlugin } from "./index-DbZYHOJt.mjs";
1
+ import { t as SSOPlugin } from "./index-D9brFUE1.mjs";
2
2
 
3
3
  //#region src/client.d.ts
4
4
  interface SSOClientOptions {
package/dist/client.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { t as PACKAGE_VERSION } from "./version-BPpah8cV.mjs";
1
+ import { t as PACKAGE_VERSION } from "./version-BeZU0Td6.mjs";
2
2
  //#region src/client.ts
3
3
  const ssoClient = (options) => {
4
4
  return {
@@ -1359,7 +1359,7 @@ declare const callbackSSOShared: (options?: SSOOptions) => better_call0.StrictEn
1359
1359
  allowedMediaTypes: readonly ["application/x-www-form-urlencoded", "application/json"];
1360
1360
  }, void>;
1361
1361
  declare const callbackSSOSAML: (options?: SSOOptions) => better_call0.StrictEndpoint<"/sso/saml2/callback/:providerId", {
1362
- method: ("POST" | "GET")[];
1362
+ method: ("GET" | "POST")[];
1363
1363
  body: z.ZodOptional<z.ZodObject<{
1364
1364
  SAMLResponse: z.ZodString;
1365
1365
  RelayState: z.ZodOptional<z.ZodString>;
@@ -1410,7 +1410,7 @@ declare const acsEndpoint: (options?: SSOOptions) => better_call0.StrictEndpoint
1410
1410
  };
1411
1411
  }, never>;
1412
1412
  declare const sloEndpoint: (options?: SSOOptions) => better_call0.StrictEndpoint<"/sso/saml2/sp/slo/:providerId", {
1413
- method: ("POST" | "GET")[];
1413
+ method: ("GET" | "POST")[];
1414
1414
  body: z.ZodOptional<z.ZodObject<{
1415
1415
  SAMLRequest: z.ZodOptional<z.ZodString>;
1416
1416
  SAMLResponse: z.ZodOptional<z.ZodString>;
package/dist/index.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { A as DataEncryptionAlgorithm, C as TimestampValidationOptions, D as SSOOptions, E as SAMLConfig, M as DigestAlgorithm, N as KeyEncryptionAlgorithm, O as SSOProvider, P as SignatureAlgorithm, S as SAMLConditions, T as OIDCConfig, _ as REQUIRED_DISCOVERY_FIELDS, a as fetchDiscoveryDocument, b as DEFAULT_MAX_SAML_METADATA_SIZE, c as normalizeUrl, d as validateDiscoveryUrl, f as DiscoverOIDCConfigParams, g as OIDCDiscoveryDocument, h as HydratedOIDCConfig, i as discoverOIDCConfig, j as DeprecatedAlgorithmBehavior, k as AlgorithmValidationOptions, l as selectTokenEndpointAuthMethod, m as DiscoveryErrorCode, n as sso, o as needsRuntimeDiscovery, p as DiscoveryError, r as computeDiscoveryUrl, s as normalizeDiscoveryUrls, t as SSOPlugin, u as validateDiscoveryDocument, v as RequiredDiscoveryField, w as validateSAMLTimestamp, x as DEFAULT_MAX_SAML_RESPONSE_SIZE, y as DEFAULT_CLOCK_SKEW_MS } from "./index-DbZYHOJt.mjs";
1
+ import { A as DataEncryptionAlgorithm, C as TimestampValidationOptions, D as SSOOptions, E as SAMLConfig, M as DigestAlgorithm, N as KeyEncryptionAlgorithm, O as SSOProvider, P as SignatureAlgorithm, S as SAMLConditions, T as OIDCConfig, _ as REQUIRED_DISCOVERY_FIELDS, a as fetchDiscoveryDocument, b as DEFAULT_MAX_SAML_METADATA_SIZE, c as normalizeUrl, d as validateDiscoveryUrl, f as DiscoverOIDCConfigParams, g as OIDCDiscoveryDocument, h as HydratedOIDCConfig, i as discoverOIDCConfig, j as DeprecatedAlgorithmBehavior, k as AlgorithmValidationOptions, l as selectTokenEndpointAuthMethod, m as DiscoveryErrorCode, n as sso, o as needsRuntimeDiscovery, p as DiscoveryError, r as computeDiscoveryUrl, s as normalizeDiscoveryUrls, t as SSOPlugin, u as validateDiscoveryDocument, v as RequiredDiscoveryField, w as validateSAMLTimestamp, x as DEFAULT_MAX_SAML_RESPONSE_SIZE, y as DEFAULT_CLOCK_SKEW_MS } from "./index-D9brFUE1.mjs";
2
2
  export { AlgorithmValidationOptions, DEFAULT_CLOCK_SKEW_MS, DEFAULT_MAX_SAML_METADATA_SIZE, DEFAULT_MAX_SAML_RESPONSE_SIZE, DataEncryptionAlgorithm, DeprecatedAlgorithmBehavior, DigestAlgorithm, DiscoverOIDCConfigParams, DiscoveryError, DiscoveryErrorCode, HydratedOIDCConfig, KeyEncryptionAlgorithm, OIDCConfig, OIDCDiscoveryDocument, REQUIRED_DISCOVERY_FIELDS, RequiredDiscoveryField, SAMLConditions, SAMLConfig, SSOOptions, SSOPlugin, SSOProvider, SignatureAlgorithm, TimestampValidationOptions, computeDiscoveryUrl, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, selectTokenEndpointAuthMethod, sso, validateDiscoveryDocument, validateDiscoveryUrl, validateSAMLTimestamp };
package/dist/index.mjs CHANGED
@@ -1,11 +1,11 @@
1
- import { t as PACKAGE_VERSION } from "./version-BPpah8cV.mjs";
1
+ import { t as PACKAGE_VERSION } from "./version-BeZU0Td6.mjs";
2
2
  import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
3
3
  import { XMLParser, XMLValidator } from "fast-xml-parser";
4
4
  import { X509Certificate } from "node:crypto";
5
5
  import { getHostname } from "tldts";
6
6
  import { generateRandomString } from "better-auth/crypto";
7
7
  import * as z from "zod";
8
- import { isPublicRoutableHost } from "@better-auth/core/utils/host";
8
+ import { classifyHost, isPublicRoutableHost } from "@better-auth/core/utils/host";
9
9
  import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
10
10
  import { base64 } from "@better-auth/utils/base64";
11
11
  import { isAPIError } from "@better-auth/core/utils/is-api-error";
@@ -24,8 +24,6 @@ import samlifyDefault from "samlify";
24
24
  */
25
25
  /** Prefix for AuthnRequest IDs used in InResponseTo validation */
26
26
  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
27
  /** Prefix for SAML session data (NameID + SessionIndex) for SLO */
30
28
  const SAML_SESSION_KEY_PREFIX = "saml-session:";
31
29
  /** Prefix for reverse lookup of SAML session by Better Auth session ID */
@@ -86,6 +84,16 @@ const domainMatches = (searchDomain, domainList) => {
86
84
  return domainList.split(",").map((d) => d.trim().toLowerCase()).filter(Boolean).some((d) => search === d || search.endsWith(`.${d}`));
87
85
  };
88
86
  /**
87
+ * Strictly parse a provider-supplied email-verification claim.
88
+ *
89
+ * OIDC userInfo, OIDC id-token, and SAML attribute values are frequently
90
+ * strings, so a loose `Boolean(value)` or truthy fallback treats the string
91
+ * `"false"` as verified. Only a boolean `true` or the exact string `"true"`
92
+ * count as verified; every other value, including `"false"`, `"0"`, `""`,
93
+ * numbers, arrays, and objects, is unverified.
94
+ */
95
+ const parseProviderEmailVerified = (value) => value === true || value === "true";
96
+ /**
89
97
  * Validates email domain against allowed domain(s).
90
98
  * Supports comma-separated domains for multi-domain SSO.
91
99
  */
@@ -211,171 +219,6 @@ async function assignOrganizationByDomain(ctx, options) {
211
219
  });
212
220
  }
213
221
  //#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
222
  //#region src/oidc/types.ts
380
223
  /**
381
224
  * Custom error class for OIDC discovery failures.
@@ -527,6 +370,75 @@ function validateSkipDiscoveryEndpoints(config, isTrustedOrigin) {
527
370
  for (const [name, url] of fields) if (url) validateSkipDiscoveryEndpoint(name, url, isTrustedOrigin);
528
371
  }
529
372
  /**
373
+ * Re-validate an endpoint by resolving its hostname and rejecting any resolved
374
+ * address that is not publicly routable.
375
+ *
376
+ * {@link validateSkipDiscoveryEndpoint} only classifies the literal hostname, so
377
+ * a host like `idp.example` whose DNS record points at `127.0.0.1`,
378
+ * `169.254.169.254`, or an RFC 1918 address passes that check unchanged. This
379
+ * function closes that gap by performing the same RFC 6890 classification on the
380
+ * addresses the host actually resolves to, right before the server-side fetch.
381
+ *
382
+ * Best-effort by design:
383
+ * - Operator-allowlisted origins (trustedOrigins) are skipped — this is the
384
+ * documented escape hatch for internal IdPs.
385
+ * - IP-literal hosts are already fully covered by the synchronous check.
386
+ * - On runtimes without `node:dns` (e.g. Cloudflare Workers / edge), DNS
387
+ * resolution is unavailable; we fall back to the synchronous host check and
388
+ * the platform's own egress controls.
389
+ *
390
+ * Note: this resolves once and validates the result; it does not pin the address
391
+ * for the subsequent connection, so a change in the resolved address between
392
+ * this lookup and the fetch remains theoretically possible. It nonetheless
393
+ * rejects the common case of a DNS record that statically points at an internal
394
+ * address.
395
+ *
396
+ * @throws DiscoveryError(discovery_private_host) if any resolved address is not public
397
+ */
398
+ async function assertEndpointResolvesPublic(name, endpoint, isTrustedOrigin) {
399
+ const parsed = parseURL(name, endpoint);
400
+ if (isTrustedOrigin(parsed.toString())) return;
401
+ const host = parsed.hostname;
402
+ if (classifyHost(host).literal !== "fqdn") return;
403
+ let dns;
404
+ try {
405
+ dns = await import("node:dns/promises");
406
+ } catch {
407
+ return;
408
+ }
409
+ let resolved;
410
+ try {
411
+ resolved = await dns.lookup(host, { all: true });
412
+ } catch {
413
+ return;
414
+ }
415
+ 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.`, {
416
+ endpoint: name,
417
+ url: endpoint,
418
+ hostname: host,
419
+ resolved: address
420
+ });
421
+ }
422
+ /**
423
+ * Re-validate, at fetch time, every OIDC endpoint that is fetched server-side
424
+ * (token, userinfo, jwks). Runs the synchronous host classification plus the
425
+ * best-effort DNS resolution check. `authorizationEndpoint` is intentionally
426
+ * excluded — it is a browser redirect target, not a server-side fetch, so these
427
+ * checks don't apply to it.
428
+ */
429
+ async function assertOIDCEndpointsResolvePublic(config, isTrustedOrigin) {
430
+ const fields = [
431
+ ["tokenEndpoint", config.tokenEndpoint],
432
+ ["userInfoEndpoint", config.userInfoEndpoint],
433
+ ["jwksEndpoint", config.jwksEndpoint]
434
+ ];
435
+ for (const [name, url] of fields) {
436
+ if (!url) continue;
437
+ validateSkipDiscoveryEndpoint(name, url, isTrustedOrigin);
438
+ await assertEndpointResolvesPublic(name, url, isTrustedOrigin);
439
+ }
440
+ }
441
+ /**
530
442
  * Fetch the OIDC discovery document from the IdP.
531
443
  *
532
444
  * @param url - The discovery endpoint URL
@@ -538,7 +450,8 @@ async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT)
538
450
  try {
539
451
  const response = await betterFetch(url, {
540
452
  method: "GET",
541
- timeout
453
+ timeout,
454
+ redirect: "error"
542
455
  });
543
456
  if (response.error) {
544
457
  const { status } = response.error;
@@ -706,20 +619,24 @@ function needsRuntimeDiscovery(config) {
706
619
  * Throws if discovery fails.
707
620
  */
708
621
  async function ensureRuntimeDiscovery(config, issuer, isTrustedOrigin) {
709
- if (!needsRuntimeDiscovery(config)) return config;
710
- const hydrated = await discoverOIDCConfig({
711
- issuer,
712
- existingConfig: config,
713
- isTrustedOrigin
714
- });
715
- return {
716
- ...config,
717
- authorizationEndpoint: hydrated.authorizationEndpoint,
718
- tokenEndpoint: hydrated.tokenEndpoint,
719
- tokenEndpointAuthentication: hydrated.tokenEndpointAuthentication,
720
- userInfoEndpoint: hydrated.userInfoEndpoint,
721
- jwksEndpoint: hydrated.jwksEndpoint
722
- };
622
+ let resolved = config;
623
+ if (needsRuntimeDiscovery(config)) {
624
+ const hydrated = await discoverOIDCConfig({
625
+ issuer,
626
+ existingConfig: config,
627
+ isTrustedOrigin
628
+ });
629
+ resolved = {
630
+ ...config,
631
+ authorizationEndpoint: hydrated.authorizationEndpoint,
632
+ tokenEndpoint: hydrated.tokenEndpoint,
633
+ tokenEndpointAuthentication: hydrated.tokenEndpointAuthentication,
634
+ userInfoEndpoint: hydrated.userInfoEndpoint,
635
+ jwksEndpoint: hydrated.jwksEndpoint
636
+ };
637
+ }
638
+ await assertOIDCEndpointsResolvePublic(resolved, isTrustedOrigin);
639
+ return resolved;
723
640
  }
724
641
  //#endregion
725
642
  //#region src/oidc/errors.ts
@@ -1431,6 +1348,119 @@ const deleteSSOProvider = () => {
1431
1348
  });
1432
1349
  };
1433
1350
  //#endregion
1351
+ //#region src/routes/domain-verification.ts
1352
+ const DNS_LABEL_MAX_LENGTH = 63;
1353
+ const DEFAULT_TOKEN_PREFIX = "better-auth-token";
1354
+ const domainVerificationBodySchema = z.object({ providerId: z.string() });
1355
+ function getVerificationIdentifier(options, providerId) {
1356
+ return `_${options.domainVerification?.tokenPrefix || DEFAULT_TOKEN_PREFIX}-${providerId}`;
1357
+ }
1358
+ const requestDomainVerification = (options) => {
1359
+ return createAuthEndpoint("/sso/request-domain-verification", {
1360
+ method: "POST",
1361
+ body: domainVerificationBodySchema,
1362
+ metadata: { openapi: {
1363
+ summary: "Request a domain verification",
1364
+ description: "Request a domain verification for the given SSO provider",
1365
+ responses: {
1366
+ "404": { description: "Provider not found" },
1367
+ "409": { description: "Domain has already been verified" },
1368
+ "201": { description: "Domain submitted for verification" }
1369
+ }
1370
+ } },
1371
+ use: [sessionMiddleware]
1372
+ }, async (ctx) => {
1373
+ const body = ctx.body;
1374
+ const provider = await checkProviderAccess(ctx, body.providerId);
1375
+ if (provider.domainVerified) throw new APIError("CONFLICT", {
1376
+ message: "Domain has already been verified",
1377
+ code: "DOMAIN_VERIFIED"
1378
+ });
1379
+ const identifier = getVerificationIdentifier(options, provider.providerId);
1380
+ const activeVerification = await ctx.context.internalAdapter.findVerificationValue(identifier);
1381
+ if (activeVerification && new Date(activeVerification.expiresAt) > /* @__PURE__ */ new Date()) {
1382
+ ctx.setStatus(201);
1383
+ return ctx.json({ domainVerificationToken: activeVerification.value });
1384
+ }
1385
+ const domainVerificationToken = generateRandomString(24);
1386
+ await ctx.context.internalAdapter.createVerificationValue({
1387
+ identifier,
1388
+ value: domainVerificationToken,
1389
+ expiresAt: new Date(Date.now() + 3600 * 24 * 7 * 1e3)
1390
+ });
1391
+ ctx.setStatus(201);
1392
+ return ctx.json({ domainVerificationToken });
1393
+ });
1394
+ };
1395
+ const verifyDomain = (options) => {
1396
+ return createAuthEndpoint("/sso/verify-domain", {
1397
+ method: "POST",
1398
+ body: domainVerificationBodySchema,
1399
+ metadata: { openapi: {
1400
+ summary: "Verify the provider domain ownership",
1401
+ description: "Verify the provider domain ownership via DNS records",
1402
+ responses: {
1403
+ "404": { description: "Provider not found" },
1404
+ "409": { description: "Domain has already been verified or no pending verification exists" },
1405
+ "502": { description: "Unable to verify domain ownership due to upstream validator error" },
1406
+ "204": { description: "Domain ownership was verified" }
1407
+ }
1408
+ } },
1409
+ use: [sessionMiddleware]
1410
+ }, async (ctx) => {
1411
+ const body = ctx.body;
1412
+ const provider = await checkProviderAccess(ctx, body.providerId);
1413
+ if (provider.domainVerified) throw new APIError("CONFLICT", {
1414
+ message: "Domain has already been verified",
1415
+ code: "DOMAIN_VERIFIED"
1416
+ });
1417
+ const identifier = getVerificationIdentifier(options, provider.providerId);
1418
+ if (identifier.length > DNS_LABEL_MAX_LENGTH) throw new APIError("BAD_REQUEST", {
1419
+ message: `Verification identifier exceeds the DNS label limit of ${DNS_LABEL_MAX_LENGTH} characters`,
1420
+ code: "IDENTIFIER_TOO_LONG"
1421
+ });
1422
+ const activeVerification = await ctx.context.internalAdapter.findVerificationValue(identifier);
1423
+ if (!activeVerification || new Date(activeVerification.expiresAt) <= /* @__PURE__ */ new Date()) throw new APIError("NOT_FOUND", {
1424
+ message: "No pending domain verification exists",
1425
+ code: "NO_PENDING_VERIFICATION"
1426
+ });
1427
+ let records = [];
1428
+ let dns;
1429
+ try {
1430
+ dns = await import("node:dns/promises");
1431
+ } catch (error) {
1432
+ ctx.context.logger.error("The core node:dns module is required for the domain verification feature", error);
1433
+ throw new APIError("INTERNAL_SERVER_ERROR", {
1434
+ message: "Unable to verify domain ownership due to server error",
1435
+ code: "DOMAIN_VERIFICATION_FAILED"
1436
+ });
1437
+ }
1438
+ const hostname = getHostnameFromDomain(provider.domain);
1439
+ if (!hostname) throw new APIError("BAD_REQUEST", {
1440
+ message: "Invalid domain",
1441
+ code: "INVALID_DOMAIN"
1442
+ });
1443
+ try {
1444
+ records = (await dns.resolveTxt(`${identifier}.${hostname}`)).flat();
1445
+ } catch (error) {
1446
+ ctx.context.logger.warn("DNS resolution failure while validating domain ownership", error);
1447
+ }
1448
+ if (!records.find((record) => record.includes(`${activeVerification.identifier}=${activeVerification.value}`))) throw new APIError("BAD_GATEWAY", {
1449
+ message: "Unable to verify domain ownership. Try again later",
1450
+ code: "DOMAIN_VERIFICATION_FAILED"
1451
+ });
1452
+ await ctx.context.adapter.update({
1453
+ model: "ssoProvider",
1454
+ where: [{
1455
+ field: "providerId",
1456
+ value: provider.providerId
1457
+ }],
1458
+ update: { domainVerified: true }
1459
+ });
1460
+ ctx.setStatus(204);
1461
+ });
1462
+ };
1463
+ //#endregion
1434
1464
  //#region src/saml/error-codes.ts
1435
1465
  const SAML_ERROR_CODES = defineErrorCodes({
1436
1466
  SINGLE_LOGOUT_NOT_ENABLED: "Single Logout is not enabled",
@@ -1727,11 +1757,10 @@ async function processSAMLResponse(ctx, params, options) {
1727
1757
  if (options?.saml?.enableInResponseToValidation !== false) {
1728
1758
  const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
1729
1759
  if (inResponseTo) {
1760
+ const consumed = await ctx.context.internalAdapter.consumeVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1730
1761
  let storedRequest = null;
1731
- const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1732
- if (verification) try {
1733
- storedRequest = JSON.parse(verification.value);
1734
- if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
1762
+ if (consumed) try {
1763
+ storedRequest = JSON.parse(consumed.value);
1735
1764
  } catch {
1736
1765
  storedRequest = null;
1737
1766
  }
@@ -1748,10 +1777,8 @@ async function processSAMLResponse(ctx, params, options) {
1748
1777
  expectedProvider: storedRequest.providerId,
1749
1778
  actualProvider: providerId
1750
1779
  });
1751
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1752
1780
  throw ctx.redirect(`${samlRedirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
1753
1781
  }
1754
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1755
1782
  } else if (!allowIdpInitiated) {
1756
1783
  ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId });
1757
1784
  throw ctx.redirect(`${samlRedirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
@@ -1764,26 +1791,8 @@ async function processSAMLResponse(ctx, params, options) {
1764
1791
  const conditions = extract.conditions;
1765
1792
  const clockSkew = options?.saml?.clockSkew ?? 3e5;
1766
1793
  const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
1767
- const existingAssertion = await ctx.context.internalAdapter.findVerificationValue(`${USED_ASSERTION_KEY_PREFIX}${assertionId}`);
1768
- let isReplay = false;
1769
- if (existingAssertion) try {
1770
- if (JSON.parse(existingAssertion.value).expiresAt >= Date.now()) isReplay = true;
1771
- } catch (error) {
1772
- ctx.context.logger.warn("Failed to parse stored assertion record", {
1773
- assertionId,
1774
- error
1775
- });
1776
- }
1777
- if (isReplay) {
1778
- ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
1779
- assertionId,
1780
- issuer,
1781
- providerId
1782
- });
1783
- throw ctx.redirect(`${samlRedirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
1784
- }
1785
- await ctx.context.internalAdapter.createVerificationValue({
1786
- identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
1794
+ if (!await ctx.context.internalAdapter.reserveVerificationValue({
1795
+ identifier: `saml-used-assertion:${assertionId}`,
1787
1796
  value: JSON.stringify({
1788
1797
  assertionId,
1789
1798
  issuer,
@@ -1792,7 +1801,14 @@ async function processSAMLResponse(ctx, params, options) {
1792
1801
  expiresAt
1793
1802
  }),
1794
1803
  expiresAt: new Date(expiresAt)
1795
- });
1804
+ })) {
1805
+ ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
1806
+ assertionId,
1807
+ issuer,
1808
+ providerId
1809
+ });
1810
+ throw ctx.redirect(`${samlRedirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
1811
+ }
1796
1812
  } else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
1797
1813
  const attributes = extract.attributes || {};
1798
1814
  const mapping = parsedSamlConfig.mapping ?? {};
@@ -1805,7 +1821,7 @@ async function processSAMLResponse(ctx, params, options) {
1805
1821
  id: attr(mapping.id || "nameID") || extract.nameID,
1806
1822
  email: (attr(mapping.email || "email") || extract.nameID || "").toLowerCase(),
1807
1823
  name: [attr(mapping.firstName || "givenName"), attr(mapping.lastName || "surname")].filter(Boolean).join(" ") || attr(mapping.name || "displayName") || extract.nameID,
1808
- emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attr(mapping.emailVerified) || false : false
1824
+ emailVerified: options?.trustEmailVerified && mapping.emailVerified ? parseProviderEmailVerified(attr(mapping.emailVerified)) : false
1809
1825
  };
1810
1826
  if (!userInfo.id || !userInfo.email) {
1811
1827
  ctx.context.logger.error("Missing essential user info from SAML response", {
@@ -1816,7 +1832,7 @@ async function processSAMLResponse(ctx, params, options) {
1816
1832
  });
1817
1833
  throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
1818
1834
  }
1819
- const isTrustedProvider = ctx.context.trustedProviders.includes(providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
1835
+ const isTrustedProvider = "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
1820
1836
  const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1821
1837
  const errorUrl = relayState?.errorURL || samlRedirectUrl;
1822
1838
  let result;
@@ -1826,7 +1842,7 @@ async function processSAMLResponse(ctx, params, options) {
1826
1842
  email: userInfo.email,
1827
1843
  name: userInfo.name || userInfo.email,
1828
1844
  id: userInfo.id,
1829
- emailVerified: Boolean(userInfo.emailVerified)
1845
+ emailVerified: userInfo.emailVerified
1830
1846
  },
1831
1847
  account: {
1832
1848
  providerId,
@@ -1836,7 +1852,8 @@ async function processSAMLResponse(ctx, params, options) {
1836
1852
  },
1837
1853
  callbackURL: callbackUrl,
1838
1854
  disableSignUp: options?.disableImplicitSignUp,
1839
- isTrustedProvider
1855
+ isTrustedProvider,
1856
+ trustProviderByName: false
1840
1857
  });
1841
1858
  } catch (e) {
1842
1859
  if (isAPIError(e) && e.body?.code) {
@@ -1861,7 +1878,7 @@ async function processSAMLResponse(ctx, params, options) {
1861
1878
  providerId,
1862
1879
  accountId: userInfo.id,
1863
1880
  email: userInfo.email,
1864
- emailVerified: Boolean(userInfo.emailVerified),
1881
+ emailVerified: userInfo.emailVerified,
1865
1882
  rawAttributes: attributes
1866
1883
  },
1867
1884
  provider,
@@ -2227,6 +2244,14 @@ const registerSSOProvider = (options) => {
2227
2244
  if (!member) throw new APIError("BAD_REQUEST", { message: "You are not a member of the organization" });
2228
2245
  if (ctx.context.hasPlugin("organization") && !hasOrgAdminRole(member)) throw new APIError("FORBIDDEN", { message: "You must be an organization owner or admin to register SSO providers" });
2229
2246
  }
2247
+ if (new Set([
2248
+ "credential",
2249
+ ...ctx.context.socialProviders.map((p) => p.id),
2250
+ ...ctx.context.trustedProviders
2251
+ ]).has(body.providerId)) {
2252
+ ctx.context.logger.warn(`SSO provider registration rejected for reserved providerId: ${body.providerId}`);
2253
+ throw new APIError("UNPROCESSABLE_ENTITY", { message: "This providerId is reserved and cannot be used for an SSO provider" });
2254
+ }
2230
2255
  if (await ctx.context.adapter.findOne({
2231
2256
  model: "ssoProvider",
2232
2257
  where: [{
@@ -2687,14 +2712,17 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2687
2712
  let userInfo = null;
2688
2713
  const mapping = config.mapping || {};
2689
2714
  if (config.userInfoEndpoint) {
2690
- const userInfoResponse = await betterFetch(config.userInfoEndpoint, { headers: { Authorization: `Bearer ${tokenResponse.accessToken}` } });
2715
+ const userInfoResponse = await betterFetch(config.userInfoEndpoint, {
2716
+ headers: { Authorization: `Bearer ${tokenResponse.accessToken}` },
2717
+ redirect: "error"
2718
+ });
2691
2719
  if (userInfoResponse.error) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${userInfoResponse.error.message}`);
2692
2720
  const rawUserInfo = userInfoResponse.data;
2693
2721
  userInfo = {
2694
2722
  ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, rawUserInfo[value]])),
2695
2723
  id: rawUserInfo[mapping.id || "sub"],
2696
2724
  email: rawUserInfo[mapping.email || "email"],
2697
- emailVerified: options?.trustEmailVerified ? rawUserInfo[mapping.emailVerified || "email_verified"] : false,
2725
+ emailVerified: options?.trustEmailVerified ? parseProviderEmailVerified(rawUserInfo[mapping.emailVerified || "email_verified"]) : false,
2698
2726
  name: rawUserInfo[mapping.name || "name"],
2699
2727
  image: rawUserInfo[mapping.image || "picture"]
2700
2728
  };
@@ -2713,7 +2741,7 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2713
2741
  ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, verified.payload[value]])),
2714
2742
  id: idToken[mapping.id || "sub"],
2715
2743
  email: idToken[mapping.email || "email"],
2716
- emailVerified: options?.trustEmailVerified ? idToken[mapping.emailVerified || "email_verified"] : false,
2744
+ emailVerified: options?.trustEmailVerified ? parseProviderEmailVerified(idToken[mapping.emailVerified || "email_verified"]) : false,
2717
2745
  name: idToken[mapping.name || "name"],
2718
2746
  image: idToken[mapping.image || "picture"]
2719
2747
  };
@@ -2743,7 +2771,8 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2743
2771
  callbackURL,
2744
2772
  disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
2745
2773
  overrideUserInfo: config.overrideUserInfo,
2746
- isTrustedProvider
2774
+ isTrustedProvider,
2775
+ trustProviderByName: false
2747
2776
  });
2748
2777
  } catch (e) {
2749
2778
  if (isAPIError(e) && e.body?.code) {
@@ -1,5 +1,5 @@
1
1
  //#endregion
2
2
  //#region src/version.ts
3
- const PACKAGE_VERSION = "1.6.15";
3
+ const PACKAGE_VERSION = "1.6.17";
4
4
  //#endregion
5
5
  export { PACKAGE_VERSION as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/sso",
3
- "version": "1.6.15",
3
+ "version": "1.6.17",
4
4
  "description": "SSO plugin for Better Auth",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -65,20 +65,20 @@
65
65
  "devDependencies": {
66
66
  "@types/body-parser": "^1.19.6",
67
67
  "@types/express": "^5.0.6",
68
- "better-call": "1.3.5",
68
+ "better-call": "1.3.6",
69
69
  "body-parser": "^2.2.2",
70
70
  "express": "^5.2.1",
71
71
  "oauth2-mock-server": "^8.2.2",
72
72
  "tsdown": "0.21.1",
73
- "@better-auth/core": "1.6.15",
74
- "better-auth": "1.6.15"
73
+ "better-auth": "1.6.17",
74
+ "@better-auth/core": "1.6.17"
75
75
  },
76
76
  "peerDependencies": {
77
77
  "@better-auth/utils": "0.4.1",
78
- "@better-fetch/fetch": "1.1.21",
79
- "better-call": "1.3.5",
80
- "@better-auth/core": "^1.6.15",
81
- "better-auth": "^1.6.15"
78
+ "@better-fetch/fetch": "1.3.0",
79
+ "better-call": "1.3.6",
80
+ "@better-auth/core": "^1.6.17",
81
+ "better-auth": "^1.6.17"
82
82
  },
83
83
  "scripts": {
84
84
  "build": "tsdown",