@better-auth/sso 1.4.7-beta.3 → 1.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -223,6 +223,352 @@ const verifyDomain = (options) => {
223
223
  });
224
224
  };
225
225
 
226
+ //#endregion
227
+ //#region src/oidc/types.ts
228
+ /**
229
+ * Custom error class for OIDC discovery failures.
230
+ * Can be caught and mapped to APIError at the edge.
231
+ */
232
+ var DiscoveryError = class DiscoveryError extends Error {
233
+ code;
234
+ details;
235
+ constructor(code, message, details, options) {
236
+ super(message, options);
237
+ this.name = "DiscoveryError";
238
+ this.code = code;
239
+ this.details = details;
240
+ if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
241
+ }
242
+ };
243
+ /**
244
+ * Required fields that must be present in a valid discovery document.
245
+ */
246
+ const REQUIRED_DISCOVERY_FIELDS = [
247
+ "issuer",
248
+ "authorization_endpoint",
249
+ "token_endpoint",
250
+ "jwks_uri"
251
+ ];
252
+
253
+ //#endregion
254
+ //#region src/oidc/discovery.ts
255
+ /**
256
+ * OIDC Discovery Pipeline
257
+ *
258
+ * Implements OIDC discovery document fetching, validation, and hydration.
259
+ * This module is used both at provider registration time (to persist validated config)
260
+ * and at runtime (to hydrate legacy providers that are missing metadata).
261
+ *
262
+ * @see https://openid.net/specs/openid-connect-discovery-1_0.html
263
+ */
264
+ /** Default timeout for discovery requests (10 seconds) */
265
+ const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
266
+ /**
267
+ * Main entry point: Discover and hydrate OIDC configuration from an issuer.
268
+ *
269
+ * This function:
270
+ * 1. Computes the discovery URL from the issuer
271
+ * 2. Validates the discovery URL
272
+ * 3. Fetches the discovery document
273
+ * 4. Validates the discovery document (issuer match + required fields)
274
+ * 5. Normalizes URLs
275
+ * 6. Selects token endpoint auth method
276
+ * 7. Merges with existing config (existing values take precedence)
277
+ *
278
+ * @param params - Discovery parameters
279
+ * @param isTrustedOrigin - Origin verification tester function
280
+ * @returns Hydrated OIDC configuration ready for persistence
281
+ * @throws DiscoveryError on any failure
282
+ */
283
+ async function discoverOIDCConfig(params) {
284
+ const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
285
+ const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
286
+ validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
287
+ const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
288
+ validateDiscoveryDocument(discoveryDoc, issuer);
289
+ const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
290
+ const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
291
+ return {
292
+ issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
293
+ discoveryEndpoint: existingConfig?.discoveryEndpoint ?? discoveryUrl,
294
+ authorizationEndpoint: existingConfig?.authorizationEndpoint ?? normalizedDoc.authorization_endpoint,
295
+ tokenEndpoint: existingConfig?.tokenEndpoint ?? normalizedDoc.token_endpoint,
296
+ jwksEndpoint: existingConfig?.jwksEndpoint ?? normalizedDoc.jwks_uri,
297
+ userInfoEndpoint: existingConfig?.userInfoEndpoint ?? normalizedDoc.userinfo_endpoint,
298
+ tokenEndpointAuthentication: existingConfig?.tokenEndpointAuthentication ?? tokenEndpointAuth,
299
+ scopesSupported: existingConfig?.scopesSupported ?? normalizedDoc.scopes_supported
300
+ };
301
+ }
302
+ /**
303
+ * Compute the discovery URL from an issuer URL.
304
+ *
305
+ * Per OIDC Discovery spec, the discovery document is located at:
306
+ * <issuer>/.well-known/openid-configuration
307
+ *
308
+ * Handles trailing slashes correctly.
309
+ */
310
+ function computeDiscoveryUrl(issuer) {
311
+ return `${issuer.endsWith("/") ? issuer.slice(0, -1) : issuer}/.well-known/openid-configuration`;
312
+ }
313
+ /**
314
+ * Validate a discovery URL before fetching.
315
+ *
316
+ * @param url - The discovery URL to validate
317
+ * @param isTrustedOrigin - Origin verification tester function
318
+ * @throws DiscoveryError if URL is invalid
319
+ */
320
+ function validateDiscoveryUrl(url, isTrustedOrigin) {
321
+ const discoveryEndpoint = parseURL("discoveryEndpoint", url).toString();
322
+ if (!isTrustedOrigin(discoveryEndpoint)) throw new DiscoveryError("discovery_untrusted_origin", `The main discovery endpoint "${discoveryEndpoint}" is not trusted by your trusted origins configuration.`, { url: discoveryEndpoint });
323
+ }
324
+ /**
325
+ * Fetch the OIDC discovery document from the IdP.
326
+ *
327
+ * @param url - The discovery endpoint URL
328
+ * @param timeout - Request timeout in milliseconds
329
+ * @returns The parsed discovery document
330
+ * @throws DiscoveryError on network errors, timeouts, or invalid responses
331
+ */
332
+ async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
333
+ try {
334
+ const response = await betterFetch(url, {
335
+ method: "GET",
336
+ timeout
337
+ });
338
+ if (response.error) {
339
+ const { status } = response.error;
340
+ if (status === 404) throw new DiscoveryError("discovery_not_found", "Discovery endpoint not found", {
341
+ url,
342
+ status
343
+ });
344
+ if (status === 408) throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
345
+ url,
346
+ timeout
347
+ });
348
+ throw new DiscoveryError("discovery_unexpected_error", `Unexpected discovery error: ${response.error.statusText}`, {
349
+ url,
350
+ ...response.error
351
+ });
352
+ }
353
+ if (!response.data) throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned an empty response", { url });
354
+ const data = response.data;
355
+ if (typeof data === "string") throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned invalid JSON", {
356
+ url,
357
+ bodyPreview: data.slice(0, 200)
358
+ });
359
+ return data;
360
+ } catch (error) {
361
+ if (error instanceof DiscoveryError) throw error;
362
+ if (error instanceof Error && error.name === "AbortError") throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
363
+ url,
364
+ timeout
365
+ });
366
+ throw new DiscoveryError("discovery_unexpected_error", `Unexpected error during discovery: ${error instanceof Error ? error.message : String(error)}`, { url }, { cause: error });
367
+ }
368
+ }
369
+ /**
370
+ * Validate a discovery document.
371
+ *
372
+ * Checks:
373
+ * 1. All required fields are present
374
+ * 2. Issuer matches the configured issuer (case-sensitive, exact match)
375
+ *
376
+ * Invariant: If this function returns without throwing, the document is safe
377
+ * to use for hydrating OIDC config (required fields present, issuer matches
378
+ * configured value, basic structural sanity verified).
379
+ *
380
+ * @param doc - The discovery document to validate
381
+ * @param configuredIssuer - The expected issuer value
382
+ * @throws DiscoveryError if validation fails
383
+ */
384
+ function validateDiscoveryDocument(doc, configuredIssuer) {
385
+ const missingFields = [];
386
+ for (const field of REQUIRED_DISCOVERY_FIELDS) if (!doc[field]) missingFields.push(field);
387
+ if (missingFields.length > 0) throw new DiscoveryError("discovery_incomplete", `Discovery document is missing required fields: ${missingFields.join(", ")}`, { missingFields });
388
+ if ((doc.issuer.endsWith("/") ? doc.issuer.slice(0, -1) : doc.issuer) !== (configuredIssuer.endsWith("/") ? configuredIssuer.slice(0, -1) : configuredIssuer)) throw new DiscoveryError("issuer_mismatch", `Discovered issuer "${doc.issuer}" does not match configured issuer "${configuredIssuer}"`, {
389
+ discovered: doc.issuer,
390
+ configured: configuredIssuer
391
+ });
392
+ }
393
+ /**
394
+ * Normalize URLs in the discovery document.
395
+ *
396
+ * @param document - The discovery document
397
+ * @param issuer - The base issuer URL
398
+ * @param isTrustedOrigin - Origin verification tester function
399
+ * @returns The normalized discovery document
400
+ */
401
+ function normalizeDiscoveryUrls(document, issuer, isTrustedOrigin) {
402
+ const doc = { ...document };
403
+ doc.token_endpoint = normalizeAndValidateUrl("token_endpoint", doc.token_endpoint, issuer, isTrustedOrigin);
404
+ doc.authorization_endpoint = normalizeAndValidateUrl("authorization_endpoint", doc.authorization_endpoint, issuer, isTrustedOrigin);
405
+ doc.jwks_uri = normalizeAndValidateUrl("jwks_uri", doc.jwks_uri, issuer, isTrustedOrigin);
406
+ if (doc.userinfo_endpoint) doc.userinfo_endpoint = normalizeAndValidateUrl("userinfo_endpoint", doc.userinfo_endpoint, issuer, isTrustedOrigin);
407
+ if (doc.revocation_endpoint) doc.revocation_endpoint = normalizeAndValidateUrl("revocation_endpoint", doc.revocation_endpoint, issuer, isTrustedOrigin);
408
+ if (doc.end_session_endpoint) doc.end_session_endpoint = normalizeAndValidateUrl("end_session_endpoint", doc.end_session_endpoint, issuer, isTrustedOrigin);
409
+ if (doc.introspection_endpoint) doc.introspection_endpoint = normalizeAndValidateUrl("introspection_endpoint", doc.introspection_endpoint, issuer, isTrustedOrigin);
410
+ return doc;
411
+ }
412
+ /**
413
+ * Normalizes and validates a single URL endpoint
414
+ * @param name The url name
415
+ * @param endpoint The url to validate
416
+ * @param issuer The issuer base url
417
+ * @param isTrustedOrigin - Origin verification tester function
418
+ * @returns
419
+ */
420
+ function normalizeAndValidateUrl(name, endpoint, issuer, isTrustedOrigin) {
421
+ const url = normalizeUrl(name, endpoint, issuer);
422
+ if (!isTrustedOrigin(url)) throw new DiscoveryError("discovery_untrusted_origin", `The ${name} "${url}" is not trusted by your trusted origins configuration.`, {
423
+ endpoint: name,
424
+ url
425
+ });
426
+ return url;
427
+ }
428
+ /**
429
+ * Normalize a single URL endpoint.
430
+ *
431
+ * @param name - The endpoint name (e.g token_endpoint)
432
+ * @param endpoint - The endpoint URL to normalize
433
+ * @param issuer - The base issuer URL
434
+ * @returns The normalized endpoint URL
435
+ */
436
+ function normalizeUrl(name, endpoint, issuer) {
437
+ try {
438
+ return parseURL(name, endpoint).toString();
439
+ } catch {
440
+ const issuerURL = parseURL(name, issuer);
441
+ const basePath = issuerURL.pathname.replace(/\/+$/, "");
442
+ const endpointPath = endpoint.replace(/^\/+/, "");
443
+ return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
444
+ }
445
+ }
446
+ /**
447
+ * Parses the given URL or throws in case of invalid or unsupported protocols
448
+ *
449
+ * @param name the url name
450
+ * @param endpoint the endpoint url
451
+ * @param [base] optional base path
452
+ * @returns
453
+ */
454
+ function parseURL(name, endpoint, base) {
455
+ let endpointURL;
456
+ try {
457
+ endpointURL = new URL(endpoint, base);
458
+ if (endpointURL.protocol === "http:" || endpointURL.protocol === "https:") return endpointURL;
459
+ } catch (error) {
460
+ throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
461
+ }
462
+ throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must use the http or https supported protocols: ${endpoint}`, {
463
+ url: endpoint,
464
+ protocol: endpointURL.protocol
465
+ });
466
+ }
467
+ /**
468
+ * Select the token endpoint authentication method.
469
+ *
470
+ * @param doc - The discovery document
471
+ * @param existing - Existing authentication method from config
472
+ * @returns The selected authentication method
473
+ */
474
+ function selectTokenEndpointAuthMethod(doc, existing) {
475
+ if (existing) return existing;
476
+ const supported = doc.token_endpoint_auth_methods_supported;
477
+ if (!supported || supported.length === 0) return "client_secret_basic";
478
+ if (supported.includes("client_secret_basic")) return "client_secret_basic";
479
+ if (supported.includes("client_secret_post")) return "client_secret_post";
480
+ return "client_secret_basic";
481
+ }
482
+ /**
483
+ * Check if a provider configuration needs runtime discovery.
484
+ *
485
+ * Returns true if we need discovery at runtime to complete the token exchange
486
+ * and validation. Specifically checks for:
487
+ * - `tokenEndpoint` - required for exchanging authorization code for tokens
488
+ * - `jwksEndpoint` - required for validating ID token signatures
489
+ *
490
+ * Note: `authorizationEndpoint` is handled separately in the sign-in flow,
491
+ * so it's not checked here.
492
+ *
493
+ * @param config - Partial OIDC config from the provider
494
+ * @returns true if runtime discovery should be performed
495
+ */
496
+ function needsRuntimeDiscovery(config) {
497
+ if (!config) return true;
498
+ return !config.tokenEndpoint || !config.jwksEndpoint;
499
+ }
500
+
501
+ //#endregion
502
+ //#region src/oidc/errors.ts
503
+ /**
504
+ * OIDC Discovery Error Mapping
505
+ *
506
+ * Maps DiscoveryError codes to appropriate APIError responses.
507
+ * Used at the boundary between the discovery pipeline and HTTP handlers.
508
+ */
509
+ /**
510
+ * Maps a DiscoveryError to an appropriate APIError for HTTP responses.
511
+ *
512
+ * Error code mapping:
513
+ * - discovery_invalid_url → 400 BAD_REQUEST
514
+ * - discovery_not_found → 400 BAD_REQUEST
515
+ * - discovery_invalid_json → 400 BAD_REQUEST
516
+ * - discovery_incomplete → 400 BAD_REQUEST
517
+ * - issuer_mismatch → 400 BAD_REQUEST
518
+ * - unsupported_token_auth_method → 400 BAD_REQUEST
519
+ * - discovery_timeout → 502 BAD_GATEWAY
520
+ * - discovery_unexpected_error → 502 BAD_GATEWAY
521
+ *
522
+ * @param error - The DiscoveryError to map
523
+ * @returns An APIError with appropriate status and message
524
+ */
525
+ function mapDiscoveryErrorToAPIError(error) {
526
+ switch (error.code) {
527
+ case "discovery_timeout": return new APIError("BAD_GATEWAY", {
528
+ message: `OIDC discovery timed out: ${error.message}`,
529
+ code: error.code
530
+ });
531
+ case "discovery_unexpected_error": return new APIError("BAD_GATEWAY", {
532
+ message: `OIDC discovery failed: ${error.message}`,
533
+ code: error.code
534
+ });
535
+ case "discovery_not_found": return new APIError("BAD_REQUEST", {
536
+ message: `OIDC discovery endpoint not found. The issuer may not support OIDC discovery, or the URL is incorrect. ${error.message}`,
537
+ code: error.code
538
+ });
539
+ case "discovery_invalid_url": return new APIError("BAD_REQUEST", {
540
+ message: `Invalid OIDC discovery URL: ${error.message}`,
541
+ code: error.code
542
+ });
543
+ case "discovery_untrusted_origin": return new APIError("BAD_REQUEST", {
544
+ message: `Untrusted OIDC discovery URL: ${error.message}`,
545
+ code: error.code
546
+ });
547
+ case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
548
+ message: `OIDC discovery returned invalid data: ${error.message}`,
549
+ code: error.code
550
+ });
551
+ case "discovery_incomplete": return new APIError("BAD_REQUEST", {
552
+ message: `OIDC discovery document is missing required fields: ${error.message}`,
553
+ code: error.code
554
+ });
555
+ case "issuer_mismatch": return new APIError("BAD_REQUEST", {
556
+ message: `OIDC issuer mismatch: ${error.message}`,
557
+ code: error.code
558
+ });
559
+ case "unsupported_token_auth_method": return new APIError("BAD_REQUEST", {
560
+ message: `Incompatible OIDC provider: ${error.message}`,
561
+ code: error.code
562
+ });
563
+ default:
564
+ error.code;
565
+ return new APIError("INTERNAL_SERVER_ERROR", {
566
+ message: `Unexpected discovery error: ${error.message}`,
567
+ code: "discovery_unexpected_error"
568
+ });
569
+ }
570
+ }
571
+
226
572
  //#endregion
227
573
  //#region src/utils.ts
228
574
  /**
@@ -254,6 +600,47 @@ const validateEmailDomain = (email, domain) => {
254
600
  //#endregion
255
601
  //#region src/routes/sso.ts
256
602
  const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
603
+ /** Default clock skew tolerance: 5 minutes */
604
+ const DEFAULT_CLOCK_SKEW_MS = 300 * 1e3;
605
+ /**
606
+ * Validates SAML assertion timestamp conditions (NotBefore/NotOnOrAfter).
607
+ * Prevents acceptance of expired or future-dated assertions.
608
+ * @throws {APIError} If timestamps are invalid, expired, or not yet valid
609
+ */
610
+ function validateSAMLTimestamp(conditions, options = {}) {
611
+ const clockSkew = options.clockSkew ?? DEFAULT_CLOCK_SKEW_MS;
612
+ if (!(conditions?.notBefore || conditions?.notOnOrAfter)) {
613
+ if (options.requireTimestamps) throw new APIError("BAD_REQUEST", {
614
+ message: "SAML assertion missing required timestamp conditions",
615
+ details: "Assertions must include NotBefore and/or NotOnOrAfter conditions"
616
+ });
617
+ options.logger?.warn("SAML assertion accepted without timestamp conditions", { hasConditions: !!conditions });
618
+ return;
619
+ }
620
+ const now = Date.now();
621
+ if (conditions?.notBefore) {
622
+ const notBeforeTime = new Date(conditions.notBefore).getTime();
623
+ if (Number.isNaN(notBeforeTime)) throw new APIError("BAD_REQUEST", {
624
+ message: "SAML assertion has invalid NotBefore timestamp",
625
+ details: `Unable to parse NotBefore value: ${conditions.notBefore}`
626
+ });
627
+ if (now < notBeforeTime - clockSkew) throw new APIError("BAD_REQUEST", {
628
+ message: "SAML assertion is not yet valid",
629
+ details: `Current time is before NotBefore (with ${clockSkew}ms clock skew tolerance)`
630
+ });
631
+ }
632
+ if (conditions?.notOnOrAfter) {
633
+ const notOnOrAfterTime = new Date(conditions.notOnOrAfter).getTime();
634
+ if (Number.isNaN(notOnOrAfterTime)) throw new APIError("BAD_REQUEST", {
635
+ message: "SAML assertion has invalid NotOnOrAfter timestamp",
636
+ details: `Unable to parse NotOnOrAfter value: ${conditions.notOnOrAfter}`
637
+ });
638
+ if (now > notOnOrAfterTime + clockSkew) throw new APIError("BAD_REQUEST", {
639
+ message: "SAML assertion has expired",
640
+ details: `Current time is after NotOnOrAfter (with ${clockSkew}ms clock skew tolerance)`
641
+ });
642
+ }
643
+ }
257
644
  const spMetadataQuerySchema = z.object({
258
645
  providerId: z.string(),
259
646
  format: z.enum(["xml", "json"]).default("xml")
@@ -304,6 +691,7 @@ const ssoProviderBodySchema = z.object({
304
691
  tokenEndpointAuthentication: z.enum(["client_secret_post", "client_secret_basic"]).optional(),
305
692
  jwksEndpoint: z.string({}).meta({ description: "The JWKS endpoint" }).optional(),
306
693
  discoveryEndpoint: z.string().optional(),
694
+ skipDiscovery: z.boolean().meta({ description: "Skip OIDC discovery during registration. When true, you must provide authorizationEndpoint, tokenEndpoint, and jwksEndpoint manually." }).optional(),
307
695
  scopes: z.array(z.string(), {}).meta({ description: "The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']" }).optional(),
308
696
  pkce: z.boolean({}).meta({ description: "Whether to use PKCE for the authorization flow" }).default(true).optional(),
309
697
  mapping: z.object({
@@ -571,27 +959,65 @@ const registerSSOProvider = (options) => {
571
959
  ctx.context.logger.info(`SSO provider creation attempt with existing providerId: ${body.providerId}`);
572
960
  throw new APIError("UNPROCESSABLE_ENTITY", { message: "SSO provider with this providerId already exists" });
573
961
  }
962
+ let hydratedOIDCConfig = null;
963
+ if (body.oidcConfig && !body.oidcConfig.skipDiscovery) try {
964
+ hydratedOIDCConfig = await discoverOIDCConfig({
965
+ issuer: body.issuer,
966
+ existingConfig: {
967
+ discoveryEndpoint: body.oidcConfig.discoveryEndpoint,
968
+ authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
969
+ tokenEndpoint: body.oidcConfig.tokenEndpoint,
970
+ jwksEndpoint: body.oidcConfig.jwksEndpoint,
971
+ userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
972
+ tokenEndpointAuthentication: body.oidcConfig.tokenEndpointAuthentication
973
+ },
974
+ isTrustedOrigin: ctx.context.isTrustedOrigin
975
+ });
976
+ } catch (error) {
977
+ if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
978
+ throw error;
979
+ }
980
+ const buildOIDCConfig = () => {
981
+ if (!body.oidcConfig) return null;
982
+ if (body.oidcConfig.skipDiscovery) return JSON.stringify({
983
+ issuer: body.issuer,
984
+ clientId: body.oidcConfig.clientId,
985
+ clientSecret: body.oidcConfig.clientSecret,
986
+ authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
987
+ tokenEndpoint: body.oidcConfig.tokenEndpoint,
988
+ tokenEndpointAuthentication: body.oidcConfig.tokenEndpointAuthentication || "client_secret_basic",
989
+ jwksEndpoint: body.oidcConfig.jwksEndpoint,
990
+ pkce: body.oidcConfig.pkce,
991
+ discoveryEndpoint: body.oidcConfig.discoveryEndpoint || `${body.issuer}/.well-known/openid-configuration`,
992
+ mapping: body.oidcConfig.mapping,
993
+ scopes: body.oidcConfig.scopes,
994
+ userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
995
+ overrideUserInfo: ctx.body.overrideUserInfo || options?.defaultOverrideUserInfo || false
996
+ });
997
+ if (!hydratedOIDCConfig) return null;
998
+ return JSON.stringify({
999
+ issuer: hydratedOIDCConfig.issuer,
1000
+ clientId: body.oidcConfig.clientId,
1001
+ clientSecret: body.oidcConfig.clientSecret,
1002
+ authorizationEndpoint: hydratedOIDCConfig.authorizationEndpoint,
1003
+ tokenEndpoint: hydratedOIDCConfig.tokenEndpoint,
1004
+ tokenEndpointAuthentication: hydratedOIDCConfig.tokenEndpointAuthentication,
1005
+ jwksEndpoint: hydratedOIDCConfig.jwksEndpoint,
1006
+ pkce: body.oidcConfig.pkce,
1007
+ discoveryEndpoint: hydratedOIDCConfig.discoveryEndpoint,
1008
+ mapping: body.oidcConfig.mapping,
1009
+ scopes: body.oidcConfig.scopes,
1010
+ userInfoEndpoint: hydratedOIDCConfig.userInfoEndpoint,
1011
+ overrideUserInfo: ctx.body.overrideUserInfo || options?.defaultOverrideUserInfo || false
1012
+ });
1013
+ };
574
1014
  const provider = await ctx.context.adapter.create({
575
1015
  model: "ssoProvider",
576
1016
  data: {
577
1017
  issuer: body.issuer,
578
1018
  domain: body.domain,
579
1019
  domainVerified: false,
580
- oidcConfig: body.oidcConfig ? JSON.stringify({
581
- issuer: body.issuer,
582
- clientId: body.oidcConfig.clientId,
583
- clientSecret: body.oidcConfig.clientSecret,
584
- authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
585
- tokenEndpoint: body.oidcConfig.tokenEndpoint,
586
- tokenEndpointAuthentication: body.oidcConfig.tokenEndpointAuthentication,
587
- jwksEndpoint: body.oidcConfig.jwksEndpoint,
588
- pkce: body.oidcConfig.pkce,
589
- discoveryEndpoint: body.oidcConfig.discoveryEndpoint || `${body.issuer}/.well-known/openid-configuration`,
590
- mapping: body.oidcConfig.mapping,
591
- scopes: body.oidcConfig.scopes,
592
- userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
593
- overrideUserInfo: ctx.body.overrideUserInfo || options?.defaultOverrideUserInfo || false
594
- }) : null,
1020
+ oidcConfig: buildOIDCConfig(),
595
1021
  samlConfig: body.samlConfig ? JSON.stringify({
596
1022
  issuer: body.issuer,
597
1023
  entryPoint: body.samlConfig.entryPoint,
@@ -1141,6 +1567,11 @@ const callbackSSOSAML = (options) => {
1141
1567
  });
1142
1568
  }
1143
1569
  const { extract } = parsedResponse;
1570
+ validateSAMLTimestamp(extract.conditions, {
1571
+ clockSkew: options?.saml?.clockSkew,
1572
+ requireTimestamps: options?.saml?.requireTimestamps,
1573
+ logger: ctx.context.logger
1574
+ });
1144
1575
  const inResponseTo = extract.inResponseTo;
1145
1576
  if (options?.saml?.authnRequestStore || options?.saml?.enableInResponseToValidation) {
1146
1577
  const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
@@ -1151,6 +1582,7 @@ const callbackSSOSAML = (options) => {
1151
1582
  const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1152
1583
  if (verification) try {
1153
1584
  storedRequest = JSON.parse(verification.value);
1585
+ if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
1154
1586
  } catch {
1155
1587
  storedRequest = null;
1156
1588
  }
@@ -1386,6 +1818,11 @@ const acsEndpoint = (options) => {
1386
1818
  });
1387
1819
  }
1388
1820
  const { extract } = parsedResponse;
1821
+ validateSAMLTimestamp(extract.conditions, {
1822
+ clockSkew: options?.saml?.clockSkew,
1823
+ requireTimestamps: options?.saml?.requireTimestamps,
1824
+ logger: ctx.context.logger
1825
+ });
1389
1826
  const inResponseToAcs = extract.inResponseTo;
1390
1827
  if (options?.saml?.authnRequestStore || options?.saml?.enableInResponseToValidation) {
1391
1828
  const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
@@ -1396,6 +1833,7 @@ const acsEndpoint = (options) => {
1396
1833
  const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
1397
1834
  if (verification) try {
1398
1835
  storedRequest = JSON.parse(verification.value);
1836
+ if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
1399
1837
  } catch {
1400
1838
  storedRequest = null;
1401
1839
  }
@@ -1623,4 +2061,4 @@ function sso(options) {
1623
2061
  }
1624
2062
 
1625
2063
  //#endregion
1626
- export { DEFAULT_AUTHN_REQUEST_TTL_MS, createInMemoryAuthnRequestStore, sso };
2064
+ export { DEFAULT_AUTHN_REQUEST_TTL_MS, DEFAULT_CLOCK_SKEW_MS, DiscoveryError, REQUIRED_DISCOVERY_FIELDS, computeDiscoveryUrl, createInMemoryAuthnRequestStore, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, selectTokenEndpointAuthMethod, sso, validateDiscoveryDocument, validateDiscoveryUrl, validateSAMLTimestamp };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@better-auth/sso",
3
3
  "author": "Bereket Engida",
4
- "version": "1.4.7-beta.3",
4
+ "version": "1.4.7",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
7
7
  "types": "dist/index.d.mts",
@@ -66,10 +66,10 @@
66
66
  "express": "^5.1.0",
67
67
  "oauth2-mock-server": "^8.2.0",
68
68
  "tsdown": "^0.17.2",
69
- "better-auth": "1.4.7-beta.3"
69
+ "better-auth": "1.4.7"
70
70
  },
71
71
  "peerDependencies": {
72
- "better-auth": "1.4.7-beta.3"
72
+ "better-auth": "1.4.7"
73
73
  },
74
74
  "scripts": {
75
75
  "test": "vitest",
package/src/index.ts CHANGED
@@ -21,12 +21,39 @@ import {
21
21
  signInSSO,
22
22
  spMetadata,
23
23
  } from "./routes/sso";
24
+
25
+ export {
26
+ DEFAULT_CLOCK_SKEW_MS,
27
+ type SAMLConditions,
28
+ type TimestampValidationOptions,
29
+ validateSAMLTimestamp,
30
+ } from "./routes/sso";
31
+
24
32
  import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "./types";
25
33
 
26
34
  export type { SAMLConfig, OIDCConfig, SSOOptions, SSOProvider };
27
35
  export type { AuthnRequestStore, AuthnRequestRecord };
28
36
  export { createInMemoryAuthnRequestStore, DEFAULT_AUTHN_REQUEST_TTL_MS };
29
37
 
38
+ export {
39
+ computeDiscoveryUrl,
40
+ type DiscoverOIDCConfigParams,
41
+ DiscoveryError,
42
+ type DiscoveryErrorCode,
43
+ discoverOIDCConfig,
44
+ fetchDiscoveryDocument,
45
+ type HydratedOIDCConfig,
46
+ needsRuntimeDiscovery,
47
+ normalizeDiscoveryUrls,
48
+ normalizeUrl,
49
+ type OIDCDiscoveryDocument,
50
+ REQUIRED_DISCOVERY_FIELDS,
51
+ type RequiredDiscoveryField,
52
+ selectTokenEndpointAuthMethod,
53
+ validateDiscoveryDocument,
54
+ validateDiscoveryUrl,
55
+ } from "./oidc";
56
+
30
57
  const fastValidator = {
31
58
  async validate(xml: string) {
32
59
  const isValid = XMLValidator.validate(xml, {