@better-auth/sso 1.5.0-beta.13 → 1.5.0-beta.16

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
@@ -8,10 +8,65 @@ import z from "zod/v4";
8
8
  import { base64 } from "@better-auth/utils/base64";
9
9
  import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
10
10
  import { HIDE_METADATA, createAuthorizationURL, generateGenericState, generateState, parseGenericState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
11
- import { setSessionCookie } from "better-auth/cookies";
11
+ import { deleteSessionCookie, setSessionCookie } from "better-auth/cookies";
12
12
  import { handleOAuthUserInfo } from "better-auth/oauth2";
13
13
  import { decodeJwt } from "jose";
14
+ import { defineErrorCodes } from "@better-auth/core/utils/error-codes";
14
15
 
16
+ //#region src/constants.ts
17
+ /**
18
+ * SAML Constants
19
+ *
20
+ * Centralized constants for SAML SSO functionality.
21
+ */
22
+ /** Prefix for AuthnRequest IDs used in InResponseTo validation */
23
+ const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
24
+ /** Prefix for used Assertion IDs used in replay protection */
25
+ const USED_ASSERTION_KEY_PREFIX = "saml-used-assertion:";
26
+ /** Prefix for SAML session data (NameID + SessionIndex) for SLO */
27
+ const SAML_SESSION_KEY_PREFIX = "saml-session:";
28
+ /** Prefix for reverse lookup of SAML session by Better Auth session ID */
29
+ const SAML_SESSION_BY_ID_PREFIX = "saml-session-by-id:";
30
+ /** Prefix for LogoutRequest IDs used in SP-initiated SLO validation */
31
+ const LOGOUT_REQUEST_KEY_PREFIX = "saml-logout-request:";
32
+ /**
33
+ * Default TTL for AuthnRequest records (5 minutes).
34
+ * This should be sufficient for most IdPs while protecting against stale requests.
35
+ */
36
+ const DEFAULT_AUTHN_REQUEST_TTL_MS = 300 * 1e3;
37
+ /**
38
+ * Default TTL for used assertion records (15 minutes).
39
+ * This should match the maximum expected NotOnOrAfter window plus clock skew.
40
+ */
41
+ const DEFAULT_ASSERTION_TTL_MS = 900 * 1e3;
42
+ /**
43
+ * Default TTL for LogoutRequest records (5 minutes).
44
+ * Should be sufficient for IdP to process and respond.
45
+ */
46
+ const DEFAULT_LOGOUT_REQUEST_TTL_MS = 300 * 1e3;
47
+ /**
48
+ * Default clock skew tolerance (5 minutes).
49
+ * Allows for minor time differences between IdP and SP servers.
50
+ *
51
+ * Accommodates:
52
+ * - Network latency and processing time
53
+ * - Clock synchronization differences (NTP drift)
54
+ * - Distributed systems across timezones
55
+ */
56
+ const DEFAULT_CLOCK_SKEW_MS = 300 * 1e3;
57
+ /**
58
+ * Default maximum size for SAML responses (256 KB).
59
+ * Protects against memory exhaustion from oversized SAML payloads.
60
+ */
61
+ const DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024;
62
+ /**
63
+ * Default maximum size for IdP metadata (100 KB).
64
+ * Protects against oversized metadata documents.
65
+ */
66
+ const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024;
67
+ const SAML_STATUS_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success";
68
+
69
+ //#endregion
15
70
  //#region src/utils.ts
16
71
  /**
17
72
  * Safely parses a value that might be a JSON string or already a parsed object.
@@ -161,7 +216,12 @@ async function assignOrganizationByDomain(ctx, options) {
161
216
 
162
217
  //#endregion
163
218
  //#region src/routes/domain-verification.ts
219
+ const DNS_LABEL_MAX_LENGTH = 63;
220
+ const DEFAULT_TOKEN_PREFIX = "better-auth-token";
164
221
  const domainVerificationBodySchema = z$1.object({ providerId: z$1.string() });
222
+ function getVerificationIdentifier(options, providerId) {
223
+ return `_${options.domainVerification?.tokenPrefix || DEFAULT_TOKEN_PREFIX}-${providerId}`;
224
+ }
165
225
  const requestDomainVerification = (options) => {
166
226
  return createAuthEndpoint("/sso/request-domain-verification", {
167
227
  method: "POST",
@@ -209,11 +269,12 @@ const requestDomainVerification = (options) => {
209
269
  message: "Domain has already been verified",
210
270
  code: "DOMAIN_VERIFIED"
211
271
  });
272
+ const identifier = getVerificationIdentifier(options, provider.providerId);
212
273
  const activeVerification = await ctx.context.adapter.findOne({
213
274
  model: "verification",
214
275
  where: [{
215
276
  field: "identifier",
216
- value: options.domainVerification?.tokenPrefix ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}` : `better-auth-token-${provider.providerId}`
277
+ value: identifier
217
278
  }, {
218
279
  field: "expiresAt",
219
280
  value: /* @__PURE__ */ new Date(),
@@ -228,7 +289,7 @@ const requestDomainVerification = (options) => {
228
289
  await ctx.context.adapter.create({
229
290
  model: "verification",
230
291
  data: {
231
- identifier: options.domainVerification?.tokenPrefix ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}` : `better-auth-token-${provider.providerId}`,
292
+ identifier,
232
293
  createdAt: /* @__PURE__ */ new Date(),
233
294
  updatedAt: /* @__PURE__ */ new Date(),
234
295
  value: domainVerificationToken,
@@ -287,11 +348,16 @@ const verifyDomain = (options) => {
287
348
  message: "Domain has already been verified",
288
349
  code: "DOMAIN_VERIFIED"
289
350
  });
351
+ const identifier = getVerificationIdentifier(options, provider.providerId);
352
+ if (identifier.length > DNS_LABEL_MAX_LENGTH) throw new APIError("BAD_REQUEST", {
353
+ message: `Verification identifier exceeds the DNS label limit of ${DNS_LABEL_MAX_LENGTH} characters`,
354
+ code: "IDENTIFIER_TOO_LONG"
355
+ });
290
356
  const activeVerification = await ctx.context.adapter.findOne({
291
357
  model: "verification",
292
358
  where: [{
293
359
  field: "identifier",
294
- value: options.domainVerification?.tokenPrefix ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}` : `better-auth-token-${provider.providerId}`
360
+ value: identifier
295
361
  }, {
296
362
  field: "expiresAt",
297
363
  value: /* @__PURE__ */ new Date(),
@@ -314,7 +380,8 @@ const verifyDomain = (options) => {
314
380
  });
315
381
  }
316
382
  try {
317
- records = (await dns.resolveTxt(new URL(provider.domain).hostname)).flat();
383
+ const hostname = new URL(provider.domain).hostname;
384
+ records = (await dns.resolveTxt(`${identifier}.${hostname}`)).flat();
318
385
  } catch (error) {
319
386
  ctx.context.logger.warn("DNS resolution failure while validating domain ownership", error);
320
387
  }
@@ -334,48 +401,6 @@ const verifyDomain = (options) => {
334
401
  });
335
402
  };
336
403
 
337
- //#endregion
338
- //#region src/constants.ts
339
- /**
340
- * SAML Constants
341
- *
342
- * Centralized constants for SAML SSO functionality.
343
- */
344
- /** Prefix for AuthnRequest IDs used in InResponseTo validation */
345
- const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
346
- /** Prefix for used Assertion IDs used in replay protection */
347
- const USED_ASSERTION_KEY_PREFIX = "saml-used-assertion:";
348
- /**
349
- * Default TTL for AuthnRequest records (5 minutes).
350
- * This should be sufficient for most IdPs while protecting against stale requests.
351
- */
352
- const DEFAULT_AUTHN_REQUEST_TTL_MS = 300 * 1e3;
353
- /**
354
- * Default TTL for used assertion records (15 minutes).
355
- * This should match the maximum expected NotOnOrAfter window plus clock skew.
356
- */
357
- const DEFAULT_ASSERTION_TTL_MS = 900 * 1e3;
358
- /**
359
- * Default clock skew tolerance (5 minutes).
360
- * Allows for minor time differences between IdP and SP servers.
361
- *
362
- * Accommodates:
363
- * - Network latency and processing time
364
- * - Clock synchronization differences (NTP drift)
365
- * - Distributed systems across timezones
366
- */
367
- const DEFAULT_CLOCK_SKEW_MS = 300 * 1e3;
368
- /**
369
- * Default maximum size for SAML responses (256 KB).
370
- * Protects against memory exhaustion from oversized SAML payloads.
371
- */
372
- const DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024;
373
- /**
374
- * Default maximum size for IdP metadata (100 KB).
375
- * Protects against oversized metadata documents.
376
- */
377
- const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024;
378
-
379
404
  //#endregion
380
405
  //#region src/saml/parser.ts
381
406
  const xmlParser = new XMLParser({
@@ -829,7 +854,7 @@ const listSSOProviders = () => {
829
854
  return ctx.json({ providers });
830
855
  });
831
856
  };
832
- const getSSOProviderParamsSchema = z.object({ providerId: z.string() });
857
+ const getSSOProviderQuerySchema = z.object({ providerId: z.string() });
833
858
  async function checkProviderAccess(ctx, providerId) {
834
859
  const userId = ctx.context.session.user.id;
835
860
  const provider = await ctx.context.adapter.findOne({
@@ -848,10 +873,10 @@ async function checkProviderAccess(ctx, providerId) {
848
873
  return provider;
849
874
  }
850
875
  const getSSOProvider = () => {
851
- return createAuthEndpoint("/sso/providers/:providerId", {
876
+ return createAuthEndpoint("/sso/get-provider", {
852
877
  method: "GET",
853
878
  use: [sessionMiddleware],
854
- params: getSSOProviderParamsSchema,
879
+ query: getSSOProviderQuerySchema,
855
880
  metadata: { openapi: {
856
881
  operationId: "getSSOProvider",
857
882
  summary: "Get SSO provider details",
@@ -863,7 +888,7 @@ const getSSOProvider = () => {
863
888
  }
864
889
  } }
865
890
  }, async (ctx) => {
866
- const { providerId } = ctx.params;
891
+ const { providerId } = ctx.query;
867
892
  const provider = await checkProviderAccess(ctx, providerId);
868
893
  return ctx.json(sanitizeProvider(provider, ctx.context.baseURL));
869
894
  });
@@ -916,11 +941,10 @@ function mergeOIDCConfig(current, updates, issuer) {
916
941
  };
917
942
  }
918
943
  const updateSSOProvider = (options) => {
919
- return createAuthEndpoint("/sso/providers/:providerId", {
920
- method: "PATCH",
944
+ return createAuthEndpoint("/sso/update-provider", {
945
+ method: "POST",
921
946
  use: [sessionMiddleware],
922
- params: getSSOProviderParamsSchema,
923
- body: updateSSOProviderBodySchema,
947
+ body: updateSSOProviderBodySchema.extend({ providerId: z.string() }),
924
948
  metadata: { openapi: {
925
949
  operationId: "updateSSOProvider",
926
950
  summary: "Update SSO provider",
@@ -932,8 +956,7 @@ const updateSSOProvider = (options) => {
932
956
  }
933
957
  } }
934
958
  }, async (ctx) => {
935
- const { providerId } = ctx.params;
936
- const body = ctx.body;
959
+ const { providerId, ...body } = ctx.body;
937
960
  const { issuer, domain, samlConfig, oidcConfig } = body;
938
961
  if (!issuer && !domain && !samlConfig && !oidcConfig) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
939
962
  const existingProvider = await checkProviderAccess(ctx, providerId);
@@ -981,10 +1004,10 @@ const updateSSOProvider = (options) => {
981
1004
  });
982
1005
  };
983
1006
  const deleteSSOProvider = () => {
984
- return createAuthEndpoint("/sso/providers/:providerId", {
985
- method: "DELETE",
1007
+ return createAuthEndpoint("/sso/delete-provider", {
1008
+ method: "POST",
986
1009
  use: [sessionMiddleware],
987
- params: getSSOProviderParamsSchema,
1010
+ body: z.object({ providerId: z.string() }),
988
1011
  metadata: { openapi: {
989
1012
  operationId: "deleteSSOProvider",
990
1013
  summary: "Delete SSO provider",
@@ -996,7 +1019,7 @@ const deleteSSOProvider = () => {
996
1019
  }
997
1020
  } }
998
1021
  }, async (ctx) => {
999
- const { providerId } = ctx.params;
1022
+ const { providerId } = ctx.body;
1000
1023
  await checkProviderAccess(ctx, providerId);
1001
1024
  await ctx.context.adapter.delete({
1002
1025
  model: "ssoProvider",
@@ -1355,6 +1378,17 @@ function mapDiscoveryErrorToAPIError(error) {
1355
1378
  }
1356
1379
  }
1357
1380
 
1381
+ //#endregion
1382
+ //#region src/saml/error-codes.ts
1383
+ const SAML_ERROR_CODES = defineErrorCodes({
1384
+ SINGLE_LOGOUT_NOT_ENABLED: "Single Logout is not enabled",
1385
+ INVALID_LOGOUT_RESPONSE: "Invalid LogoutResponse",
1386
+ INVALID_LOGOUT_REQUEST: "Invalid LogoutRequest",
1387
+ LOGOUT_FAILED_AT_IDP: "Logout failed at IdP",
1388
+ IDP_SLO_NOT_SUPPORTED: "IdP does not support Single Logout Service",
1389
+ SAML_PROVIDER_NOT_FOUND: "SAML provider not found"
1390
+ });
1391
+
1358
1392
  //#endregion
1359
1393
  //#region src/saml-state.ts
1360
1394
  async function generateRelayState(c, link, additionalData) {
@@ -1398,9 +1432,102 @@ async function parseRelayState(c) {
1398
1432
  return parsedData;
1399
1433
  }
1400
1434
 
1435
+ //#endregion
1436
+ //#region src/routes/helpers.ts
1437
+ async function findSAMLProvider(providerId, options, adapter) {
1438
+ if (options?.defaultSSO?.length) {
1439
+ const match = options.defaultSSO.find((p) => p.providerId === providerId);
1440
+ if (match) return {
1441
+ ...match,
1442
+ userId: "default",
1443
+ issuer: match.samlConfig?.issuer || "",
1444
+ ...options.domainVerification?.enabled ? { domainVerified: true } : {}
1445
+ };
1446
+ }
1447
+ const res = await adapter.findOne({
1448
+ model: "ssoProvider",
1449
+ where: [{
1450
+ field: "providerId",
1451
+ value: providerId
1452
+ }]
1453
+ });
1454
+ if (!res) return null;
1455
+ return {
1456
+ ...res,
1457
+ samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
1458
+ };
1459
+ }
1460
+ function createSP(config, baseURL, providerId, sloOptions) {
1461
+ const sloLocation = `${baseURL}/sso/saml2/sp/slo/${providerId}`;
1462
+ return saml.ServiceProvider({
1463
+ entityID: config.spMetadata?.entityID || config.issuer,
1464
+ assertionConsumerService: [{
1465
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1466
+ Location: config.callbackUrl || `${baseURL}/sso/saml2/sp/acs/${providerId}`
1467
+ }],
1468
+ singleLogoutService: [{
1469
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1470
+ Location: sloLocation
1471
+ }, {
1472
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1473
+ Location: sloLocation
1474
+ }],
1475
+ wantMessageSigned: config.wantAssertionsSigned || false,
1476
+ wantLogoutRequestSigned: sloOptions?.wantLogoutRequestSigned ?? false,
1477
+ wantLogoutResponseSigned: sloOptions?.wantLogoutResponseSigned ?? false,
1478
+ metadata: config.spMetadata?.metadata,
1479
+ privateKey: config.spMetadata?.privateKey || config.privateKey,
1480
+ privateKeyPass: config.spMetadata?.privateKeyPass
1481
+ });
1482
+ }
1483
+ function createIdP(config) {
1484
+ const idpData = config.idpMetadata;
1485
+ if (idpData?.metadata) return saml.IdentityProvider({
1486
+ metadata: idpData.metadata,
1487
+ privateKey: idpData.privateKey,
1488
+ privateKeyPass: idpData.privateKeyPass,
1489
+ encPrivateKey: idpData.encPrivateKey,
1490
+ encPrivateKeyPass: idpData.encPrivateKeyPass
1491
+ });
1492
+ return saml.IdentityProvider({
1493
+ entityID: idpData?.entityID || config.issuer,
1494
+ singleSignOnService: idpData?.singleSignOnService || [{
1495
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1496
+ Location: config.entryPoint
1497
+ }],
1498
+ singleLogoutService: idpData?.singleLogoutService,
1499
+ signingCert: idpData?.cert || config.cert
1500
+ });
1501
+ }
1502
+ function escapeHtml(str) {
1503
+ if (!str) return "";
1504
+ return String(str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1505
+ }
1506
+ function createSAMLPostForm(action, samlParam, samlValue, relayState) {
1507
+ const safeAction = escapeHtml(action);
1508
+ const safeSamlParam = escapeHtml(samlParam);
1509
+ const safeSamlValue = escapeHtml(samlValue);
1510
+ const safeRelayState = relayState ? escapeHtml(relayState) : void 0;
1511
+ const html = `<!DOCTYPE html><html><body onload="document.forms[0].submit();"><form method="POST" action="${safeAction}"><input type="hidden" name="${safeSamlParam}" value="${safeSamlValue}" />${safeRelayState ? `<input type="hidden" name="RelayState" value="${safeRelayState}" />` : ""}<noscript><input type="submit" value="Continue" /></noscript></form></body></html>`;
1512
+ return new Response(html, { headers: { "Content-Type": "text/html" } });
1513
+ }
1514
+
1401
1515
  //#endregion
1402
1516
  //#region src/routes/sso.ts
1403
1517
  /**
1518
+ * Builds the OIDC redirect URI. Uses the shared `redirectURI` option
1519
+ * when set, otherwise falls back to `/sso/callback/:providerId`.
1520
+ */
1521
+ function getOIDCRedirectURI(baseURL, providerId, options) {
1522
+ if (options?.redirectURI?.trim()) try {
1523
+ new URL(options.redirectURI);
1524
+ return options.redirectURI;
1525
+ } catch {
1526
+ return `${baseURL}${options.redirectURI.startsWith("/") ? options.redirectURI : `/${options.redirectURI}`}`;
1527
+ }
1528
+ return `${baseURL}/sso/callback/${providerId}`;
1529
+ }
1530
+ /**
1404
1531
  * Validates SAML assertion timestamp conditions (NotBefore/NotOnOrAfter).
1405
1532
  * Prevents acceptance of expired or future-dated assertions.
1406
1533
  * @throws {APIError} If timestamps are invalid, expired, or not yet valid
@@ -1464,7 +1591,7 @@ const spMetadataQuerySchema = z.object({
1464
1591
  providerId: z.string(),
1465
1592
  format: z.enum(["xml", "json"]).default("xml")
1466
1593
  });
1467
- const spMetadata = () => {
1594
+ const spMetadata = (options) => {
1468
1595
  return createAuthEndpoint("/sso/saml2/sp/metadata", {
1469
1596
  method: "GET",
1470
1597
  query: spMetadataQuerySchema,
@@ -1485,12 +1612,21 @@ const spMetadata = () => {
1485
1612
  if (!provider) throw new APIError("NOT_FOUND", { message: "No provider found for the given providerId" });
1486
1613
  const parsedSamlConfig = safeJsonParse(provider.samlConfig);
1487
1614
  if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
1615
+ const sloLocation = `${ctx.context.baseURL}/sso/saml2/sp/slo/${ctx.query.providerId}`;
1616
+ const singleLogoutService = options?.saml?.enableSingleLogout ? [{
1617
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1618
+ Location: sloLocation
1619
+ }, {
1620
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1621
+ Location: sloLocation
1622
+ }] : void 0;
1488
1623
  const sp = parsedSamlConfig.spMetadata.metadata ? saml.ServiceProvider({ metadata: parsedSamlConfig.spMetadata.metadata }) : saml.SPMetadata({
1489
1624
  entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
1490
1625
  assertionConsumerService: [{
1491
1626
  Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1492
1627
  Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.id}`
1493
1628
  }],
1629
+ singleLogoutService,
1494
1630
  wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1495
1631
  authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
1496
1632
  nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
@@ -1878,7 +2014,7 @@ const registerSSOProvider = (options) => {
1878
2014
  await ctx.context.adapter.create({
1879
2015
  model: "verification",
1880
2016
  data: {
1881
- identifier: options.domainVerification?.tokenPrefix ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}` : `better-auth-token-${provider.providerId}`,
2017
+ identifier: getVerificationIdentifier(options, provider.providerId),
1882
2018
  createdAt: /* @__PURE__ */ new Date(),
1883
2019
  updatedAt: /* @__PURE__ */ new Date(),
1884
2020
  value: domainVerificationToken,
@@ -1890,7 +2026,7 @@ const registerSSOProvider = (options) => {
1890
2026
  ...provider,
1891
2027
  oidcConfig: safeJsonParse(provider.oidcConfig),
1892
2028
  samlConfig: safeJsonParse(provider.samlConfig),
1893
- redirectURI: `${ctx.context.baseURL}/sso/callback/${provider.providerId}`,
2029
+ redirectURI: getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options),
1894
2030
  ...options?.domainVerification?.enabled ? { domainVerified } : {},
1895
2031
  ...options?.domainVerification?.enabled ? { domainVerificationToken } : {}
1896
2032
  };
@@ -2042,8 +2178,8 @@ const signInSSO = (options) => {
2042
2178
  if (discovery.data) finalAuthUrl = discovery.data.authorization_endpoint;
2043
2179
  }
2044
2180
  if (!finalAuthUrl) throw new APIError("BAD_REQUEST", { message: "Invalid OIDC configuration. Authorization URL not found." });
2045
- const state = await generateState(ctx, void 0, false);
2046
- const redirectURI = `${ctx.context.baseURL}/sso/callback/${provider.providerId}`;
2181
+ const state = await generateState(ctx, void 0, options?.redirectURI?.trim() ? { ssoProviderId: provider.providerId } : false);
2182
+ const redirectURI = getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options);
2047
2183
  const authorizationURL = await createAuthorizationURL({
2048
2184
  id: provider.issuer,
2049
2185
  options: {
@@ -2141,171 +2277,214 @@ const callbackSSOQuerySchema = z.object({
2141
2277
  error: z.string().optional(),
2142
2278
  error_description: z.string().optional()
2143
2279
  });
2280
+ /**
2281
+ * Core OIDC callback handler logic, shared between the per-provider and
2282
+ * shared callback endpoints. Resolves the provider, exchanges the
2283
+ * authorization code for tokens, and creates a session.
2284
+ *
2285
+ * @param stateData - Pre-parsed state data. If not provided, it will be
2286
+ * parsed from the request context.
2287
+ */
2288
+ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2289
+ const { code, error, error_description } = ctx.query;
2290
+ if (!stateData) stateData = await parseState(ctx);
2291
+ if (!stateData) {
2292
+ const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
2293
+ throw ctx.redirect(`${errorURL}?error=invalid_state`);
2294
+ }
2295
+ const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
2296
+ if (!code || error) throw ctx.redirect(`${errorURL || callbackURL}?error=${error}&error_description=${error_description}`);
2297
+ let provider = null;
2298
+ if (options?.defaultSSO?.length) {
2299
+ const matchingDefault = options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId);
2300
+ if (matchingDefault) provider = {
2301
+ ...matchingDefault,
2302
+ issuer: matchingDefault.oidcConfig?.issuer || "",
2303
+ userId: "default",
2304
+ ...options.domainVerification?.enabled ? { domainVerified: true } : {}
2305
+ };
2306
+ }
2307
+ if (!provider) provider = await ctx.context.adapter.findOne({
2308
+ model: "ssoProvider",
2309
+ where: [{
2310
+ field: "providerId",
2311
+ value: providerId
2312
+ }]
2313
+ }).then((res) => {
2314
+ if (!res) return null;
2315
+ return {
2316
+ ...res,
2317
+ oidcConfig: safeJsonParse(res.oidcConfig) || void 0
2318
+ };
2319
+ });
2320
+ if (!provider) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
2321
+ if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
2322
+ let config = provider.oidcConfig;
2323
+ if (!config) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
2324
+ const discovery = await betterFetch(config.discoveryEndpoint);
2325
+ if (discovery.data) config = {
2326
+ tokenEndpoint: discovery.data.token_endpoint,
2327
+ tokenEndpointAuthentication: discovery.data.token_endpoint_auth_method,
2328
+ userInfoEndpoint: discovery.data.userinfo_endpoint,
2329
+ scopes: [
2330
+ "openid",
2331
+ "email",
2332
+ "profile",
2333
+ "offline_access"
2334
+ ],
2335
+ ...config
2336
+ };
2337
+ if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_endpoint_not_found`);
2338
+ const tokenResponse = await validateAuthorizationCode({
2339
+ code,
2340
+ codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
2341
+ redirectURI: getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options),
2342
+ options: {
2343
+ clientId: config.clientId,
2344
+ clientSecret: config.clientSecret
2345
+ },
2346
+ tokenEndpoint: config.tokenEndpoint,
2347
+ authentication: config.tokenEndpointAuthentication === "client_secret_post" ? "post" : "basic"
2348
+ }).catch((e) => {
2349
+ if (e instanceof BetterFetchError) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${e.message}`);
2350
+ return null;
2351
+ });
2352
+ if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_response_not_found`);
2353
+ let userInfo = null;
2354
+ if (tokenResponse.idToken) {
2355
+ const idToken = decodeJwt(tokenResponse.idToken);
2356
+ if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=jwks_endpoint_not_found`);
2357
+ const verified = await validateToken(tokenResponse.idToken, config.jwksEndpoint, {
2358
+ audience: config.clientId,
2359
+ issuer: provider.issuer
2360
+ }).catch((e) => {
2361
+ ctx.context.logger.error(e);
2362
+ return null;
2363
+ });
2364
+ if (!verified) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_not_verified`);
2365
+ const mapping = config.mapping || {};
2366
+ userInfo = {
2367
+ ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, verified.payload[value]])),
2368
+ id: idToken[mapping.id || "sub"],
2369
+ email: idToken[mapping.email || "email"],
2370
+ emailVerified: options?.trustEmailVerified ? idToken[mapping.emailVerified || "email_verified"] : false,
2371
+ name: idToken[mapping.name || "name"],
2372
+ image: idToken[mapping.image || "picture"]
2373
+ };
2374
+ }
2375
+ if (!userInfo) {
2376
+ if (!config.userInfoEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=user_info_endpoint_not_found`);
2377
+ const userInfoResponse = await betterFetch(config.userInfoEndpoint, { headers: { Authorization: `Bearer ${tokenResponse.accessToken}` } });
2378
+ if (userInfoResponse.error) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${userInfoResponse.error.message}`);
2379
+ userInfo = userInfoResponse.data;
2380
+ }
2381
+ if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=missing_user_info`);
2382
+ const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfo.email, provider.domain);
2383
+ const linked = await handleOAuthUserInfo(ctx, {
2384
+ userInfo: {
2385
+ email: userInfo.email,
2386
+ name: userInfo.name || "",
2387
+ id: userInfo.id,
2388
+ image: userInfo.image,
2389
+ emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
2390
+ },
2391
+ account: {
2392
+ idToken: tokenResponse.idToken,
2393
+ accessToken: tokenResponse.accessToken,
2394
+ refreshToken: tokenResponse.refreshToken,
2395
+ accountId: userInfo.id,
2396
+ providerId: provider.providerId,
2397
+ accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
2398
+ refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
2399
+ scope: tokenResponse.scopes?.join(",")
2400
+ },
2401
+ callbackURL,
2402
+ disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
2403
+ overrideUserInfo: config.overrideUserInfo,
2404
+ isTrustedProvider
2405
+ });
2406
+ if (linked.error) throw ctx.redirect(`${errorURL || callbackURL}?error=${linked.error}`);
2407
+ const { session, user } = linked.data;
2408
+ if (options?.provisionUser && linked.isRegister) await options.provisionUser({
2409
+ user,
2410
+ userInfo,
2411
+ token: tokenResponse,
2412
+ provider
2413
+ });
2414
+ await assignOrganizationFromProvider(ctx, {
2415
+ user,
2416
+ profile: {
2417
+ providerType: "oidc",
2418
+ providerId: provider.providerId,
2419
+ accountId: userInfo.id,
2420
+ email: userInfo.email,
2421
+ emailVerified: Boolean(userInfo.emailVerified),
2422
+ rawAttributes: userInfo
2423
+ },
2424
+ provider,
2425
+ token: tokenResponse,
2426
+ provisioningOptions: options?.organizationProvisioning
2427
+ });
2428
+ await setSessionCookie(ctx, {
2429
+ session,
2430
+ user
2431
+ });
2432
+ let toRedirectTo;
2433
+ try {
2434
+ toRedirectTo = (linked.isRegister ? newUserURL || callbackURL : callbackURL).toString();
2435
+ } catch {
2436
+ toRedirectTo = linked.isRegister ? newUserURL || callbackURL : callbackURL;
2437
+ }
2438
+ throw ctx.redirect(toRedirectTo);
2439
+ }
2440
+ const callbackSSOEndpointConfig = {
2441
+ method: "GET",
2442
+ query: callbackSSOQuerySchema,
2443
+ allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
2444
+ metadata: {
2445
+ ...HIDE_METADATA,
2446
+ openapi: {
2447
+ operationId: "handleSSOCallback",
2448
+ summary: "Callback URL for SSO provider",
2449
+ description: "This endpoint is used as the callback URL for SSO providers. It handles the authorization code and exchanges it for an access token",
2450
+ responses: { "302": { description: "Redirects to the callback URL" } }
2451
+ }
2452
+ }
2453
+ };
2144
2454
  const callbackSSO = (options) => {
2145
- return createAuthEndpoint("/sso/callback/:providerId", {
2146
- method: "GET",
2147
- query: callbackSSOQuerySchema,
2148
- allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
2455
+ return createAuthEndpoint("/sso/callback/:providerId", callbackSSOEndpointConfig, async (ctx) => {
2456
+ return handleOIDCCallback(ctx, options, ctx.params.providerId);
2457
+ });
2458
+ };
2459
+ /**
2460
+ * Shared OIDC callback endpoint (no `:providerId` in path).
2461
+ * Used when `options.redirectURI` is set — the `providerId` is read from
2462
+ * the OAuth state instead of the URL path.
2463
+ */
2464
+ const callbackSSOShared = (options) => {
2465
+ return createAuthEndpoint("/sso/callback", {
2466
+ ...callbackSSOEndpointConfig,
2149
2467
  metadata: {
2150
- ...HIDE_METADATA,
2468
+ ...callbackSSOEndpointConfig.metadata,
2151
2469
  openapi: {
2152
- operationId: "handleSSOCallback",
2153
- summary: "Callback URL for SSO provider",
2154
- description: "This endpoint is used as the callback URL for SSO providers. It handles the authorization code and exchanges it for an access token",
2155
- responses: { "302": { description: "Redirects to the callback URL" } }
2470
+ ...callbackSSOEndpointConfig.metadata.openapi,
2471
+ operationId: "handleSSOCallbackShared",
2472
+ summary: "Shared callback URL for all SSO providers",
2473
+ description: "This endpoint is used as a shared callback URL for all SSO providers when `redirectURI` is configured. The provider is identified via the OAuth state parameter."
2156
2474
  }
2157
2475
  }
2158
2476
  }, async (ctx) => {
2159
- const { code, error, error_description } = ctx.query;
2160
2477
  const stateData = await parseState(ctx);
2161
2478
  if (!stateData) {
2162
2479
  const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
2163
2480
  throw ctx.redirect(`${errorURL}?error=invalid_state`);
2164
2481
  }
2165
- const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
2166
- if (!code || error) throw ctx.redirect(`${errorURL || callbackURL}?error=${error}&error_description=${error_description}`);
2167
- let provider = null;
2168
- if (options?.defaultSSO?.length) {
2169
- const matchingDefault = options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === ctx.params.providerId);
2170
- if (matchingDefault) provider = {
2171
- ...matchingDefault,
2172
- issuer: matchingDefault.oidcConfig?.issuer || "",
2173
- userId: "default",
2174
- ...options.domainVerification?.enabled ? { domainVerified: true } : {}
2175
- };
2176
- }
2177
- if (!provider) provider = await ctx.context.adapter.findOne({
2178
- model: "ssoProvider",
2179
- where: [{
2180
- field: "providerId",
2181
- value: ctx.params.providerId
2182
- }]
2183
- }).then((res) => {
2184
- if (!res) return null;
2185
- return {
2186
- ...res,
2187
- oidcConfig: safeJsonParse(res.oidcConfig) || void 0
2188
- };
2189
- });
2190
- if (!provider) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
2191
- if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
2192
- let config = provider.oidcConfig;
2193
- if (!config) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
2194
- const discovery = await betterFetch(config.discoveryEndpoint);
2195
- if (discovery.data) config = {
2196
- tokenEndpoint: discovery.data.token_endpoint,
2197
- tokenEndpointAuthentication: discovery.data.token_endpoint_auth_method,
2198
- userInfoEndpoint: discovery.data.userinfo_endpoint,
2199
- scopes: [
2200
- "openid",
2201
- "email",
2202
- "profile",
2203
- "offline_access"
2204
- ],
2205
- ...config
2206
- };
2207
- if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_endpoint_not_found`);
2208
- const tokenResponse = await validateAuthorizationCode({
2209
- code,
2210
- codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
2211
- redirectURI: `${ctx.context.baseURL}/sso/callback/${provider.providerId}`,
2212
- options: {
2213
- clientId: config.clientId,
2214
- clientSecret: config.clientSecret
2215
- },
2216
- tokenEndpoint: config.tokenEndpoint,
2217
- authentication: config.tokenEndpointAuthentication === "client_secret_post" ? "post" : "basic"
2218
- }).catch((e) => {
2219
- if (e instanceof BetterFetchError) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${e.message}`);
2220
- return null;
2221
- });
2222
- if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_response_not_found`);
2223
- let userInfo = null;
2224
- if (tokenResponse.idToken) {
2225
- const idToken = decodeJwt(tokenResponse.idToken);
2226
- if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=jwks_endpoint_not_found`);
2227
- const verified = await validateToken(tokenResponse.idToken, config.jwksEndpoint, {
2228
- audience: config.clientId,
2229
- issuer: provider.issuer
2230
- }).catch((e) => {
2231
- ctx.context.logger.error(e);
2232
- return null;
2233
- });
2234
- if (!verified) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_not_verified`);
2235
- const mapping = config.mapping || {};
2236
- userInfo = {
2237
- ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, verified.payload[value]])),
2238
- id: idToken[mapping.id || "sub"],
2239
- email: idToken[mapping.email || "email"],
2240
- emailVerified: options?.trustEmailVerified ? idToken[mapping.emailVerified || "email_verified"] : false,
2241
- name: idToken[mapping.name || "name"],
2242
- image: idToken[mapping.image || "picture"]
2243
- };
2244
- }
2245
- if (!userInfo) {
2246
- if (!config.userInfoEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=user_info_endpoint_not_found`);
2247
- const userInfoResponse = await betterFetch(config.userInfoEndpoint, { headers: { Authorization: `Bearer ${tokenResponse.accessToken}` } });
2248
- if (userInfoResponse.error) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${userInfoResponse.error.message}`);
2249
- userInfo = userInfoResponse.data;
2482
+ const providerId = stateData.ssoProviderId;
2483
+ if (!providerId) {
2484
+ const errorURL = stateData.errorURL || stateData.callbackURL;
2485
+ throw ctx.redirect(`${errorURL}?error=invalid_state&error_description=missing_provider_id`);
2250
2486
  }
2251
- if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=missing_user_info`);
2252
- const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfo.email, provider.domain);
2253
- const linked = await handleOAuthUserInfo(ctx, {
2254
- userInfo: {
2255
- email: userInfo.email,
2256
- name: userInfo.name || userInfo.email,
2257
- id: userInfo.id,
2258
- image: userInfo.image,
2259
- emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
2260
- },
2261
- account: {
2262
- idToken: tokenResponse.idToken,
2263
- accessToken: tokenResponse.accessToken,
2264
- refreshToken: tokenResponse.refreshToken,
2265
- accountId: userInfo.id,
2266
- providerId: provider.providerId,
2267
- accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
2268
- refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
2269
- scope: tokenResponse.scopes?.join(",")
2270
- },
2271
- callbackURL,
2272
- disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
2273
- overrideUserInfo: config.overrideUserInfo,
2274
- isTrustedProvider
2275
- });
2276
- if (linked.error) throw ctx.redirect(`${errorURL || callbackURL}?error=${linked.error}`);
2277
- const { session, user } = linked.data;
2278
- if (options?.provisionUser) await options.provisionUser({
2279
- user,
2280
- userInfo,
2281
- token: tokenResponse,
2282
- provider
2283
- });
2284
- await assignOrganizationFromProvider(ctx, {
2285
- user,
2286
- profile: {
2287
- providerType: "oidc",
2288
- providerId: provider.providerId,
2289
- accountId: userInfo.id,
2290
- email: userInfo.email,
2291
- emailVerified: Boolean(userInfo.emailVerified),
2292
- rawAttributes: userInfo
2293
- },
2294
- provider,
2295
- token: tokenResponse,
2296
- provisioningOptions: options?.organizationProvisioning
2297
- });
2298
- await setSessionCookie(ctx, {
2299
- session,
2300
- user
2301
- });
2302
- let toRedirectTo;
2303
- try {
2304
- toRedirectTo = (linked.isRegister ? newUserURL || callbackURL : callbackURL).toString();
2305
- } catch {
2306
- toRedirectTo = linked.isRegister ? newUserURL || callbackURL : callbackURL;
2307
- }
2308
- throw ctx.redirect(toRedirectTo);
2487
+ return handleOIDCCallback(ctx, options, providerId, stateData);
2309
2488
  });
2310
2489
  };
2311
2490
  const callbackSSOSAMLBodySchema = z.object({
@@ -2563,7 +2742,7 @@ const callbackSSOSAML = (options) => {
2563
2742
  });
2564
2743
  throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
2565
2744
  }
2566
- const isTrustedProvider = !!ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
2745
+ const isTrustedProvider = ctx.context.trustedProviders.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
2567
2746
  const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2568
2747
  const result = await handleOAuthUserInfo(ctx, {
2569
2748
  userInfo: {
@@ -2606,6 +2785,25 @@ const callbackSSOSAML = (options) => {
2606
2785
  session,
2607
2786
  user
2608
2787
  });
2788
+ if (options?.saml?.enableSingleLogout && extract.nameID) {
2789
+ const samlSessionKey = `${SAML_SESSION_KEY_PREFIX}${provider.providerId}:${extract.nameID}`;
2790
+ const samlSessionData = {
2791
+ sessionId: session.id,
2792
+ providerId: provider.providerId,
2793
+ nameID: extract.nameID,
2794
+ sessionIndex: extract.sessionIndex
2795
+ };
2796
+ await ctx.context.internalAdapter.createVerificationValue({
2797
+ identifier: samlSessionKey,
2798
+ value: JSON.stringify(samlSessionData),
2799
+ expiresAt: session.expiresAt
2800
+ }).catch((e) => ctx.context.logger.warn("Failed to create SAML session record", { error: e }));
2801
+ await ctx.context.internalAdapter.createVerificationValue({
2802
+ identifier: `${SAML_SESSION_BY_ID_PREFIX}${session.id}`,
2803
+ value: samlSessionKey,
2804
+ expiresAt: session.expiresAt
2805
+ }).catch((e) => ctx.context.logger.warn("Failed to create SAML session lookup record", e));
2806
+ }
2609
2807
  const safeRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
2610
2808
  throw ctx.redirect(safeRedirectUrl);
2611
2809
  });
@@ -2815,7 +3013,7 @@ const acsEndpoint = (options) => {
2815
3013
  });
2816
3014
  throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
2817
3015
  }
2818
- const isTrustedProvider = !!ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
3016
+ const isTrustedProvider = ctx.context.trustedProviders.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
2819
3017
  const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2820
3018
  const result = await handleOAuthUserInfo(ctx, {
2821
3019
  userInfo: {
@@ -2858,10 +3056,186 @@ const acsEndpoint = (options) => {
2858
3056
  session,
2859
3057
  user
2860
3058
  });
3059
+ if (options?.saml?.enableSingleLogout && extract.nameID) {
3060
+ const samlSessionKey = `${SAML_SESSION_KEY_PREFIX}${providerId}:${extract.nameID}`;
3061
+ const samlSessionData = {
3062
+ sessionId: session.id,
3063
+ providerId,
3064
+ nameID: extract.nameID,
3065
+ sessionIndex: extract.sessionIndex
3066
+ };
3067
+ await ctx.context.internalAdapter.createVerificationValue({
3068
+ identifier: samlSessionKey,
3069
+ value: JSON.stringify(samlSessionData),
3070
+ expiresAt: session.expiresAt
3071
+ }).catch((e) => ctx.context.logger.warn("Failed to create SAML session record", { error: e }));
3072
+ await ctx.context.internalAdapter.createVerificationValue({
3073
+ identifier: `${SAML_SESSION_BY_ID_PREFIX}${session.id}`,
3074
+ value: samlSessionKey,
3075
+ expiresAt: session.expiresAt
3076
+ }).catch((e) => ctx.context.logger.warn("Failed to create SAML session lookup record", e));
3077
+ }
2861
3078
  const safeRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
2862
3079
  throw ctx.redirect(safeRedirectUrl);
2863
3080
  });
2864
3081
  };
3082
+ const sloSchema = z.object({
3083
+ SAMLRequest: z.string().optional(),
3084
+ SAMLResponse: z.string().optional(),
3085
+ RelayState: z.string().optional(),
3086
+ SigAlg: z.string().optional(),
3087
+ Signature: z.string().optional()
3088
+ });
3089
+ const sloEndpoint = (options) => {
3090
+ return createAuthEndpoint("/sso/saml2/sp/slo/:providerId", {
3091
+ method: ["GET", "POST"],
3092
+ body: sloSchema.optional(),
3093
+ query: sloSchema.optional(),
3094
+ metadata: {
3095
+ ...HIDE_METADATA,
3096
+ allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"]
3097
+ }
3098
+ }, async (ctx) => {
3099
+ if (!options?.saml?.enableSingleLogout) throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.SINGLE_LOGOUT_NOT_ENABLED);
3100
+ const { providerId } = ctx.params;
3101
+ const samlRequest = ctx.body?.SAMLRequest || ctx.query?.SAMLRequest;
3102
+ const samlResponse = ctx.body?.SAMLResponse || ctx.query?.SAMLResponse;
3103
+ const relayState = ctx.body?.RelayState || ctx.query?.RelayState;
3104
+ const appOrigin = new URL(ctx.context.baseURL).origin;
3105
+ const safeErrorURL = getSafeRedirectUrl(relayState, `${appOrigin}/sso/saml2/sp/slo/${providerId}`, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
3106
+ if (!samlRequest && !samlResponse) throw ctx.redirect(`${safeErrorURL}?error=invalid_request&error_description=missing_logout_data`);
3107
+ const provider = await findSAMLProvider(providerId, options, ctx.context.adapter);
3108
+ if (!provider?.samlConfig) throw APIError.from("NOT_FOUND", SAML_ERROR_CODES.SAML_PROVIDER_NOT_FOUND);
3109
+ const config = provider.samlConfig;
3110
+ const sp = createSP(config, ctx.context.baseURL, providerId, {
3111
+ wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned,
3112
+ wantLogoutResponseSigned: options?.saml?.wantLogoutResponseSigned
3113
+ });
3114
+ const idp = createIdP(config);
3115
+ if (samlResponse) return handleLogoutResponse(ctx, sp, idp, relayState, providerId);
3116
+ return handleLogoutRequest(ctx, sp, idp, relayState, providerId);
3117
+ });
3118
+ };
3119
+ async function handleLogoutResponse(ctx, sp, idp, relayState, providerId) {
3120
+ const binding = ctx.method === "POST" && ctx.body?.SAMLResponse ? "post" : "redirect";
3121
+ let parsed;
3122
+ try {
3123
+ parsed = await sp.parseLogoutResponse(idp, binding, {
3124
+ body: ctx.body,
3125
+ query: ctx.query
3126
+ });
3127
+ } catch (error) {
3128
+ ctx.context.logger.error("LogoutResponse validation failed", { error });
3129
+ throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.INVALID_LOGOUT_RESPONSE);
3130
+ }
3131
+ const extract = parsed?.extract;
3132
+ const statusCode = extract?.statusCode || extract?.status || parsed?.samlContent?.status?.statusCode;
3133
+ if (statusCode && statusCode !== SAML_STATUS_SUCCESS) {
3134
+ ctx.context.logger.warn("LogoutResponse indicates failure", { statusCode });
3135
+ throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.LOGOUT_FAILED_AT_IDP);
3136
+ }
3137
+ const inResponseTo = extract?.response?.inResponseTo;
3138
+ if (inResponseTo) {
3139
+ const key = `${LOGOUT_REQUEST_KEY_PREFIX}${inResponseTo}`;
3140
+ if (!await ctx.context.internalAdapter.findVerificationValue(key)) ctx.context.logger.warn("LogoutResponse references unknown or expired request", { inResponseTo });
3141
+ await ctx.context.internalAdapter.deleteVerificationValue(key).catch((e) => ctx.context.logger.warn("Failed to delete logout request verification value", e));
3142
+ }
3143
+ deleteSessionCookie(ctx);
3144
+ const appOrigin = new URL(ctx.context.baseURL).origin;
3145
+ const safeRedirectUrl = getSafeRedirectUrl(relayState, `${appOrigin}/sso/saml2/sp/slo/${providerId}`, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
3146
+ throw ctx.redirect(safeRedirectUrl);
3147
+ }
3148
+ async function handleLogoutRequest(ctx, sp, idp, relayState, providerId) {
3149
+ const binding = ctx.method === "POST" && ctx.body?.SAMLRequest ? "post" : "redirect";
3150
+ let parsed;
3151
+ try {
3152
+ parsed = await sp.parseLogoutRequest(idp, binding, {
3153
+ body: ctx.body,
3154
+ query: ctx.query
3155
+ });
3156
+ } catch (error) {
3157
+ ctx.context.logger.error("LogoutRequest validation failed", { error });
3158
+ throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.INVALID_LOGOUT_REQUEST);
3159
+ }
3160
+ if (!parsed?.extract) throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.INVALID_LOGOUT_REQUEST);
3161
+ const { nameID } = parsed.extract;
3162
+ const sessionIndex = parsed.extract.sessionIndex;
3163
+ const key = `${SAML_SESSION_KEY_PREFIX}${providerId}:${nameID}`;
3164
+ const stored = await ctx.context.internalAdapter.findVerificationValue(key);
3165
+ if (stored) {
3166
+ const data = safeJsonParse(stored.value);
3167
+ if (data) if (!sessionIndex || !data.sessionIndex || sessionIndex === data.sessionIndex) {
3168
+ await ctx.context.internalAdapter.deleteSession(data.sessionId).catch((e) => ctx.context.logger.warn("Failed to delete session during SLO", { error: e }));
3169
+ await ctx.context.internalAdapter.deleteVerificationValue(`${SAML_SESSION_BY_ID_PREFIX}${data.sessionId}`).catch((e) => ctx.context.logger.warn("Failed to delete SAML session lookup during SLO", e));
3170
+ } else ctx.context.logger.warn("SessionIndex mismatch in LogoutRequest - skipping session deletion", {
3171
+ providerId,
3172
+ requestedSessionIndex: sessionIndex,
3173
+ storedSessionIndex: data.sessionIndex
3174
+ });
3175
+ await ctx.context.internalAdapter.deleteVerificationValue(key).catch((e) => ctx.context.logger.warn("Failed to delete SAML session key during SLO", e));
3176
+ }
3177
+ const currentSession = await getSessionFromCtx(ctx);
3178
+ if (currentSession?.session) await ctx.context.internalAdapter.deleteSession(currentSession.session.id);
3179
+ deleteSessionCookie(ctx);
3180
+ const requestId = parsed.extract.request?.id || "";
3181
+ const res = sp.createLogoutResponse(idp, null, binding, relayState || "", (template) => template.replace("{InResponseTo}", requestId).replace("{StatusCode}", SAML_STATUS_SUCCESS));
3182
+ if (binding === "post" && res.entityEndpoint) return createSAMLPostForm(res.entityEndpoint, "SAMLResponse", res.context, relayState);
3183
+ throw ctx.redirect(res.context);
3184
+ }
3185
+ const initiateSLO = (options) => {
3186
+ return createAuthEndpoint("/sso/saml2/logout/:providerId", {
3187
+ method: "POST",
3188
+ body: z.object({ callbackURL: z.string().optional() }),
3189
+ use: [sessionMiddleware],
3190
+ metadata: HIDE_METADATA
3191
+ }, async (ctx) => {
3192
+ if (!options?.saml?.enableSingleLogout) throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.SINGLE_LOGOUT_NOT_ENABLED);
3193
+ const { providerId } = ctx.params;
3194
+ const callbackURL = ctx.body.callbackURL || ctx.context.baseURL;
3195
+ const provider = await findSAMLProvider(providerId, options, ctx.context.adapter);
3196
+ if (!provider?.samlConfig) throw APIError.from("NOT_FOUND", SAML_ERROR_CODES.SAML_PROVIDER_NOT_FOUND);
3197
+ const config = provider.samlConfig;
3198
+ if (!(config.idpMetadata?.singleLogoutService?.length || config.idpMetadata?.metadata && config.idpMetadata.metadata.includes("SingleLogoutService"))) throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.IDP_SLO_NOT_SUPPORTED);
3199
+ const sp = createSP(config, ctx.context.baseURL, providerId, {
3200
+ wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned,
3201
+ wantLogoutResponseSigned: options?.saml?.wantLogoutResponseSigned
3202
+ });
3203
+ const idp = createIdP(config);
3204
+ const session = ctx.context.session;
3205
+ const sessionLookupKey = `${SAML_SESSION_BY_ID_PREFIX}${session.session.id}`;
3206
+ const sessionLookup = await ctx.context.internalAdapter.findVerificationValue(sessionLookupKey);
3207
+ let nameID = session.user.email;
3208
+ let sessionIndex;
3209
+ let samlSessionKey;
3210
+ if (sessionLookup) {
3211
+ samlSessionKey = sessionLookup.value;
3212
+ const stored = await ctx.context.internalAdapter.findVerificationValue(samlSessionKey);
3213
+ if (stored) {
3214
+ const data = safeJsonParse(stored.value);
3215
+ if (data) {
3216
+ nameID = data.nameID || nameID;
3217
+ sessionIndex = data.sessionIndex;
3218
+ }
3219
+ }
3220
+ }
3221
+ const logoutRequest = sp.createLogoutRequest(idp, "redirect", {
3222
+ logoutNameID: nameID,
3223
+ sessionIndex,
3224
+ relayState: callbackURL
3225
+ });
3226
+ const ttl = options?.saml?.logoutRequestTTL ?? DEFAULT_LOGOUT_REQUEST_TTL_MS;
3227
+ await ctx.context.internalAdapter.createVerificationValue({
3228
+ identifier: `${LOGOUT_REQUEST_KEY_PREFIX}${logoutRequest.id}`,
3229
+ value: providerId,
3230
+ expiresAt: new Date(Date.now() + ttl)
3231
+ });
3232
+ if (samlSessionKey) await ctx.context.internalAdapter.deleteVerificationValue(samlSessionKey).catch((e) => ctx.context.logger.warn("Failed to delete SAML session key during logout", e));
3233
+ await ctx.context.internalAdapter.deleteVerificationValue(sessionLookupKey).catch((e) => ctx.context.logger.warn("Failed to delete session lookup key during logout", e));
3234
+ await ctx.context.internalAdapter.deleteSession(session.session.id);
3235
+ deleteSessionCookie(ctx);
3236
+ throw ctx.redirect(logoutRequest.context);
3237
+ });
3238
+ };
2865
3239
 
2866
3240
  //#endregion
2867
3241
  //#region src/index.ts
@@ -2874,16 +3248,23 @@ saml.setSchemaValidator({ async validate(xml) {
2874
3248
  * These endpoints receive POST requests from external Identity Providers,
2875
3249
  * which won't have a matching Origin header.
2876
3250
  */
2877
- const SAML_SKIP_ORIGIN_CHECK_PATHS = ["/sso/saml2/callback", "/sso/saml2/sp/acs"];
3251
+ const SAML_SKIP_ORIGIN_CHECK_PATHS = [
3252
+ "/sso/saml2/callback",
3253
+ "/sso/saml2/sp/acs",
3254
+ "/sso/saml2/sp/slo"
3255
+ ];
2878
3256
  function sso(options) {
2879
3257
  const optionsWithStore = options;
2880
3258
  let endpoints = {
2881
- spMetadata: spMetadata(),
3259
+ spMetadata: spMetadata(optionsWithStore),
2882
3260
  registerSSOProvider: registerSSOProvider(optionsWithStore),
2883
3261
  signInSSO: signInSSO(optionsWithStore),
2884
3262
  callbackSSO: callbackSSO(optionsWithStore),
3263
+ callbackSSOShared: callbackSSOShared(optionsWithStore),
2885
3264
  callbackSSOSAML: callbackSSOSAML(optionsWithStore),
2886
3265
  acsEndpoint: acsEndpoint(optionsWithStore),
3266
+ sloEndpoint: sloEndpoint(optionsWithStore),
3267
+ initiateSLO: initiateSLO(optionsWithStore),
2887
3268
  listSSOProviders: listSSOProviders(),
2888
3269
  getSSOProvider: getSSOProvider(),
2889
3270
  updateSSOProvider: updateSSOProvider(optionsWithStore),
@@ -2907,21 +3288,39 @@ function sso(options) {
2907
3288
  return { context: { skipOriginCheck: [...Array.isArray(existing) ? existing : [], ...SAML_SKIP_ORIGIN_CHECK_PATHS] } };
2908
3289
  },
2909
3290
  endpoints,
2910
- hooks: { after: [{
2911
- matcher(context) {
2912
- return context.path?.startsWith("/callback/") ?? false;
2913
- },
2914
- handler: createAuthMiddleware(async (ctx) => {
2915
- const newSession = ctx.context.newSession;
2916
- if (!newSession?.user) return;
2917
- if (!ctx.context.hasPlugin("organization")) return;
2918
- await assignOrganizationByDomain(ctx, {
2919
- user: newSession.user,
2920
- provisioningOptions: options?.organizationProvisioning,
2921
- domainVerification: options?.domainVerification
2922
- });
2923
- })
2924
- }] },
3291
+ hooks: {
3292
+ before: [{
3293
+ matcher(context) {
3294
+ return context.path === "/sign-out";
3295
+ },
3296
+ handler: createAuthMiddleware(async (ctx) => {
3297
+ if (!options?.saml?.enableSingleLogout) return;
3298
+ const session = await getSessionFromCtx(ctx);
3299
+ if (!session?.session?.id) return;
3300
+ const sessionLookupKey = `${SAML_SESSION_BY_ID_PREFIX}${session.session.id}`;
3301
+ const sessionLookup = await ctx.context.internalAdapter.findVerificationValue(sessionLookupKey);
3302
+ if (sessionLookup?.value) {
3303
+ await ctx.context.internalAdapter.deleteVerificationValue(sessionLookup.value).catch(() => {});
3304
+ await ctx.context.internalAdapter.deleteVerificationValue(sessionLookupKey).catch(() => {});
3305
+ }
3306
+ })
3307
+ }],
3308
+ after: [{
3309
+ matcher(context) {
3310
+ return context.path?.startsWith("/callback/") ?? false;
3311
+ },
3312
+ handler: createAuthMiddleware(async (ctx) => {
3313
+ const newSession = ctx.context.newSession;
3314
+ if (!newSession?.user) return;
3315
+ if (!ctx.context.hasPlugin("organization")) return;
3316
+ await assignOrganizationByDomain(ctx, {
3317
+ user: newSession.user,
3318
+ provisioningOptions: options?.organizationProvisioning,
3319
+ domainVerification: options?.domainVerification
3320
+ });
3321
+ })
3322
+ }]
3323
+ },
2925
3324
  schema: { ssoProvider: {
2926
3325
  modelName: options?.modelName ?? "ssoProvider",
2927
3326
  fields: {