@better-auth/sso 1.4.7-beta.2 → 1.4.7-beta.4

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
@@ -4,11 +4,51 @@ import { APIError, createAuthEndpoint, sessionMiddleware } from "better-auth/api
4
4
  import { generateRandomString } from "better-auth/crypto";
5
5
  import * as z from "zod/v4";
6
6
  import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
7
- import { createAuthorizationURL, generateState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
7
+ import { HIDE_METADATA, createAuthorizationURL, generateState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
8
8
  import { setSessionCookie } from "better-auth/cookies";
9
9
  import { handleOAuthUserInfo } from "better-auth/oauth2";
10
10
  import { decodeJwt } from "jose";
11
11
 
12
+ //#region src/authn-request-store.ts
13
+ /**
14
+ * Default TTL for AuthnRequest records (5 minutes).
15
+ * This should be sufficient for most IdPs while protecting against stale requests.
16
+ */
17
+ const DEFAULT_AUTHN_REQUEST_TTL_MS = 300 * 1e3;
18
+ /**
19
+ * In-memory implementation of AuthnRequestStore.
20
+ * ⚠️ Only suitable for testing or single-instance non-serverless deployments.
21
+ * For production, rely on the default behavior (uses verification table)
22
+ * or provide a custom Redis-backed store.
23
+ */
24
+ function createInMemoryAuthnRequestStore() {
25
+ const store = /* @__PURE__ */ new Map();
26
+ const cleanup = () => {
27
+ const now = Date.now();
28
+ for (const [id, record] of store.entries()) if (record.expiresAt < now) store.delete(id);
29
+ };
30
+ const cleanupInterval = setInterval(cleanup, 60 * 1e3);
31
+ if (typeof cleanupInterval.unref === "function") cleanupInterval.unref();
32
+ return {
33
+ async save(record) {
34
+ store.set(record.id, record);
35
+ },
36
+ async get(id) {
37
+ const record = store.get(id);
38
+ if (!record) return null;
39
+ if (record.expiresAt < Date.now()) {
40
+ store.delete(id);
41
+ return null;
42
+ }
43
+ return record;
44
+ },
45
+ async delete(id) {
46
+ store.delete(id);
47
+ }
48
+ };
49
+ }
50
+
51
+ //#endregion
12
52
  //#region src/routes/domain-verification.ts
13
53
  const domainVerificationBodySchema = z.object({ providerId: z.string() });
14
54
  const requestDomainVerification = (options) => {
@@ -183,6 +223,300 @@ const verifyDomain = (options) => {
183
223
  });
184
224
  };
185
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 (stub for now)
272
+ * 3. Fetches the discovery document
273
+ * 4. Validates the discovery document (issuer match + required fields)
274
+ * 5. Normalizes URLs (stub for now)
275
+ * 6. Selects token endpoint auth method
276
+ * 7. Merges with existing config (existing values take precedence)
277
+ *
278
+ * @param params - Discovery parameters
279
+ * @returns Hydrated OIDC configuration ready for persistence
280
+ * @throws DiscoveryError on any failure
281
+ */
282
+ async function discoverOIDCConfig(params) {
283
+ const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
284
+ const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
285
+ validateDiscoveryUrl(discoveryUrl);
286
+ const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
287
+ validateDiscoveryDocument(discoveryDoc, issuer);
288
+ const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer);
289
+ const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
290
+ return {
291
+ issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
292
+ discoveryEndpoint: existingConfig?.discoveryEndpoint ?? discoveryUrl,
293
+ authorizationEndpoint: existingConfig?.authorizationEndpoint ?? normalizedDoc.authorization_endpoint,
294
+ tokenEndpoint: existingConfig?.tokenEndpoint ?? normalizedDoc.token_endpoint,
295
+ jwksEndpoint: existingConfig?.jwksEndpoint ?? normalizedDoc.jwks_uri,
296
+ userInfoEndpoint: existingConfig?.userInfoEndpoint ?? normalizedDoc.userinfo_endpoint,
297
+ tokenEndpointAuthentication: existingConfig?.tokenEndpointAuthentication ?? tokenEndpointAuth,
298
+ scopesSupported: existingConfig?.scopesSupported ?? normalizedDoc.scopes_supported
299
+ };
300
+ }
301
+ /**
302
+ * Compute the discovery URL from an issuer URL.
303
+ *
304
+ * Per OIDC Discovery spec, the discovery document is located at:
305
+ * <issuer>/.well-known/openid-configuration
306
+ *
307
+ * Handles trailing slashes correctly.
308
+ */
309
+ function computeDiscoveryUrl(issuer) {
310
+ return `${issuer.endsWith("/") ? issuer.slice(0, -1) : issuer}/.well-known/openid-configuration`;
311
+ }
312
+ /**
313
+ * Validate a discovery URL before fetching.
314
+ *
315
+ * @param url - The discovery URL to validate
316
+ * @throws DiscoveryError if URL is invalid
317
+ */
318
+ function validateDiscoveryUrl(url) {
319
+ try {
320
+ const parsed = new URL(url);
321
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new DiscoveryError("discovery_invalid_url", `Discovery URL must use HTTP or HTTPS protocol: ${url}`, {
322
+ url,
323
+ protocol: parsed.protocol
324
+ });
325
+ } catch (error) {
326
+ if (error instanceof DiscoveryError) throw error;
327
+ throw new DiscoveryError("discovery_invalid_url", `Invalid discovery URL: ${url}`, { url }, { cause: error });
328
+ }
329
+ }
330
+ /**
331
+ * Fetch the OIDC discovery document from the IdP.
332
+ *
333
+ * @param url - The discovery endpoint URL
334
+ * @param timeout - Request timeout in milliseconds
335
+ * @returns The parsed discovery document
336
+ * @throws DiscoveryError on network errors, timeouts, or invalid responses
337
+ */
338
+ async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
339
+ try {
340
+ const response = await betterFetch(url, {
341
+ method: "GET",
342
+ timeout
343
+ });
344
+ if (response.error) {
345
+ const { status } = response.error;
346
+ if (status === 404) throw new DiscoveryError("discovery_not_found", "Discovery endpoint not found", {
347
+ url,
348
+ status
349
+ });
350
+ if (status === 408) throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
351
+ url,
352
+ timeout
353
+ });
354
+ throw new DiscoveryError("discovery_unexpected_error", `Unexpected discovery error: ${response.error.statusText}`, {
355
+ url,
356
+ ...response.error
357
+ });
358
+ }
359
+ if (!response.data) throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned an empty response", { url });
360
+ const data = response.data;
361
+ if (typeof data === "string") throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned invalid JSON", {
362
+ url,
363
+ bodyPreview: data.slice(0, 200)
364
+ });
365
+ return data;
366
+ } catch (error) {
367
+ if (error instanceof DiscoveryError) throw error;
368
+ if (error instanceof Error && error.name === "AbortError") throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
369
+ url,
370
+ timeout
371
+ });
372
+ throw new DiscoveryError("discovery_unexpected_error", `Unexpected error during discovery: ${error instanceof Error ? error.message : String(error)}`, { url }, { cause: error });
373
+ }
374
+ }
375
+ /**
376
+ * Validate a discovery document.
377
+ *
378
+ * Checks:
379
+ * 1. All required fields are present
380
+ * 2. Issuer matches the configured issuer (case-sensitive, exact match)
381
+ *
382
+ * Invariant: If this function returns without throwing, the document is safe
383
+ * to use for hydrating OIDC config (required fields present, issuer matches
384
+ * configured value, basic structural sanity verified).
385
+ *
386
+ * @param doc - The discovery document to validate
387
+ * @param configuredIssuer - The expected issuer value
388
+ * @throws DiscoveryError if validation fails
389
+ */
390
+ function validateDiscoveryDocument(doc, configuredIssuer) {
391
+ const missingFields = [];
392
+ for (const field of REQUIRED_DISCOVERY_FIELDS) if (!doc[field]) missingFields.push(field);
393
+ if (missingFields.length > 0) throw new DiscoveryError("discovery_incomplete", `Discovery document is missing required fields: ${missingFields.join(", ")}`, { missingFields });
394
+ 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}"`, {
395
+ discovered: doc.issuer,
396
+ configured: configuredIssuer
397
+ });
398
+ }
399
+ /**
400
+ * Normalize URLs in the discovery document.
401
+ *
402
+ * @param doc - The discovery document
403
+ * @param _issuerBase - The base issuer URL
404
+ * @returns The normalized discovery document
405
+ */
406
+ function normalizeDiscoveryUrls(doc, _issuerBase) {
407
+ return doc;
408
+ }
409
+ /**
410
+ * Normalize a single URL endpoint.
411
+ *
412
+ * @param endpoint - The endpoint URL to normalize
413
+ * @param _issuerBase - The base issuer URL
414
+ * @returns The normalized endpoint URL
415
+ */
416
+ function normalizeUrl(endpoint, _issuerBase) {
417
+ return endpoint;
418
+ }
419
+ /**
420
+ * Select the token endpoint authentication method.
421
+ *
422
+ * @param doc - The discovery document
423
+ * @param existing - Existing authentication method from config
424
+ * @returns The selected authentication method
425
+ */
426
+ function selectTokenEndpointAuthMethod(doc, existing) {
427
+ if (existing) return existing;
428
+ const supported = doc.token_endpoint_auth_methods_supported;
429
+ if (!supported || supported.length === 0) return "client_secret_basic";
430
+ if (supported.includes("client_secret_basic")) return "client_secret_basic";
431
+ if (supported.includes("client_secret_post")) return "client_secret_post";
432
+ return "client_secret_basic";
433
+ }
434
+ /**
435
+ * Check if a provider configuration needs runtime discovery.
436
+ *
437
+ * Returns true if we need discovery at runtime to complete the token exchange
438
+ * and validation. Specifically checks for:
439
+ * - `tokenEndpoint` - required for exchanging authorization code for tokens
440
+ * - `jwksEndpoint` - required for validating ID token signatures
441
+ *
442
+ * Note: `authorizationEndpoint` is handled separately in the sign-in flow,
443
+ * so it's not checked here.
444
+ *
445
+ * @param config - Partial OIDC config from the provider
446
+ * @returns true if runtime discovery should be performed
447
+ */
448
+ function needsRuntimeDiscovery(config) {
449
+ if (!config) return true;
450
+ return !config.tokenEndpoint || !config.jwksEndpoint;
451
+ }
452
+
453
+ //#endregion
454
+ //#region src/oidc/errors.ts
455
+ /**
456
+ * OIDC Discovery Error Mapping
457
+ *
458
+ * Maps DiscoveryError codes to appropriate APIError responses.
459
+ * Used at the boundary between the discovery pipeline and HTTP handlers.
460
+ */
461
+ /**
462
+ * Maps a DiscoveryError to an appropriate APIError for HTTP responses.
463
+ *
464
+ * Error code mapping:
465
+ * - discovery_invalid_url → 400 BAD_REQUEST
466
+ * - discovery_not_found → 400 BAD_REQUEST
467
+ * - discovery_invalid_json → 400 BAD_REQUEST
468
+ * - discovery_incomplete → 400 BAD_REQUEST
469
+ * - issuer_mismatch → 400 BAD_REQUEST
470
+ * - unsupported_token_auth_method → 400 BAD_REQUEST
471
+ * - discovery_timeout → 502 BAD_GATEWAY
472
+ * - discovery_unexpected_error → 502 BAD_GATEWAY
473
+ *
474
+ * @param error - The DiscoveryError to map
475
+ * @returns An APIError with appropriate status and message
476
+ */
477
+ function mapDiscoveryErrorToAPIError(error) {
478
+ switch (error.code) {
479
+ case "discovery_timeout": return new APIError("BAD_GATEWAY", {
480
+ message: `OIDC discovery timed out: ${error.message}`,
481
+ code: error.code
482
+ });
483
+ case "discovery_unexpected_error": return new APIError("BAD_GATEWAY", {
484
+ message: `OIDC discovery failed: ${error.message}`,
485
+ code: error.code
486
+ });
487
+ case "discovery_not_found": return new APIError("BAD_REQUEST", {
488
+ message: `OIDC discovery endpoint not found. The issuer may not support OIDC discovery, or the URL is incorrect. ${error.message}`,
489
+ code: error.code
490
+ });
491
+ case "discovery_invalid_url": return new APIError("BAD_REQUEST", {
492
+ message: `Invalid OIDC discovery URL: ${error.message}`,
493
+ code: error.code
494
+ });
495
+ case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
496
+ message: `OIDC discovery returned invalid data: ${error.message}`,
497
+ code: error.code
498
+ });
499
+ case "discovery_incomplete": return new APIError("BAD_REQUEST", {
500
+ message: `OIDC discovery document is missing required fields: ${error.message}`,
501
+ code: error.code
502
+ });
503
+ case "issuer_mismatch": return new APIError("BAD_REQUEST", {
504
+ message: `OIDC issuer mismatch: ${error.message}`,
505
+ code: error.code
506
+ });
507
+ case "unsupported_token_auth_method": return new APIError("BAD_REQUEST", {
508
+ message: `Incompatible OIDC provider: ${error.message}`,
509
+ code: error.code
510
+ });
511
+ default:
512
+ error.code;
513
+ return new APIError("INTERNAL_SERVER_ERROR", {
514
+ message: `Unexpected discovery error: ${error.message}`,
515
+ code: "discovery_unexpected_error"
516
+ });
517
+ }
518
+ }
519
+
186
520
  //#endregion
187
521
  //#region src/utils.ts
188
522
  /**
@@ -213,6 +547,48 @@ const validateEmailDomain = (email, domain) => {
213
547
 
214
548
  //#endregion
215
549
  //#region src/routes/sso.ts
550
+ const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
551
+ /** Default clock skew tolerance: 5 minutes */
552
+ const DEFAULT_CLOCK_SKEW_MS = 300 * 1e3;
553
+ /**
554
+ * Validates SAML assertion timestamp conditions (NotBefore/NotOnOrAfter).
555
+ * Prevents acceptance of expired or future-dated assertions.
556
+ * @throws {APIError} If timestamps are invalid, expired, or not yet valid
557
+ */
558
+ function validateSAMLTimestamp(conditions, options = {}) {
559
+ const clockSkew = options.clockSkew ?? DEFAULT_CLOCK_SKEW_MS;
560
+ if (!(conditions?.notBefore || conditions?.notOnOrAfter)) {
561
+ if (options.requireTimestamps) throw new APIError("BAD_REQUEST", {
562
+ message: "SAML assertion missing required timestamp conditions",
563
+ details: "Assertions must include NotBefore and/or NotOnOrAfter conditions"
564
+ });
565
+ options.logger?.warn("SAML assertion accepted without timestamp conditions", { hasConditions: !!conditions });
566
+ return;
567
+ }
568
+ const now = Date.now();
569
+ if (conditions?.notBefore) {
570
+ const notBeforeTime = new Date(conditions.notBefore).getTime();
571
+ if (Number.isNaN(notBeforeTime)) throw new APIError("BAD_REQUEST", {
572
+ message: "SAML assertion has invalid NotBefore timestamp",
573
+ details: `Unable to parse NotBefore value: ${conditions.notBefore}`
574
+ });
575
+ if (now < notBeforeTime - clockSkew) throw new APIError("BAD_REQUEST", {
576
+ message: "SAML assertion is not yet valid",
577
+ details: `Current time is before NotBefore (with ${clockSkew}ms clock skew tolerance)`
578
+ });
579
+ }
580
+ if (conditions?.notOnOrAfter) {
581
+ const notOnOrAfterTime = new Date(conditions.notOnOrAfter).getTime();
582
+ if (Number.isNaN(notOnOrAfterTime)) throw new APIError("BAD_REQUEST", {
583
+ message: "SAML assertion has invalid NotOnOrAfter timestamp",
584
+ details: `Unable to parse NotOnOrAfter value: ${conditions.notOnOrAfter}`
585
+ });
586
+ if (now > notOnOrAfterTime + clockSkew) throw new APIError("BAD_REQUEST", {
587
+ message: "SAML assertion has expired",
588
+ details: `Current time is after NotOnOrAfter (with ${clockSkew}ms clock skew tolerance)`
589
+ });
590
+ }
591
+ }
216
592
  const spMetadataQuerySchema = z.object({
217
593
  providerId: z.string(),
218
594
  format: z.enum(["xml", "json"]).default("xml")
@@ -263,6 +639,7 @@ const ssoProviderBodySchema = z.object({
263
639
  tokenEndpointAuthentication: z.enum(["client_secret_post", "client_secret_basic"]).optional(),
264
640
  jwksEndpoint: z.string({}).meta({ description: "The JWKS endpoint" }).optional(),
265
641
  discoveryEndpoint: z.string().optional(),
642
+ skipDiscovery: z.boolean().meta({ description: "Skip OIDC discovery during registration. When true, you must provide authorizationEndpoint, tokenEndpoint, and jwksEndpoint manually." }).optional(),
266
643
  scopes: z.array(z.string(), {}).meta({ description: "The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']" }).optional(),
267
644
  pkce: z.boolean({}).meta({ description: "Whether to use PKCE for the authorization flow" }).default(true).optional(),
268
645
  mapping: z.object({
@@ -530,27 +907,64 @@ const registerSSOProvider = (options) => {
530
907
  ctx.context.logger.info(`SSO provider creation attempt with existing providerId: ${body.providerId}`);
531
908
  throw new APIError("UNPROCESSABLE_ENTITY", { message: "SSO provider with this providerId already exists" });
532
909
  }
910
+ let hydratedOIDCConfig = null;
911
+ if (body.oidcConfig && !body.oidcConfig.skipDiscovery) try {
912
+ hydratedOIDCConfig = await discoverOIDCConfig({
913
+ issuer: body.issuer,
914
+ existingConfig: {
915
+ discoveryEndpoint: body.oidcConfig.discoveryEndpoint,
916
+ authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
917
+ tokenEndpoint: body.oidcConfig.tokenEndpoint,
918
+ jwksEndpoint: body.oidcConfig.jwksEndpoint,
919
+ userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
920
+ tokenEndpointAuthentication: body.oidcConfig.tokenEndpointAuthentication
921
+ }
922
+ });
923
+ } catch (error) {
924
+ if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
925
+ throw error;
926
+ }
927
+ const buildOIDCConfig = () => {
928
+ if (!body.oidcConfig) return null;
929
+ if (body.oidcConfig.skipDiscovery) return JSON.stringify({
930
+ issuer: body.issuer,
931
+ clientId: body.oidcConfig.clientId,
932
+ clientSecret: body.oidcConfig.clientSecret,
933
+ authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
934
+ tokenEndpoint: body.oidcConfig.tokenEndpoint,
935
+ tokenEndpointAuthentication: body.oidcConfig.tokenEndpointAuthentication || "client_secret_basic",
936
+ jwksEndpoint: body.oidcConfig.jwksEndpoint,
937
+ pkce: body.oidcConfig.pkce,
938
+ discoveryEndpoint: body.oidcConfig.discoveryEndpoint || `${body.issuer}/.well-known/openid-configuration`,
939
+ mapping: body.oidcConfig.mapping,
940
+ scopes: body.oidcConfig.scopes,
941
+ userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
942
+ overrideUserInfo: ctx.body.overrideUserInfo || options?.defaultOverrideUserInfo || false
943
+ });
944
+ if (!hydratedOIDCConfig) return null;
945
+ return JSON.stringify({
946
+ issuer: hydratedOIDCConfig.issuer,
947
+ clientId: body.oidcConfig.clientId,
948
+ clientSecret: body.oidcConfig.clientSecret,
949
+ authorizationEndpoint: hydratedOIDCConfig.authorizationEndpoint,
950
+ tokenEndpoint: hydratedOIDCConfig.tokenEndpoint,
951
+ tokenEndpointAuthentication: hydratedOIDCConfig.tokenEndpointAuthentication,
952
+ jwksEndpoint: hydratedOIDCConfig.jwksEndpoint,
953
+ pkce: body.oidcConfig.pkce,
954
+ discoveryEndpoint: hydratedOIDCConfig.discoveryEndpoint,
955
+ mapping: body.oidcConfig.mapping,
956
+ scopes: body.oidcConfig.scopes,
957
+ userInfoEndpoint: hydratedOIDCConfig.userInfoEndpoint,
958
+ overrideUserInfo: ctx.body.overrideUserInfo || options?.defaultOverrideUserInfo || false
959
+ });
960
+ };
533
961
  const provider = await ctx.context.adapter.create({
534
962
  model: "ssoProvider",
535
963
  data: {
536
964
  issuer: body.issuer,
537
965
  domain: body.domain,
538
966
  domainVerified: false,
539
- oidcConfig: body.oidcConfig ? JSON.stringify({
540
- issuer: body.issuer,
541
- clientId: body.oidcConfig.clientId,
542
- clientSecret: body.oidcConfig.clientSecret,
543
- authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
544
- tokenEndpoint: body.oidcConfig.tokenEndpoint,
545
- tokenEndpointAuthentication: body.oidcConfig.tokenEndpointAuthentication,
546
- jwksEndpoint: body.oidcConfig.jwksEndpoint,
547
- pkce: body.oidcConfig.pkce,
548
- discoveryEndpoint: body.oidcConfig.discoveryEndpoint || `${body.issuer}/.well-known/openid-configuration`,
549
- mapping: body.oidcConfig.mapping,
550
- scopes: body.oidcConfig.scopes,
551
- userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
552
- overrideUserInfo: ctx.body.overrideUserInfo || options?.defaultOverrideUserInfo || false
553
- }) : null,
967
+ oidcConfig: buildOIDCConfig(),
554
968
  samlConfig: body.samlConfig ? JSON.stringify({
555
969
  issuer: body.issuer,
556
970
  entryPoint: body.samlConfig.entryPoint,
@@ -781,6 +1195,21 @@ const signInSSO = (options) => {
781
1195
  });
782
1196
  const loginRequest = sp.createLoginRequest(idp, "redirect");
783
1197
  if (!loginRequest) throw new APIError("BAD_REQUEST", { message: "Invalid SAML request" });
1198
+ if (loginRequest.id && (options?.saml?.authnRequestStore || options?.saml?.enableInResponseToValidation)) {
1199
+ const ttl = options?.saml?.requestTTL ?? DEFAULT_AUTHN_REQUEST_TTL_MS;
1200
+ const record = {
1201
+ id: loginRequest.id,
1202
+ providerId: provider.providerId,
1203
+ createdAt: Date.now(),
1204
+ expiresAt: Date.now() + ttl
1205
+ };
1206
+ if (options?.saml?.authnRequestStore) await options.saml.authnRequestStore.save(record);
1207
+ else await ctx.context.internalAdapter.createVerificationValue({
1208
+ identifier: `${AUTHN_REQUEST_KEY_PREFIX}${record.id}`,
1209
+ value: JSON.stringify(record),
1210
+ expiresAt: new Date(record.expiresAt)
1211
+ });
1212
+ }
784
1213
  return ctx.json({
785
1214
  url: `${loginRequest.context}&RelayState=${encodeURIComponent(body.callbackURL)}`,
786
1215
  redirect: true
@@ -801,7 +1230,7 @@ const callbackSSO = (options) => {
801
1230
  query: callbackSSOQuerySchema,
802
1231
  allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
803
1232
  metadata: {
804
- isAction: false,
1233
+ ...HIDE_METADATA,
805
1234
  openapi: {
806
1235
  operationId: "handleSSOCallback",
807
1236
  summary: "Callback URL for SSO provider",
@@ -986,7 +1415,7 @@ const callbackSSOSAML = (options) => {
986
1415
  method: "POST",
987
1416
  body: callbackSSOSAMLBodySchema,
988
1417
  metadata: {
989
- isAction: false,
1418
+ ...HIDE_METADATA,
990
1419
  allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
991
1420
  openapi: {
992
1421
  operationId: "handleSAMLCallback",
@@ -1069,22 +1498,10 @@ const callbackSSOSAML = (options) => {
1069
1498
  });
1070
1499
  let parsedResponse;
1071
1500
  try {
1072
- const decodedResponse = Buffer.from(SAMLResponse, "base64").toString("utf-8");
1073
- try {
1074
- parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
1075
- SAMLResponse,
1076
- RelayState: RelayState || void 0
1077
- } });
1078
- } catch (parseError) {
1079
- const nameIDMatch = decodedResponse.match(/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/);
1080
- if (!nameIDMatch) throw parseError;
1081
- parsedResponse = { extract: {
1082
- nameID: nameIDMatch[1],
1083
- attributes: { nameID: nameIDMatch[1] },
1084
- sessionIndex: {},
1085
- conditions: {}
1086
- } };
1087
- }
1501
+ parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
1502
+ SAMLResponse,
1503
+ RelayState: RelayState || void 0
1504
+ } });
1088
1505
  if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
1089
1506
  } catch (error) {
1090
1507
  ctx.context.logger.error("SAML response validation failed", {
@@ -1097,6 +1514,53 @@ const callbackSSOSAML = (options) => {
1097
1514
  });
1098
1515
  }
1099
1516
  const { extract } = parsedResponse;
1517
+ validateSAMLTimestamp(extract.conditions, {
1518
+ clockSkew: options?.saml?.clockSkew,
1519
+ requireTimestamps: options?.saml?.requireTimestamps,
1520
+ logger: ctx.context.logger
1521
+ });
1522
+ const inResponseTo = extract.inResponseTo;
1523
+ if (options?.saml?.authnRequestStore || options?.saml?.enableInResponseToValidation) {
1524
+ const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
1525
+ if (inResponseTo) {
1526
+ let storedRequest = null;
1527
+ if (options?.saml?.authnRequestStore) storedRequest = await options.saml.authnRequestStore.get(inResponseTo);
1528
+ else {
1529
+ const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1530
+ if (verification) try {
1531
+ storedRequest = JSON.parse(verification.value);
1532
+ if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
1533
+ } catch {
1534
+ storedRequest = null;
1535
+ }
1536
+ }
1537
+ if (!storedRequest) {
1538
+ ctx.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
1539
+ inResponseTo,
1540
+ providerId: provider.providerId
1541
+ });
1542
+ const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1543
+ throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
1544
+ }
1545
+ if (storedRequest.providerId !== provider.providerId) {
1546
+ ctx.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
1547
+ inResponseTo,
1548
+ expectedProvider: storedRequest.providerId,
1549
+ actualProvider: provider.providerId
1550
+ });
1551
+ if (options?.saml?.authnRequestStore) await options.saml.authnRequestStore.delete(inResponseTo);
1552
+ else await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1553
+ const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1554
+ throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
1555
+ }
1556
+ if (options?.saml?.authnRequestStore) await options.saml.authnRequestStore.delete(inResponseTo);
1557
+ else await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1558
+ } else if (!allowIdpInitiated) {
1559
+ ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: provider.providerId });
1560
+ const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1561
+ throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
1562
+ }
1563
+ }
1100
1564
  const attributes = extract.attributes || {};
1101
1565
  const mapping = parsedSamlConfig.mapping ?? {};
1102
1566
  const userInfo = {
@@ -1223,7 +1687,7 @@ const acsEndpoint = (options) => {
1223
1687
  params: acsEndpointParamsSchema,
1224
1688
  body: acsEndpointBodySchema,
1225
1689
  metadata: {
1226
- isAction: false,
1690
+ ...HIDE_METADATA,
1227
1691
  allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
1228
1692
  openapi: {
1229
1693
  operationId: "handleSAMLAssertionConsumerService",
@@ -1285,26 +1749,10 @@ const acsEndpoint = (options) => {
1285
1749
  }) : saml.IdentityProvider({ metadata: idpData.metadata });
1286
1750
  let parsedResponse;
1287
1751
  try {
1288
- let decodedResponse = Buffer.from(SAMLResponse, "base64").toString("utf-8");
1289
- if (!decodedResponse.includes("StatusCode")) {
1290
- const insertPoint = decodedResponse.indexOf("</saml2:Issuer>");
1291
- if (insertPoint !== -1) decodedResponse = decodedResponse.slice(0, insertPoint + 14) + "<saml2:Status><saml2:StatusCode Value=\"urn:oasis:names:tc:SAML:2.0:status:Success\"/></saml2:Status>" + decodedResponse.slice(insertPoint + 14);
1292
- } else if (!decodedResponse.includes("saml2:Success")) decodedResponse = decodedResponse.replace(/<saml2:StatusCode Value="[^"]+"/, "<saml2:StatusCode Value=\"urn:oasis:names:tc:SAML:2.0:status:Success\"");
1293
- try {
1294
- parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
1295
- SAMLResponse,
1296
- RelayState: RelayState || void 0
1297
- } });
1298
- } catch (parseError) {
1299
- const nameIDMatch = decodedResponse.match(/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/);
1300
- if (!nameIDMatch) throw parseError;
1301
- parsedResponse = { extract: {
1302
- nameID: nameIDMatch[1],
1303
- attributes: { nameID: nameIDMatch[1] },
1304
- sessionIndex: {},
1305
- conditions: {}
1306
- } };
1307
- }
1752
+ parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
1753
+ SAMLResponse,
1754
+ RelayState: RelayState || void 0
1755
+ } });
1308
1756
  if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
1309
1757
  } catch (error) {
1310
1758
  ctx.context.logger.error("SAML response validation failed", {
@@ -1317,6 +1765,53 @@ const acsEndpoint = (options) => {
1317
1765
  });
1318
1766
  }
1319
1767
  const { extract } = parsedResponse;
1768
+ validateSAMLTimestamp(extract.conditions, {
1769
+ clockSkew: options?.saml?.clockSkew,
1770
+ requireTimestamps: options?.saml?.requireTimestamps,
1771
+ logger: ctx.context.logger
1772
+ });
1773
+ const inResponseToAcs = extract.inResponseTo;
1774
+ if (options?.saml?.authnRequestStore || options?.saml?.enableInResponseToValidation) {
1775
+ const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
1776
+ if (inResponseToAcs) {
1777
+ let storedRequest = null;
1778
+ if (options?.saml?.authnRequestStore) storedRequest = await options.saml.authnRequestStore.get(inResponseToAcs);
1779
+ else {
1780
+ const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
1781
+ if (verification) try {
1782
+ storedRequest = JSON.parse(verification.value);
1783
+ if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
1784
+ } catch {
1785
+ storedRequest = null;
1786
+ }
1787
+ }
1788
+ if (!storedRequest) {
1789
+ ctx.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
1790
+ inResponseTo: inResponseToAcs,
1791
+ providerId
1792
+ });
1793
+ const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1794
+ throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
1795
+ }
1796
+ if (storedRequest.providerId !== providerId) {
1797
+ ctx.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
1798
+ inResponseTo: inResponseToAcs,
1799
+ expectedProvider: storedRequest.providerId,
1800
+ actualProvider: providerId
1801
+ });
1802
+ if (options?.saml?.authnRequestStore) await options.saml.authnRequestStore.delete(inResponseToAcs);
1803
+ else await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
1804
+ const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1805
+ throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
1806
+ }
1807
+ if (options?.saml?.authnRequestStore) await options.saml.authnRequestStore.delete(inResponseToAcs);
1808
+ else await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
1809
+ } else if (!allowIdpInitiated) {
1810
+ ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId });
1811
+ const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1812
+ throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
1813
+ }
1814
+ }
1320
1815
  const attributes = extract.attributes || {};
1321
1816
  const mapping = parsedSamlConfig.mapping ?? {};
1322
1817
  const userInfo = {
@@ -1439,18 +1934,19 @@ saml.setSchemaValidator({ async validate(xml) {
1439
1934
  throw "ERR_INVALID_XML";
1440
1935
  } });
1441
1936
  function sso(options) {
1937
+ const optionsWithStore = options;
1442
1938
  let endpoints = {
1443
1939
  spMetadata: spMetadata(),
1444
- registerSSOProvider: registerSSOProvider(options),
1445
- signInSSO: signInSSO(options),
1446
- callbackSSO: callbackSSO(options),
1447
- callbackSSOSAML: callbackSSOSAML(options),
1448
- acsEndpoint: acsEndpoint(options)
1940
+ registerSSOProvider: registerSSOProvider(optionsWithStore),
1941
+ signInSSO: signInSSO(optionsWithStore),
1942
+ callbackSSO: callbackSSO(optionsWithStore),
1943
+ callbackSSOSAML: callbackSSOSAML(optionsWithStore),
1944
+ acsEndpoint: acsEndpoint(optionsWithStore)
1449
1945
  };
1450
1946
  if (options?.domainVerification?.enabled) {
1451
1947
  const domainVerificationEndpoints = {
1452
- requestDomainVerification: requestDomainVerification(options),
1453
- verifyDomain: verifyDomain(options)
1948
+ requestDomainVerification: requestDomainVerification(optionsWithStore),
1949
+ verifyDomain: verifyDomain(optionsWithStore)
1454
1950
  };
1455
1951
  endpoints = {
1456
1952
  ...endpoints,
@@ -1512,4 +2008,4 @@ function sso(options) {
1512
2008
  }
1513
2009
 
1514
2010
  //#endregion
1515
- export { sso };
2011
+ 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 };