@better-auth/sso 1.7.0-rc.1 → 1.7.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,24 +1,25 @@
1
- import { t as PACKAGE_VERSION } from "./version-lPD-GbqS.mjs";
1
+ import { t as PACKAGE_VERSION } from "./version-m-Zfjx6T.mjs";
2
2
  import { APIError, addOAuthServerContext, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
3
3
  import { XMLParser, XMLValidator } from "fast-xml-parser";
4
4
  import { X509Certificate } from "node:crypto";
5
5
  import { getHostname } from "tldts";
6
6
  import { generateRandomString } from "better-auth/crypto";
7
7
  import * as z from "zod";
8
- import { getCurrentAdapter, runWithTransaction } from "@better-auth/core/context";
8
+ import { getCurrentAdapter, getCurrentDBAdapterAsyncLocalStorage, runWithTransaction } from "@better-auth/core/context";
9
9
  import { filterOutputFields } from "@better-auth/core/utils/db";
10
10
  import { classifyHost, isPublicRoutableHost } from "@better-auth/core/utils/host";
11
11
  import { betterFetch } from "@better-fetch/fetch";
12
- import { createRemoteJWKSet, customFetch, decodeJwt, jwtVerify } from "jose";
13
- import { base64 } from "@better-auth/utils/base64";
12
+ import { createRemoteJWKSet, customFetch, jwtVerify } from "jose";
13
+ import { base64, base64Url } from "@better-auth/utils/base64";
14
14
  import { defineErrorCodes } from "@better-auth/core/utils/error-codes";
15
+ import * as samlifyNamespace from "samlify";
16
+ import samlifyDefault from "samlify";
15
17
  import { parseInputData, toZodSchema } from "better-auth/db";
16
18
  import { isAPIError } from "@better-auth/core/utils/is-api-error";
17
19
  import { HIDE_METADATA, PRIVATE_KEY_JWT_SIGNING_ALGORITHMS, authorizationCodeRequest, createAuthorizationURL, createPrivateKeyJwtClientAssertionGetter, generateGenericState, generateState, getOAuth2Tokens, parseGenericState, parseState } from "better-auth";
18
- import { deleteSessionCookie, setSessionCookie } from "better-auth/cookies";
20
+ import { deleteSessionCookie, setAccountCookie, setSessionCookie } from "better-auth/cookies";
19
21
  import { additionalAuthorizationParamsSchema, handleOAuthUserInfo } from "better-auth/oauth2";
20
- import * as samlifyNamespace from "samlify";
21
- import samlifyDefault from "samlify";
22
+ import { createHash } from "@better-auth/utils/hash";
22
23
  //#region src/constants.ts
23
24
  /**
24
25
  * SAML Constants
@@ -60,6 +61,8 @@ const DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024;
60
61
  const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024;
61
62
  //#endregion
62
63
  //#region src/utils.ts
64
+ const unsafeSAMLRedirectPathPrefix = /^\/(?:\/|\\|%2f|%5c)/i;
65
+ const isSafeSAMLRedirectPath = (url) => url.startsWith("/") && !unsafeSAMLRedirectPathPrefix.test(url);
63
66
  /**
64
67
  * Safely parses a value that might be a JSON string or already a parsed object.
65
68
  * This handles cases where ORMs like Drizzle might return already parsed objects
@@ -515,15 +518,21 @@ async function fetchOIDCEndpointResponse(name, endpoint, init, isTrustedOrigin)
515
518
  if (isHttpRedirectStatus(response.status)) throwRedirectError(name, endpoint, response.status, response.headers.get("location"));
516
519
  return response;
517
520
  }
521
+ /** Enforces the OpenID Connect authorized-party rules for ID Tokens. */
522
+ function assertOIDCAuthorizedParty(payload, clientId) {
523
+ if (Array.isArray(payload.aud) && payload.aud.length > 1 && payload.azp === void 0 || payload.azp !== void 0 && payload.azp !== clientId) throw new Error("OIDC ID token authorized party does not match the client");
524
+ }
518
525
  /**
519
526
  * Validate an OIDC ID token using the same endpoint fetch policy as the rest of
520
527
  * the SSO OIDC flow.
521
528
  */
522
529
  async function validateOIDCIdToken(token, jwksEndpoint, options, isTrustedOrigin) {
523
- return jwtVerify(token, createRemoteJWKSet(new URL(jwksEndpoint), { [customFetch]: (url, init) => fetchOIDCEndpointResponse("jwksEndpoint", url, init, isTrustedOrigin) }), {
530
+ const verified = await jwtVerify(token, createRemoteJWKSet(new URL(jwksEndpoint), { [customFetch]: (url, init) => fetchOIDCEndpointResponse("jwksEndpoint", url, init, isTrustedOrigin) }), {
524
531
  audience: options.audience,
525
532
  issuer: options.issuer
526
533
  });
534
+ if (typeof options.audience === "string") assertOIDCAuthorizedParty(verified.payload, options.audience);
535
+ return verified;
527
536
  }
528
537
  /**
529
538
  * Fetch the OIDC discovery document from the IdP.
@@ -1301,8 +1310,131 @@ function validateAudience(c, ctx) {
1301
1310
  throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Audience mismatch"));
1302
1311
  }
1303
1312
  }
1313
+ const saml = typeof samlifyNamespace.SPMetadata === "function" && typeof samlifyNamespace.setSchemaValidator === "function" ? samlifyNamespace : samlifyDefault ?? samlifyNamespace;
1314
+ //#endregion
1315
+ //#region src/routes/helpers.ts
1316
+ /**
1317
+ * Same as `normalizePem`, but applied across the resolved list of IdP signing
1318
+ * certificates so multi-cert rotation configs survive the line-trim step.
1319
+ */
1320
+ function normalizePemList(certs) {
1321
+ if (!certs) return certs;
1322
+ return certs.map((pem) => normalizePem(pem) ?? pem);
1323
+ }
1324
+ async function findSAMLProvider(providerId, options, adapter) {
1325
+ if (options?.defaultSSO?.length) {
1326
+ const match = options.defaultSSO.find((p) => p.providerId === providerId);
1327
+ if (match) return {
1328
+ ...match,
1329
+ userId: "default",
1330
+ issuer: match.samlConfig?.issuer || "",
1331
+ ...options.domainVerification?.enabled ? { domainVerified: true } : {}
1332
+ };
1333
+ }
1334
+ const res = await adapter.findOne({
1335
+ model: "ssoProvider",
1336
+ where: [{
1337
+ field: "providerId",
1338
+ value: providerId
1339
+ }]
1340
+ });
1341
+ if (!res) return null;
1342
+ return {
1343
+ ...res,
1344
+ samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
1345
+ };
1346
+ }
1347
+ function createSP(config, baseURL, providerId, opts) {
1348
+ const spData = config.spMetadata;
1349
+ const sloLocation = `${baseURL}/sso/saml2/sp/slo/${providerId}`;
1350
+ const acsUrl = `${baseURL}/sso/saml2/sp/acs/${providerId}`;
1351
+ let metadata = spData?.metadata;
1352
+ if (!metadata) metadata = saml.SPMetadata({
1353
+ entityID: spData?.entityID || config.issuer,
1354
+ assertionConsumerService: [{
1355
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1356
+ Location: acsUrl
1357
+ }],
1358
+ singleLogoutService: opts?.sloOptions ? [{
1359
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1360
+ Location: sloLocation
1361
+ }, {
1362
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1363
+ Location: sloLocation
1364
+ }] : void 0,
1365
+ wantMessageSigned: config.wantAssertionsSigned || false,
1366
+ authnRequestsSigned: config.authnRequestsSigned || false,
1367
+ nameIDFormat: config.identifierFormat ? [config.identifierFormat] : void 0
1368
+ }).getMetadata() || "";
1369
+ return saml.ServiceProvider({
1370
+ metadata,
1371
+ allowCreate: true,
1372
+ wantLogoutRequestSigned: opts?.sloOptions?.wantLogoutRequestSigned ?? false,
1373
+ wantLogoutResponseSigned: opts?.sloOptions?.wantLogoutResponseSigned ?? false,
1374
+ privateKey: normalizePem(spData?.privateKey || config.privateKey),
1375
+ privateKeyPass: spData?.privateKeyPass,
1376
+ isAssertionEncrypted: spData?.isAssertionEncrypted || false,
1377
+ encPrivateKey: normalizePem(spData?.encPrivateKey),
1378
+ encPrivateKeyPass: spData?.encPrivateKeyPass,
1379
+ relayState: opts?.relayState,
1380
+ clockDrifts: opts?.clockSkew && opts?.clockSkew !== 0 ? [-opts.clockSkew, opts.clockSkew] : void 0
1381
+ });
1382
+ }
1383
+ function assertSAMLIdentityProviderAuthority(config) {
1384
+ if (config.idpMetadata?.metadata || config.idpMetadata?.entityID) return;
1385
+ throw new APIError("BAD_REQUEST", { message: "SAML manual IdP configuration requires idpMetadata.entityID; issuer identifies the service provider and cannot identify the IdP" });
1386
+ }
1387
+ function createIdP(config) {
1388
+ assertSAMLIdentityProviderAuthority(config);
1389
+ const idpData = config.idpMetadata;
1390
+ if (idpData?.metadata) return saml.IdentityProvider({
1391
+ metadata: idpData.metadata,
1392
+ privateKey: normalizePem(idpData.privateKey),
1393
+ privateKeyPass: idpData.privateKeyPass,
1394
+ isAssertionEncrypted: idpData.isAssertionEncrypted,
1395
+ encPrivateKey: normalizePem(idpData.encPrivateKey),
1396
+ encPrivateKeyPass: idpData.encPrivateKeyPass
1397
+ });
1398
+ return saml.IdentityProvider({
1399
+ entityID: idpData.entityID,
1400
+ singleSignOnService: idpData.singleSignOnService || [{
1401
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1402
+ Location: config.entryPoint
1403
+ }],
1404
+ singleLogoutService: idpData.singleLogoutService,
1405
+ signingCert: normalizePemList(resolveSigningCerts(config)),
1406
+ wantAuthnRequestsSigned: config.authnRequestsSigned || false,
1407
+ isAssertionEncrypted: idpData.isAssertionEncrypted || false,
1408
+ encPrivateKey: normalizePem(idpData.encPrivateKey),
1409
+ encPrivateKeyPass: idpData.encPrivateKeyPass
1410
+ });
1411
+ }
1412
+ function escapeHtml(str) {
1413
+ if (!str) return "";
1414
+ return String(str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1415
+ }
1416
+ function isSAMLPostBindingLocation(value) {
1417
+ let url;
1418
+ try {
1419
+ url = new URL(value);
1420
+ } catch {
1421
+ return false;
1422
+ }
1423
+ return url.protocol === "http:" || url.protocol === "https:";
1424
+ }
1425
+ function createSAMLPostForm(action, samlParam, samlValue, relayState) {
1426
+ if (!isSAMLPostBindingLocation(action)) throw new APIError("BAD_REQUEST", { message: "SAML POST binding location must be an absolute http or https URL" });
1427
+ const safeAction = escapeHtml(action);
1428
+ const safeSamlParam = escapeHtml(samlParam);
1429
+ const safeSamlValue = escapeHtml(samlValue);
1430
+ const safeRelayState = relayState ? escapeHtml(relayState) : void 0;
1431
+ 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>`;
1432
+ return new Response(html, { headers: { "Content-Type": "text/html" } });
1433
+ }
1304
1434
  //#endregion
1305
1435
  //#region src/routes/schemas.ts
1436
+ const absoluteUrlSchema = z.url();
1437
+ const samlRedirectUrlSchema = z.string().refine((url) => isSafeSAMLRedirectPath(url) || absoluteUrlSchema.safeParse(url).success, { message: "Expected an absolute URL or a relative path starting with /" });
1306
1438
  function getSSOProviderAdditionalFields$1(options) {
1307
1439
  return options?.schema?.ssoProvider?.additionalFields ?? {};
1308
1440
  }
@@ -1327,16 +1459,14 @@ function parseSSOProviderAdditionalFields(options, data, action) {
1327
1459
  action
1328
1460
  });
1329
1461
  }
1330
- const oidcMappingSchema = z.object({
1331
- id: z.string().meta({ description: "Field mapping for user ID (defaults to 'sub')" }),
1462
+ const oidcMappingSchema = z.strictObject({
1332
1463
  email: z.string().meta({ description: "Field mapping for email (defaults to 'email')" }),
1333
1464
  emailVerified: z.string().meta({ description: "Field mapping for email verification (defaults to 'email_verified')" }).optional(),
1334
1465
  name: z.string().meta({ description: "Field mapping for name (defaults to 'name')" }),
1335
1466
  image: z.string().meta({ description: "Field mapping for image (defaults to 'picture')" }).optional(),
1336
1467
  extraFields: z.record(z.string(), z.any()).optional()
1337
1468
  }).optional();
1338
- const samlMappingSchema = z.object({
1339
- id: z.string().meta({ description: "Field mapping for user ID (defaults to 'nameID')" }),
1469
+ const samlMappingSchema = z.strictObject({
1340
1470
  email: z.string().meta({ description: "Field mapping for email (defaults to 'email')" }),
1341
1471
  emailVerified: z.string().meta({ description: "Field mapping for email verification" }).optional(),
1342
1472
  name: z.string().meta({ description: "Field mapping for name (defaults to 'displayName')" }),
@@ -1366,29 +1496,40 @@ const oidcConfigSchema = z.object({
1366
1496
  overrideUserInfo: z.boolean().optional(),
1367
1497
  mapping: oidcMappingSchema
1368
1498
  });
1499
+ const samlIdentityProviderConfigSchema = z.object({
1500
+ cert: signingCertSchema.meta({ description: "IdP signing certificate(s). Pass a single PEM string or an array for rolling rotation. Takes precedence over the top-level `cert`." }).optional(),
1501
+ privateKey: z.string().optional(),
1502
+ privateKeyPass: z.string().optional(),
1503
+ isAssertionEncrypted: z.boolean().optional(),
1504
+ encPrivateKey: z.string().optional(),
1505
+ encPrivateKeyPass: z.string().optional(),
1506
+ singleSignOnService: z.array(z.object({
1507
+ Binding: z.string().meta({ description: "The binding type for the SSO service" }),
1508
+ Location: z.string().url().meta({ description: "The URL for the SSO service" })
1509
+ })).meta({ description: "Single Sign-On service configuration" }).optional(),
1510
+ singleLogoutService: z.array(z.object({
1511
+ Binding: z.string(),
1512
+ Location: z.string().url()
1513
+ })).optional()
1514
+ });
1515
+ const samlIdentityProviderMetadataSchema = z.union([samlIdentityProviderConfigSchema.extend({
1516
+ metadata: z.string().min(1),
1517
+ entityID: z.string().optional()
1518
+ }), samlIdentityProviderConfigSchema.extend({
1519
+ metadata: z.undefined().optional(),
1520
+ entityID: z.string().min(1)
1521
+ })], "idpMetadata.entityID is required when IdP metadata XML is not provided");
1522
+ const samlIdentityProviderMetadataUpdateSchema = samlIdentityProviderConfigSchema.extend({
1523
+ metadata: z.string().min(1).optional(),
1524
+ entityID: z.string().min(1).optional()
1525
+ });
1369
1526
  const samlConfigSchema = z.object({
1370
1527
  entryPoint: z.string().url().meta({ description: "The IdP SSO URL (entry point)" }),
1371
1528
  cert: signingCertSchema.meta({ description: "IdP signing certificate(s). Pass a single PEM string or an array for rolling rotation. Omit when `idpMetadata.metadata` XML carries the certs. When both this and `idpMetadata.cert` are set, `idpMetadata.cert` wins." }).optional(),
1372
1529
  audience: z.string().optional(),
1373
1530
  callbackUrl: z.string().refine((url) => !url.includes("#"), { message: "callbackUrl must not contain a fragment" }).optional(),
1374
- idpMetadata: z.object({
1375
- metadata: z.string().optional(),
1376
- entityID: z.string().optional(),
1377
- cert: signingCertSchema.meta({ description: "IdP signing certificate(s). Pass a single PEM string or an array for rolling rotation. Takes precedence over the top-level `cert`." }).optional(),
1378
- privateKey: z.string().optional(),
1379
- privateKeyPass: z.string().optional(),
1380
- isAssertionEncrypted: z.boolean().optional(),
1381
- encPrivateKey: z.string().optional(),
1382
- encPrivateKeyPass: z.string().optional(),
1383
- singleSignOnService: z.array(z.object({
1384
- Binding: z.string().meta({ description: "The binding type for the SSO service" }),
1385
- Location: z.string().url().meta({ description: "The URL for the SSO service" })
1386
- })).meta({ description: "Single Sign-On service configuration" }).optional(),
1387
- singleLogoutService: z.array(z.object({
1388
- Binding: z.string(),
1389
- Location: z.string().url()
1390
- })).optional()
1391
- }).optional(),
1531
+ idpInitiatedCallbackUrl: samlRedirectUrlSchema.optional(),
1532
+ idpMetadata: samlIdentityProviderMetadataSchema,
1392
1533
  spMetadata: z.object({
1393
1534
  metadata: z.string().optional(),
1394
1535
  entityID: z.string().optional(),
@@ -1423,7 +1564,10 @@ const updateSSOProviderBodySchema = z.object({
1423
1564
  issuer: z.string().url().optional(),
1424
1565
  domain: z.string().optional(),
1425
1566
  oidcConfig: oidcConfigSchema.partial().optional(),
1426
- samlConfig: samlConfigSchema.partial().optional()
1567
+ samlConfig: samlConfigSchema.omit({ idpMetadata: true }).partial().extend({
1568
+ idpInitiatedCallbackUrl: samlRedirectUrlSchema.nullable().optional(),
1569
+ idpMetadata: samlIdentityProviderMetadataUpdateSchema.optional()
1570
+ }).optional()
1427
1571
  });
1428
1572
  function getUpdateSSOProviderBodySchema(options) {
1429
1573
  return updateSSOProviderBodySchema.extend({
@@ -1454,12 +1598,12 @@ const SAML_IDP_BOUNDARY_FIELDS = [
1454
1598
  "singleSignOnService"
1455
1599
  ];
1456
1600
  const SAML_SP_BOUNDARY_FIELDS = ["metadata", "entityID"];
1457
- function isRecord(value) {
1601
+ function isRecord$2(value) {
1458
1602
  return typeof value === "object" && value !== null && !Array.isArray(value);
1459
1603
  }
1460
1604
  function stableStringify(value) {
1461
1605
  if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
1462
- if (isRecord(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
1606
+ if (isRecord$2(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
1463
1607
  return JSON.stringify(value) ?? String(value);
1464
1608
  }
1465
1609
  function identityValueChanged(current, updated) {
@@ -1469,10 +1613,10 @@ function hasChangedField(current, updated, fields) {
1469
1613
  return fields.some((field) => identityValueChanged(current?.[field], updated?.[field]));
1470
1614
  }
1471
1615
  function oidcIdentityBoundaryChanged(current, updated) {
1472
- return hasChangedField(current, updated, OIDC_IDENTITY_BOUNDARY_FIELDS) || identityValueChanged(current.mapping?.id, updated.mapping?.id);
1616
+ return hasChangedField(current, updated, OIDC_IDENTITY_BOUNDARY_FIELDS);
1473
1617
  }
1474
1618
  function samlIdentityBoundaryChanged(current, updated) {
1475
- return hasChangedField(current, updated, SAML_IDENTITY_BOUNDARY_FIELDS) || identityValueChanged(current.mapping?.id, updated.mapping?.id) || hasChangedField(current.idpMetadata, updated.idpMetadata, SAML_IDP_BOUNDARY_FIELDS) || hasChangedField(current.spMetadata, updated.spMetadata, SAML_SP_BOUNDARY_FIELDS);
1619
+ return hasChangedField(current, updated, SAML_IDENTITY_BOUNDARY_FIELDS) || hasChangedField(current.idpMetadata, updated.idpMetadata, SAML_IDP_BOUNDARY_FIELDS) || hasChangedField(current.spMetadata, updated.spMetadata, SAML_SP_BOUNDARY_FIELDS);
1476
1620
  }
1477
1621
  function parseConfigSnapshot(config, configType) {
1478
1622
  if (!config) return;
@@ -1499,14 +1643,12 @@ async function lockSSOProviderRow(adapter, providerId) {
1499
1643
  });
1500
1644
  }
1501
1645
  async function lockSSOProviderForAccountLink(ctx, provider) {
1646
+ if (typeof provider.id !== "string") return;
1502
1647
  const lockedProvider = await lockSSOProviderRow(ctx.context.adapter, provider.providerId);
1503
- if (!lockedProvider) {
1504
- if (provider.userId === "default") return;
1505
- throw new APIError("CONFLICT", {
1506
- code: "SSO_PROVIDER_CHANGED",
1507
- message: "SSO provider changed while account linking was in progress"
1508
- });
1509
- }
1648
+ if (!lockedProvider) throw new APIError("CONFLICT", {
1649
+ code: "SSO_PROVIDER_CHANGED",
1650
+ message: "SSO provider changed while account linking was in progress"
1651
+ });
1510
1652
  if (ssoProviderIdentityBoundaryChanged(provider, lockedProvider)) throw new APIError("CONFLICT", {
1511
1653
  code: "SSO_PROVIDER_CHANGED",
1512
1654
  message: "SSO provider changed while account linking was in progress"
@@ -1607,6 +1749,8 @@ function sanitizeProvider(provider, baseURL, options) {
1607
1749
  } : void 0,
1608
1750
  samlConfig: samlConfig ? {
1609
1751
  entryPoint: samlConfig.entryPoint,
1752
+ callbackUrl: samlConfig.callbackUrl,
1753
+ idpInitiatedCallbackUrl: samlConfig.idpInitiatedCallbackUrl,
1610
1754
  audience: samlConfig.audience,
1611
1755
  wantAssertionsSigned: samlConfig.wantAssertionsSigned,
1612
1756
  authnRequestsSigned: samlConfig.authnRequestsSigned,
@@ -1696,6 +1840,15 @@ function parseAndValidateConfig(configString, configType) {
1696
1840
  if (!config) throw new APIError("BAD_REQUEST", { message: `Cannot update ${configType} config for a provider that doesn't have ${configType} configured` });
1697
1841
  return config;
1698
1842
  }
1843
+ function mergeSAMLIdentityProviderMetadata(current, updates) {
1844
+ if (!updates) return current;
1845
+ const config = { idpMetadata: {
1846
+ ...current,
1847
+ ...updates
1848
+ } };
1849
+ assertSAMLIdentityProviderAuthority(config);
1850
+ return config.idpMetadata;
1851
+ }
1699
1852
  function mergeSAMLConfig(current, updates, issuer) {
1700
1853
  return {
1701
1854
  ...current,
@@ -1703,11 +1856,15 @@ function mergeSAMLConfig(current, updates, issuer) {
1703
1856
  issuer,
1704
1857
  entryPoint: updates.entryPoint ?? current.entryPoint,
1705
1858
  cert: updates.cert ?? current.cert,
1706
- spMetadata: updates.spMetadata ?? current.spMetadata,
1707
- idpMetadata: updates.idpMetadata ?? current.idpMetadata,
1859
+ spMetadata: updates.spMetadata ? {
1860
+ ...current.spMetadata,
1861
+ ...updates.spMetadata
1862
+ } : current.spMetadata,
1863
+ idpMetadata: mergeSAMLIdentityProviderMetadata(current.idpMetadata, updates.idpMetadata),
1708
1864
  mapping: updates.mapping ?? current.mapping,
1709
1865
  audience: updates.audience ?? current.audience,
1710
1866
  callbackUrl: updates.callbackUrl ?? current.callbackUrl,
1867
+ idpInitiatedCallbackUrl: updates.idpInitiatedCallbackUrl === null ? void 0 : updates.idpInitiatedCallbackUrl ?? current.idpInitiatedCallbackUrl,
1711
1868
  wantAssertionsSigned: updates.wantAssertionsSigned ?? current.wantAssertionsSigned,
1712
1869
  authnRequestsSigned: updates.authnRequestsSigned ?? current.authnRequestsSigned,
1713
1870
  identifierFormat: updates.identifierFormat ?? current.identifierFormat,
@@ -1780,6 +1937,7 @@ const updateSSOProvider = (options) => {
1780
1937
  const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
1781
1938
  const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
1782
1939
  validateCertSources(updatedSamlConfig);
1940
+ assertSAMLIdentityProviderAuthority(updatedSamlConfig);
1783
1941
  if (samlIdentityBoundaryChanged(currentSamlConfig, updatedSamlConfig)) providerIdentityBoundaryChanged = true;
1784
1942
  updateData.samlConfig = JSON.stringify(updatedSamlConfig);
1785
1943
  }
@@ -1986,6 +2144,80 @@ const verifyDomain = (options) => {
1986
2144
  });
1987
2145
  };
1988
2146
  //#endregion
2147
+ //#region src/provider-reference.ts
2148
+ const SSO_PROVIDER_STATE_KEY = "ssoProviderReference";
2149
+ function serializeCanonical(value) {
2150
+ if (value === null) return "null";
2151
+ if (Array.isArray(value)) return `[${value.map((entry) => entry === void 0 ? "null" : serializeCanonical(entry)).join(",")}]`;
2152
+ switch (typeof value) {
2153
+ case "boolean":
2154
+ case "number":
2155
+ case "string": return JSON.stringify(value);
2156
+ case "object": return `{${Object.entries(value).filter(([, entry]) => entry !== void 0).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([key, entry]) => `${JSON.stringify(key)}:${serializeCanonical(entry)}`).join(",")}}`;
2157
+ default: throw new TypeError("SSO provider configuration must be JSON-serializable");
2158
+ }
2159
+ }
2160
+ function withoutOIDCSecret(configuration) {
2161
+ if (!configuration) return void 0;
2162
+ const result = { ...configuration };
2163
+ result.clientSecret = void 0;
2164
+ return result;
2165
+ }
2166
+ function getProviderSource(provider) {
2167
+ return typeof provider.id === "string" && provider.id.length > 0 ? {
2168
+ type: "persisted",
2169
+ recordId: provider.id
2170
+ } : { type: "configured" };
2171
+ }
2172
+ async function computeProviderAuthenticationFingerprint(provider) {
2173
+ const digest = await createHash("SHA-256").digest(serializeCanonical({
2174
+ domain: provider.domain,
2175
+ domainVerified: "domainVerified" in provider ? provider.domainVerified : void 0,
2176
+ issuer: provider.issuer,
2177
+ organizationId: provider.organizationId,
2178
+ oidcConfig: withoutOIDCSecret(provider.oidcConfig)
2179
+ }));
2180
+ return base64Url.encode(new Uint8Array(digest), { padding: false });
2181
+ }
2182
+ async function computeSSOProviderReference(provider) {
2183
+ return {
2184
+ providerId: provider.providerId,
2185
+ source: getProviderSource(provider),
2186
+ authenticationConfigurationFingerprint: await computeProviderAuthenticationFingerprint(provider)
2187
+ };
2188
+ }
2189
+ function isRecord$1(value) {
2190
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2191
+ }
2192
+ function parseSSOProviderReference(value) {
2193
+ if (!isRecord$1(value) || !isRecord$1(value.source)) return null;
2194
+ const providerId = value.providerId;
2195
+ const authenticationConfigurationFingerprint = value.authenticationConfigurationFingerprint;
2196
+ const source = value.source;
2197
+ if (typeof providerId !== "string" || providerId.length === 0 || typeof authenticationConfigurationFingerprint !== "string" || authenticationConfigurationFingerprint.length === 0) return null;
2198
+ if (source.type === "configured") return {
2199
+ providerId,
2200
+ source: { type: "configured" },
2201
+ authenticationConfigurationFingerprint
2202
+ };
2203
+ if (source.type === "persisted" && typeof source.recordId === "string" && source.recordId.length > 0) return {
2204
+ providerId,
2205
+ source: {
2206
+ type: "persisted",
2207
+ recordId: source.recordId
2208
+ },
2209
+ authenticationConfigurationFingerprint
2210
+ };
2211
+ return null;
2212
+ }
2213
+ async function isCurrentSSOProviderReference(provider, reference) {
2214
+ if (!reference || reference.providerId !== provider.providerId) return false;
2215
+ const source = getProviderSource(provider);
2216
+ if (source.type !== reference.source.type) return false;
2217
+ if (source.type === "persisted" && (reference.source.type !== "persisted" || source.recordId !== reference.source.recordId)) return false;
2218
+ return reference.authenticationConfigurationFingerprint === await computeProviderAuthenticationFingerprint(provider);
2219
+ }
2220
+ //#endregion
1989
2221
  //#region src/saml-state.ts
1990
2222
  async function generateRelayState(c, link) {
1991
2223
  const callbackURL = c.body.callbackURL;
@@ -1996,6 +2228,9 @@ async function generateRelayState(c, link) {
1996
2228
  errorURL: c.body.errorCallbackURL,
1997
2229
  newUserURL: c.body.newUserCallbackURL,
1998
2230
  link,
2231
+ /**
2232
+ * This is the actual expiry time of the state
2233
+ */
1999
2234
  expiresAt: Date.now() + 600 * 1e3,
2000
2235
  requestSignUp: c.body.requestSignUp
2001
2236
  };
@@ -2016,6 +2251,10 @@ async function parseRelayState(c) {
2016
2251
  try {
2017
2252
  parsedData = await parseGenericState(c, state, {
2018
2253
  cookieName: "relay_state",
2254
+ /**
2255
+ * SAML ACS receives a POST from the IdP, which is typically cross-origin.
2256
+ * SameSite=Lax (default) cookies are not sent on cross-site POST requests.
2257
+ */
2019
2258
  skipStateCookieCheck: true
2020
2259
  });
2021
2260
  } catch (error) {
@@ -2028,121 +2267,80 @@ async function parseRelayState(c) {
2028
2267
  if (!parsedData.errorURL) parsedData.errorURL = errorURL;
2029
2268
  return parsedData;
2030
2269
  }
2031
- const saml = typeof samlifyNamespace.SPMetadata === "function" && typeof samlifyNamespace.setSchemaValidator === "function" ? samlifyNamespace : samlifyDefault ?? samlifyNamespace;
2032
2270
  //#endregion
2033
- //#region src/routes/helpers.ts
2034
- /**
2035
- * Same as `normalizePem`, but applied across the resolved list of IdP signing
2036
- * certificates so multi-cert rotation configs survive the line-trim step.
2037
- */
2038
- function normalizePemList(certs) {
2039
- if (!certs) return certs;
2040
- return certs.map((pem) => normalizePem(pem) ?? pem);
2271
+ //#region src/user-resolution.ts
2272
+ const SSO_AUTHENTICATION_FAILURE = Symbol("SSO_AUTHENTICATION_FAILURE");
2273
+ function isRecord(value) {
2274
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2041
2275
  }
2042
- async function findSAMLProvider(providerId, options, adapter) {
2043
- if (options?.defaultSSO?.length) {
2044
- const match = options.defaultSSO.find((p) => p.providerId === providerId);
2045
- if (match) return {
2046
- ...match,
2047
- userId: "default",
2048
- issuer: match.samlConfig?.issuer || "",
2049
- ...options.domainVerification?.enabled ? { domainVerified: true } : {}
2050
- };
2051
- }
2052
- const res = await adapter.findOne({
2053
- model: "ssoProvider",
2054
- where: [{
2055
- field: "providerId",
2056
- value: providerId
2057
- }]
2058
- });
2059
- if (!res) return null;
2060
- return {
2061
- ...res,
2062
- samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
2063
- };
2276
+ function isNonEmptyString(value) {
2277
+ return typeof value === "string" && value.trim().length > 0;
2064
2278
  }
2065
- function createSP(config, baseURL, providerId, opts) {
2066
- const spData = config.spMetadata;
2067
- const sloLocation = `${baseURL}/sso/saml2/sp/slo/${providerId}`;
2068
- const acsUrl = `${baseURL}/sso/saml2/sp/acs/${providerId}`;
2069
- let metadata = spData?.metadata;
2070
- if (!metadata) metadata = saml.SPMetadata({
2071
- entityID: spData?.entityID || config.issuer,
2072
- assertionConsumerService: [{
2073
- Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
2074
- Location: acsUrl
2075
- }],
2076
- singleLogoutService: opts?.sloOptions ? [{
2077
- Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
2078
- Location: sloLocation
2079
- }, {
2080
- Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
2081
- Location: sloLocation
2082
- }] : void 0,
2083
- wantMessageSigned: config.wantAssertionsSigned || false,
2084
- authnRequestsSigned: config.authnRequestsSigned || false,
2085
- nameIDFormat: config.identifierFormat ? [config.identifierFormat] : void 0
2086
- }).getMetadata() || "";
2087
- return saml.ServiceProvider({
2088
- metadata,
2089
- allowCreate: true,
2090
- wantLogoutRequestSigned: opts?.sloOptions?.wantLogoutRequestSigned ?? false,
2091
- wantLogoutResponseSigned: opts?.sloOptions?.wantLogoutResponseSigned ?? false,
2092
- privateKey: normalizePem(spData?.privateKey || config.privateKey),
2093
- privateKeyPass: spData?.privateKeyPass,
2094
- isAssertionEncrypted: spData?.isAssertionEncrypted || false,
2095
- encPrivateKey: normalizePem(spData?.encPrivateKey),
2096
- encPrivateKeyPass: spData?.encPrivateKeyPass,
2097
- relayState: opts?.relayState,
2098
- clockDrifts: opts?.clockSkew && opts?.clockSkew !== 0 ? [-opts.clockSkew, opts.clockSkew] : void 0
2099
- });
2279
+ function isSSOUserResolution(value) {
2280
+ if (!isRecord(value)) return false;
2281
+ if (value.action === "continue") return true;
2282
+ if (value.action === "link") return isNonEmptyString(value.userId) && (value.profile === "preserve" || value.profile === "update");
2283
+ return value.action === "reject" && isNonEmptyString(value.code) && (value.message === void 0 || typeof value.message === "string");
2100
2284
  }
2101
- function createIdP(config) {
2102
- const idpData = config.idpMetadata;
2103
- if (idpData?.metadata) return saml.IdentityProvider({
2104
- metadata: idpData.metadata,
2105
- privateKey: normalizePem(idpData.privateKey),
2106
- privateKeyPass: idpData.privateKeyPass,
2107
- isAssertionEncrypted: idpData.isAssertionEncrypted,
2108
- encPrivateKey: normalizePem(idpData.encPrivateKey),
2109
- encPrivateKeyPass: idpData.encPrivateKeyPass
2110
- });
2111
- return saml.IdentityProvider({
2112
- entityID: idpData?.entityID || config.issuer,
2113
- singleSignOnService: idpData?.singleSignOnService || [{
2114
- Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
2115
- Location: config.entryPoint
2116
- }],
2117
- singleLogoutService: idpData?.singleLogoutService,
2118
- signingCert: normalizePemList(resolveSigningCerts(config)),
2119
- wantAuthnRequestsSigned: config.authnRequestsSigned || false,
2120
- isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
2121
- encPrivateKey: normalizePem(idpData?.encPrivateKey),
2122
- encPrivateKeyPass: idpData?.encPrivateKeyPass
2285
+ function logFailure(logger, message, error) {
2286
+ try {
2287
+ logger.error(message, ...error === void 0 ? [] : [error]);
2288
+ } catch {}
2289
+ }
2290
+ function resolutionFailure() {
2291
+ return new APIError("INTERNAL_SERVER_ERROR", {
2292
+ code: "SSO_USER_RESOLUTION_FAILED",
2293
+ message: "Unable to resolve the SSO user"
2123
2294
  });
2124
2295
  }
2125
- function escapeHtml(str) {
2126
- if (!str) return "";
2127
- return String(str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
2296
+ async function resolveSSOUser(resolveUser, input, database, logger) {
2297
+ let resolution;
2298
+ try {
2299
+ resolution = await resolveUser(input, { database });
2300
+ } catch (error) {
2301
+ logFailure(logger, "SSO user resolution failed", error);
2302
+ throw resolutionFailure();
2303
+ }
2304
+ if (!isSSOUserResolution(resolution)) {
2305
+ logFailure(logger, "SSO user resolver returned an invalid decision");
2306
+ throw resolutionFailure();
2307
+ }
2308
+ return resolution;
2128
2309
  }
2129
- function isSAMLPostBindingLocation(value) {
2130
- let url;
2310
+ function assertSSOUserResolutionNativeTransactionSupport(adapter) {
2311
+ if (typeof adapter.options?.adapterConfig.transaction === "function") return;
2312
+ throw new APIError("NOT_IMPLEMENTED", {
2313
+ code: "SSO_USER_RESOLUTION_REQUIRES_NATIVE_TRANSACTIONS",
2314
+ message: "SSO user resolution requires a database adapter with native transaction support"
2315
+ });
2316
+ }
2317
+ async function assertSSOUserResolutionAsyncContextSupport(getStorage = getCurrentDBAdapterAsyncLocalStorage) {
2131
2318
  try {
2132
- url = new URL(value);
2319
+ await getStorage();
2133
2320
  } catch {
2134
- return false;
2321
+ throw new APIError("NOT_IMPLEMENTED", {
2322
+ code: "SSO_USER_RESOLUTION_REQUIRES_ASYNC_CONTEXT",
2323
+ message: "SSO user resolution requires database transaction async context support"
2324
+ });
2135
2325
  }
2136
- return url.protocol === "http:" || url.protocol === "https:";
2137
2326
  }
2138
- function createSAMLPostForm(action, samlParam, samlValue, relayState) {
2139
- if (!isSAMLPostBindingLocation(action)) throw new APIError("BAD_REQUEST", { message: "SAML POST binding location must be an absolute http or https URL" });
2140
- const safeAction = escapeHtml(action);
2141
- const safeSamlParam = escapeHtml(samlParam);
2142
- const safeSamlValue = escapeHtml(samlValue);
2143
- const safeRelayState = relayState ? escapeHtml(relayState) : void 0;
2144
- 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>`;
2145
- return new Response(html, { headers: { "Content-Type": "text/html" } });
2327
+ function assertSSOUserResolutionSessionStorage(options) {
2328
+ if (!options.secondaryStorage || options.session?.storeSessionInDatabase === true && options.session.preserveSessionInDatabase !== true) return;
2329
+ throw new APIError("NOT_IMPLEMENTED", {
2330
+ code: "SSO_USER_RESOLUTION_REQUIRES_DATABASE_SESSIONS",
2331
+ message: "SSO user resolution requires database-backed sessions with database fallback"
2332
+ });
2333
+ }
2334
+ function requireSuccessfulSSOAuthentication(result) {
2335
+ if (!result.error) return result;
2336
+ throw Object.assign(/* @__PURE__ */ new Error("SSO authentication failed"), {
2337
+ [SSO_AUTHENTICATION_FAILURE]: true,
2338
+ result
2339
+ });
2340
+ }
2341
+ function getFailedSSOAuthenticationResult(error) {
2342
+ if (!(error instanceof Error) || !(SSO_AUTHENTICATION_FAILURE in error) || error[SSO_AUTHENTICATION_FAILURE] !== true || !("result" in error)) return;
2343
+ return error.result;
2146
2344
  }
2147
2345
  //#endregion
2148
2346
  //#region src/saml/timestamp.ts
@@ -2187,42 +2385,43 @@ function validateSAMLTimestamp(conditions, options = {}) {
2187
2385
  }
2188
2386
  //#endregion
2189
2387
  //#region src/routes/saml-pipeline.ts
2190
- /**
2191
- * Validates and returns a safe redirect URL.
2192
- * - Prevents open redirect attacks by validating against trusted origins
2193
- * - Prevents redirect loops by checking if URL points to callback route
2194
- * - Falls back to appOrigin if URL is invalid or unsafe
2195
- */
2196
- function getSafeRedirectUrl(url, callbackPath, appOrigin, isTrustedOrigin) {
2197
- if (!url) return appOrigin;
2198
- if (url.startsWith("/") && !url.startsWith("//")) {
2388
+ function getSafeRedirectCandidate(url, callbackPathname, appOrigin, isTrustedOrigin) {
2389
+ if (!url) return;
2390
+ if (url.startsWith("/")) {
2391
+ if (!isSafeSAMLRedirectPath(url)) return;
2199
2392
  try {
2200
2393
  const absoluteUrl = new URL(url, appOrigin);
2201
- if (absoluteUrl.origin !== appOrigin) return appOrigin;
2202
- const callbackPathname = new URL(callbackPath).pathname;
2203
- if (absoluteUrl.pathname === callbackPathname) return appOrigin;
2394
+ if (absoluteUrl.origin !== appOrigin || absoluteUrl.pathname === callbackPathname) return;
2395
+ return url;
2204
2396
  } catch {
2205
- return appOrigin;
2397
+ return;
2206
2398
  }
2207
- return url;
2208
2399
  }
2400
+ let absoluteUrl;
2209
2401
  try {
2210
- const absoluteUrl = new URL(url);
2211
- if (absoluteUrl.origin === appOrigin) {
2212
- const callbackPathname = new URL(callbackPath).pathname;
2213
- if (absoluteUrl.pathname === callbackPathname) return appOrigin;
2214
- return url;
2215
- }
2216
- } catch {}
2217
- if (!isTrustedOrigin(url, { allowRelativePaths: false })) return appOrigin;
2218
- try {
2219
- const callbackPathname = new URL(callbackPath).pathname;
2220
- if (new URL(url).pathname === callbackPathname) return appOrigin;
2402
+ absoluteUrl = new URL(url);
2221
2403
  } catch {
2222
- if (url === callbackPath || url.startsWith(`${callbackPath}?`)) return appOrigin;
2404
+ return;
2223
2405
  }
2406
+ if (absoluteUrl.origin !== appOrigin && !isTrustedOrigin(url, { allowRelativePaths: false })) return;
2407
+ if (absoluteUrl.origin === appOrigin && absoluteUrl.pathname === callbackPathname) return;
2224
2408
  return url;
2225
2409
  }
2410
+ /**
2411
+ * Returns the first safe redirect URL from an ordered list of candidates.
2412
+ * - Prevents open redirect attacks by validating against trusted origins
2413
+ * - Prevents redirect loops by checking if URL points to callback route
2414
+ * - Tries the next candidate when a URL is invalid or unsafe
2415
+ * - Falls back to appOrigin when no candidate is safe
2416
+ */
2417
+ function getSafeRedirectUrl(candidates, callbackPath, appOrigin, isTrustedOrigin) {
2418
+ const callbackPathname = new URL(callbackPath).pathname;
2419
+ for (const candidate of candidates) {
2420
+ const safeCandidate = getSafeRedirectCandidate(candidate, callbackPathname, appOrigin, isTrustedOrigin);
2421
+ if (safeCandidate) return safeCandidate;
2422
+ }
2423
+ return appOrigin;
2424
+ }
2226
2425
  function buildSAMLRedirectUrl(url, params) {
2227
2426
  const searchParams = new URLSearchParams(params);
2228
2427
  try {
@@ -2238,6 +2437,14 @@ function buildSAMLRedirectUrl(url, params) {
2238
2437
  return `${urlWithoutFragment}${urlWithoutFragment.includes("?") ? "&" : "?"}${searchParams.toString()}${fragment}`;
2239
2438
  }
2240
2439
  }
2440
+ function getSAMLRedirectCandidates(relayStateCallbackUrl, samlConfig, samlOptions) {
2441
+ return [
2442
+ relayStateCallbackUrl,
2443
+ samlConfig?.idpInitiatedCallbackUrl,
2444
+ samlOptions?.idpInitiatedCallbackUrl,
2445
+ samlConfig?.callbackUrl
2446
+ ];
2447
+ }
2241
2448
  function toArray(value) {
2242
2449
  if (Array.isArray(value)) return value;
2243
2450
  return value ? [value] : [];
@@ -2304,7 +2511,10 @@ async function processSAMLResponse(ctx, params, options) {
2304
2511
  if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
2305
2512
  const sp = createSP(parsedSamlConfig, ctx.context.baseURL, providerId, { clockSkew: options?.saml?.clockSkew });
2306
2513
  const idp = createIdP(parsedSamlConfig);
2307
- const samlRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, params.currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
2514
+ const redirectCandidates = getSAMLRedirectCandidates(relayState?.callbackURL, parsedSamlConfig, options?.saml);
2515
+ const samlRedirectUrl = getSafeRedirectUrl(redirectCandidates, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
2516
+ const samlErrorRedirectUrl = getSafeRedirectUrl([relayState?.errorURL, samlRedirectUrl], currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
2517
+ params.onErrorRedirectResolved?.(samlErrorRedirectUrl);
2308
2518
  validateSingleAssertion(SAMLResponse);
2309
2519
  let parsedResponse;
2310
2520
  try {
@@ -2349,7 +2559,7 @@ async function processSAMLResponse(ctx, params, options) {
2349
2559
  expectedAudiences: expectedAudiences.filter(Boolean),
2350
2560
  expectedRecipients: expectedRecipients.filter(Boolean)
2351
2561
  });
2352
- throw ctx.redirect(buildSAMLRedirectUrl(samlRedirectUrl, {
2562
+ throw ctx.redirect(buildSAMLRedirectUrl(samlErrorRedirectUrl, {
2353
2563
  error: "invalid_saml_response",
2354
2564
  error_description: error.body?.message || error.message || "Invalid SAML response"
2355
2565
  }));
@@ -2360,7 +2570,7 @@ async function processSAMLResponse(ctx, params, options) {
2360
2570
  expectedAudiences: expectedAudiences.filter(Boolean),
2361
2571
  expectedRecipients: expectedRecipients.filter(Boolean)
2362
2572
  });
2363
- throw ctx.redirect(buildSAMLRedirectUrl(samlRedirectUrl, {
2573
+ throw ctx.redirect(buildSAMLRedirectUrl(samlErrorRedirectUrl, {
2364
2574
  error: "invalid_saml_response",
2365
2575
  error_description: "SAML response binding could not be validated"
2366
2576
  }));
@@ -2372,17 +2582,17 @@ async function processSAMLResponse(ctx, params, options) {
2372
2582
  enableInResponseToValidation: options?.saml?.enableInResponseToValidation,
2373
2583
  allowIdpInitiated: options?.saml?.allowIdpInitiated
2374
2584
  },
2375
- redirectUrl: samlRedirectUrl
2585
+ redirectUrl: samlErrorRedirectUrl
2376
2586
  });
2377
2587
  validateAudience(ctx, {
2378
2588
  extract,
2379
2589
  expectedAudience: parsedSamlConfig.audience || sp.entityMeta.getEntityID(),
2380
2590
  providerId,
2381
- redirectUrl: samlRedirectUrl
2591
+ redirectUrl: samlErrorRedirectUrl
2382
2592
  });
2593
+ const issuer = idp.entityMeta.getEntityID();
2383
2594
  const assertionId = extractAssertionId(samlBindingContent);
2384
2595
  if (assertionId) {
2385
- const issuer = idp.entityMeta.getEntityID();
2386
2596
  const conditions = extract.conditions;
2387
2597
  const clockSkew = options?.saml?.clockSkew ?? 3e5;
2388
2598
  const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
@@ -2402,7 +2612,7 @@ async function processSAMLResponse(ctx, params, options) {
2402
2612
  issuer,
2403
2613
  providerId
2404
2614
  });
2405
- throw ctx.redirect(buildSAMLRedirectUrl(samlRedirectUrl, {
2615
+ throw ctx.redirect(buildSAMLRedirectUrl(samlErrorRedirectUrl, {
2406
2616
  error: "replay_detected",
2407
2617
  error_description: "SAML assertion has already been used"
2408
2618
  }));
@@ -2416,7 +2626,7 @@ async function processSAMLResponse(ctx, params, options) {
2416
2626
  };
2417
2627
  const userInfo = {
2418
2628
  ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
2419
- id: attr(mapping.id || "nameID") || extract.nameID,
2629
+ id: extract.nameID,
2420
2630
  email: (attr(mapping.email || "email") || extract.nameID || "").toLowerCase(),
2421
2631
  name: [attr(mapping.firstName || "givenName"), attr(mapping.lastName || "surname")].filter(Boolean).join(" ") || attr(mapping.name || "displayName") || extract.nameID,
2422
2632
  emailVerified: options?.trustEmailVerified && mapping.emailVerified ? parseProviderEmailVerified(attr(mapping.emailVerified)) : false
@@ -2431,8 +2641,8 @@ async function processSAMLResponse(ctx, params, options) {
2431
2641
  throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
2432
2642
  }
2433
2643
  const isTrustedProvider = "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
2434
- const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2435
- const errorUrl = relayState?.errorURL || samlRedirectUrl;
2644
+ const callbackUrl = redirectCandidates.some(Boolean) ? samlRedirectUrl : ctx.context.baseURL;
2645
+ const errorUrl = samlErrorRedirectUrl;
2436
2646
  let result;
2437
2647
  try {
2438
2648
  result = await runWithTransaction(ctx.context.adapter, async () => {
@@ -2446,7 +2656,8 @@ async function processSAMLResponse(ctx, params, options) {
2446
2656
  },
2447
2657
  account: {
2448
2658
  providerId,
2449
- accountId: userInfo.id,
2659
+ issuer,
2660
+ providerAccountId: userInfo.id,
2450
2661
  accessToken: "",
2451
2662
  refreshToken: ""
2452
2663
  },
@@ -2482,7 +2693,7 @@ async function processSAMLResponse(ctx, params, options) {
2482
2693
  profile: {
2483
2694
  providerType: "saml",
2484
2695
  providerId,
2485
- accountId: userInfo.id,
2696
+ providerAccountId: userInfo.id,
2486
2697
  email: userInfo.email,
2487
2698
  emailVerified: userInfo.emailVerified,
2488
2699
  rawAttributes: attributes
@@ -2514,7 +2725,7 @@ async function processSAMLResponse(ctx, params, options) {
2514
2725
  expiresAt: session.expiresAt
2515
2726
  }).catch((e) => ctx.context.logger.warn("Failed to create SAML session lookup record", e));
2516
2727
  }
2517
- return getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
2728
+ return samlRedirectUrl;
2518
2729
  }
2519
2730
  //#endregion
2520
2731
  //#region src/routes/sso.ts
@@ -2660,10 +2871,6 @@ const registerSSOProvider = (options) => {
2660
2871
  type: "object",
2661
2872
  nullable: true,
2662
2873
  properties: {
2663
- id: {
2664
- type: "string",
2665
- description: "Field mapping for user ID (defaults to 'sub')"
2666
- },
2667
2874
  email: {
2668
2875
  type: "string",
2669
2876
  description: "Field mapping for email (defaults to 'email')"
@@ -2689,11 +2896,7 @@ const registerSSOProvider = (options) => {
2689
2896
  description: "Additional field mappings"
2690
2897
  }
2691
2898
  },
2692
- required: [
2693
- "id",
2694
- "email",
2695
- "name"
2696
- ]
2899
+ required: ["email", "name"]
2697
2900
  }
2698
2901
  },
2699
2902
  required: [
@@ -2768,13 +2971,13 @@ const registerSSOProvider = (options) => {
2768
2971
  if (!member) throw new APIError("BAD_REQUEST", { message: "You are not a member of the organization" });
2769
2972
  if (ctx.context.hasPlugin("organization") && !hasOrgAdminRole(member)) throw new APIError("FORBIDDEN", { message: "You must be an organization owner or admin to register SSO providers" });
2770
2973
  }
2771
- if (new Set([
2974
+ if ((/* @__PURE__ */ new Set([
2772
2975
  ...BUILT_IN_ACCOUNT_PROVIDER_IDS,
2773
2976
  ...Object.keys(ctx.context.options.socialProviders ?? {}),
2774
2977
  ...ctx.context.socialProviders.map((p) => p.id),
2775
2978
  ...ctx.context.trustedProviders,
2776
2979
  ...options?.defaultSSO?.map((p) => p.providerId) ?? []
2777
- ]).has(body.providerId)) {
2980
+ ])).has(body.providerId)) {
2778
2981
  ctx.context.logger.warn(`SSO provider registration rejected for reserved providerId: ${body.providerId}`);
2779
2982
  throw new APIError("UNPROCESSABLE_ENTITY", { message: "This providerId is reserved and cannot be used for an SSO provider" });
2780
2983
  }
@@ -2868,6 +3071,7 @@ const registerSSOProvider = (options) => {
2868
3071
  digestAlgorithm: body.samlConfig.digestAlgorithm
2869
3072
  }, options?.saml?.algorithms);
2870
3073
  validateCertSources(body.samlConfig);
3074
+ assertSAMLIdentityProviderAuthority(body.samlConfig);
2871
3075
  const hasIdpMetadata = body.samlConfig.idpMetadata?.metadata;
2872
3076
  let hasEntryPoint = false;
2873
3077
  if (body.samlConfig.entryPoint) try {
@@ -2899,6 +3103,7 @@ const registerSSOProvider = (options) => {
2899
3103
  cert: body.samlConfig.cert,
2900
3104
  audience: body.samlConfig.audience,
2901
3105
  callbackUrl: body.samlConfig.callbackUrl,
3106
+ idpInitiatedCallbackUrl: body.samlConfig.idpInitiatedCallbackUrl,
2902
3107
  idpMetadata: body.samlConfig.idpMetadata,
2903
3108
  spMetadata: body.samlConfig.spMetadata,
2904
3109
  wantAssertionsSigned: body.samlConfig.wantAssertionsSigned,
@@ -3107,7 +3312,7 @@ const signInSSO = (options) => {
3107
3312
  throw error;
3108
3313
  }
3109
3314
  if (!config.authorizationEndpoint) throw new APIError("BAD_REQUEST", { message: "Invalid OIDC configuration. Authorization URL not found." });
3110
- if (options?.redirectURI?.trim()) await addOAuthServerContext({ ssoProviderId: provider.providerId });
3315
+ await addOAuthServerContext({ [SSO_PROVIDER_STATE_KEY]: await computeSSOProviderReference(provider) });
3111
3316
  const state = await generateState(ctx);
3112
3317
  const redirectURI = getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options);
3113
3318
  const authorizationURL = await createAuthorizationURL({
@@ -3196,13 +3401,14 @@ function getOIDCErrorDescription(error, fallback) {
3196
3401
  * @param stateData - Pre-parsed state data. If not provided, it will be
3197
3402
  * parsed from the request context.
3198
3403
  */
3199
- async function handleOIDCCallback(ctx, options, providerId, stateData) {
3404
+ async function handleOIDCCallback(ctx, options, providerId, stateData, parsedProviderReference) {
3200
3405
  const { code, error, error_description } = ctx.query;
3201
3406
  if (!stateData) stateData = await parseState(ctx);
3202
3407
  if (!stateData) {
3203
3408
  const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
3204
3409
  throw ctx.redirect(`${errorURL}?error=invalid_state`);
3205
3410
  }
3411
+ const providerReference = parsedProviderReference ?? parseSSOProviderReference(stateData.serverContext?.["ssoProviderReference"]);
3206
3412
  const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
3207
3413
  const redirectOIDCError = (error, description) => {
3208
3414
  const baseURL = errorURL || callbackURL;
@@ -3216,6 +3422,7 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
3216
3422
  if (!code || error) redirectOIDCError(error || "invalid_request", error_description || (error ? error : "authorization_code_not_found"));
3217
3423
  const provider = await resolveOIDCProvider(ctx, options, providerId);
3218
3424
  if (!provider) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
3425
+ if (!await isCurrentSSOProviderReference(provider, providerReference)) redirectOIDCError("invalid_state", "sso_provider_changed_during_authentication");
3219
3426
  if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
3220
3427
  let config = provider.oidcConfig;
3221
3428
  if (!config) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
@@ -3287,7 +3494,28 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
3287
3494
  if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_response_not_found`);
3288
3495
  let userInfo = null;
3289
3496
  const mapping = config.mapping || {};
3497
+ const readStringClaim = (claims, claim) => {
3498
+ const value = claims[claim];
3499
+ return typeof value === "string" && value.length > 0 ? value : void 0;
3500
+ };
3290
3501
  let rawProfile;
3502
+ let verifiedIdToken = null;
3503
+ if (tokenResponse.idToken) {
3504
+ const jwksEndpoint = config.jwksEndpoint;
3505
+ if (!jwksEndpoint) redirectOIDCError("invalid_provider", "jwks_endpoint_not_found");
3506
+ const verified = await validateOIDCIdToken(tokenResponse.idToken, jwksEndpoint, {
3507
+ audience: config.clientId,
3508
+ issuer: provider.issuer
3509
+ }, (url) => ctx.context.isTrustedOrigin(url)).catch((error) => {
3510
+ if (error instanceof DiscoveryError) redirectOIDCError("invalid_provider", error.message);
3511
+ ctx.context.logger.error(error);
3512
+ return null;
3513
+ });
3514
+ if (!verified) redirectOIDCError("invalid_provider", "token_not_verified");
3515
+ if (!readStringClaim(verified.payload, "sub")) redirectOIDCError("invalid_provider", "id_token_subject_missing");
3516
+ verifiedIdToken = verified;
3517
+ }
3518
+ if (options?.resolveUser && !verifiedIdToken) redirectOIDCError("invalid_provider", "id_token_required_for_user_resolution");
3291
3519
  if (config.userInfoEndpoint) {
3292
3520
  const userInfoResponse = await fetchOIDCEndpoint("userInfoEndpoint", config.userInfoEndpoint, { headers: { Authorization: `Bearer ${tokenResponse.accessToken}` } }, (url) => ctx.context.isTrustedOrigin(url)).catch((e) => {
3293
3521
  if (e instanceof DiscoveryError) redirectOIDCError("invalid_provider", e.message);
@@ -3295,58 +3523,83 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
3295
3523
  });
3296
3524
  if (userInfoResponse.error) redirectOIDCError("invalid_provider", userInfoResponse.error.message || userInfoResponse.error.statusText || "userinfo_response_error");
3297
3525
  const rawUserInfo = userInfoResponse.data ?? redirectOIDCError("invalid_provider", "userinfo_response_not_found");
3526
+ if (verifiedIdToken && rawUserInfo.sub !== verifiedIdToken.payload.sub) redirectOIDCError("invalid_provider", "id_token_userinfo_subject_mismatch");
3298
3527
  rawProfile = rawUserInfo;
3299
3528
  userInfo = {
3300
3529
  ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, rawUserInfo[value]])),
3301
- id: rawUserInfo[mapping.id || "sub"],
3302
- email: rawUserInfo[mapping.email || "email"],
3530
+ id: readStringClaim(rawUserInfo, "sub"),
3531
+ email: readStringClaim(rawUserInfo, mapping.email || "email"),
3303
3532
  emailVerified: options?.trustEmailVerified ? parseProviderEmailVerified(rawUserInfo[mapping.emailVerified || "email_verified"]) : false,
3304
- name: rawUserInfo[mapping.name || "name"],
3305
- image: rawUserInfo[mapping.image || "picture"]
3533
+ name: readStringClaim(rawUserInfo, mapping.name || "name"),
3534
+ image: readStringClaim(rawUserInfo, mapping.image || "picture")
3306
3535
  };
3307
- } else if (tokenResponse.idToken) {
3308
- const idToken = decodeJwt(tokenResponse.idToken);
3536
+ } else if (verifiedIdToken) {
3537
+ const idToken = verifiedIdToken.payload;
3309
3538
  rawProfile = idToken;
3310
- if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=jwks_endpoint_not_found`);
3311
- const verified = await validateOIDCIdToken(tokenResponse.idToken, config.jwksEndpoint, {
3312
- audience: config.clientId,
3313
- issuer: provider.issuer
3314
- }, (url) => ctx.context.isTrustedOrigin(url)).catch((e) => {
3315
- if (e instanceof DiscoveryError) redirectOIDCError("invalid_provider", e.message);
3316
- ctx.context.logger.error(e);
3317
- return null;
3318
- });
3319
- if (!verified) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_not_verified`);
3320
3539
  userInfo = {
3321
- ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, verified.payload[value]])),
3322
- id: idToken[mapping.id || "sub"],
3323
- email: idToken[mapping.email || "email"],
3540
+ ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, idToken[value]])),
3541
+ id: idToken.sub,
3542
+ email: readStringClaim(idToken, mapping.email || "email"),
3324
3543
  emailVerified: options?.trustEmailVerified ? parseProviderEmailVerified(idToken[mapping.emailVerified || "email_verified"]) : false,
3325
- name: idToken[mapping.name || "name"],
3326
- image: idToken[mapping.image || "picture"]
3544
+ name: readStringClaim(idToken, mapping.name || "name"),
3545
+ image: readStringClaim(idToken, mapping.image || "picture")
3327
3546
  };
3328
3547
  } else throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=user_info_endpoint_not_found`);
3329
3548
  if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=missing_user_info`);
3330
3549
  const userInfoEmail = userInfo.email;
3331
3550
  const userInfoId = userInfo.id;
3551
+ const providerUser = {
3552
+ ...Object.fromEntries(Object.entries(userInfo).filter(([key]) => key !== "id")),
3553
+ email: userInfoEmail,
3554
+ name: typeof userInfo.name === "string" ? userInfo.name : "",
3555
+ image: typeof userInfo.image === "string" ? userInfo.image : void 0,
3556
+ emailVerified: options?.trustEmailVerified ? userInfo.emailVerified === true : false
3557
+ };
3558
+ const accountKey = {
3559
+ issuer: verifiedIdToken && readStringClaim(verifiedIdToken.payload, "iss") || provider.issuer,
3560
+ providerAccountId: userInfoId
3561
+ };
3332
3562
  const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfoEmail, provider.domain);
3333
3563
  let linked;
3334
3564
  try {
3565
+ if (options?.resolveUser) {
3566
+ assertSSOUserResolutionNativeTransactionSupport(ctx.context.adapter);
3567
+ assertSSOUserResolutionSessionStorage(ctx.context.options);
3568
+ await assertSSOUserResolutionAsyncContextSupport();
3569
+ }
3335
3570
  linked = await runWithTransaction(ctx.context.adapter, async () => {
3336
3571
  await lockSSOProviderForAccountLink(ctx, provider);
3337
- return handleOAuthUserInfo(ctx, {
3572
+ const currentProvider = await resolveOIDCProvider(ctx, options, providerId, await getCurrentAdapter(ctx.context.adapter));
3573
+ if (!currentProvider || !await isCurrentSSOProviderReference(currentProvider, providerReference)) throw new APIError("CONFLICT", {
3574
+ code: "SSO_PROVIDER_CHANGED",
3575
+ message: "SSO provider changed while account linking was in progress"
3576
+ });
3577
+ const resolution = options?.resolveUser ? await resolveSSOUser(options.resolveUser, {
3578
+ protocol: "oidc",
3579
+ providerId: provider.providerId,
3580
+ accountKey,
3581
+ providerUser,
3582
+ providerClaims: rawProfile ?? {}
3583
+ }, await getCurrentAdapter(ctx.context.adapter), ctx.context.logger) : void 0;
3584
+ if (resolution?.action === "reject") throw new APIError("FORBIDDEN", {
3585
+ code: resolution.code,
3586
+ ...resolution.message === void 0 ? {} : { message: resolution.message }
3587
+ });
3588
+ const authentication = await handleOAuthUserInfo(ctx, {
3338
3589
  userInfo: {
3339
- email: userInfoEmail,
3340
- name: userInfo.name || "",
3590
+ ...providerUser,
3591
+ email: providerUser.email,
3592
+ name: providerUser.name,
3341
3593
  id: userInfoId,
3342
- image: userInfo.image,
3343
- emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
3594
+ image: providerUser.image,
3595
+ emailVerified: providerUser.emailVerified
3344
3596
  },
3345
3597
  account: {
3346
3598
  idToken: tokenResponse.idToken,
3347
3599
  accessToken: tokenResponse.accessToken,
3348
3600
  refreshToken: tokenResponse.refreshToken,
3349
- accountId: userInfoId,
3601
+ issuer: accountKey.issuer,
3602
+ providerAccountId: userInfoId,
3350
3603
  providerId: provider.providerId,
3351
3604
  accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
3352
3605
  refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
@@ -3363,18 +3616,28 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
3363
3616
  }
3364
3617
  },
3365
3618
  isTrustedProvider,
3366
- trustProviderByName: false
3619
+ trustProviderByName: false,
3620
+ selectedUser: resolution?.action === "link" ? {
3621
+ userId: resolution.userId,
3622
+ profile: resolution.profile
3623
+ } : void 0,
3624
+ deferNonDatabaseWrites: !!options?.resolveUser,
3625
+ requireExactAccountBinding: !!options?.resolveUser
3367
3626
  });
3368
- });
3627
+ return options?.resolveUser ? requireSuccessfulSSOAuthentication(authentication) : authentication;
3628
+ }, { onAfterCommitHookError(error) {
3629
+ ctx.context.logger.error("Committed SSO authentication after-hook failed", error);
3630
+ } });
3369
3631
  } catch (e) {
3370
- if (isAPIError(e) && e.body?.code) {
3632
+ const failedAuthentication = getFailedSSOAuthenticationResult(e);
3633
+ if (failedAuthentication) linked = failedAuthentication;
3634
+ else if (isAPIError(e) && e.body?.code) {
3371
3635
  const baseURL = errorURL || callbackURL;
3372
3636
  const params = new URLSearchParams({ error: e.body.code });
3373
3637
  if (e.body.message) params.set("error_description", e.body.message);
3374
3638
  const sep = baseURL.includes("?") ? "&" : "?";
3375
3639
  throw ctx.redirect(`${baseURL}${sep}${params.toString()}`);
3376
- }
3377
- throw e;
3640
+ } else throw e;
3378
3641
  }
3379
3642
  if (linked.error) {
3380
3643
  const baseURL = errorURL || callbackURL;
@@ -3394,7 +3657,7 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
3394
3657
  profile: {
3395
3658
  providerType: "oidc",
3396
3659
  providerId: provider.providerId,
3397
- accountId: userInfoId,
3660
+ providerAccountId: userInfoId,
3398
3661
  email: userInfoEmail,
3399
3662
  emailVerified: Boolean(userInfo.emailVerified),
3400
3663
  rawAttributes: userInfo
@@ -3403,6 +3666,7 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
3403
3666
  token: tokenResponse,
3404
3667
  provisioningOptions: options?.organizationProvisioning
3405
3668
  });
3669
+ if ("accountCookie" in linked && linked.accountCookie) await setAccountCookie(ctx, linked.accountCookie);
3406
3670
  await setSessionCookie(ctx, {
3407
3671
  session,
3408
3672
  user
@@ -3434,7 +3698,7 @@ const callbackSSOEndpointConfig = {
3434
3698
  * and falling back to the `ssoProvider` table. Returns `null` when no match is
3435
3699
  * found so the caller can decide how to react (redirect, silently skip, etc.).
3436
3700
  */
3437
- async function resolveOIDCProvider(ctx, options, providerId) {
3701
+ async function resolveOIDCProvider(ctx, options, providerId, adapter = ctx.context.adapter) {
3438
3702
  const matchingDefault = options?.defaultSSO?.find((defaultProvider) => defaultProvider.providerId === providerId);
3439
3703
  if (matchingDefault) return {
3440
3704
  ...matchingDefault,
@@ -3442,7 +3706,7 @@ async function resolveOIDCProvider(ctx, options, providerId) {
3442
3706
  userId: "default",
3443
3707
  ...options?.domainVerification?.enabled ? { domainVerified: true } : {}
3444
3708
  };
3445
- return ctx.context.adapter.findOne({
3709
+ return adapter.findOne({
3446
3710
  model: "ssoProvider",
3447
3711
  where: [{
3448
3712
  field: "providerId",
@@ -3482,7 +3746,7 @@ async function bounceIfIdpInitiated(ctx, options, providerId) {
3482
3746
  });
3483
3747
  return;
3484
3748
  }
3485
- if (options?.redirectURI?.trim()) await addOAuthServerContext({ ssoProviderId: provider.providerId });
3749
+ await addOAuthServerContext({ [SSO_PROVIDER_STATE_KEY]: await computeSSOProviderReference(provider) });
3486
3750
  const state = await generateState(ctx);
3487
3751
  const redirectURI = getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options);
3488
3752
  const authorizationURL = await createAuthorizationURL({
@@ -3534,12 +3798,12 @@ const callbackSSOShared = (options) => {
3534
3798
  const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
3535
3799
  throw ctx.redirect(`${errorURL}?error=invalid_state`);
3536
3800
  }
3537
- const providerId = stateData.serverContext?.ssoProviderId;
3538
- if (!providerId) {
3801
+ const providerReference = parseSSOProviderReference(stateData.serverContext?.[SSO_PROVIDER_STATE_KEY]);
3802
+ if (!providerReference) {
3539
3803
  const errorURL = stateData.errorURL || stateData.callbackURL;
3540
- throw ctx.redirect(`${errorURL}?error=invalid_state&error_description=missing_provider_id`);
3804
+ throw ctx.redirect(`${errorURL}?error=invalid_state&error_description=missing_sso_provider_reference`);
3541
3805
  }
3542
- return handleOIDCCallback(ctx, options, providerId, stateData);
3806
+ return handleOIDCCallback(ctx, options, providerReference.providerId, stateData, providerReference);
3543
3807
  });
3544
3808
  };
3545
3809
  const acsEndpointBodySchema = z.object({
@@ -3569,13 +3833,14 @@ const acsEndpoint = (options) => {
3569
3833
  const { providerId } = ctx.params;
3570
3834
  const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`;
3571
3835
  const appOrigin = new URL(ctx.context.baseURL).origin;
3836
+ let resolvedErrorRedirectUrl;
3572
3837
  if (ctx.method === "GET" && !ctx.body?.SAMLResponse) {
3573
3838
  if (!(await getSessionFromCtx(ctx))?.session) {
3574
3839
  const errorURL = ctx.context.options.onAPIError?.errorURL || `${appOrigin}/error`;
3575
3840
  throw ctx.redirect(`${errorURL}?error=invalid_request`);
3576
3841
  }
3577
3842
  const relayState = ctx.query?.RelayState;
3578
- throw ctx.redirect(getSafeRedirectUrl(relayState, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings)));
3843
+ throw ctx.redirect(getSafeRedirectUrl([relayState], currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings)));
3579
3844
  }
3580
3845
  if (!ctx.body?.SAMLResponse) throw new APIError("BAD_REQUEST", { message: "SAMLResponse is required for POST requests" });
3581
3846
  try {
@@ -3583,15 +3848,33 @@ const acsEndpoint = (options) => {
3583
3848
  SAMLResponse: ctx.body.SAMLResponse,
3584
3849
  RelayState: ctx.body.RelayState,
3585
3850
  providerId,
3586
- currentCallbackPath
3851
+ currentCallbackPath,
3852
+ onErrorRedirectResolved: (url) => {
3853
+ resolvedErrorRedirectUrl = url;
3854
+ }
3587
3855
  }, options);
3588
3856
  throw ctx.redirect(safeRedirectUrl);
3589
3857
  } catch (error) {
3590
3858
  if (error instanceof Response || error && typeof error === "object" && "status" in error && error.status === 302) throw error;
3591
3859
  if (error instanceof APIError && error.statusCode === 400) {
3592
3860
  const errorCode = (error.body?.code || "saml_error").toLowerCase();
3593
- const redirectUrl = getSafeRedirectUrl(ctx.body?.RelayState || void 0, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
3594
- throw ctx.redirect(`${redirectUrl}${redirectUrl.includes("?") ? "&" : "?"}error=${encodeURIComponent(errorCode)}&error_description=${encodeURIComponent(error.message)}`);
3861
+ let redirectUrl = resolvedErrorRedirectUrl;
3862
+ if (!redirectUrl) {
3863
+ let parsedSamlConfig;
3864
+ try {
3865
+ parsedSamlConfig = (await findSAMLProvider(providerId, options, ctx.context.adapter))?.samlConfig;
3866
+ } catch (providerLookupError) {
3867
+ ctx.context.logger.warn("Failed to resolve SAML provider for error redirect", {
3868
+ providerId,
3869
+ error: providerLookupError
3870
+ });
3871
+ }
3872
+ redirectUrl = getSafeRedirectUrl(getSAMLRedirectCandidates(void 0, parsedSamlConfig, options?.saml), currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
3873
+ }
3874
+ throw ctx.redirect(buildSAMLRedirectUrl(redirectUrl, {
3875
+ error: errorCode,
3876
+ error_description: error.message
3877
+ }));
3595
3878
  }
3596
3879
  throw error;
3597
3880
  }
@@ -3620,7 +3903,7 @@ const sloEndpoint = (options) => {
3620
3903
  const samlResponse = ctx.body?.SAMLResponse || ctx.query?.SAMLResponse;
3621
3904
  const relayState = ctx.body?.RelayState || ctx.query?.RelayState;
3622
3905
  const appOrigin = new URL(ctx.context.baseURL).origin;
3623
- const safeErrorURL = getSafeRedirectUrl(relayState, `${appOrigin}/sso/saml2/sp/slo/${providerId}`, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
3906
+ const safeErrorURL = getSafeRedirectUrl([relayState], `${appOrigin}/sso/saml2/sp/slo/${providerId}`, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
3624
3907
  if (!samlRequest && !samlResponse) throw ctx.redirect(`${safeErrorURL}?error=invalid_request&error_description=missing_logout_data`);
3625
3908
  const provider = await findSAMLProvider(providerId, options, ctx.context.adapter);
3626
3909
  if (!provider?.samlConfig) throw APIError.from("NOT_FOUND", SAML_ERROR_CODES.SAML_PROVIDER_NOT_FOUND);
@@ -3660,7 +3943,7 @@ async function handleLogoutResponse(ctx, sp, idp, relayState, providerId) {
3660
3943
  }
3661
3944
  deleteSessionCookie(ctx);
3662
3945
  const appOrigin = new URL(ctx.context.baseURL).origin;
3663
- const safeRedirectUrl = getSafeRedirectUrl(relayState, `${appOrigin}/sso/saml2/sp/slo/${providerId}`, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
3946
+ const safeRedirectUrl = getSafeRedirectUrl([relayState], `${appOrigin}/sso/saml2/sp/slo/${providerId}`, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
3664
3947
  throw ctx.redirect(safeRedirectUrl);
3665
3948
  }
3666
3949
  async function handleLogoutRequest(ctx, sp, idp, relayState, providerId) {