@better-auth/sso 1.4.18 → 1.4.19

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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @better-auth/sso@1.4.18 build /home/runner/work/better-auth/better-auth/packages/sso
2
+ > @better-auth/sso@1.4.19 build /home/runner/work/better-auth/better-auth/packages/sso
3
3
  > tsdown
4
4
 
5
5
  ℹ tsdown v0.17.2 powered by rolldown v1.0.0-beta.53
@@ -7,12 +7,12 @@
7
7
  ℹ entry: src/index.ts, src/client.ts
8
8
  ℹ tsconfig: tsconfig.json
9
9
  ℹ Build start
10
- ℹ dist/index.mjs 120.19 kB │ gzip: 23.98 kB
10
+ ℹ dist/index.mjs 120.93 kB │ gzip: 24.08 kB
11
11
  ℹ dist/client.mjs  0.28 kB │ gzip: 0.21 kB
12
- ℹ dist/index.mjs.map 244.62 kB │ gzip: 47.01 kB
12
+ ℹ dist/index.mjs.map 246.56 kB │ gzip: 47.33 kB
13
13
  ℹ dist/client.mjs.map  0.94 kB │ gzip: 0.50 kB
14
14
  ℹ dist/index.d.mts  1.67 kB │ gzip: 0.57 kB
15
- ℹ dist/client.d.mts  0.62 kB │ gzip: 0.36 kB
16
- ℹ dist/index-C4nbdf2g.d.mts  55.71 kB │ gzip: 9.86 kB
17
- ℹ 7 files, total: 424.03 kB
18
- ✔ Build complete in 18417ms
15
+ ℹ dist/client.d.mts  0.62 kB │ gzip: 0.35 kB
16
+ ℹ dist/index-D-VInsst.d.mts  55.92 kB │ gzip: 9.95 kB
17
+ ℹ 7 files, total: 426.92 kB
18
+ ✔ Build complete in 18184ms
package/dist/client.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { t as SSOPlugin } from "./index-C4nbdf2g.mjs";
1
+ import { t as SSOPlugin } from "./index-D-VInsst.mjs";
2
2
 
3
3
  //#region src/client.d.ts
4
4
  interface SSOClientOptions {
@@ -109,6 +109,7 @@ interface SAMLConfig {
109
109
  encPrivateKeyPass?: string | undefined;
110
110
  };
111
111
  wantAssertionsSigned?: boolean | undefined;
112
+ authnRequestsSigned?: boolean | undefined;
112
113
  signatureAlgorithm?: string | undefined;
113
114
  digestAlgorithm?: string | undefined;
114
115
  identifierFormat?: string | undefined;
@@ -278,9 +279,12 @@ interface SSOOptions {
278
279
  */
279
280
  enabled?: boolean;
280
281
  /**
281
- * Prefix used to generate the domain verification token
282
+ * Prefix used to generate the domain verification token.
283
+ * An underscore is automatically prepended to follow DNS
284
+ * infrastructure subdomain conventions (RFC 8552), so do
285
+ * not include a leading underscore.
282
286
  *
283
- * @default "better-auth-token-"
287
+ * @default "better-auth-token"
284
288
  */
285
289
  tokenPrefix?: string;
286
290
  };
@@ -1656,4 +1660,4 @@ declare function sso<O extends SSOOptions>(options?: O | undefined): {
1656
1660
  };
1657
1661
  //#endregion
1658
1662
  export { DataEncryptionAlgorithm as A, TimestampValidationOptions as C, SSOOptions as D, SAMLConfig as E, DigestAlgorithm as M, KeyEncryptionAlgorithm as N, SSOProvider as O, SignatureAlgorithm as P, SAMLConditions as S, OIDCConfig as T, REQUIRED_DISCOVERY_FIELDS as _, fetchDiscoveryDocument as a, DEFAULT_MAX_SAML_METADATA_SIZE as b, normalizeUrl as c, validateDiscoveryUrl as d, DiscoverOIDCConfigParams as f, OIDCDiscoveryDocument as g, HydratedOIDCConfig as h, discoverOIDCConfig as i, DeprecatedAlgorithmBehavior as j, AlgorithmValidationOptions as k, selectTokenEndpointAuthMethod as l, DiscoveryErrorCode as m, sso as n, needsRuntimeDiscovery as o, DiscoveryError as p, computeDiscoveryUrl as r, normalizeDiscoveryUrls as s, SSOPlugin as t, validateDiscoveryDocument as u, RequiredDiscoveryField as v, validateSAMLTimestamp as w, DEFAULT_MAX_SAML_RESPONSE_SIZE as x, DEFAULT_CLOCK_SKEW_MS as y };
1659
- //# sourceMappingURL=index-C4nbdf2g.d.mts.map
1663
+ //# sourceMappingURL=index-D-VInsst.d.mts.map
package/dist/index.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { A as DataEncryptionAlgorithm, C as TimestampValidationOptions, D as SSOOptions, E as SAMLConfig, M as DigestAlgorithm, N as KeyEncryptionAlgorithm, O as SSOProvider, P as SignatureAlgorithm, S as SAMLConditions, T as OIDCConfig, _ as REQUIRED_DISCOVERY_FIELDS, a as fetchDiscoveryDocument, b as DEFAULT_MAX_SAML_METADATA_SIZE, c as normalizeUrl, d as validateDiscoveryUrl, f as DiscoverOIDCConfigParams, g as OIDCDiscoveryDocument, h as HydratedOIDCConfig, i as discoverOIDCConfig, j as DeprecatedAlgorithmBehavior, k as AlgorithmValidationOptions, l as selectTokenEndpointAuthMethod, m as DiscoveryErrorCode, n as sso, o as needsRuntimeDiscovery, p as DiscoveryError, r as computeDiscoveryUrl, s as normalizeDiscoveryUrls, t as SSOPlugin, u as validateDiscoveryDocument, v as RequiredDiscoveryField, w as validateSAMLTimestamp, x as DEFAULT_MAX_SAML_RESPONSE_SIZE, y as DEFAULT_CLOCK_SKEW_MS } from "./index-C4nbdf2g.mjs";
1
+ import { A as DataEncryptionAlgorithm, C as TimestampValidationOptions, D as SSOOptions, E as SAMLConfig, M as DigestAlgorithm, N as KeyEncryptionAlgorithm, O as SSOProvider, P as SignatureAlgorithm, S as SAMLConditions, T as OIDCConfig, _ as REQUIRED_DISCOVERY_FIELDS, a as fetchDiscoveryDocument, b as DEFAULT_MAX_SAML_METADATA_SIZE, c as normalizeUrl, d as validateDiscoveryUrl, f as DiscoverOIDCConfigParams, g as OIDCDiscoveryDocument, h as HydratedOIDCConfig, i as discoverOIDCConfig, j as DeprecatedAlgorithmBehavior, k as AlgorithmValidationOptions, l as selectTokenEndpointAuthMethod, m as DiscoveryErrorCode, n as sso, o as needsRuntimeDiscovery, p as DiscoveryError, r as computeDiscoveryUrl, s as normalizeDiscoveryUrls, t as SSOPlugin, u as validateDiscoveryDocument, v as RequiredDiscoveryField, w as validateSAMLTimestamp, x as DEFAULT_MAX_SAML_RESPONSE_SIZE, y as DEFAULT_CLOCK_SKEW_MS } from "./index-D-VInsst.mjs";
2
2
  export { AlgorithmValidationOptions, DEFAULT_CLOCK_SKEW_MS, DEFAULT_MAX_SAML_METADATA_SIZE, DEFAULT_MAX_SAML_RESPONSE_SIZE, DataEncryptionAlgorithm, DeprecatedAlgorithmBehavior, DigestAlgorithm, DiscoverOIDCConfigParams, DiscoveryError, DiscoveryErrorCode, HydratedOIDCConfig, KeyEncryptionAlgorithm, OIDCConfig, OIDCDiscoveryDocument, REQUIRED_DISCOVERY_FIELDS, RequiredDiscoveryField, SAMLConditions, SAMLConfig, SSOOptions, SSOPlugin, SSOProvider, SignatureAlgorithm, TimestampValidationOptions, computeDiscoveryUrl, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, selectTokenEndpointAuthMethod, sso, validateDiscoveryDocument, validateDiscoveryUrl, validateSAMLTimestamp };
package/dist/index.mjs CHANGED
@@ -11,7 +11,6 @@ import { HIDE_METADATA, createAuthorizationURL, generateGenericState, generateSt
11
11
  import { setSessionCookie } from "better-auth/cookies";
12
12
  import { handleOAuthUserInfo } from "better-auth/oauth2";
13
13
  import { decodeJwt } from "jose";
14
- import { APIError as APIError$1 } from "better-call";
15
14
 
16
15
  //#region src/utils.ts
17
16
  /**
@@ -162,7 +161,12 @@ async function assignOrganizationByDomain(ctx, options) {
162
161
 
163
162
  //#endregion
164
163
  //#region src/routes/domain-verification.ts
164
+ const DNS_LABEL_MAX_LENGTH = 63;
165
+ const DEFAULT_TOKEN_PREFIX = "better-auth-token";
165
166
  const domainVerificationBodySchema = z$1.object({ providerId: z$1.string() });
167
+ function getVerificationIdentifier(options, providerId) {
168
+ return `_${options.domainVerification?.tokenPrefix || DEFAULT_TOKEN_PREFIX}-${providerId}`;
169
+ }
166
170
  const requestDomainVerification = (options) => {
167
171
  return createAuthEndpoint("/sso/request-domain-verification", {
168
172
  method: "POST",
@@ -210,11 +214,12 @@ const requestDomainVerification = (options) => {
210
214
  message: "Domain has already been verified",
211
215
  code: "DOMAIN_VERIFIED"
212
216
  });
217
+ const identifier = getVerificationIdentifier(options, provider.providerId);
213
218
  const activeVerification = await ctx.context.adapter.findOne({
214
219
  model: "verification",
215
220
  where: [{
216
221
  field: "identifier",
217
- value: options.domainVerification?.tokenPrefix ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}` : `better-auth-token-${provider.providerId}`
222
+ value: identifier
218
223
  }, {
219
224
  field: "expiresAt",
220
225
  value: /* @__PURE__ */ new Date(),
@@ -229,7 +234,7 @@ const requestDomainVerification = (options) => {
229
234
  await ctx.context.adapter.create({
230
235
  model: "verification",
231
236
  data: {
232
- identifier: options.domainVerification?.tokenPrefix ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}` : `better-auth-token-${provider.providerId}`,
237
+ identifier,
233
238
  createdAt: /* @__PURE__ */ new Date(),
234
239
  updatedAt: /* @__PURE__ */ new Date(),
235
240
  value: domainVerificationToken,
@@ -288,11 +293,16 @@ const verifyDomain = (options) => {
288
293
  message: "Domain has already been verified",
289
294
  code: "DOMAIN_VERIFIED"
290
295
  });
296
+ const identifier = getVerificationIdentifier(options, provider.providerId);
297
+ if (identifier.length > DNS_LABEL_MAX_LENGTH) throw new APIError("BAD_REQUEST", {
298
+ message: `Verification identifier exceeds the DNS label limit of ${DNS_LABEL_MAX_LENGTH} characters`,
299
+ code: "IDENTIFIER_TOO_LONG"
300
+ });
291
301
  const activeVerification = await ctx.context.adapter.findOne({
292
302
  model: "verification",
293
303
  where: [{
294
304
  field: "identifier",
295
- value: options.domainVerification?.tokenPrefix ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}` : `better-auth-token-${provider.providerId}`
305
+ value: identifier
296
306
  }, {
297
307
  field: "expiresAt",
298
308
  value: /* @__PURE__ */ new Date(),
@@ -315,7 +325,8 @@ const verifyDomain = (options) => {
315
325
  });
316
326
  }
317
327
  try {
318
- records = (await dns.resolveTxt(new URL(provider.domain).hostname)).flat();
328
+ const hostname = new URL(provider.domain).hostname;
329
+ records = (await dns.resolveTxt(`${identifier}.${hostname}`)).flat();
319
330
  } catch (error) {
320
331
  ctx.context.logger.warn("DNS resolution failure while validating domain ownership", error);
321
332
  }
@@ -1357,7 +1368,7 @@ function mapDiscoveryErrorToAPIError(error) {
1357
1368
  //#region src/saml-state.ts
1358
1369
  async function generateRelayState(c, link, additionalData) {
1359
1370
  const callbackURL = c.body.callbackURL;
1360
- if (!callbackURL) throw new APIError$1("BAD_REQUEST", { message: "callbackURL is required" });
1371
+ if (!callbackURL) throw new APIError("BAD_REQUEST", { message: "callbackURL is required" });
1361
1372
  const codeVerifier = generateRandomString(128);
1362
1373
  const stateData = {
1363
1374
  ...additionalData ? additionalData : {},
@@ -1373,7 +1384,7 @@ async function generateRelayState(c, link, additionalData) {
1373
1384
  return generateGenericState(c, stateData, { cookieName: "relay_state" });
1374
1385
  } catch (error) {
1375
1386
  c.context.logger.error("Failed to create verification for relay state", error);
1376
- throw new APIError$1("INTERNAL_SERVER_ERROR", {
1387
+ throw new APIError("INTERNAL_SERVER_ERROR", {
1377
1388
  message: "State error: Unable to create verification for relay state",
1378
1389
  cause: error
1379
1390
  });
@@ -1387,7 +1398,7 @@ async function parseRelayState(c) {
1387
1398
  parsedData = await parseGenericState(c, state, { cookieName: "relay_state" });
1388
1399
  } catch (error) {
1389
1400
  c.context.logger.error("Failed to parse relay state", error);
1390
- throw new APIError$1("BAD_REQUEST", {
1401
+ throw new APIError("BAD_REQUEST", {
1391
1402
  message: "State error: failed to validate relay state",
1392
1403
  cause: error
1393
1404
  });
@@ -1489,6 +1500,7 @@ const spMetadata = () => {
1489
1500
  Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1490
1501
  Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.id}`
1491
1502
  }],
1503
+ authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
1492
1504
  wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1493
1505
  nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
1494
1506
  });
@@ -1873,7 +1885,7 @@ const registerSSOProvider = (options) => {
1873
1885
  await ctx.context.adapter.create({
1874
1886
  model: "verification",
1875
1887
  data: {
1876
- identifier: options.domainVerification?.tokenPrefix ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}` : `better-auth-token-${provider.providerId}`,
1888
+ identifier: getVerificationIdentifier(options, provider.providerId),
1877
1889
  createdAt: /* @__PURE__ */ new Date(),
1878
1890
  updatedAt: /* @__PURE__ */ new Date(),
1879
1891
  value: domainVerificationToken,
@@ -2072,18 +2084,37 @@ const signInSSO = (options) => {
2072
2084
  Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
2073
2085
  Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.providerId}`
2074
2086
  }],
2087
+ authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
2075
2088
  wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
2076
2089
  nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
2077
2090
  }).getMetadata() || "";
2078
2091
  const sp = saml.ServiceProvider({
2079
2092
  metadata,
2093
+ privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
2094
+ privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
2080
2095
  allowCreate: true
2081
2096
  });
2082
- const idp = saml.IdentityProvider({
2083
- metadata: parsedSamlConfig.idpMetadata?.metadata,
2084
- entityID: parsedSamlConfig.idpMetadata?.entityID,
2085
- encryptCert: parsedSamlConfig.idpMetadata?.cert,
2086
- singleSignOnService: parsedSamlConfig.idpMetadata?.singleSignOnService
2097
+ const idpData = parsedSamlConfig.idpMetadata;
2098
+ let idp;
2099
+ if (!idpData?.metadata) idp = saml.IdentityProvider({
2100
+ entityID: idpData?.entityID || parsedSamlConfig.issuer,
2101
+ singleSignOnService: idpData?.singleSignOnService || [{
2102
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
2103
+ Location: parsedSamlConfig.entryPoint
2104
+ }],
2105
+ signingCert: idpData?.cert || parsedSamlConfig.cert,
2106
+ wantAuthnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
2107
+ isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
2108
+ encPrivateKey: idpData?.encPrivateKey,
2109
+ encPrivateKeyPass: idpData?.encPrivateKeyPass
2110
+ });
2111
+ else idp = saml.IdentityProvider({
2112
+ metadata: idpData.metadata,
2113
+ privateKey: idpData.privateKey,
2114
+ privateKeyPass: idpData.privateKeyPass,
2115
+ isAssertionEncrypted: idpData.isAssertionEncrypted,
2116
+ encPrivateKey: idpData.encPrivateKey,
2117
+ encPrivateKeyPass: idpData.encPrivateKeyPass
2087
2118
  });
2088
2119
  const loginRequest = sp.createLoginRequest(idp, "redirect");
2089
2120
  if (!loginRequest) throw new APIError("BAD_REQUEST", { message: "Invalid SAML request" });
@@ -2162,10 +2193,10 @@ const callbackSSO = (options) => {
2162
2193
  oidcConfig: safeJsonParse(res.oidcConfig) || void 0
2163
2194
  };
2164
2195
  });
2165
- if (!provider) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=provider not found`);
2196
+ if (!provider) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
2166
2197
  if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
2167
2198
  let config = provider.oidcConfig;
2168
- if (!config) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=provider not found`);
2199
+ if (!config) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
2169
2200
  const discovery = await betterFetch(config.discoveryEndpoint);
2170
2201
  if (discovery.data) config = {
2171
2202
  tokenEndpoint: discovery.data.token_endpoint,
@@ -2179,7 +2210,7 @@ const callbackSSO = (options) => {
2179
2210
  ],
2180
2211
  ...config
2181
2212
  };
2182
- if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=token_endpoint_not_found`);
2213
+ if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_endpoint_not_found`);
2183
2214
  const tokenResponse = await validateAuthorizationCode({
2184
2215
  code,
2185
2216
  codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
@@ -2194,17 +2225,19 @@ const callbackSSO = (options) => {
2194
2225
  if (e instanceof BetterFetchError) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${e.message}`);
2195
2226
  return null;
2196
2227
  });
2197
- if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=token_response_not_found`);
2228
+ if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_response_not_found`);
2198
2229
  let userInfo = null;
2199
2230
  if (tokenResponse.idToken) {
2200
2231
  const idToken = decodeJwt(tokenResponse.idToken);
2201
- if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=jwks_endpoint_not_found`);
2202
- const verified = await validateToken(tokenResponse.idToken, config.jwksEndpoint).catch((e) => {
2232
+ if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=jwks_endpoint_not_found`);
2233
+ const verified = await validateToken(tokenResponse.idToken, config.jwksEndpoint, {
2234
+ audience: config.clientId,
2235
+ issuer: provider.issuer
2236
+ }).catch((e) => {
2203
2237
  ctx.context.logger.error(e);
2204
2238
  return null;
2205
2239
  });
2206
- if (!verified) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=token_not_verified`);
2207
- if (verified.payload.iss !== provider.issuer) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=issuer_mismatch`);
2240
+ if (!verified) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_not_verified`);
2208
2241
  const mapping = config.mapping || {};
2209
2242
  userInfo = {
2210
2243
  ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, verified.payload[value]])),
@@ -2216,12 +2249,12 @@ const callbackSSO = (options) => {
2216
2249
  };
2217
2250
  }
2218
2251
  if (!userInfo) {
2219
- if (!config.userInfoEndpoint) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=user_info_endpoint_not_found`);
2252
+ if (!config.userInfoEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=user_info_endpoint_not_found`);
2220
2253
  const userInfoResponse = await betterFetch(config.userInfoEndpoint, { headers: { Authorization: `Bearer ${tokenResponse.accessToken}` } });
2221
- if (userInfoResponse.error) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=${userInfoResponse.error.message}`);
2254
+ if (userInfoResponse.error) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${userInfoResponse.error.message}`);
2222
2255
  userInfo = userInfoResponse.data;
2223
2256
  }
2224
- if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=missing_user_info`);
2257
+ if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=missing_user_info`);
2225
2258
  const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfo.email, provider.domain);
2226
2259
  const linked = await handleOAuthUserInfo(ctx, {
2227
2260
  userInfo: {
@@ -2246,7 +2279,7 @@ const callbackSSO = (options) => {
2246
2279
  overrideUserInfo: config.overrideUserInfo,
2247
2280
  isTrustedProvider
2248
2281
  });
2249
- if (linked.error) throw ctx.redirect(`${errorURL || callbackURL}/error?error=${linked.error}`);
2282
+ if (linked.error) throw ctx.redirect(`${errorURL || callbackURL}?error=${linked.error}`);
2250
2283
  const { session, user } = linked.data;
2251
2284
  if (options?.provisionUser) await options.provisionUser({
2252
2285
  user,
@@ -2340,8 +2373,8 @@ const callbackSSOSAML = (options) => {
2340
2373
  if (ctx.method === "GET" && !ctx.body?.SAMLResponse) {
2341
2374
  if (!(await getSessionFromCtx(ctx))?.session) throw ctx.redirect(`${errorURL}?error=invalid_request`);
2342
2375
  const relayState$1 = ctx.query?.RelayState;
2343
- const safeRedirectUrl$1 = getSafeRedirectUrl(relayState$1, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
2344
- throw ctx.redirect(safeRedirectUrl$1);
2376
+ const safeRedirectUrl = getSafeRedirectUrl(relayState$1, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
2377
+ throw ctx.redirect(safeRedirectUrl);
2345
2378
  }
2346
2379
  if (!ctx.body?.SAMLResponse) throw new APIError("BAD_REQUEST", { message: "SAMLResponse is required for POST requests" });
2347
2380
  const { SAMLResponse } = ctx.body;
@@ -2380,16 +2413,18 @@ const callbackSSOSAML = (options) => {
2380
2413
  if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
2381
2414
  const parsedSamlConfig = safeJsonParse(provider.samlConfig);
2382
2415
  if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
2416
+ const isTrusted = (url, settings) => ctx.context.isTrustedOrigin(url, settings);
2417
+ const safeErrorUrl = getSafeRedirectUrl(relayState?.errorURL || relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, isTrusted);
2383
2418
  const idpData = parsedSamlConfig.idpMetadata;
2384
2419
  let idp = null;
2385
2420
  if (!idpData?.metadata) idp = saml.IdentityProvider({
2386
2421
  entityID: idpData?.entityID || parsedSamlConfig.issuer,
2387
- singleSignOnService: [{
2422
+ singleSignOnService: idpData?.singleSignOnService || [{
2388
2423
  Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
2389
2424
  Location: parsedSamlConfig.entryPoint
2390
2425
  }],
2391
2426
  signingCert: idpData?.cert || parsedSamlConfig.cert,
2392
- wantAuthnRequestsSigned: parsedSamlConfig.wantAssertionsSigned || false,
2427
+ wantAuthnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
2393
2428
  isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
2394
2429
  encPrivateKey: idpData?.encPrivateKey,
2395
2430
  encPrivateKeyPass: idpData?.encPrivateKeyPass
@@ -2429,7 +2464,7 @@ const callbackSSOSAML = (options) => {
2429
2464
  } catch (error) {
2430
2465
  ctx.context.logger.error("SAML response validation failed", {
2431
2466
  error,
2432
- decodedResponse: Buffer.from(SAMLResponse, "base64").toString("utf-8")
2467
+ decodedResponse: new TextDecoder().decode(base64.decode(SAMLResponse))
2433
2468
  });
2434
2469
  throw new APIError("BAD_REQUEST", {
2435
2470
  message: "Invalid SAML response",
@@ -2460,8 +2495,7 @@ const callbackSSOSAML = (options) => {
2460
2495
  inResponseTo,
2461
2496
  providerId: provider.providerId
2462
2497
  });
2463
- const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2464
- throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
2498
+ throw ctx.redirect(`${safeErrorUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
2465
2499
  }
2466
2500
  if (storedRequest.providerId !== provider.providerId) {
2467
2501
  ctx.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
@@ -2470,14 +2504,12 @@ const callbackSSOSAML = (options) => {
2470
2504
  actualProvider: provider.providerId
2471
2505
  });
2472
2506
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
2473
- const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2474
- throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
2507
+ throw ctx.redirect(`${safeErrorUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
2475
2508
  }
2476
2509
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
2477
2510
  } else if (!allowIdpInitiated) {
2478
2511
  ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: provider.providerId });
2479
- const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2480
- throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
2512
+ throw ctx.redirect(`${safeErrorUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
2481
2513
  }
2482
2514
  }
2483
2515
  const samlContent = parsedResponse.samlContent;
@@ -2503,8 +2535,7 @@ const callbackSSOSAML = (options) => {
2503
2535
  issuer,
2504
2536
  providerId: provider.providerId
2505
2537
  });
2506
- const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2507
- throw ctx.redirect(`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
2538
+ throw ctx.redirect(`${safeErrorUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
2508
2539
  }
2509
2540
  await ctx.context.internalAdapter.createVerificationValue({
2510
2541
  identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
@@ -2537,7 +2568,7 @@ const callbackSSOSAML = (options) => {
2537
2568
  throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
2538
2569
  }
2539
2570
  const isTrustedProvider = !!ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
2540
- const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2571
+ const safeCallbackUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, isTrusted);
2541
2572
  const result = await handleOAuthUserInfo(ctx, {
2542
2573
  userInfo: {
2543
2574
  email: userInfo.email,
@@ -2551,11 +2582,11 @@ const callbackSSOSAML = (options) => {
2551
2582
  accessToken: "",
2552
2583
  refreshToken: ""
2553
2584
  },
2554
- callbackURL: callbackUrl,
2585
+ callbackURL: safeCallbackUrl,
2555
2586
  disableSignUp: options?.disableImplicitSignUp,
2556
2587
  isTrustedProvider
2557
2588
  });
2558
- if (result.error) throw ctx.redirect(`${callbackUrl}?error=${result.error.split(" ").join("_")}`);
2589
+ if (result.error) throw ctx.redirect(`${safeCallbackUrl}?error=${result.error.split(" ").join("_")}`);
2559
2590
  const { session, user } = result.data;
2560
2591
  if (options?.provisionUser) await options.provisionUser({
2561
2592
  user,
@@ -2579,8 +2610,7 @@ const callbackSSOSAML = (options) => {
2579
2610
  session,
2580
2611
  user
2581
2612
  });
2582
- const safeRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
2583
- throw ctx.redirect(safeRedirectUrl);
2613
+ throw ctx.redirect(safeCallbackUrl);
2584
2614
  });
2585
2615
  };
2586
2616
  const acsEndpointBodySchema = z.object({
@@ -2602,10 +2632,18 @@ const acsEndpoint = (options) => {
2602
2632
  }
2603
2633
  }
2604
2634
  }, async (ctx) => {
2605
- const { SAMLResponse, RelayState = "" } = ctx.body;
2635
+ const { SAMLResponse } = ctx.body;
2606
2636
  const { providerId } = ctx.params;
2637
+ const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`;
2638
+ const appOrigin = new URL(ctx.context.baseURL).origin;
2607
2639
  const maxResponseSize = options?.saml?.maxResponseSize ?? DEFAULT_MAX_SAML_RESPONSE_SIZE;
2608
2640
  if (new TextEncoder().encode(SAMLResponse).length > maxResponseSize) throw new APIError("BAD_REQUEST", { message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)` });
2641
+ let relayState = null;
2642
+ if (ctx.body.RelayState) try {
2643
+ relayState = await parseRelayState(ctx);
2644
+ } catch {
2645
+ relayState = null;
2646
+ }
2609
2647
  let provider = null;
2610
2648
  if (options?.defaultSSO?.length) {
2611
2649
  const matchingDefault = providerId ? options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId) : options.defaultSSO[0];
@@ -2633,6 +2671,8 @@ const acsEndpoint = (options) => {
2633
2671
  if (!provider?.samlConfig) throw new APIError("NOT_FOUND", { message: "No SAML provider found" });
2634
2672
  if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
2635
2673
  const parsedSamlConfig = provider.samlConfig;
2674
+ const isTrusted = (url, settings) => ctx.context.isTrustedOrigin(url, settings);
2675
+ const safeErrorUrl = getSafeRedirectUrl(relayState?.errorURL || relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, isTrusted);
2636
2676
  const sp = saml.ServiceProvider({
2637
2677
  entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
2638
2678
  assertionConsumerService: [{
@@ -2658,9 +2698,8 @@ const acsEndpoint = (options) => {
2658
2698
  validateSingleAssertion(SAMLResponse);
2659
2699
  } catch (error) {
2660
2700
  if (error instanceof APIError) {
2661
- const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2662
2701
  const errorCode = error.body?.code === "SAML_MULTIPLE_ASSERTIONS" ? "multiple_assertions" : "no_assertion";
2663
- throw ctx.redirect(`${redirectUrl}?error=${errorCode}&error_description=${encodeURIComponent(error.message)}`);
2702
+ throw ctx.redirect(`${safeErrorUrl}?error=${errorCode}&error_description=${encodeURIComponent(error.message)}`);
2664
2703
  }
2665
2704
  throw error;
2666
2705
  }
@@ -2668,13 +2707,13 @@ const acsEndpoint = (options) => {
2668
2707
  try {
2669
2708
  parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
2670
2709
  SAMLResponse,
2671
- RelayState: RelayState || void 0
2710
+ RelayState: ctx.body.RelayState || void 0
2672
2711
  } });
2673
2712
  if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
2674
2713
  } catch (error) {
2675
2714
  ctx.context.logger.error("SAML response validation failed", {
2676
2715
  error,
2677
- decodedResponse: Buffer.from(SAMLResponse, "base64").toString("utf-8")
2716
+ decodedResponse: new TextDecoder().decode(base64.decode(SAMLResponse))
2678
2717
  });
2679
2718
  throw new APIError("BAD_REQUEST", {
2680
2719
  message: "Invalid SAML response",
@@ -2705,8 +2744,7 @@ const acsEndpoint = (options) => {
2705
2744
  inResponseTo: inResponseToAcs,
2706
2745
  providerId
2707
2746
  });
2708
- const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2709
- throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
2747
+ throw ctx.redirect(`${safeErrorUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
2710
2748
  }
2711
2749
  if (storedRequest.providerId !== providerId) {
2712
2750
  ctx.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
@@ -2715,17 +2753,15 @@ const acsEndpoint = (options) => {
2715
2753
  actualProvider: providerId
2716
2754
  });
2717
2755
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
2718
- const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2719
- throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
2756
+ throw ctx.redirect(`${safeErrorUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
2720
2757
  }
2721
2758
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
2722
2759
  } else if (!allowIdpInitiated) {
2723
2760
  ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId });
2724
- const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2725
- throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
2761
+ throw ctx.redirect(`${safeErrorUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
2726
2762
  }
2727
2763
  }
2728
- const assertionIdAcs = extractAssertionId(Buffer.from(SAMLResponse, "base64").toString("utf-8"));
2764
+ const assertionIdAcs = extractAssertionId(new TextDecoder().decode(base64.decode(SAMLResponse)));
2729
2765
  if (assertionIdAcs) {
2730
2766
  const issuer = idp.entityMeta.getEntityID();
2731
2767
  const conditions = extract.conditions;
@@ -2747,8 +2783,7 @@ const acsEndpoint = (options) => {
2747
2783
  issuer,
2748
2784
  providerId
2749
2785
  });
2750
- const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2751
- throw ctx.redirect(`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
2786
+ throw ctx.redirect(`${safeErrorUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
2752
2787
  }
2753
2788
  await ctx.context.internalAdapter.createVerificationValue({
2754
2789
  identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionIdAcs}`,
@@ -2781,7 +2816,7 @@ const acsEndpoint = (options) => {
2781
2816
  throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
2782
2817
  }
2783
2818
  const isTrustedProvider = !!ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
2784
- const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2819
+ const safeCallbackUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, isTrusted);
2785
2820
  const result = await handleOAuthUserInfo(ctx, {
2786
2821
  userInfo: {
2787
2822
  email: userInfo.email,
@@ -2795,11 +2830,11 @@ const acsEndpoint = (options) => {
2795
2830
  accessToken: "",
2796
2831
  refreshToken: ""
2797
2832
  },
2798
- callbackURL: callbackUrl,
2833
+ callbackURL: safeCallbackUrl,
2799
2834
  disableSignUp: options?.disableImplicitSignUp,
2800
2835
  isTrustedProvider
2801
2836
  });
2802
- if (result.error) throw ctx.redirect(`${callbackUrl}?error=${result.error.split(" ").join("_")}`);
2837
+ if (result.error) throw ctx.redirect(`${safeCallbackUrl}?error=${result.error.split(" ").join("_")}`);
2803
2838
  const { session, user } = result.data;
2804
2839
  if (options?.provisionUser) await options.provisionUser({
2805
2840
  user,
@@ -2823,7 +2858,7 @@ const acsEndpoint = (options) => {
2823
2858
  session,
2824
2859
  user
2825
2860
  });
2826
- throw ctx.redirect(callbackUrl);
2861
+ throw ctx.redirect(safeCallbackUrl);
2827
2862
  });
2828
2863
  };
2829
2864