@better-auth/sso 1.8.0-beta.7 → 1.8.0-beta.8

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/client.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { t as PACKAGE_VERSION } from "./version-B5en_Bdq.mjs";
1
+ import { t as PACKAGE_VERSION } from "./version-WwwFpRb_.mjs";
2
2
  //#region src/client.ts
3
3
  const ssoClient = (options) => {
4
4
  return {
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { t as PACKAGE_VERSION } from "./version-B5en_Bdq.mjs";
2
- import { APIError, addOAuthServerContext, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
1
+ import { t as PACKAGE_VERSION } from "./version-WwwFpRb_.mjs";
2
+ import { APIError, addOAuthServerContext, createAuthEndpoint, createAuthMiddleware, generateGenericState, getSessionFromCtx, parseGenericState, 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";
@@ -17,11 +17,12 @@ import { DOMParser } from "@xmldom/xmldom";
17
17
  import samlifyDefault, * as samlifyNamespace from "samlify";
18
18
  import { defineErrorCodes } from "@better-auth/core/utils/error-codes";
19
19
  import { parseInputData, toZodSchema } from "better-auth/db";
20
+ import { HIDE_METADATA } from "@better-auth/core/api";
21
+ import { PRIVATE_KEY_JWT_SIGNING_ALGORITHMS, authorizationCodeRequest, createAuthorizationURL, createPrivateKeyJwtClientAssertionGetter, getOAuth2Tokens } from "@better-auth/core/oauth2";
20
22
  import { isAPIError } from "@better-auth/core/utils/is-api-error";
21
23
  import { appendQueryParams } from "@better-auth/core/utils/url";
22
- import { HIDE_METADATA, PRIVATE_KEY_JWT_SIGNING_ALGORITHMS, authorizationCodeRequest, createAuthorizationURL, createPrivateKeyJwtClientAssertionGetter, generateGenericState, generateState, getOAuth2Tokens, parseGenericState, parseState } from "better-auth";
23
24
  import { deleteSessionCookie, setAccountCookie, setSessionCookie } from "better-auth/cookies";
24
- import { additionalAuthorizationParamsSchema, handleOAuthUserInfo } from "better-auth/oauth2";
25
+ import { additionalAuthorizationParamsSchema, generateState, handleOAuthUserInfo, parseState } from "better-auth/oauth2";
25
26
  //#region src/constants.ts
26
27
  /**
27
28
  * SAML Constants
@@ -1383,7 +1384,7 @@ function parseSAMLServiceProviderMetadata(metadata) {
1383
1384
  if (value) nameIDFormats.push(value);
1384
1385
  }
1385
1386
  for (const service of directMetadataChildren(descriptor, "AssertionConsumerService")) {
1386
- if (service.getAttribute("Binding") !== "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST") continue;
1387
+ if (service.getAttribute("Binding") !== SAML_HTTP_POST_BINDING) continue;
1387
1388
  const location = service.getAttribute("Location")?.trim();
1388
1389
  if (!location || !isAbsoluteHttpUrl(location)) throw new Error("Invalid SAML POST AssertionConsumerService");
1389
1390
  postAssertionConsumerServiceUrls.push(location);
@@ -1693,6 +1694,7 @@ async function findSAMLProvider(providerId, options, adapter) {
1693
1694
  }
1694
1695
  function createSP(config, baseURL, providerId, opts) {
1695
1696
  const spData = config.spMetadata;
1697
+ const clockSkew = opts?.clockSkew ?? 3e5;
1696
1698
  const sloLocation = `${baseURL}/sso/saml2/sp/slo/${providerId}`;
1697
1699
  const acsUrl = `${baseURL}/sso/saml2/sp/acs/${providerId}`;
1698
1700
  let metadata = spData?.metadata;
@@ -1725,7 +1727,7 @@ function createSP(config, baseURL, providerId, opts) {
1725
1727
  encPrivateKey: normalizePem(spData?.encPrivateKey),
1726
1728
  encPrivateKeyPass: spData?.encPrivateKeyPass,
1727
1729
  relayState: opts?.relayState,
1728
- clockDrifts: opts?.clockSkew && opts?.clockSkew !== 0 ? [-opts.clockSkew, opts.clockSkew] : void 0
1730
+ clockDrifts: [-clockSkew, clockSkew]
1729
1731
  });
1730
1732
  }
1731
1733
  /**
@@ -2806,17 +2808,12 @@ function getSAMLRedirectCandidates(relayStateCallbackUrl, samlConfig, samlOption
2806
2808
  samlConfig?.callbackUrl
2807
2809
  ];
2808
2810
  }
2809
- function toArray(value) {
2810
- if (Array.isArray(value)) return value;
2811
- return value ? [value] : [];
2812
- }
2813
- function getExpectedSAMLRecipients(config, baseURL, providerId, currentCallbackPath, assertionConsumerServiceUrl) {
2811
+ function getExpectedSAMLRecipients(config, baseURL, providerId, currentCallbackPath) {
2814
2812
  const configuredPostAssertionConsumerServiceUrls = getSAMLPostAssertionConsumerServiceUrls(config.spMetadata?.metadata);
2815
2813
  return [
2816
2814
  currentCallbackPath,
2817
2815
  `${baseURL}/sso/saml2/sp/acs/${providerId}`,
2818
- ...configuredPostAssertionConsumerServiceUrls,
2819
- ...toArray(assertionConsumerServiceUrl)
2816
+ ...configuredPostAssertionConsumerServiceUrls
2820
2817
  ];
2821
2818
  }
2822
2819
  async function getSAMLResponseBindingContent(sp, samlContent) {
@@ -2827,20 +2824,16 @@ async function getSAMLResponseBindingContent(sp, samlContent) {
2827
2824
  /**
2828
2825
  * Extracts the Assertion ID from a SAML response XML.
2829
2826
  * Used for replay protection per SAML 2.0 Core section 2.3.3.
2827
+ *
2828
+ * Reads the ID through the DOM so character references are decoded the same
2829
+ * way the signature check decodes them: re-spelling the ID keeps the
2830
+ * signature valid, so it must not change the replay key.
2830
2831
  */
2831
2832
  function extractAssertionId(samlContent) {
2832
2833
  try {
2833
- const parsed = new XMLParser({
2834
- ignoreAttributes: false,
2835
- attributeNamePrefix: "@_",
2836
- removeNSPrefix: true
2837
- }).parse(samlContent);
2838
- const response = parsed.Response || parsed["samlp:Response"];
2839
- if (!response) return null;
2840
- const rawAssertion = response.Assertion || response["saml:Assertion"];
2841
- const assertion = Array.isArray(rawAssertion) ? rawAssertion[0] : rawAssertion;
2842
- if (!assertion) return null;
2843
- return assertion["@_ID"] || null;
2834
+ const response = new DOMParser().parseFromString(samlContent, "text/xml").documentElement;
2835
+ if (response?.localName !== "Response") return null;
2836
+ return Array.from(response.childNodes).find((node) => node.nodeType === 1 && node.localName === "Assertion")?.getAttribute("ID") || null;
2844
2837
  } catch {
2845
2838
  return null;
2846
2839
  }
@@ -2899,8 +2892,7 @@ async function processSAMLResponse(ctx, params, options) {
2899
2892
  logger: ctx.context.logger
2900
2893
  });
2901
2894
  const expectedAudiences = [sp.entityMeta.getEntityID(), parsedSamlConfig.audience];
2902
- const assertionConsumerServiceUrl = sp.entityMeta.getAssertionConsumerService(SAML_HTTP_POST_BINDING);
2903
- const expectedRecipients = getExpectedSAMLRecipients(parsedSamlConfig, ctx.context.baseURL, providerId, currentCallbackPath, assertionConsumerServiceUrl);
2895
+ const expectedRecipients = getExpectedSAMLRecipients(parsedSamlConfig, ctx.context.baseURL, providerId, currentCallbackPath);
2904
2896
  const serviceProviderPolicy = deriveSAMLServiceProviderPolicy(parsedSamlConfig);
2905
2897
  let samlBindingContent;
2906
2898
  try {
@@ -4428,7 +4420,7 @@ const initiateSLO = (options) => {
4428
4420
  const session = ctx.context.session;
4429
4421
  const sessionLookupKey = `${SAML_SESSION_BY_ID_PREFIX}${session.session.id}`;
4430
4422
  const sessionLookup = await ctx.context.internalAdapter.findVerificationValue(sessionLookupKey);
4431
- let nameID = session.user.email;
4423
+ let nameID;
4432
4424
  let sessionIndex;
4433
4425
  let samlSessionKey;
4434
4426
  if (sessionLookup) {
@@ -4437,11 +4429,15 @@ const initiateSLO = (options) => {
4437
4429
  if (stored) {
4438
4430
  const data = safeJsonParse(stored.value);
4439
4431
  if (data) {
4440
- nameID = data.nameID || nameID;
4432
+ nameID = data.nameID;
4441
4433
  sessionIndex = data.sessionIndex;
4442
4434
  }
4443
4435
  }
4444
4436
  }
4437
+ if (!nameID) {
4438
+ const [account] = (await ctx.context.internalAdapter.findAccounts(session.user.id)).filter((account) => account.providerId === providerId).sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
4439
+ nameID = account?.accountId ?? session.user.email;
4440
+ }
4445
4441
  const logoutRequest = sp.createLogoutRequest(idp, "redirect", {
4446
4442
  logoutNameID: nameID,
4447
4443
  sessionIndex,
@@ -4506,6 +4502,53 @@ function assertNoAdditionalFieldCollisions(options) {
4506
4502
  if (builtInFieldNames.has(fieldName)) throw new Error(`ssoProvider additional field "${key}" maps to built-in field "${fieldName}"`);
4507
4503
  }
4508
4504
  }
4505
+ const SAML_SESSION_RECORD_PAGE_SIZE = 100;
4506
+ async function findSAMLSessionRecord(ctx, identifier, isOwnRecord) {
4507
+ for (let offset = 0;; offset += SAML_SESSION_RECORD_PAGE_SIZE) {
4508
+ const page = await ctx.adapter.findMany({
4509
+ model: "verification",
4510
+ where: [{
4511
+ field: "identifier",
4512
+ value: identifier
4513
+ }],
4514
+ sortBy: {
4515
+ field: "createdAt",
4516
+ direction: "desc"
4517
+ },
4518
+ limit: SAML_SESSION_RECORD_PAGE_SIZE,
4519
+ offset
4520
+ });
4521
+ const record = page.find(isOwnRecord);
4522
+ if (record || page.length < SAML_SESSION_RECORD_PAGE_SIZE) return record;
4523
+ }
4524
+ }
4525
+ /**
4526
+ * SLO records are written with the session's expiry at sign-in. Move it when a
4527
+ * refresh extends the session, so expired-row cleanup doesn't drop them early.
4528
+ */
4529
+ async function extendSAMLSessionRecords(ctx, session) {
4530
+ const lookupKey = `${SAML_SESSION_BY_ID_PREFIX}${session.id}`;
4531
+ const lookup = await ctx.internalAdapter.findVerificationValue(lookupKey);
4532
+ const expiresAt = new Date(session.expiresAt);
4533
+ if (!lookup || new Date(lookup.expiresAt) >= expiresAt) return;
4534
+ const isOwnRecord = (record) => safeJsonParse(record.value)?.sessionId === session.id;
4535
+ const latest = await ctx.internalAdapter.findVerificationValue(lookup.value);
4536
+ const secondaryStorageOnly = ctx.options.secondaryStorage && !ctx.options.verification?.storeInDatabase;
4537
+ if (latest && secondaryStorageOnly) {
4538
+ if (isOwnRecord(latest)) await ctx.internalAdapter.updateVerificationByIdentifier(lookup.value, { expiresAt });
4539
+ } else if (latest) {
4540
+ const record = await findSAMLSessionRecord(ctx, latest.identifier, isOwnRecord);
4541
+ if (record) await ctx.adapter.update({
4542
+ model: "verification",
4543
+ where: [{
4544
+ field: "id",
4545
+ value: record.id
4546
+ }],
4547
+ update: { expiresAt }
4548
+ });
4549
+ }
4550
+ await ctx.internalAdapter.updateVerificationByIdentifier(lookupKey, { expiresAt });
4551
+ }
4509
4552
  function sso(options) {
4510
4553
  assertNoAdditionalFieldCollisions(options);
4511
4554
  const optionsWithStore = options;
@@ -4538,8 +4581,13 @@ function sso(options) {
4538
4581
  version: PACKAGE_VERSION,
4539
4582
  init(ctx) {
4540
4583
  const existing = ctx.skipOriginCheck;
4541
- if (existing === true) return {};
4542
- return { context: { skipOriginCheck: [...Array.isArray(existing) ? existing : [], ...SAML_SKIP_ORIGIN_CHECK_PATHS] } };
4584
+ return {
4585
+ ...existing !== true && { context: { skipOriginCheck: [...Array.isArray(existing) ? existing : [], ...SAML_SKIP_ORIGIN_CHECK_PATHS] } },
4586
+ options: { databaseHooks: { session: { update: { async after(session) {
4587
+ if (!options?.saml?.enableSingleLogout) return;
4588
+ await extendSAMLSessionRecords(ctx, session).catch((error) => ctx.logger.warn("Failed to extend SAML session records", { error }));
4589
+ } } } } }
4590
+ };
4543
4591
  },
4544
4592
  endpoints,
4545
4593
  hooks: {
@@ -1,5 +1,5 @@
1
1
  //#endregion
2
2
  //#region src/version.ts
3
- const PACKAGE_VERSION = "1.8.0-beta.7";
3
+ const PACKAGE_VERSION = "1.8.0-beta.8";
4
4
  //#endregion
5
5
  export { PACKAGE_VERSION as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/sso",
3
- "version": "1.8.0-beta.7",
3
+ "version": "1.8.0-beta.8",
4
4
  "description": "SSO plugin for Better Auth",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -72,16 +72,16 @@
72
72
  "kysely": "^0.28.17 || ^0.29.0",
73
73
  "oauth2-mock-server": "^9.0.0",
74
74
  "tsdown": "0.23.1",
75
- "@better-auth/kysely-adapter": "1.8.0-beta.7",
76
- "better-auth": "1.8.0-beta.7",
77
- "@better-auth/core": "1.8.0-beta.7"
75
+ "@better-auth/core": "1.8.0-beta.8",
76
+ "@better-auth/kysely-adapter": "1.8.0-beta.8",
77
+ "better-auth": "1.8.0-beta.8"
78
78
  },
79
79
  "peerDependencies": {
80
80
  "@better-auth/utils": "^0.4.2 || ^0.5.0",
81
81
  "@better-fetch/fetch": "1.3.3",
82
82
  "better-call": "1.4.5",
83
- "@better-auth/core": "^1.8.0-beta.7",
84
- "better-auth": "^1.8.0-beta.7"
83
+ "@better-auth/core": "^1.8.0-beta.8",
84
+ "better-auth": "^1.8.0-beta.8"
85
85
  },
86
86
  "scripts": {
87
87
  "build": "tsdown",