@better-auth/sso 1.6.15 → 1.6.17
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.d.mts
CHANGED
package/dist/client.mjs
CHANGED
|
@@ -1359,7 +1359,7 @@ declare const callbackSSOShared: (options?: SSOOptions) => better_call0.StrictEn
|
|
|
1359
1359
|
allowedMediaTypes: readonly ["application/x-www-form-urlencoded", "application/json"];
|
|
1360
1360
|
}, void>;
|
|
1361
1361
|
declare const callbackSSOSAML: (options?: SSOOptions) => better_call0.StrictEndpoint<"/sso/saml2/callback/:providerId", {
|
|
1362
|
-
method: ("
|
|
1362
|
+
method: ("GET" | "POST")[];
|
|
1363
1363
|
body: z.ZodOptional<z.ZodObject<{
|
|
1364
1364
|
SAMLResponse: z.ZodString;
|
|
1365
1365
|
RelayState: z.ZodOptional<z.ZodString>;
|
|
@@ -1410,7 +1410,7 @@ declare const acsEndpoint: (options?: SSOOptions) => better_call0.StrictEndpoint
|
|
|
1410
1410
|
};
|
|
1411
1411
|
}, never>;
|
|
1412
1412
|
declare const sloEndpoint: (options?: SSOOptions) => better_call0.StrictEndpoint<"/sso/saml2/sp/slo/:providerId", {
|
|
1413
|
-
method: ("
|
|
1413
|
+
method: ("GET" | "POST")[];
|
|
1414
1414
|
body: z.ZodOptional<z.ZodObject<{
|
|
1415
1415
|
SAMLRequest: z.ZodOptional<z.ZodString>;
|
|
1416
1416
|
SAMLResponse: z.ZodOptional<z.ZodString>;
|
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-
|
|
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-D9brFUE1.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
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { t as PACKAGE_VERSION } from "./version-
|
|
1
|
+
import { t as PACKAGE_VERSION } from "./version-BeZU0Td6.mjs";
|
|
2
2
|
import { APIError, 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 { isPublicRoutableHost } from "@better-auth/core/utils/host";
|
|
8
|
+
import { classifyHost, isPublicRoutableHost } from "@better-auth/core/utils/host";
|
|
9
9
|
import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
|
|
10
10
|
import { base64 } from "@better-auth/utils/base64";
|
|
11
11
|
import { isAPIError } from "@better-auth/core/utils/is-api-error";
|
|
@@ -24,8 +24,6 @@ import samlifyDefault from "samlify";
|
|
|
24
24
|
*/
|
|
25
25
|
/** Prefix for AuthnRequest IDs used in InResponseTo validation */
|
|
26
26
|
const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
|
|
27
|
-
/** Prefix for used Assertion IDs used in replay protection */
|
|
28
|
-
const USED_ASSERTION_KEY_PREFIX = "saml-used-assertion:";
|
|
29
27
|
/** Prefix for SAML session data (NameID + SessionIndex) for SLO */
|
|
30
28
|
const SAML_SESSION_KEY_PREFIX = "saml-session:";
|
|
31
29
|
/** Prefix for reverse lookup of SAML session by Better Auth session ID */
|
|
@@ -86,6 +84,16 @@ const domainMatches = (searchDomain, domainList) => {
|
|
|
86
84
|
return domainList.split(",").map((d) => d.trim().toLowerCase()).filter(Boolean).some((d) => search === d || search.endsWith(`.${d}`));
|
|
87
85
|
};
|
|
88
86
|
/**
|
|
87
|
+
* Strictly parse a provider-supplied email-verification claim.
|
|
88
|
+
*
|
|
89
|
+
* OIDC userInfo, OIDC id-token, and SAML attribute values are frequently
|
|
90
|
+
* strings, so a loose `Boolean(value)` or truthy fallback treats the string
|
|
91
|
+
* `"false"` as verified. Only a boolean `true` or the exact string `"true"`
|
|
92
|
+
* count as verified; every other value, including `"false"`, `"0"`, `""`,
|
|
93
|
+
* numbers, arrays, and objects, is unverified.
|
|
94
|
+
*/
|
|
95
|
+
const parseProviderEmailVerified = (value) => value === true || value === "true";
|
|
96
|
+
/**
|
|
89
97
|
* Validates email domain against allowed domain(s).
|
|
90
98
|
* Supports comma-separated domains for multi-domain SSO.
|
|
91
99
|
*/
|
|
@@ -211,171 +219,6 @@ async function assignOrganizationByDomain(ctx, options) {
|
|
|
211
219
|
});
|
|
212
220
|
}
|
|
213
221
|
//#endregion
|
|
214
|
-
//#region src/routes/domain-verification.ts
|
|
215
|
-
const DNS_LABEL_MAX_LENGTH = 63;
|
|
216
|
-
const DEFAULT_TOKEN_PREFIX = "better-auth-token";
|
|
217
|
-
const domainVerificationBodySchema = z.object({ providerId: z.string() });
|
|
218
|
-
function getVerificationIdentifier(options, providerId) {
|
|
219
|
-
return `_${options.domainVerification?.tokenPrefix || DEFAULT_TOKEN_PREFIX}-${providerId}`;
|
|
220
|
-
}
|
|
221
|
-
const requestDomainVerification = (options) => {
|
|
222
|
-
return createAuthEndpoint("/sso/request-domain-verification", {
|
|
223
|
-
method: "POST",
|
|
224
|
-
body: domainVerificationBodySchema,
|
|
225
|
-
metadata: { openapi: {
|
|
226
|
-
summary: "Request a domain verification",
|
|
227
|
-
description: "Request a domain verification for the given SSO provider",
|
|
228
|
-
responses: {
|
|
229
|
-
"404": { description: "Provider not found" },
|
|
230
|
-
"409": { description: "Domain has already been verified" },
|
|
231
|
-
"201": { description: "Domain submitted for verification" }
|
|
232
|
-
}
|
|
233
|
-
} },
|
|
234
|
-
use: [sessionMiddleware]
|
|
235
|
-
}, async (ctx) => {
|
|
236
|
-
const body = ctx.body;
|
|
237
|
-
const provider = await ctx.context.adapter.findOne({
|
|
238
|
-
model: "ssoProvider",
|
|
239
|
-
where: [{
|
|
240
|
-
field: "providerId",
|
|
241
|
-
value: body.providerId
|
|
242
|
-
}]
|
|
243
|
-
});
|
|
244
|
-
if (!provider) throw new APIError("NOT_FOUND", {
|
|
245
|
-
message: "Provider not found",
|
|
246
|
-
code: "PROVIDER_NOT_FOUND"
|
|
247
|
-
});
|
|
248
|
-
const userId = ctx.context.session.user.id;
|
|
249
|
-
let isOrgMember = true;
|
|
250
|
-
if (provider.organizationId) isOrgMember = await ctx.context.adapter.count({
|
|
251
|
-
model: "member",
|
|
252
|
-
where: [{
|
|
253
|
-
field: "userId",
|
|
254
|
-
value: userId
|
|
255
|
-
}, {
|
|
256
|
-
field: "organizationId",
|
|
257
|
-
value: provider.organizationId
|
|
258
|
-
}]
|
|
259
|
-
}) > 0;
|
|
260
|
-
if (provider.userId !== userId || !isOrgMember) throw new APIError("FORBIDDEN", {
|
|
261
|
-
message: "User must be owner of or belong to the SSO provider organization",
|
|
262
|
-
code: "INSUFICCIENT_ACCESS"
|
|
263
|
-
});
|
|
264
|
-
if ("domainVerified" in provider && provider.domainVerified) throw new APIError("CONFLICT", {
|
|
265
|
-
message: "Domain has already been verified",
|
|
266
|
-
code: "DOMAIN_VERIFIED"
|
|
267
|
-
});
|
|
268
|
-
const identifier = getVerificationIdentifier(options, provider.providerId);
|
|
269
|
-
const activeVerification = await ctx.context.internalAdapter.findVerificationValue(identifier);
|
|
270
|
-
if (activeVerification && new Date(activeVerification.expiresAt) > /* @__PURE__ */ new Date()) {
|
|
271
|
-
ctx.setStatus(201);
|
|
272
|
-
return ctx.json({ domainVerificationToken: activeVerification.value });
|
|
273
|
-
}
|
|
274
|
-
const domainVerificationToken = generateRandomString(24);
|
|
275
|
-
await ctx.context.internalAdapter.createVerificationValue({
|
|
276
|
-
identifier,
|
|
277
|
-
value: domainVerificationToken,
|
|
278
|
-
expiresAt: new Date(Date.now() + 3600 * 24 * 7 * 1e3)
|
|
279
|
-
});
|
|
280
|
-
ctx.setStatus(201);
|
|
281
|
-
return ctx.json({ domainVerificationToken });
|
|
282
|
-
});
|
|
283
|
-
};
|
|
284
|
-
const verifyDomain = (options) => {
|
|
285
|
-
return createAuthEndpoint("/sso/verify-domain", {
|
|
286
|
-
method: "POST",
|
|
287
|
-
body: domainVerificationBodySchema,
|
|
288
|
-
metadata: { openapi: {
|
|
289
|
-
summary: "Verify the provider domain ownership",
|
|
290
|
-
description: "Verify the provider domain ownership via DNS records",
|
|
291
|
-
responses: {
|
|
292
|
-
"404": { description: "Provider not found" },
|
|
293
|
-
"409": { description: "Domain has already been verified or no pending verification exists" },
|
|
294
|
-
"502": { description: "Unable to verify domain ownership due to upstream validator error" },
|
|
295
|
-
"204": { description: "Domain ownership was verified" }
|
|
296
|
-
}
|
|
297
|
-
} },
|
|
298
|
-
use: [sessionMiddleware]
|
|
299
|
-
}, async (ctx) => {
|
|
300
|
-
const body = ctx.body;
|
|
301
|
-
const provider = await ctx.context.adapter.findOne({
|
|
302
|
-
model: "ssoProvider",
|
|
303
|
-
where: [{
|
|
304
|
-
field: "providerId",
|
|
305
|
-
value: body.providerId
|
|
306
|
-
}]
|
|
307
|
-
});
|
|
308
|
-
if (!provider) throw new APIError("NOT_FOUND", {
|
|
309
|
-
message: "Provider not found",
|
|
310
|
-
code: "PROVIDER_NOT_FOUND"
|
|
311
|
-
});
|
|
312
|
-
const userId = ctx.context.session.user.id;
|
|
313
|
-
let isOrgMember = true;
|
|
314
|
-
if (provider.organizationId) isOrgMember = await ctx.context.adapter.count({
|
|
315
|
-
model: "member",
|
|
316
|
-
where: [{
|
|
317
|
-
field: "userId",
|
|
318
|
-
value: userId
|
|
319
|
-
}, {
|
|
320
|
-
field: "organizationId",
|
|
321
|
-
value: provider.organizationId
|
|
322
|
-
}]
|
|
323
|
-
}) > 0;
|
|
324
|
-
if (provider.userId !== userId || !isOrgMember) throw new APIError("FORBIDDEN", {
|
|
325
|
-
message: "User must be owner of or belong to the SSO provider organization",
|
|
326
|
-
code: "INSUFICCIENT_ACCESS"
|
|
327
|
-
});
|
|
328
|
-
if ("domainVerified" in provider && provider.domainVerified) throw new APIError("CONFLICT", {
|
|
329
|
-
message: "Domain has already been verified",
|
|
330
|
-
code: "DOMAIN_VERIFIED"
|
|
331
|
-
});
|
|
332
|
-
const identifier = getVerificationIdentifier(options, provider.providerId);
|
|
333
|
-
if (identifier.length > DNS_LABEL_MAX_LENGTH) throw new APIError("BAD_REQUEST", {
|
|
334
|
-
message: `Verification identifier exceeds the DNS label limit of ${DNS_LABEL_MAX_LENGTH} characters`,
|
|
335
|
-
code: "IDENTIFIER_TOO_LONG"
|
|
336
|
-
});
|
|
337
|
-
const activeVerification = await ctx.context.internalAdapter.findVerificationValue(identifier);
|
|
338
|
-
if (!activeVerification || new Date(activeVerification.expiresAt) <= /* @__PURE__ */ new Date()) throw new APIError("NOT_FOUND", {
|
|
339
|
-
message: "No pending domain verification exists",
|
|
340
|
-
code: "NO_PENDING_VERIFICATION"
|
|
341
|
-
});
|
|
342
|
-
let records = [];
|
|
343
|
-
let dns;
|
|
344
|
-
try {
|
|
345
|
-
dns = await import("node:dns/promises");
|
|
346
|
-
} catch (error) {
|
|
347
|
-
ctx.context.logger.error("The core node:dns module is required for the domain verification feature", error);
|
|
348
|
-
throw new APIError("INTERNAL_SERVER_ERROR", {
|
|
349
|
-
message: "Unable to verify domain ownership due to server error",
|
|
350
|
-
code: "DOMAIN_VERIFICATION_FAILED"
|
|
351
|
-
});
|
|
352
|
-
}
|
|
353
|
-
const hostname = getHostnameFromDomain(provider.domain);
|
|
354
|
-
if (!hostname) throw new APIError("BAD_REQUEST", {
|
|
355
|
-
message: "Invalid domain",
|
|
356
|
-
code: "INVALID_DOMAIN"
|
|
357
|
-
});
|
|
358
|
-
try {
|
|
359
|
-
records = (await dns.resolveTxt(`${identifier}.${hostname}`)).flat();
|
|
360
|
-
} catch (error) {
|
|
361
|
-
ctx.context.logger.warn("DNS resolution failure while validating domain ownership", error);
|
|
362
|
-
}
|
|
363
|
-
if (!records.find((record) => record.includes(`${activeVerification.identifier}=${activeVerification.value}`))) throw new APIError("BAD_GATEWAY", {
|
|
364
|
-
message: "Unable to verify domain ownership. Try again later",
|
|
365
|
-
code: "DOMAIN_VERIFICATION_FAILED"
|
|
366
|
-
});
|
|
367
|
-
await ctx.context.adapter.update({
|
|
368
|
-
model: "ssoProvider",
|
|
369
|
-
where: [{
|
|
370
|
-
field: "providerId",
|
|
371
|
-
value: provider.providerId
|
|
372
|
-
}],
|
|
373
|
-
update: { domainVerified: true }
|
|
374
|
-
});
|
|
375
|
-
ctx.setStatus(204);
|
|
376
|
-
});
|
|
377
|
-
};
|
|
378
|
-
//#endregion
|
|
379
222
|
//#region src/oidc/types.ts
|
|
380
223
|
/**
|
|
381
224
|
* Custom error class for OIDC discovery failures.
|
|
@@ -527,6 +370,75 @@ function validateSkipDiscoveryEndpoints(config, isTrustedOrigin) {
|
|
|
527
370
|
for (const [name, url] of fields) if (url) validateSkipDiscoveryEndpoint(name, url, isTrustedOrigin);
|
|
528
371
|
}
|
|
529
372
|
/**
|
|
373
|
+
* Re-validate an endpoint by resolving its hostname and rejecting any resolved
|
|
374
|
+
* address that is not publicly routable.
|
|
375
|
+
*
|
|
376
|
+
* {@link validateSkipDiscoveryEndpoint} only classifies the literal hostname, so
|
|
377
|
+
* a host like `idp.example` whose DNS record points at `127.0.0.1`,
|
|
378
|
+
* `169.254.169.254`, or an RFC 1918 address passes that check unchanged. This
|
|
379
|
+
* function closes that gap by performing the same RFC 6890 classification on the
|
|
380
|
+
* addresses the host actually resolves to, right before the server-side fetch.
|
|
381
|
+
*
|
|
382
|
+
* Best-effort by design:
|
|
383
|
+
* - Operator-allowlisted origins (trustedOrigins) are skipped — this is the
|
|
384
|
+
* documented escape hatch for internal IdPs.
|
|
385
|
+
* - IP-literal hosts are already fully covered by the synchronous check.
|
|
386
|
+
* - On runtimes without `node:dns` (e.g. Cloudflare Workers / edge), DNS
|
|
387
|
+
* resolution is unavailable; we fall back to the synchronous host check and
|
|
388
|
+
* the platform's own egress controls.
|
|
389
|
+
*
|
|
390
|
+
* Note: this resolves once and validates the result; it does not pin the address
|
|
391
|
+
* for the subsequent connection, so a change in the resolved address between
|
|
392
|
+
* this lookup and the fetch remains theoretically possible. It nonetheless
|
|
393
|
+
* rejects the common case of a DNS record that statically points at an internal
|
|
394
|
+
* address.
|
|
395
|
+
*
|
|
396
|
+
* @throws DiscoveryError(discovery_private_host) if any resolved address is not public
|
|
397
|
+
*/
|
|
398
|
+
async function assertEndpointResolvesPublic(name, endpoint, isTrustedOrigin) {
|
|
399
|
+
const parsed = parseURL(name, endpoint);
|
|
400
|
+
if (isTrustedOrigin(parsed.toString())) return;
|
|
401
|
+
const host = parsed.hostname;
|
|
402
|
+
if (classifyHost(host).literal !== "fqdn") return;
|
|
403
|
+
let dns;
|
|
404
|
+
try {
|
|
405
|
+
dns = await import("node:dns/promises");
|
|
406
|
+
} catch {
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
let resolved;
|
|
410
|
+
try {
|
|
411
|
+
resolved = await dns.lookup(host, { all: true });
|
|
412
|
+
} catch {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
for (const { address } of resolved) if (!isPublicRoutableHost(address)) throw new DiscoveryError("discovery_private_host", `The ${name} host "${host}" resolves to a non-publicly-routable address (${address}). If this is an internal IdP, add its origin to trustedOrigins.`, {
|
|
416
|
+
endpoint: name,
|
|
417
|
+
url: endpoint,
|
|
418
|
+
hostname: host,
|
|
419
|
+
resolved: address
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Re-validate, at fetch time, every OIDC endpoint that is fetched server-side
|
|
424
|
+
* (token, userinfo, jwks). Runs the synchronous host classification plus the
|
|
425
|
+
* best-effort DNS resolution check. `authorizationEndpoint` is intentionally
|
|
426
|
+
* excluded — it is a browser redirect target, not a server-side fetch, so these
|
|
427
|
+
* checks don't apply to it.
|
|
428
|
+
*/
|
|
429
|
+
async function assertOIDCEndpointsResolvePublic(config, isTrustedOrigin) {
|
|
430
|
+
const fields = [
|
|
431
|
+
["tokenEndpoint", config.tokenEndpoint],
|
|
432
|
+
["userInfoEndpoint", config.userInfoEndpoint],
|
|
433
|
+
["jwksEndpoint", config.jwksEndpoint]
|
|
434
|
+
];
|
|
435
|
+
for (const [name, url] of fields) {
|
|
436
|
+
if (!url) continue;
|
|
437
|
+
validateSkipDiscoveryEndpoint(name, url, isTrustedOrigin);
|
|
438
|
+
await assertEndpointResolvesPublic(name, url, isTrustedOrigin);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
530
442
|
* Fetch the OIDC discovery document from the IdP.
|
|
531
443
|
*
|
|
532
444
|
* @param url - The discovery endpoint URL
|
|
@@ -538,7 +450,8 @@ async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT)
|
|
|
538
450
|
try {
|
|
539
451
|
const response = await betterFetch(url, {
|
|
540
452
|
method: "GET",
|
|
541
|
-
timeout
|
|
453
|
+
timeout,
|
|
454
|
+
redirect: "error"
|
|
542
455
|
});
|
|
543
456
|
if (response.error) {
|
|
544
457
|
const { status } = response.error;
|
|
@@ -706,20 +619,24 @@ function needsRuntimeDiscovery(config) {
|
|
|
706
619
|
* Throws if discovery fails.
|
|
707
620
|
*/
|
|
708
621
|
async function ensureRuntimeDiscovery(config, issuer, isTrustedOrigin) {
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
622
|
+
let resolved = config;
|
|
623
|
+
if (needsRuntimeDiscovery(config)) {
|
|
624
|
+
const hydrated = await discoverOIDCConfig({
|
|
625
|
+
issuer,
|
|
626
|
+
existingConfig: config,
|
|
627
|
+
isTrustedOrigin
|
|
628
|
+
});
|
|
629
|
+
resolved = {
|
|
630
|
+
...config,
|
|
631
|
+
authorizationEndpoint: hydrated.authorizationEndpoint,
|
|
632
|
+
tokenEndpoint: hydrated.tokenEndpoint,
|
|
633
|
+
tokenEndpointAuthentication: hydrated.tokenEndpointAuthentication,
|
|
634
|
+
userInfoEndpoint: hydrated.userInfoEndpoint,
|
|
635
|
+
jwksEndpoint: hydrated.jwksEndpoint
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
await assertOIDCEndpointsResolvePublic(resolved, isTrustedOrigin);
|
|
639
|
+
return resolved;
|
|
723
640
|
}
|
|
724
641
|
//#endregion
|
|
725
642
|
//#region src/oidc/errors.ts
|
|
@@ -1431,6 +1348,119 @@ const deleteSSOProvider = () => {
|
|
|
1431
1348
|
});
|
|
1432
1349
|
};
|
|
1433
1350
|
//#endregion
|
|
1351
|
+
//#region src/routes/domain-verification.ts
|
|
1352
|
+
const DNS_LABEL_MAX_LENGTH = 63;
|
|
1353
|
+
const DEFAULT_TOKEN_PREFIX = "better-auth-token";
|
|
1354
|
+
const domainVerificationBodySchema = z.object({ providerId: z.string() });
|
|
1355
|
+
function getVerificationIdentifier(options, providerId) {
|
|
1356
|
+
return `_${options.domainVerification?.tokenPrefix || DEFAULT_TOKEN_PREFIX}-${providerId}`;
|
|
1357
|
+
}
|
|
1358
|
+
const requestDomainVerification = (options) => {
|
|
1359
|
+
return createAuthEndpoint("/sso/request-domain-verification", {
|
|
1360
|
+
method: "POST",
|
|
1361
|
+
body: domainVerificationBodySchema,
|
|
1362
|
+
metadata: { openapi: {
|
|
1363
|
+
summary: "Request a domain verification",
|
|
1364
|
+
description: "Request a domain verification for the given SSO provider",
|
|
1365
|
+
responses: {
|
|
1366
|
+
"404": { description: "Provider not found" },
|
|
1367
|
+
"409": { description: "Domain has already been verified" },
|
|
1368
|
+
"201": { description: "Domain submitted for verification" }
|
|
1369
|
+
}
|
|
1370
|
+
} },
|
|
1371
|
+
use: [sessionMiddleware]
|
|
1372
|
+
}, async (ctx) => {
|
|
1373
|
+
const body = ctx.body;
|
|
1374
|
+
const provider = await checkProviderAccess(ctx, body.providerId);
|
|
1375
|
+
if (provider.domainVerified) throw new APIError("CONFLICT", {
|
|
1376
|
+
message: "Domain has already been verified",
|
|
1377
|
+
code: "DOMAIN_VERIFIED"
|
|
1378
|
+
});
|
|
1379
|
+
const identifier = getVerificationIdentifier(options, provider.providerId);
|
|
1380
|
+
const activeVerification = await ctx.context.internalAdapter.findVerificationValue(identifier);
|
|
1381
|
+
if (activeVerification && new Date(activeVerification.expiresAt) > /* @__PURE__ */ new Date()) {
|
|
1382
|
+
ctx.setStatus(201);
|
|
1383
|
+
return ctx.json({ domainVerificationToken: activeVerification.value });
|
|
1384
|
+
}
|
|
1385
|
+
const domainVerificationToken = generateRandomString(24);
|
|
1386
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
1387
|
+
identifier,
|
|
1388
|
+
value: domainVerificationToken,
|
|
1389
|
+
expiresAt: new Date(Date.now() + 3600 * 24 * 7 * 1e3)
|
|
1390
|
+
});
|
|
1391
|
+
ctx.setStatus(201);
|
|
1392
|
+
return ctx.json({ domainVerificationToken });
|
|
1393
|
+
});
|
|
1394
|
+
};
|
|
1395
|
+
const verifyDomain = (options) => {
|
|
1396
|
+
return createAuthEndpoint("/sso/verify-domain", {
|
|
1397
|
+
method: "POST",
|
|
1398
|
+
body: domainVerificationBodySchema,
|
|
1399
|
+
metadata: { openapi: {
|
|
1400
|
+
summary: "Verify the provider domain ownership",
|
|
1401
|
+
description: "Verify the provider domain ownership via DNS records",
|
|
1402
|
+
responses: {
|
|
1403
|
+
"404": { description: "Provider not found" },
|
|
1404
|
+
"409": { description: "Domain has already been verified or no pending verification exists" },
|
|
1405
|
+
"502": { description: "Unable to verify domain ownership due to upstream validator error" },
|
|
1406
|
+
"204": { description: "Domain ownership was verified" }
|
|
1407
|
+
}
|
|
1408
|
+
} },
|
|
1409
|
+
use: [sessionMiddleware]
|
|
1410
|
+
}, async (ctx) => {
|
|
1411
|
+
const body = ctx.body;
|
|
1412
|
+
const provider = await checkProviderAccess(ctx, body.providerId);
|
|
1413
|
+
if (provider.domainVerified) throw new APIError("CONFLICT", {
|
|
1414
|
+
message: "Domain has already been verified",
|
|
1415
|
+
code: "DOMAIN_VERIFIED"
|
|
1416
|
+
});
|
|
1417
|
+
const identifier = getVerificationIdentifier(options, provider.providerId);
|
|
1418
|
+
if (identifier.length > DNS_LABEL_MAX_LENGTH) throw new APIError("BAD_REQUEST", {
|
|
1419
|
+
message: `Verification identifier exceeds the DNS label limit of ${DNS_LABEL_MAX_LENGTH} characters`,
|
|
1420
|
+
code: "IDENTIFIER_TOO_LONG"
|
|
1421
|
+
});
|
|
1422
|
+
const activeVerification = await ctx.context.internalAdapter.findVerificationValue(identifier);
|
|
1423
|
+
if (!activeVerification || new Date(activeVerification.expiresAt) <= /* @__PURE__ */ new Date()) throw new APIError("NOT_FOUND", {
|
|
1424
|
+
message: "No pending domain verification exists",
|
|
1425
|
+
code: "NO_PENDING_VERIFICATION"
|
|
1426
|
+
});
|
|
1427
|
+
let records = [];
|
|
1428
|
+
let dns;
|
|
1429
|
+
try {
|
|
1430
|
+
dns = await import("node:dns/promises");
|
|
1431
|
+
} catch (error) {
|
|
1432
|
+
ctx.context.logger.error("The core node:dns module is required for the domain verification feature", error);
|
|
1433
|
+
throw new APIError("INTERNAL_SERVER_ERROR", {
|
|
1434
|
+
message: "Unable to verify domain ownership due to server error",
|
|
1435
|
+
code: "DOMAIN_VERIFICATION_FAILED"
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
const hostname = getHostnameFromDomain(provider.domain);
|
|
1439
|
+
if (!hostname) throw new APIError("BAD_REQUEST", {
|
|
1440
|
+
message: "Invalid domain",
|
|
1441
|
+
code: "INVALID_DOMAIN"
|
|
1442
|
+
});
|
|
1443
|
+
try {
|
|
1444
|
+
records = (await dns.resolveTxt(`${identifier}.${hostname}`)).flat();
|
|
1445
|
+
} catch (error) {
|
|
1446
|
+
ctx.context.logger.warn("DNS resolution failure while validating domain ownership", error);
|
|
1447
|
+
}
|
|
1448
|
+
if (!records.find((record) => record.includes(`${activeVerification.identifier}=${activeVerification.value}`))) throw new APIError("BAD_GATEWAY", {
|
|
1449
|
+
message: "Unable to verify domain ownership. Try again later",
|
|
1450
|
+
code: "DOMAIN_VERIFICATION_FAILED"
|
|
1451
|
+
});
|
|
1452
|
+
await ctx.context.adapter.update({
|
|
1453
|
+
model: "ssoProvider",
|
|
1454
|
+
where: [{
|
|
1455
|
+
field: "providerId",
|
|
1456
|
+
value: provider.providerId
|
|
1457
|
+
}],
|
|
1458
|
+
update: { domainVerified: true }
|
|
1459
|
+
});
|
|
1460
|
+
ctx.setStatus(204);
|
|
1461
|
+
});
|
|
1462
|
+
};
|
|
1463
|
+
//#endregion
|
|
1434
1464
|
//#region src/saml/error-codes.ts
|
|
1435
1465
|
const SAML_ERROR_CODES = defineErrorCodes({
|
|
1436
1466
|
SINGLE_LOGOUT_NOT_ENABLED: "Single Logout is not enabled",
|
|
@@ -1727,11 +1757,10 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1727
1757
|
if (options?.saml?.enableInResponseToValidation !== false) {
|
|
1728
1758
|
const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
|
|
1729
1759
|
if (inResponseTo) {
|
|
1760
|
+
const consumed = await ctx.context.internalAdapter.consumeVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
1730
1761
|
let storedRequest = null;
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
storedRequest = JSON.parse(verification.value);
|
|
1734
|
-
if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
|
|
1762
|
+
if (consumed) try {
|
|
1763
|
+
storedRequest = JSON.parse(consumed.value);
|
|
1735
1764
|
} catch {
|
|
1736
1765
|
storedRequest = null;
|
|
1737
1766
|
}
|
|
@@ -1748,10 +1777,8 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1748
1777
|
expectedProvider: storedRequest.providerId,
|
|
1749
1778
|
actualProvider: providerId
|
|
1750
1779
|
});
|
|
1751
|
-
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
1752
1780
|
throw ctx.redirect(`${samlRedirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
|
|
1753
1781
|
}
|
|
1754
|
-
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
1755
1782
|
} else if (!allowIdpInitiated) {
|
|
1756
1783
|
ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId });
|
|
1757
1784
|
throw ctx.redirect(`${samlRedirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
|
|
@@ -1764,26 +1791,8 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1764
1791
|
const conditions = extract.conditions;
|
|
1765
1792
|
const clockSkew = options?.saml?.clockSkew ?? 3e5;
|
|
1766
1793
|
const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
if (existingAssertion) try {
|
|
1770
|
-
if (JSON.parse(existingAssertion.value).expiresAt >= Date.now()) isReplay = true;
|
|
1771
|
-
} catch (error) {
|
|
1772
|
-
ctx.context.logger.warn("Failed to parse stored assertion record", {
|
|
1773
|
-
assertionId,
|
|
1774
|
-
error
|
|
1775
|
-
});
|
|
1776
|
-
}
|
|
1777
|
-
if (isReplay) {
|
|
1778
|
-
ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
|
|
1779
|
-
assertionId,
|
|
1780
|
-
issuer,
|
|
1781
|
-
providerId
|
|
1782
|
-
});
|
|
1783
|
-
throw ctx.redirect(`${samlRedirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
|
|
1784
|
-
}
|
|
1785
|
-
await ctx.context.internalAdapter.createVerificationValue({
|
|
1786
|
-
identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
|
|
1794
|
+
if (!await ctx.context.internalAdapter.reserveVerificationValue({
|
|
1795
|
+
identifier: `saml-used-assertion:${assertionId}`,
|
|
1787
1796
|
value: JSON.stringify({
|
|
1788
1797
|
assertionId,
|
|
1789
1798
|
issuer,
|
|
@@ -1792,7 +1801,14 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1792
1801
|
expiresAt
|
|
1793
1802
|
}),
|
|
1794
1803
|
expiresAt: new Date(expiresAt)
|
|
1795
|
-
})
|
|
1804
|
+
})) {
|
|
1805
|
+
ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
|
|
1806
|
+
assertionId,
|
|
1807
|
+
issuer,
|
|
1808
|
+
providerId
|
|
1809
|
+
});
|
|
1810
|
+
throw ctx.redirect(`${samlRedirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
|
|
1811
|
+
}
|
|
1796
1812
|
} else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
|
|
1797
1813
|
const attributes = extract.attributes || {};
|
|
1798
1814
|
const mapping = parsedSamlConfig.mapping ?? {};
|
|
@@ -1805,7 +1821,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1805
1821
|
id: attr(mapping.id || "nameID") || extract.nameID,
|
|
1806
1822
|
email: (attr(mapping.email || "email") || extract.nameID || "").toLowerCase(),
|
|
1807
1823
|
name: [attr(mapping.firstName || "givenName"), attr(mapping.lastName || "surname")].filter(Boolean).join(" ") || attr(mapping.name || "displayName") || extract.nameID,
|
|
1808
|
-
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attr(mapping.emailVerified)
|
|
1824
|
+
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? parseProviderEmailVerified(attr(mapping.emailVerified)) : false
|
|
1809
1825
|
};
|
|
1810
1826
|
if (!userInfo.id || !userInfo.email) {
|
|
1811
1827
|
ctx.context.logger.error("Missing essential user info from SAML response", {
|
|
@@ -1816,7 +1832,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1816
1832
|
});
|
|
1817
1833
|
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
1818
1834
|
}
|
|
1819
|
-
const isTrustedProvider =
|
|
1835
|
+
const isTrustedProvider = "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
1820
1836
|
const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1821
1837
|
const errorUrl = relayState?.errorURL || samlRedirectUrl;
|
|
1822
1838
|
let result;
|
|
@@ -1826,7 +1842,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1826
1842
|
email: userInfo.email,
|
|
1827
1843
|
name: userInfo.name || userInfo.email,
|
|
1828
1844
|
id: userInfo.id,
|
|
1829
|
-
emailVerified:
|
|
1845
|
+
emailVerified: userInfo.emailVerified
|
|
1830
1846
|
},
|
|
1831
1847
|
account: {
|
|
1832
1848
|
providerId,
|
|
@@ -1836,7 +1852,8 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1836
1852
|
},
|
|
1837
1853
|
callbackURL: callbackUrl,
|
|
1838
1854
|
disableSignUp: options?.disableImplicitSignUp,
|
|
1839
|
-
isTrustedProvider
|
|
1855
|
+
isTrustedProvider,
|
|
1856
|
+
trustProviderByName: false
|
|
1840
1857
|
});
|
|
1841
1858
|
} catch (e) {
|
|
1842
1859
|
if (isAPIError(e) && e.body?.code) {
|
|
@@ -1861,7 +1878,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1861
1878
|
providerId,
|
|
1862
1879
|
accountId: userInfo.id,
|
|
1863
1880
|
email: userInfo.email,
|
|
1864
|
-
emailVerified:
|
|
1881
|
+
emailVerified: userInfo.emailVerified,
|
|
1865
1882
|
rawAttributes: attributes
|
|
1866
1883
|
},
|
|
1867
1884
|
provider,
|
|
@@ -2227,6 +2244,14 @@ const registerSSOProvider = (options) => {
|
|
|
2227
2244
|
if (!member) throw new APIError("BAD_REQUEST", { message: "You are not a member of the organization" });
|
|
2228
2245
|
if (ctx.context.hasPlugin("organization") && !hasOrgAdminRole(member)) throw new APIError("FORBIDDEN", { message: "You must be an organization owner or admin to register SSO providers" });
|
|
2229
2246
|
}
|
|
2247
|
+
if (new Set([
|
|
2248
|
+
"credential",
|
|
2249
|
+
...ctx.context.socialProviders.map((p) => p.id),
|
|
2250
|
+
...ctx.context.trustedProviders
|
|
2251
|
+
]).has(body.providerId)) {
|
|
2252
|
+
ctx.context.logger.warn(`SSO provider registration rejected for reserved providerId: ${body.providerId}`);
|
|
2253
|
+
throw new APIError("UNPROCESSABLE_ENTITY", { message: "This providerId is reserved and cannot be used for an SSO provider" });
|
|
2254
|
+
}
|
|
2230
2255
|
if (await ctx.context.adapter.findOne({
|
|
2231
2256
|
model: "ssoProvider",
|
|
2232
2257
|
where: [{
|
|
@@ -2687,14 +2712,17 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2687
2712
|
let userInfo = null;
|
|
2688
2713
|
const mapping = config.mapping || {};
|
|
2689
2714
|
if (config.userInfoEndpoint) {
|
|
2690
|
-
const userInfoResponse = await betterFetch(config.userInfoEndpoint, {
|
|
2715
|
+
const userInfoResponse = await betterFetch(config.userInfoEndpoint, {
|
|
2716
|
+
headers: { Authorization: `Bearer ${tokenResponse.accessToken}` },
|
|
2717
|
+
redirect: "error"
|
|
2718
|
+
});
|
|
2691
2719
|
if (userInfoResponse.error) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${userInfoResponse.error.message}`);
|
|
2692
2720
|
const rawUserInfo = userInfoResponse.data;
|
|
2693
2721
|
userInfo = {
|
|
2694
2722
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, rawUserInfo[value]])),
|
|
2695
2723
|
id: rawUserInfo[mapping.id || "sub"],
|
|
2696
2724
|
email: rawUserInfo[mapping.email || "email"],
|
|
2697
|
-
emailVerified: options?.trustEmailVerified ? rawUserInfo[mapping.emailVerified || "email_verified"] : false,
|
|
2725
|
+
emailVerified: options?.trustEmailVerified ? parseProviderEmailVerified(rawUserInfo[mapping.emailVerified || "email_verified"]) : false,
|
|
2698
2726
|
name: rawUserInfo[mapping.name || "name"],
|
|
2699
2727
|
image: rawUserInfo[mapping.image || "picture"]
|
|
2700
2728
|
};
|
|
@@ -2713,7 +2741,7 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2713
2741
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, verified.payload[value]])),
|
|
2714
2742
|
id: idToken[mapping.id || "sub"],
|
|
2715
2743
|
email: idToken[mapping.email || "email"],
|
|
2716
|
-
emailVerified: options?.trustEmailVerified ? idToken[mapping.emailVerified || "email_verified"] : false,
|
|
2744
|
+
emailVerified: options?.trustEmailVerified ? parseProviderEmailVerified(idToken[mapping.emailVerified || "email_verified"]) : false,
|
|
2717
2745
|
name: idToken[mapping.name || "name"],
|
|
2718
2746
|
image: idToken[mapping.image || "picture"]
|
|
2719
2747
|
};
|
|
@@ -2743,7 +2771,8 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2743
2771
|
callbackURL,
|
|
2744
2772
|
disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
|
|
2745
2773
|
overrideUserInfo: config.overrideUserInfo,
|
|
2746
|
-
isTrustedProvider
|
|
2774
|
+
isTrustedProvider,
|
|
2775
|
+
trustProviderByName: false
|
|
2747
2776
|
});
|
|
2748
2777
|
} catch (e) {
|
|
2749
2778
|
if (isAPIError(e) && e.body?.code) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-auth/sso",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.17",
|
|
4
4
|
"description": "SSO plugin for Better Auth",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -65,20 +65,20 @@
|
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@types/body-parser": "^1.19.6",
|
|
67
67
|
"@types/express": "^5.0.6",
|
|
68
|
-
"better-call": "1.3.
|
|
68
|
+
"better-call": "1.3.6",
|
|
69
69
|
"body-parser": "^2.2.2",
|
|
70
70
|
"express": "^5.2.1",
|
|
71
71
|
"oauth2-mock-server": "^8.2.2",
|
|
72
72
|
"tsdown": "0.21.1",
|
|
73
|
-
"
|
|
74
|
-
"better-auth": "1.6.
|
|
73
|
+
"better-auth": "1.6.17",
|
|
74
|
+
"@better-auth/core": "1.6.17"
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
77
77
|
"@better-auth/utils": "0.4.1",
|
|
78
|
-
"@better-fetch/fetch": "1.
|
|
79
|
-
"better-call": "1.3.
|
|
80
|
-
"@better-auth/core": "^1.6.
|
|
81
|
-
"better-auth": "^1.6.
|
|
78
|
+
"@better-fetch/fetch": "1.3.0",
|
|
79
|
+
"better-call": "1.3.6",
|
|
80
|
+
"@better-auth/core": "^1.6.17",
|
|
81
|
+
"better-auth": "^1.6.17"
|
|
82
82
|
},
|
|
83
83
|
"scripts": {
|
|
84
84
|
"build": "tsdown",
|