@better-auth/sso 1.8.0-beta.6 → 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
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { t as PACKAGE_VERSION } from "./version-
|
|
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") !==
|
|
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:
|
|
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
|
|
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
|
|
2834
|
-
|
|
2835
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
4542
|
-
|
|
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: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-auth/sso",
|
|
3
|
-
"version": "1.8.0-beta.
|
|
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/core": "1.8.0-beta.
|
|
76
|
-
"@better-auth/kysely-adapter": "1.8.0-beta.
|
|
77
|
-
"better-auth": "1.8.0-beta.
|
|
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.
|
|
84
|
-
"better-auth": "^1.8.0-beta.
|
|
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",
|