@better-auth/sso 1.7.0-beta.5 → 1.7.0-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
|
-
import { t as PACKAGE_VERSION } from "./version-
|
|
1
|
+
import { t as PACKAGE_VERSION } from "./version-DQW8cveo.mjs";
|
|
2
2
|
import { APIError, addOAuthServerContext, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
|
|
3
3
|
import { XMLParser, XMLValidator } from "fast-xml-parser";
|
|
4
4
|
import { X509Certificate } from "node:crypto";
|
|
5
5
|
import { getHostname } from "tldts";
|
|
6
6
|
import { generateRandomString } from "better-auth/crypto";
|
|
7
7
|
import * as z from "zod";
|
|
8
|
+
import { filterOutputFields } from "@better-auth/core/utils/db";
|
|
8
9
|
import { classifyHost, isPublicRoutableHost } from "@better-auth/core/utils/host";
|
|
9
|
-
import {
|
|
10
|
+
import { betterFetch } from "@better-fetch/fetch";
|
|
11
|
+
import { createRemoteJWKSet, customFetch, decodeJwt, jwtVerify } from "jose";
|
|
10
12
|
import { base64 } from "@better-auth/utils/base64";
|
|
11
13
|
import { defineErrorCodes } from "@better-auth/core/utils/error-codes";
|
|
14
|
+
import { parseInputData, toZodSchema } from "better-auth/db";
|
|
12
15
|
import { isAPIError } from "@better-auth/core/utils/is-api-error";
|
|
13
|
-
import { HIDE_METADATA, PRIVATE_KEY_JWT_SIGNING_ALGORITHMS, createAuthorizationURL, createPrivateKeyJwtClientAssertionGetter, generateGenericState, generateState, parseGenericState, parseState
|
|
16
|
+
import { HIDE_METADATA, PRIVATE_KEY_JWT_SIGNING_ALGORITHMS, authorizationCodeRequest, createAuthorizationURL, createPrivateKeyJwtClientAssertionGetter, generateGenericState, generateState, getOAuth2Tokens, parseGenericState, parseState } from "better-auth";
|
|
14
17
|
import { deleteSessionCookie, setSessionCookie } from "better-auth/cookies";
|
|
15
18
|
import { additionalAuthorizationParamsSchema, signInWithOAuthIdentity } from "better-auth/oauth2";
|
|
16
|
-
import { decodeJwt } from "jose";
|
|
17
19
|
import * as samlifyNamespace from "samlify";
|
|
18
20
|
import samlifyDefault from "samlify";
|
|
19
21
|
//#region src/constants.ts
|
|
@@ -24,8 +26,6 @@ import samlifyDefault from "samlify";
|
|
|
24
26
|
*/
|
|
25
27
|
/** Prefix for AuthnRequest IDs used in InResponseTo validation */
|
|
26
28
|
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
29
|
/** Prefix for SAML session data (NameID + SessionIndex) for SLO */
|
|
30
30
|
const SAML_SESSION_KEY_PREFIX = "saml-session:";
|
|
31
31
|
/** Prefix for reverse lookup of SAML session by Better Auth session ID */
|
|
@@ -86,6 +86,16 @@ const domainMatches = (searchDomain, domainList) => {
|
|
|
86
86
|
return domainList.split(",").map((d) => d.trim().toLowerCase()).filter(Boolean).some((d) => search === d || search.endsWith(`.${d}`));
|
|
87
87
|
};
|
|
88
88
|
/**
|
|
89
|
+
* Strictly parse a provider-supplied email-verification claim.
|
|
90
|
+
*
|
|
91
|
+
* OIDC userInfo, OIDC id-token, and SAML attribute values are frequently
|
|
92
|
+
* strings, so a loose `Boolean(value)` or truthy fallback treats the string
|
|
93
|
+
* `"false"` as verified. Only a boolean `true` or the exact string `"true"`
|
|
94
|
+
* count as verified; every other value, including `"false"`, `"0"`, `""`,
|
|
95
|
+
* numbers, arrays, and objects, is unverified.
|
|
96
|
+
*/
|
|
97
|
+
const parseProviderEmailVerified = (value) => value === true || value === "true";
|
|
98
|
+
/**
|
|
89
99
|
* Validates email domain against allowed domain(s).
|
|
90
100
|
* Supports comma-separated domains for multi-domain SSO.
|
|
91
101
|
*/
|
|
@@ -211,171 +221,6 @@ async function assignOrganizationByDomain(ctx, options) {
|
|
|
211
221
|
});
|
|
212
222
|
}
|
|
213
223
|
//#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
224
|
//#region src/oidc/types.ts
|
|
380
225
|
/**
|
|
381
226
|
* Custom error class for OIDC discovery failures.
|
|
@@ -414,6 +259,9 @@ const REQUIRED_DISCOVERY_FIELDS = [
|
|
|
414
259
|
*/
|
|
415
260
|
/** Default timeout for discovery requests (10 seconds) */
|
|
416
261
|
const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
|
|
262
|
+
function isHttpRedirectStatus(status) {
|
|
263
|
+
return status >= 300 && status < 400;
|
|
264
|
+
}
|
|
417
265
|
/**
|
|
418
266
|
* Main entry point: Discover and hydrate OIDC configuration from an issuer.
|
|
419
267
|
*
|
|
@@ -435,7 +283,7 @@ async function discoverOIDCConfig(params) {
|
|
|
435
283
|
const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
|
|
436
284
|
const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
|
|
437
285
|
validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
|
|
438
|
-
const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
|
|
286
|
+
const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout, params.isTrustedOrigin);
|
|
439
287
|
validateDiscoveryDocument(discoveryDoc, issuer);
|
|
440
288
|
const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
|
|
441
289
|
const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
|
|
@@ -496,7 +344,7 @@ function validateDiscoveryUrl(url, isTrustedOrigin) {
|
|
|
496
344
|
* @throws DiscoveryError(discovery_invalid_url) — malformed URL or non-http(s) scheme
|
|
497
345
|
* @throws DiscoveryError(discovery_private_host) — host is not publicly routable and not allowlisted
|
|
498
346
|
*/
|
|
499
|
-
function
|
|
347
|
+
function validateOIDCEndpointUrl(name, endpoint, isTrustedOrigin) {
|
|
500
348
|
const parsed = parseURL(name, endpoint);
|
|
501
349
|
if (isPublicRoutableHost(parsed.hostname)) return;
|
|
502
350
|
if (isTrustedOrigin(parsed.toString())) return;
|
|
@@ -509,14 +357,14 @@ function validateSkipDiscoveryEndpoint(name, endpoint, isTrustedOrigin) {
|
|
|
509
357
|
/**
|
|
510
358
|
* Validate every present OIDC endpoint URL in a registration or update body.
|
|
511
359
|
*
|
|
512
|
-
* Each provided URL is checked with {@link
|
|
360
|
+
* Each provided URL is checked with {@link validateOIDCEndpointUrl}.
|
|
513
361
|
* Omitted (undefined / null / empty) fields are skipped.
|
|
514
362
|
*
|
|
515
363
|
* @param config - OIDC endpoint URLs from the request body
|
|
516
364
|
* @param isTrustedOrigin - Predicate matching the configured `trustedOrigins`
|
|
517
365
|
* @throws DiscoveryError on the first invalid endpoint
|
|
518
366
|
*/
|
|
519
|
-
function
|
|
367
|
+
function validateOIDCEndpointUrls(config, isTrustedOrigin) {
|
|
520
368
|
const fields = [
|
|
521
369
|
["authorizationEndpoint", config.authorizationEndpoint],
|
|
522
370
|
["tokenEndpoint", config.tokenEndpoint],
|
|
@@ -524,13 +372,13 @@ function validateSkipDiscoveryEndpoints(config, isTrustedOrigin) {
|
|
|
524
372
|
["jwksEndpoint", config.jwksEndpoint],
|
|
525
373
|
["discoveryEndpoint", config.discoveryEndpoint]
|
|
526
374
|
];
|
|
527
|
-
for (const [name, url] of fields) if (url)
|
|
375
|
+
for (const [name, url] of fields) if (url) validateOIDCEndpointUrl(name, url, isTrustedOrigin);
|
|
528
376
|
}
|
|
529
377
|
/**
|
|
530
378
|
* Re-validate an endpoint by resolving its hostname and rejecting any resolved
|
|
531
379
|
* address that is not publicly routable.
|
|
532
380
|
*
|
|
533
|
-
* {@link
|
|
381
|
+
* {@link validateOIDCEndpointUrl} only classifies the literal hostname, so
|
|
534
382
|
* a host like `idp.example` whose DNS record points at `127.0.0.1`,
|
|
535
383
|
* `169.254.169.254`, or an RFC 1918 address passes that check unchanged. This
|
|
536
384
|
* function closes that gap by performing the same RFC 6890 classification on the
|
|
@@ -577,13 +425,18 @@ async function assertEndpointResolvesPublic(name, endpoint, isTrustedOrigin) {
|
|
|
577
425
|
});
|
|
578
426
|
}
|
|
579
427
|
/**
|
|
428
|
+
* Validate an OIDC endpoint immediately before a server-side fetch.
|
|
429
|
+
*/
|
|
430
|
+
async function assertOIDCEndpointAllowed(name, endpoint, isTrustedOrigin) {
|
|
431
|
+
validateOIDCEndpointUrl(name, endpoint, isTrustedOrigin);
|
|
432
|
+
await assertEndpointResolvesPublic(name, endpoint, isTrustedOrigin);
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
580
435
|
* Re-validate, at fetch time, every OIDC endpoint that is fetched server-side
|
|
581
|
-
* (token, userinfo, jwks).
|
|
582
|
-
*
|
|
583
|
-
* excluded — it is a browser redirect target, not a server-side fetch, so these
|
|
584
|
-
* checks don't apply to it.
|
|
436
|
+
* (token, userinfo, jwks). `authorizationEndpoint` is intentionally excluded
|
|
437
|
+
* because it is a browser redirect target, not a server-side fetch.
|
|
585
438
|
*/
|
|
586
|
-
async function
|
|
439
|
+
async function assertServerFetchedOIDCEndpointsAllowed(config, isTrustedOrigin) {
|
|
587
440
|
const fields = [
|
|
588
441
|
["tokenEndpoint", config.tokenEndpoint],
|
|
589
442
|
["userInfoEndpoint", config.userInfoEndpoint],
|
|
@@ -591,11 +444,67 @@ async function assertOIDCEndpointsResolvePublic(config, isTrustedOrigin) {
|
|
|
591
444
|
];
|
|
592
445
|
for (const [name, url] of fields) {
|
|
593
446
|
if (!url) continue;
|
|
594
|
-
|
|
595
|
-
await assertEndpointResolvesPublic(name, url, isTrustedOrigin);
|
|
447
|
+
await assertOIDCEndpointAllowed(name, url, isTrustedOrigin);
|
|
596
448
|
}
|
|
597
449
|
}
|
|
598
450
|
/**
|
|
451
|
+
* Convert an explicit HTTP redirect response into the OIDC configuration error
|
|
452
|
+
* used by all server-side endpoint fetches.
|
|
453
|
+
*/
|
|
454
|
+
function throwRedirectError(name, endpoint, status, location) {
|
|
455
|
+
throw new DiscoveryError("oidc_endpoint_redirect", `The ${name} (${endpoint}) returned an HTTP ${status} redirect. Configure the final OIDC endpoint URL instead of a redirecting URL.`, {
|
|
456
|
+
endpoint: name,
|
|
457
|
+
url: endpoint,
|
|
458
|
+
status,
|
|
459
|
+
location
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Fetch a configured OIDC endpoint without following redirects.
|
|
464
|
+
*
|
|
465
|
+
* Every server-side OIDC request goes through this helper so private-host
|
|
466
|
+
* checks and redirect handling stay consistent across discovery, token, and
|
|
467
|
+
* userinfo calls.
|
|
468
|
+
*/
|
|
469
|
+
async function fetchOIDCEndpoint(name, endpoint, options, isTrustedOrigin) {
|
|
470
|
+
await assertOIDCEndpointAllowed(name, endpoint, isTrustedOrigin);
|
|
471
|
+
let redirectLocation = null;
|
|
472
|
+
const { onError, ...fetchOptions } = options;
|
|
473
|
+
const response = await betterFetch(endpoint, {
|
|
474
|
+
...fetchOptions,
|
|
475
|
+
redirect: "manual",
|
|
476
|
+
onError: async (context) => {
|
|
477
|
+
if (isHttpRedirectStatus(context.response.status)) redirectLocation = context.response.headers.get("location");
|
|
478
|
+
await onError?.(context);
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
if (response.error && isHttpRedirectStatus(response.error.status)) throwRedirectError(name, endpoint, response.error.status, redirectLocation);
|
|
482
|
+
return response;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Native-fetch variant for libraries that require a `fetch` implementation,
|
|
486
|
+
* such as jose's remote JWKS loader.
|
|
487
|
+
*/
|
|
488
|
+
async function fetchOIDCEndpointResponse(name, endpoint, init, isTrustedOrigin) {
|
|
489
|
+
await assertOIDCEndpointAllowed(name, endpoint, isTrustedOrigin);
|
|
490
|
+
const response = await fetch(endpoint, {
|
|
491
|
+
...init,
|
|
492
|
+
redirect: "manual"
|
|
493
|
+
});
|
|
494
|
+
if (isHttpRedirectStatus(response.status)) throwRedirectError(name, endpoint, response.status, response.headers.get("location"));
|
|
495
|
+
return response;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Validate an OIDC ID token using the same endpoint fetch policy as the rest of
|
|
499
|
+
* the SSO OIDC flow.
|
|
500
|
+
*/
|
|
501
|
+
async function validateOIDCIdToken(token, jwksEndpoint, options, isTrustedOrigin) {
|
|
502
|
+
return jwtVerify(token, createRemoteJWKSet(new URL(jwksEndpoint), { [customFetch]: (url, init) => fetchOIDCEndpointResponse("jwksEndpoint", url, init, isTrustedOrigin) }), {
|
|
503
|
+
audience: options.audience,
|
|
504
|
+
issuer: options.issuer
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
599
508
|
* Fetch the OIDC discovery document from the IdP.
|
|
600
509
|
*
|
|
601
510
|
* @param url - The discovery endpoint URL
|
|
@@ -603,13 +512,12 @@ async function assertOIDCEndpointsResolvePublic(config, isTrustedOrigin) {
|
|
|
603
512
|
* @returns The parsed discovery document
|
|
604
513
|
* @throws DiscoveryError on network errors, timeouts, or invalid responses
|
|
605
514
|
*/
|
|
606
|
-
async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
|
|
515
|
+
async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT, isTrustedOrigin = () => false) {
|
|
607
516
|
try {
|
|
608
|
-
const response = await
|
|
517
|
+
const response = await fetchOIDCEndpoint("discoveryEndpoint", url, {
|
|
609
518
|
method: "GET",
|
|
610
|
-
timeout
|
|
611
|
-
|
|
612
|
-
});
|
|
519
|
+
timeout
|
|
520
|
+
}, isTrustedOrigin);
|
|
613
521
|
if (response.error) {
|
|
614
522
|
const { status } = response.error;
|
|
615
523
|
if (status === 404) throw new DiscoveryError("discovery_not_found", "Discovery endpoint not found", {
|
|
@@ -794,7 +702,7 @@ async function ensureRuntimeDiscovery(config, issuer, isTrustedOrigin) {
|
|
|
794
702
|
jwksEndpoint: hydrated.jwksEndpoint
|
|
795
703
|
};
|
|
796
704
|
}
|
|
797
|
-
await
|
|
705
|
+
await assertServerFetchedOIDCEndpointsAllowed(resolved, isTrustedOrigin);
|
|
798
706
|
return resolved;
|
|
799
707
|
}
|
|
800
708
|
//#endregion
|
|
@@ -813,6 +721,7 @@ async function ensureRuntimeDiscovery(config, issuer, isTrustedOrigin) {
|
|
|
813
721
|
* - discovery_not_found → 400 BAD_REQUEST
|
|
814
722
|
* - discovery_untrusted_origin → 400 BAD_REQUEST
|
|
815
723
|
* - discovery_private_host → 400 BAD_REQUEST
|
|
724
|
+
* - oidc_endpoint_redirect → 400 BAD_REQUEST
|
|
816
725
|
* - discovery_invalid_json → 400 BAD_REQUEST
|
|
817
726
|
* - discovery_incomplete → 400 BAD_REQUEST
|
|
818
727
|
* - issuer_mismatch → 400 BAD_REQUEST
|
|
@@ -849,6 +758,10 @@ function mapDiscoveryErrorToAPIError(error) {
|
|
|
849
758
|
message: error.message,
|
|
850
759
|
code: error.code
|
|
851
760
|
});
|
|
761
|
+
case "oidc_endpoint_redirect": return new APIError("BAD_REQUEST", {
|
|
762
|
+
message: error.message,
|
|
763
|
+
code: error.code
|
|
764
|
+
});
|
|
852
765
|
case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
|
|
853
766
|
message: `OIDC discovery returned invalid data: ${error.message}`,
|
|
854
767
|
code: error.code
|
|
@@ -1256,6 +1169,30 @@ function validateAudience(c, ctx) {
|
|
|
1256
1169
|
}
|
|
1257
1170
|
//#endregion
|
|
1258
1171
|
//#region src/routes/schemas.ts
|
|
1172
|
+
function getSSOProviderAdditionalFields$1(options) {
|
|
1173
|
+
return options?.schema?.ssoProvider?.additionalFields ?? {};
|
|
1174
|
+
}
|
|
1175
|
+
function getSSOProviderAdditionalFieldsSchema(options) {
|
|
1176
|
+
const additionalFields = getSSOProviderAdditionalFields$1(options);
|
|
1177
|
+
const schema = toZodSchema({
|
|
1178
|
+
fields: additionalFields,
|
|
1179
|
+
isClientSide: true
|
|
1180
|
+
});
|
|
1181
|
+
const blockedInputFields = {};
|
|
1182
|
+
for (const key in additionalFields) if (additionalFields[key]?.input === false) blockedInputFields[key] = z.any().optional();
|
|
1183
|
+
return schema.extend(blockedInputFields);
|
|
1184
|
+
}
|
|
1185
|
+
function assertNoBlockedAdditionalFieldInput(fields, data) {
|
|
1186
|
+
for (const key in fields) if (fields[key]?.input === false && key in data) throw new APIError("BAD_REQUEST", { message: `${key} is not allowed to be set` });
|
|
1187
|
+
}
|
|
1188
|
+
function parseSSOProviderAdditionalFields(options, data, action) {
|
|
1189
|
+
const fields = getSSOProviderAdditionalFields$1(options);
|
|
1190
|
+
assertNoBlockedAdditionalFieldInput(fields, data);
|
|
1191
|
+
return parseInputData(data, {
|
|
1192
|
+
fields,
|
|
1193
|
+
action
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1259
1196
|
const oidcMappingSchema = z.object({
|
|
1260
1197
|
id: z.string().meta({ description: "Field mapping for user ID (defaults to 'sub')" }),
|
|
1261
1198
|
email: z.string().meta({ description: "Field mapping for email (defaults to 'email')" }),
|
|
@@ -1344,15 +1281,39 @@ const registerSSOProviderBodySchema = z.object({
|
|
|
1344
1281
|
organizationId: z.string().meta({ description: "If organization plugin is enabled, the organization id to link the provider to" }).optional(),
|
|
1345
1282
|
overrideUserInfo: z.boolean().meta({ description: "Override user info with the provider info. Defaults to false" }).default(false).optional()
|
|
1346
1283
|
});
|
|
1284
|
+
function getRegisterSSOProviderBodySchema(options) {
|
|
1285
|
+
return registerSSOProviderBodySchema.extend({ ...getSSOProviderAdditionalFieldsSchema(options).shape });
|
|
1286
|
+
}
|
|
1347
1287
|
const updateSSOProviderBodySchema = z.object({
|
|
1348
1288
|
issuer: z.string().url().optional(),
|
|
1349
1289
|
domain: z.string().optional(),
|
|
1350
1290
|
oidcConfig: oidcConfigSchema.partial().optional(),
|
|
1351
1291
|
samlConfig: samlConfigSchema.partial().optional()
|
|
1352
1292
|
});
|
|
1293
|
+
function getUpdateSSOProviderBodySchema(options) {
|
|
1294
|
+
return updateSSOProviderBodySchema.extend({
|
|
1295
|
+
providerId: z.string(),
|
|
1296
|
+
...getSSOProviderAdditionalFieldsSchema(options).partial().shape
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1353
1299
|
//#endregion
|
|
1354
1300
|
//#region src/routes/providers.ts
|
|
1355
1301
|
const ADMIN_ROLES = ["owner", "admin"];
|
|
1302
|
+
function getSSOProviderAdditionalFields(options) {
|
|
1303
|
+
return options?.schema?.ssoProvider?.additionalFields ?? {};
|
|
1304
|
+
}
|
|
1305
|
+
function filterSSOProviderAdditionalFields(provider, options) {
|
|
1306
|
+
return filterOutputFields(provider, getSSOProviderAdditionalFields(options));
|
|
1307
|
+
}
|
|
1308
|
+
function getReturnedSSOProviderAdditionalFields(provider, options) {
|
|
1309
|
+
const additionalFields = getSSOProviderAdditionalFields(options);
|
|
1310
|
+
const result = {};
|
|
1311
|
+
for (const key in additionalFields) {
|
|
1312
|
+
if (additionalFields[key]?.returned === false) continue;
|
|
1313
|
+
if (key in provider) result[key] = provider[key];
|
|
1314
|
+
}
|
|
1315
|
+
return result;
|
|
1316
|
+
}
|
|
1356
1317
|
function hasOrgAdminRole(member) {
|
|
1357
1318
|
return member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()));
|
|
1358
1319
|
}
|
|
@@ -1398,7 +1359,7 @@ async function batchCheckOrgAdmin(ctx, userId, organizationIds) {
|
|
|
1398
1359
|
for (const member of members) if (hasOrgAdminRole(member)) adminOrgIds.add(member.organizationId);
|
|
1399
1360
|
return adminOrgIds;
|
|
1400
1361
|
}
|
|
1401
|
-
function sanitizeProvider(provider, baseURL) {
|
|
1362
|
+
function sanitizeProvider(provider, baseURL, options) {
|
|
1402
1363
|
let oidcConfig = null;
|
|
1403
1364
|
let samlConfig = null;
|
|
1404
1365
|
try {
|
|
@@ -1413,6 +1374,7 @@ function sanitizeProvider(provider, baseURL) {
|
|
|
1413
1374
|
}
|
|
1414
1375
|
const type = samlConfig ? "saml" : "oidc";
|
|
1415
1376
|
return {
|
|
1377
|
+
...getReturnedSSOProviderAdditionalFields(provider, options),
|
|
1416
1378
|
providerId: provider.providerId,
|
|
1417
1379
|
type,
|
|
1418
1380
|
issuer: provider.issuer,
|
|
@@ -1443,7 +1405,7 @@ function sanitizeProvider(provider, baseURL) {
|
|
|
1443
1405
|
spMetadataUrl: `${baseURL}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(provider.providerId)}`
|
|
1444
1406
|
};
|
|
1445
1407
|
}
|
|
1446
|
-
const listSSOProviders = () => {
|
|
1408
|
+
const listSSOProviders = (options) => {
|
|
1447
1409
|
return createAuthEndpoint("/sso/providers", {
|
|
1448
1410
|
method: "GET",
|
|
1449
1411
|
use: [sessionMiddleware],
|
|
@@ -1468,7 +1430,7 @@ const listSSOProviders = () => {
|
|
|
1468
1430
|
const userOwnedOrgProviders = orgProviders.filter((p) => p.userId === userId);
|
|
1469
1431
|
accessibleProviders = [...accessibleProviders, ...userOwnedOrgProviders];
|
|
1470
1432
|
}
|
|
1471
|
-
const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL));
|
|
1433
|
+
const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL, options));
|
|
1472
1434
|
return ctx.json({ providers });
|
|
1473
1435
|
});
|
|
1474
1436
|
};
|
|
@@ -1490,7 +1452,7 @@ async function checkProviderAccess(ctx, providerId) {
|
|
|
1490
1452
|
if (!hasAccess) throw new APIError("FORBIDDEN", { message: "You don't have access to this provider" });
|
|
1491
1453
|
return provider;
|
|
1492
1454
|
}
|
|
1493
|
-
const getSSOProvider = () => {
|
|
1455
|
+
const getSSOProvider = (options) => {
|
|
1494
1456
|
return createAuthEndpoint("/sso/get-provider", {
|
|
1495
1457
|
method: "GET",
|
|
1496
1458
|
use: [sessionMiddleware],
|
|
@@ -1508,7 +1470,7 @@ const getSSOProvider = () => {
|
|
|
1508
1470
|
}, async (ctx) => {
|
|
1509
1471
|
const { providerId } = ctx.query;
|
|
1510
1472
|
const provider = await checkProviderAccess(ctx, providerId);
|
|
1511
|
-
return ctx.json(sanitizeProvider(provider, ctx.context.baseURL));
|
|
1473
|
+
return ctx.json(sanitizeProvider(provider, ctx.context.baseURL, options));
|
|
1512
1474
|
});
|
|
1513
1475
|
};
|
|
1514
1476
|
function parseAndValidateConfig(configString, configType) {
|
|
@@ -1560,10 +1522,11 @@ function mergeOIDCConfig(current, updates, issuer) {
|
|
|
1560
1522
|
};
|
|
1561
1523
|
}
|
|
1562
1524
|
const updateSSOProvider = (options) => {
|
|
1525
|
+
const updateBodySchema = getUpdateSSOProviderBodySchema(options);
|
|
1563
1526
|
return createAuthEndpoint("/sso/update-provider", {
|
|
1564
1527
|
method: "POST",
|
|
1565
1528
|
use: [sessionMiddleware],
|
|
1566
|
-
body:
|
|
1529
|
+
body: updateBodySchema,
|
|
1567
1530
|
metadata: { openapi: {
|
|
1568
1531
|
operationId: "updateSSOProvider",
|
|
1569
1532
|
summary: "Update SSO provider",
|
|
@@ -1577,9 +1540,10 @@ const updateSSOProvider = (options) => {
|
|
|
1577
1540
|
}, async (ctx) => {
|
|
1578
1541
|
const { providerId, ...body } = ctx.body;
|
|
1579
1542
|
const { issuer, domain, samlConfig, oidcConfig } = body;
|
|
1580
|
-
|
|
1543
|
+
const additionalFields = parseSSOProviderAdditionalFields(options, body, "update");
|
|
1544
|
+
if (!issuer && !domain && !samlConfig && !oidcConfig && Object.keys(additionalFields).length === 0) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
|
|
1581
1545
|
const existingProvider = await checkProviderAccess(ctx, providerId);
|
|
1582
|
-
const updateData = {};
|
|
1546
|
+
const updateData = { ...additionalFields };
|
|
1583
1547
|
if (body.issuer !== void 0) updateData.issuer = body.issuer;
|
|
1584
1548
|
if (body.domain !== void 0) {
|
|
1585
1549
|
updateData.domain = body.domain;
|
|
@@ -1601,7 +1565,7 @@ const updateSSOProvider = (options) => {
|
|
|
1601
1565
|
}
|
|
1602
1566
|
if (body.oidcConfig) {
|
|
1603
1567
|
try {
|
|
1604
|
-
|
|
1568
|
+
validateOIDCEndpointUrls(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
|
|
1605
1569
|
} catch (error) {
|
|
1606
1570
|
if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
|
|
1607
1571
|
throw error;
|
|
@@ -1628,7 +1592,7 @@ const updateSSOProvider = (options) => {
|
|
|
1628
1592
|
}]
|
|
1629
1593
|
});
|
|
1630
1594
|
if (!fullProvider) throw new APIError("NOT_FOUND", { message: "Provider not found after update" });
|
|
1631
|
-
return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL));
|
|
1595
|
+
return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL, options));
|
|
1632
1596
|
});
|
|
1633
1597
|
};
|
|
1634
1598
|
const deleteSSOProvider = () => {
|
|
@@ -1660,6 +1624,119 @@ const deleteSSOProvider = () => {
|
|
|
1660
1624
|
});
|
|
1661
1625
|
};
|
|
1662
1626
|
//#endregion
|
|
1627
|
+
//#region src/routes/domain-verification.ts
|
|
1628
|
+
const DNS_LABEL_MAX_LENGTH = 63;
|
|
1629
|
+
const DEFAULT_TOKEN_PREFIX = "better-auth-token";
|
|
1630
|
+
const domainVerificationBodySchema = z.object({ providerId: z.string() });
|
|
1631
|
+
function getVerificationIdentifier(options, providerId) {
|
|
1632
|
+
return `_${options.domainVerification?.tokenPrefix || DEFAULT_TOKEN_PREFIX}-${providerId}`;
|
|
1633
|
+
}
|
|
1634
|
+
const requestDomainVerification = (options) => {
|
|
1635
|
+
return createAuthEndpoint("/sso/request-domain-verification", {
|
|
1636
|
+
method: "POST",
|
|
1637
|
+
body: domainVerificationBodySchema,
|
|
1638
|
+
metadata: { openapi: {
|
|
1639
|
+
summary: "Request a domain verification",
|
|
1640
|
+
description: "Request a domain verification for the given SSO provider",
|
|
1641
|
+
responses: {
|
|
1642
|
+
"404": { description: "Provider not found" },
|
|
1643
|
+
"409": { description: "Domain has already been verified" },
|
|
1644
|
+
"201": { description: "Domain submitted for verification" }
|
|
1645
|
+
}
|
|
1646
|
+
} },
|
|
1647
|
+
use: [sessionMiddleware]
|
|
1648
|
+
}, async (ctx) => {
|
|
1649
|
+
const body = ctx.body;
|
|
1650
|
+
const provider = await checkProviderAccess(ctx, body.providerId);
|
|
1651
|
+
if (provider.domainVerified) throw new APIError("CONFLICT", {
|
|
1652
|
+
message: "Domain has already been verified",
|
|
1653
|
+
code: "DOMAIN_VERIFIED"
|
|
1654
|
+
});
|
|
1655
|
+
const identifier = getVerificationIdentifier(options, provider.providerId);
|
|
1656
|
+
const activeVerification = await ctx.context.internalAdapter.findVerificationValue(identifier);
|
|
1657
|
+
if (activeVerification && new Date(activeVerification.expiresAt) > /* @__PURE__ */ new Date()) {
|
|
1658
|
+
ctx.setStatus(201);
|
|
1659
|
+
return ctx.json({ domainVerificationToken: activeVerification.value });
|
|
1660
|
+
}
|
|
1661
|
+
const domainVerificationToken = generateRandomString(24);
|
|
1662
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
1663
|
+
identifier,
|
|
1664
|
+
value: domainVerificationToken,
|
|
1665
|
+
expiresAt: new Date(Date.now() + 3600 * 24 * 7 * 1e3)
|
|
1666
|
+
});
|
|
1667
|
+
ctx.setStatus(201);
|
|
1668
|
+
return ctx.json({ domainVerificationToken });
|
|
1669
|
+
});
|
|
1670
|
+
};
|
|
1671
|
+
const verifyDomain = (options) => {
|
|
1672
|
+
return createAuthEndpoint("/sso/verify-domain", {
|
|
1673
|
+
method: "POST",
|
|
1674
|
+
body: domainVerificationBodySchema,
|
|
1675
|
+
metadata: { openapi: {
|
|
1676
|
+
summary: "Verify the provider domain ownership",
|
|
1677
|
+
description: "Verify the provider domain ownership via DNS records",
|
|
1678
|
+
responses: {
|
|
1679
|
+
"404": { description: "Provider not found" },
|
|
1680
|
+
"409": { description: "Domain has already been verified or no pending verification exists" },
|
|
1681
|
+
"502": { description: "Unable to verify domain ownership due to upstream validator error" },
|
|
1682
|
+
"204": { description: "Domain ownership was verified" }
|
|
1683
|
+
}
|
|
1684
|
+
} },
|
|
1685
|
+
use: [sessionMiddleware]
|
|
1686
|
+
}, async (ctx) => {
|
|
1687
|
+
const body = ctx.body;
|
|
1688
|
+
const provider = await checkProviderAccess(ctx, body.providerId);
|
|
1689
|
+
if (provider.domainVerified) throw new APIError("CONFLICT", {
|
|
1690
|
+
message: "Domain has already been verified",
|
|
1691
|
+
code: "DOMAIN_VERIFIED"
|
|
1692
|
+
});
|
|
1693
|
+
const identifier = getVerificationIdentifier(options, provider.providerId);
|
|
1694
|
+
if (identifier.length > DNS_LABEL_MAX_LENGTH) throw new APIError("BAD_REQUEST", {
|
|
1695
|
+
message: `Verification identifier exceeds the DNS label limit of ${DNS_LABEL_MAX_LENGTH} characters`,
|
|
1696
|
+
code: "IDENTIFIER_TOO_LONG"
|
|
1697
|
+
});
|
|
1698
|
+
const activeVerification = await ctx.context.internalAdapter.findVerificationValue(identifier);
|
|
1699
|
+
if (!activeVerification || new Date(activeVerification.expiresAt) <= /* @__PURE__ */ new Date()) throw new APIError("NOT_FOUND", {
|
|
1700
|
+
message: "No pending domain verification exists",
|
|
1701
|
+
code: "NO_PENDING_VERIFICATION"
|
|
1702
|
+
});
|
|
1703
|
+
let records = [];
|
|
1704
|
+
let dns;
|
|
1705
|
+
try {
|
|
1706
|
+
dns = await import("node:dns/promises");
|
|
1707
|
+
} catch (error) {
|
|
1708
|
+
ctx.context.logger.error("The core node:dns module is required for the domain verification feature", error);
|
|
1709
|
+
throw new APIError("INTERNAL_SERVER_ERROR", {
|
|
1710
|
+
message: "Unable to verify domain ownership due to server error",
|
|
1711
|
+
code: "DOMAIN_VERIFICATION_FAILED"
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1714
|
+
const hostname = getHostnameFromDomain(provider.domain);
|
|
1715
|
+
if (!hostname) throw new APIError("BAD_REQUEST", {
|
|
1716
|
+
message: "Invalid domain",
|
|
1717
|
+
code: "INVALID_DOMAIN"
|
|
1718
|
+
});
|
|
1719
|
+
try {
|
|
1720
|
+
records = (await dns.resolveTxt(`${identifier}.${hostname}`)).flat();
|
|
1721
|
+
} catch (error) {
|
|
1722
|
+
ctx.context.logger.warn("DNS resolution failure while validating domain ownership", error);
|
|
1723
|
+
}
|
|
1724
|
+
if (!records.find((record) => record.includes(`${activeVerification.identifier}=${activeVerification.value}`))) throw new APIError("BAD_GATEWAY", {
|
|
1725
|
+
message: "Unable to verify domain ownership. Try again later",
|
|
1726
|
+
code: "DOMAIN_VERIFICATION_FAILED"
|
|
1727
|
+
});
|
|
1728
|
+
await ctx.context.adapter.update({
|
|
1729
|
+
model: "ssoProvider",
|
|
1730
|
+
where: [{
|
|
1731
|
+
field: "providerId",
|
|
1732
|
+
value: provider.providerId
|
|
1733
|
+
}],
|
|
1734
|
+
update: { domainVerified: true }
|
|
1735
|
+
});
|
|
1736
|
+
ctx.setStatus(204);
|
|
1737
|
+
});
|
|
1738
|
+
};
|
|
1739
|
+
//#endregion
|
|
1663
1740
|
//#region src/saml-state.ts
|
|
1664
1741
|
async function generateRelayState(c, link) {
|
|
1665
1742
|
const callbackURL = c.body.callbackURL;
|
|
@@ -1975,26 +2052,8 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1975
2052
|
const conditions = extract.conditions;
|
|
1976
2053
|
const clockSkew = options?.saml?.clockSkew ?? 3e5;
|
|
1977
2054
|
const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
if (existingAssertion) try {
|
|
1981
|
-
if (JSON.parse(existingAssertion.value).expiresAt >= Date.now()) isReplay = true;
|
|
1982
|
-
} catch (error) {
|
|
1983
|
-
ctx.context.logger.warn("Failed to parse stored assertion record", {
|
|
1984
|
-
assertionId,
|
|
1985
|
-
error
|
|
1986
|
-
});
|
|
1987
|
-
}
|
|
1988
|
-
if (isReplay) {
|
|
1989
|
-
ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
|
|
1990
|
-
assertionId,
|
|
1991
|
-
issuer,
|
|
1992
|
-
providerId
|
|
1993
|
-
});
|
|
1994
|
-
throw ctx.redirect(`${samlRedirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
|
|
1995
|
-
}
|
|
1996
|
-
await ctx.context.internalAdapter.createVerificationValue({
|
|
1997
|
-
identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
|
|
2055
|
+
if (!await ctx.context.internalAdapter.reserveVerificationValue({
|
|
2056
|
+
identifier: `saml-used-assertion:${assertionId}`,
|
|
1998
2057
|
value: JSON.stringify({
|
|
1999
2058
|
assertionId,
|
|
2000
2059
|
issuer,
|
|
@@ -2003,7 +2062,14 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2003
2062
|
expiresAt
|
|
2004
2063
|
}),
|
|
2005
2064
|
expiresAt: new Date(expiresAt)
|
|
2006
|
-
})
|
|
2065
|
+
})) {
|
|
2066
|
+
ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
|
|
2067
|
+
assertionId,
|
|
2068
|
+
issuer,
|
|
2069
|
+
providerId
|
|
2070
|
+
});
|
|
2071
|
+
throw ctx.redirect(`${samlRedirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
|
|
2072
|
+
}
|
|
2007
2073
|
} else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
|
|
2008
2074
|
const attributes = extract.attributes || {};
|
|
2009
2075
|
const mapping = parsedSamlConfig.mapping ?? {};
|
|
@@ -2016,7 +2082,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2016
2082
|
id: attr(mapping.id || "nameID") || extract.nameID,
|
|
2017
2083
|
email: (attr(mapping.email || "email") || extract.nameID || "").toLowerCase(),
|
|
2018
2084
|
name: [attr(mapping.firstName || "givenName"), attr(mapping.lastName || "surname")].filter(Boolean).join(" ") || attr(mapping.name || "displayName") || extract.nameID,
|
|
2019
|
-
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attr(mapping.emailVerified)
|
|
2085
|
+
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? parseProviderEmailVerified(attr(mapping.emailVerified)) : false
|
|
2020
2086
|
};
|
|
2021
2087
|
if (!userInfo.id || !userInfo.email) {
|
|
2022
2088
|
ctx.context.logger.error("Missing essential user info from SAML response", {
|
|
@@ -2037,7 +2103,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2037
2103
|
email: userInfo.email,
|
|
2038
2104
|
name: userInfo.name || userInfo.email,
|
|
2039
2105
|
id: userInfo.id,
|
|
2040
|
-
emailVerified:
|
|
2106
|
+
emailVerified: userInfo.emailVerified
|
|
2041
2107
|
},
|
|
2042
2108
|
providerId,
|
|
2043
2109
|
accountId: userInfo.id,
|
|
@@ -2077,7 +2143,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2077
2143
|
providerId,
|
|
2078
2144
|
accountId: userInfo.id,
|
|
2079
2145
|
email: userInfo.email,
|
|
2080
|
-
emailVerified:
|
|
2146
|
+
emailVerified: userInfo.emailVerified,
|
|
2081
2147
|
rawAttributes: attributes
|
|
2082
2148
|
},
|
|
2083
2149
|
provider,
|
|
@@ -2150,174 +2216,177 @@ const spMetadata = (options) => {
|
|
|
2150
2216
|
const registerSSOProvider = (options) => {
|
|
2151
2217
|
return createAuthEndpoint("/sso/register", {
|
|
2152
2218
|
method: "POST",
|
|
2153
|
-
body:
|
|
2219
|
+
body: getRegisterSSOProviderBodySchema(options),
|
|
2154
2220
|
use: [sessionMiddleware],
|
|
2155
|
-
metadata: {
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
description: "OIDC provider
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
type: "string",
|
|
2232
|
-
enum: ["client_secret_post", "client_secret_basic"],
|
|
2233
|
-
nullable: true,
|
|
2234
|
-
description: "Authentication method for the token endpoint"
|
|
2235
|
-
},
|
|
2236
|
-
jwksEndpoint: {
|
|
2237
|
-
type: "string",
|
|
2238
|
-
format: "uri",
|
|
2239
|
-
nullable: true,
|
|
2240
|
-
description: "The JWKS endpoint URL"
|
|
2241
|
-
},
|
|
2242
|
-
mapping: {
|
|
2243
|
-
type: "object",
|
|
2244
|
-
nullable: true,
|
|
2245
|
-
properties: {
|
|
2246
|
-
id: {
|
|
2247
|
-
type: "string",
|
|
2248
|
-
description: "Field mapping for user ID (defaults to 'sub')"
|
|
2249
|
-
},
|
|
2250
|
-
email: {
|
|
2251
|
-
type: "string",
|
|
2252
|
-
description: "Field mapping for email (defaults to 'email')"
|
|
2253
|
-
},
|
|
2254
|
-
emailVerified: {
|
|
2255
|
-
type: "string",
|
|
2256
|
-
nullable: true,
|
|
2257
|
-
description: "Field mapping for email verification (defaults to 'email_verified')"
|
|
2258
|
-
},
|
|
2259
|
-
name: {
|
|
2260
|
-
type: "string",
|
|
2261
|
-
description: "Field mapping for name (defaults to 'name')"
|
|
2262
|
-
},
|
|
2263
|
-
image: {
|
|
2264
|
-
type: "string",
|
|
2265
|
-
nullable: true,
|
|
2266
|
-
description: "Field mapping for image (defaults to 'picture')"
|
|
2267
|
-
},
|
|
2268
|
-
extraFields: {
|
|
2269
|
-
type: "object",
|
|
2270
|
-
additionalProperties: { type: "string" },
|
|
2271
|
-
nullable: true,
|
|
2272
|
-
description: "Additional field mappings"
|
|
2273
|
-
}
|
|
2221
|
+
metadata: {
|
|
2222
|
+
$Infer: { body: {} },
|
|
2223
|
+
openapi: {
|
|
2224
|
+
operationId: "registerSSOProvider",
|
|
2225
|
+
summary: "Register an OIDC provider",
|
|
2226
|
+
description: "This endpoint is used to register an OIDC provider. This is used to configure the provider and link it to an organization",
|
|
2227
|
+
responses: { "200": {
|
|
2228
|
+
description: "OIDC provider created successfully",
|
|
2229
|
+
content: { "application/json": { schema: {
|
|
2230
|
+
type: "object",
|
|
2231
|
+
properties: {
|
|
2232
|
+
issuer: {
|
|
2233
|
+
type: "string",
|
|
2234
|
+
format: "uri",
|
|
2235
|
+
description: "The issuer URL of the provider"
|
|
2236
|
+
},
|
|
2237
|
+
domain: {
|
|
2238
|
+
type: "string",
|
|
2239
|
+
description: "The domain of the provider, used for email matching"
|
|
2240
|
+
},
|
|
2241
|
+
domainVerified: {
|
|
2242
|
+
type: "boolean",
|
|
2243
|
+
description: "A boolean indicating whether the domain has been verified or not"
|
|
2244
|
+
},
|
|
2245
|
+
domainVerificationToken: {
|
|
2246
|
+
type: "string",
|
|
2247
|
+
description: "Domain verification token. It can be used to prove ownership over the SSO domain"
|
|
2248
|
+
},
|
|
2249
|
+
oidcConfig: {
|
|
2250
|
+
type: "object",
|
|
2251
|
+
properties: {
|
|
2252
|
+
issuer: {
|
|
2253
|
+
type: "string",
|
|
2254
|
+
format: "uri",
|
|
2255
|
+
description: "The issuer URL of the provider"
|
|
2256
|
+
},
|
|
2257
|
+
pkce: {
|
|
2258
|
+
type: "boolean",
|
|
2259
|
+
description: "Whether PKCE is enabled for the authorization flow"
|
|
2260
|
+
},
|
|
2261
|
+
clientId: {
|
|
2262
|
+
type: "string",
|
|
2263
|
+
description: "The client ID for the provider"
|
|
2264
|
+
},
|
|
2265
|
+
clientSecret: {
|
|
2266
|
+
type: "string",
|
|
2267
|
+
description: "The client secret for the provider"
|
|
2268
|
+
},
|
|
2269
|
+
authorizationEndpoint: {
|
|
2270
|
+
type: "string",
|
|
2271
|
+
format: "uri",
|
|
2272
|
+
nullable: true,
|
|
2273
|
+
description: "The authorization endpoint URL"
|
|
2274
|
+
},
|
|
2275
|
+
discoveryEndpoint: {
|
|
2276
|
+
type: "string",
|
|
2277
|
+
format: "uri",
|
|
2278
|
+
description: "The discovery endpoint URL"
|
|
2279
|
+
},
|
|
2280
|
+
userInfoEndpoint: {
|
|
2281
|
+
type: "string",
|
|
2282
|
+
format: "uri",
|
|
2283
|
+
nullable: true,
|
|
2284
|
+
description: "The user info endpoint URL"
|
|
2285
|
+
},
|
|
2286
|
+
scopes: {
|
|
2287
|
+
type: "array",
|
|
2288
|
+
items: { type: "string" },
|
|
2289
|
+
nullable: true,
|
|
2290
|
+
description: "The scopes requested from the provider"
|
|
2291
|
+
},
|
|
2292
|
+
tokenEndpoint: {
|
|
2293
|
+
type: "string",
|
|
2294
|
+
format: "uri",
|
|
2295
|
+
nullable: true,
|
|
2296
|
+
description: "The token endpoint URL"
|
|
2274
2297
|
},
|
|
2275
|
-
|
|
2276
|
-
"
|
|
2277
|
-
"
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2298
|
+
tokenEndpointAuthentication: {
|
|
2299
|
+
type: "string",
|
|
2300
|
+
enum: ["client_secret_post", "client_secret_basic"],
|
|
2301
|
+
nullable: true,
|
|
2302
|
+
description: "Authentication method for the token endpoint"
|
|
2303
|
+
},
|
|
2304
|
+
jwksEndpoint: {
|
|
2305
|
+
type: "string",
|
|
2306
|
+
format: "uri",
|
|
2307
|
+
nullable: true,
|
|
2308
|
+
description: "The JWKS endpoint URL"
|
|
2309
|
+
},
|
|
2310
|
+
mapping: {
|
|
2311
|
+
type: "object",
|
|
2312
|
+
nullable: true,
|
|
2313
|
+
properties: {
|
|
2314
|
+
id: {
|
|
2315
|
+
type: "string",
|
|
2316
|
+
description: "Field mapping for user ID (defaults to 'sub')"
|
|
2317
|
+
},
|
|
2318
|
+
email: {
|
|
2319
|
+
type: "string",
|
|
2320
|
+
description: "Field mapping for email (defaults to 'email')"
|
|
2321
|
+
},
|
|
2322
|
+
emailVerified: {
|
|
2323
|
+
type: "string",
|
|
2324
|
+
nullable: true,
|
|
2325
|
+
description: "Field mapping for email verification (defaults to 'email_verified')"
|
|
2326
|
+
},
|
|
2327
|
+
name: {
|
|
2328
|
+
type: "string",
|
|
2329
|
+
description: "Field mapping for name (defaults to 'name')"
|
|
2330
|
+
},
|
|
2331
|
+
image: {
|
|
2332
|
+
type: "string",
|
|
2333
|
+
nullable: true,
|
|
2334
|
+
description: "Field mapping for image (defaults to 'picture')"
|
|
2335
|
+
},
|
|
2336
|
+
extraFields: {
|
|
2337
|
+
type: "object",
|
|
2338
|
+
additionalProperties: { type: "string" },
|
|
2339
|
+
nullable: true,
|
|
2340
|
+
description: "Additional field mappings"
|
|
2341
|
+
}
|
|
2342
|
+
},
|
|
2343
|
+
required: [
|
|
2344
|
+
"id",
|
|
2345
|
+
"email",
|
|
2346
|
+
"name"
|
|
2347
|
+
]
|
|
2348
|
+
}
|
|
2349
|
+
},
|
|
2350
|
+
required: [
|
|
2351
|
+
"issuer",
|
|
2352
|
+
"pkce",
|
|
2353
|
+
"clientId",
|
|
2354
|
+
"clientSecret",
|
|
2355
|
+
"discoveryEndpoint"
|
|
2356
|
+
],
|
|
2357
|
+
description: "OIDC configuration for the provider"
|
|
2281
2358
|
},
|
|
2282
|
-
|
|
2283
|
-
"
|
|
2284
|
-
|
|
2285
|
-
"
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
providerId: {
|
|
2301
|
-
type: "string",
|
|
2302
|
-
description: "Unique identifier for the provider"
|
|
2359
|
+
organizationId: {
|
|
2360
|
+
type: "string",
|
|
2361
|
+
nullable: true,
|
|
2362
|
+
description: "ID of the linked organization, if any"
|
|
2363
|
+
},
|
|
2364
|
+
userId: {
|
|
2365
|
+
type: "string",
|
|
2366
|
+
description: "ID of the user who registered the provider"
|
|
2367
|
+
},
|
|
2368
|
+
providerId: {
|
|
2369
|
+
type: "string",
|
|
2370
|
+
description: "Unique identifier for the provider"
|
|
2371
|
+
},
|
|
2372
|
+
redirectURI: {
|
|
2373
|
+
type: "string",
|
|
2374
|
+
format: "uri",
|
|
2375
|
+
description: "The redirect URI for the provider callback"
|
|
2376
|
+
}
|
|
2303
2377
|
},
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
"redirectURI"
|
|
2317
|
-
]
|
|
2318
|
-
} } }
|
|
2319
|
-
} }
|
|
2320
|
-
} }
|
|
2378
|
+
required: [
|
|
2379
|
+
"issuer",
|
|
2380
|
+
"domain",
|
|
2381
|
+
"oidcConfig",
|
|
2382
|
+
"userId",
|
|
2383
|
+
"providerId",
|
|
2384
|
+
"redirectURI"
|
|
2385
|
+
]
|
|
2386
|
+
} } }
|
|
2387
|
+
} }
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2321
2390
|
}, async (ctx) => {
|
|
2322
2391
|
const user = ctx.context.session?.user;
|
|
2323
2392
|
if (!user) throw new APIError("UNAUTHORIZED");
|
|
@@ -2331,6 +2400,7 @@ const registerSSOProvider = (options) => {
|
|
|
2331
2400
|
}]
|
|
2332
2401
|
})).length >= limit) throw new APIError("FORBIDDEN", { message: "You have reached the maximum number of SSO providers" });
|
|
2333
2402
|
const body = ctx.body;
|
|
2403
|
+
const additionalFields = parseSSOProviderAdditionalFields(options, body, "create");
|
|
2334
2404
|
if (body.samlConfig?.idpMetadata?.metadata) {
|
|
2335
2405
|
const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
|
|
2336
2406
|
if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
|
|
@@ -2368,7 +2438,7 @@ const registerSSOProvider = (options) => {
|
|
|
2368
2438
|
throw new APIError("UNPROCESSABLE_ENTITY", { message: "SSO provider with this providerId already exists" });
|
|
2369
2439
|
}
|
|
2370
2440
|
if (body.oidcConfig) try {
|
|
2371
|
-
|
|
2441
|
+
validateOIDCEndpointUrls(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
|
|
2372
2442
|
} catch (error) {
|
|
2373
2443
|
if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
|
|
2374
2444
|
throw error;
|
|
@@ -2450,6 +2520,7 @@ const registerSSOProvider = (options) => {
|
|
|
2450
2520
|
issuer: body.issuer,
|
|
2451
2521
|
domain: body.domain,
|
|
2452
2522
|
domainVerified: false,
|
|
2523
|
+
...additionalFields,
|
|
2453
2524
|
oidcConfig: (() => {
|
|
2454
2525
|
const config = buildOIDCConfig();
|
|
2455
2526
|
if (config) {
|
|
@@ -2491,7 +2562,7 @@ const registerSSOProvider = (options) => {
|
|
|
2491
2562
|
});
|
|
2492
2563
|
}
|
|
2493
2564
|
const result = {
|
|
2494
|
-
...provider,
|
|
2565
|
+
...filterSSOProviderAdditionalFields(provider, options),
|
|
2495
2566
|
oidcConfig: safeJsonParse(provider.oidcConfig),
|
|
2496
2567
|
samlConfig: safeJsonParse(provider.samlConfig),
|
|
2497
2568
|
redirectURI: getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options),
|
|
@@ -2738,6 +2809,22 @@ const callbackSSOQuerySchema = z.object({
|
|
|
2738
2809
|
error: z.string().optional(),
|
|
2739
2810
|
error_description: z.string().optional()
|
|
2740
2811
|
});
|
|
2812
|
+
function getStringErrorField(value, field) {
|
|
2813
|
+
if (!value || typeof value !== "object") return;
|
|
2814
|
+
const fieldValue = value[field];
|
|
2815
|
+
return typeof fieldValue === "string" && fieldValue.length > 0 ? fieldValue : void 0;
|
|
2816
|
+
}
|
|
2817
|
+
function getOIDCErrorDescription(error, fallback) {
|
|
2818
|
+
const nestedError = error && typeof error === "object" ? error.error : void 0;
|
|
2819
|
+
const description = getStringErrorField(nestedError, "error_description") || getStringErrorField(error, "error_description") || getStringErrorField(nestedError, "message") || getStringErrorField(error, "message") || getStringErrorField(error, "statusText") || getStringErrorField(nestedError, "error") || getStringErrorField(error, "error");
|
|
2820
|
+
if (description) return description;
|
|
2821
|
+
if (error && typeof error === "object") {
|
|
2822
|
+
const status = error.status;
|
|
2823
|
+
if (typeof status === "number") return `HTTP ${status}`;
|
|
2824
|
+
if (typeof status === "string" && status.length > 0) return status;
|
|
2825
|
+
}
|
|
2826
|
+
return fallback;
|
|
2827
|
+
}
|
|
2741
2828
|
/**
|
|
2742
2829
|
* Core OIDC callback handler logic, shared between the per-provider and
|
|
2743
2830
|
* shared callback endpoints. Resolves the provider, exchanges the
|
|
@@ -2754,7 +2841,16 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2754
2841
|
throw ctx.redirect(`${errorURL}?error=invalid_state`);
|
|
2755
2842
|
}
|
|
2756
2843
|
const { callbackURL, errorURL, newUserURL, requestSignUp, requestedScopes } = stateData;
|
|
2757
|
-
|
|
2844
|
+
const redirectOIDCError = (error, description) => {
|
|
2845
|
+
const baseURL = errorURL || callbackURL;
|
|
2846
|
+
const params = new URLSearchParams({
|
|
2847
|
+
error,
|
|
2848
|
+
error_description: description
|
|
2849
|
+
});
|
|
2850
|
+
const separator = baseURL.includes("?") ? "&" : "?";
|
|
2851
|
+
throw ctx.redirect(`${baseURL}${separator}${params.toString()}`);
|
|
2852
|
+
};
|
|
2853
|
+
if (!code || error) redirectOIDCError(error || "invalid_request", error_description || (error ? error : "authorization_code_not_found"));
|
|
2758
2854
|
const provider = await resolveOIDCProvider(ctx, options, providerId);
|
|
2759
2855
|
if (!provider) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
|
|
2760
2856
|
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
@@ -2776,6 +2872,7 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2776
2872
|
]
|
|
2777
2873
|
};
|
|
2778
2874
|
if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_endpoint_not_found`);
|
|
2875
|
+
const tokenEndpoint = config.tokenEndpoint;
|
|
2779
2876
|
let tokenEndpointAuth = config.tokenEndpointAuthentication === "client_secret_post" ? { method: "client_secret_post" } : { method: "client_secret_basic" };
|
|
2780
2877
|
if (config.tokenEndpointAuthentication === "private_key_jwt") {
|
|
2781
2878
|
let resolved;
|
|
@@ -2801,35 +2898,46 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2801
2898
|
}
|
|
2802
2899
|
const tokenRequestOptions = { clientId: config.clientId };
|
|
2803
2900
|
if (tokenEndpointAuth.method !== "private_key_jwt") tokenRequestOptions.clientSecret = config.clientSecret;
|
|
2804
|
-
const tokenResponse = await
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2901
|
+
const tokenResponse = await (async () => {
|
|
2902
|
+
const { body, headers } = await authorizationCodeRequest({
|
|
2903
|
+
code,
|
|
2904
|
+
codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
|
|
2905
|
+
redirectURI: getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options),
|
|
2906
|
+
options: tokenRequestOptions,
|
|
2907
|
+
tokenEndpoint,
|
|
2908
|
+
tokenEndpointAuth
|
|
2909
|
+
});
|
|
2910
|
+
const { data, error } = await fetchOIDCEndpoint("tokenEndpoint", tokenEndpoint, {
|
|
2911
|
+
method: "POST",
|
|
2912
|
+
body,
|
|
2913
|
+
headers
|
|
2914
|
+
}, (url) => ctx.context.isTrustedOrigin(url));
|
|
2915
|
+
if (error) redirectOIDCError("invalid_provider", getOIDCErrorDescription(error, "token_response_error"));
|
|
2916
|
+
if (!data) throw new Error("Token endpoint returned an empty response");
|
|
2917
|
+
return getOAuth2Tokens(data);
|
|
2918
|
+
})().catch((e) => {
|
|
2919
|
+
if (isAPIError(e)) throw e;
|
|
2812
2920
|
ctx.context.logger.error("Error validating authorization code", e);
|
|
2813
|
-
if (e instanceof
|
|
2814
|
-
|
|
2921
|
+
if (e instanceof DiscoveryError) redirectOIDCError("invalid_provider", e.message);
|
|
2922
|
+
redirectOIDCError("invalid_provider", getOIDCErrorDescription(e, "token_response_error"));
|
|
2815
2923
|
});
|
|
2816
2924
|
if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_response_not_found`);
|
|
2817
2925
|
let userInfo = null;
|
|
2818
2926
|
const mapping = config.mapping || {};
|
|
2819
2927
|
let rawProfile;
|
|
2820
2928
|
if (config.userInfoEndpoint) {
|
|
2821
|
-
const userInfoResponse = await
|
|
2822
|
-
|
|
2823
|
-
|
|
2929
|
+
const userInfoResponse = await fetchOIDCEndpoint("userInfoEndpoint", config.userInfoEndpoint, { headers: { Authorization: `Bearer ${tokenResponse.accessToken}` } }, (url) => ctx.context.isTrustedOrigin(url)).catch((e) => {
|
|
2930
|
+
if (e instanceof DiscoveryError) redirectOIDCError("invalid_provider", e.message);
|
|
2931
|
+
throw e;
|
|
2824
2932
|
});
|
|
2825
|
-
if (userInfoResponse.error)
|
|
2826
|
-
const rawUserInfo = userInfoResponse.data;
|
|
2933
|
+
if (userInfoResponse.error) redirectOIDCError("invalid_provider", userInfoResponse.error.message || userInfoResponse.error.statusText || "userinfo_response_error");
|
|
2934
|
+
const rawUserInfo = userInfoResponse.data ?? redirectOIDCError("invalid_provider", "userinfo_response_not_found");
|
|
2827
2935
|
rawProfile = rawUserInfo;
|
|
2828
2936
|
userInfo = {
|
|
2829
2937
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, rawUserInfo[value]])),
|
|
2830
2938
|
id: rawUserInfo[mapping.id || "sub"],
|
|
2831
2939
|
email: rawUserInfo[mapping.email || "email"],
|
|
2832
|
-
emailVerified: options?.trustEmailVerified ? rawUserInfo[mapping.emailVerified || "email_verified"] : false,
|
|
2940
|
+
emailVerified: options?.trustEmailVerified ? parseProviderEmailVerified(rawUserInfo[mapping.emailVerified || "email_verified"]) : false,
|
|
2833
2941
|
name: rawUserInfo[mapping.name || "name"],
|
|
2834
2942
|
image: rawUserInfo[mapping.image || "picture"]
|
|
2835
2943
|
};
|
|
@@ -2837,10 +2945,11 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2837
2945
|
const idToken = decodeJwt(tokenResponse.idToken);
|
|
2838
2946
|
rawProfile = idToken;
|
|
2839
2947
|
if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=jwks_endpoint_not_found`);
|
|
2840
|
-
const verified = await
|
|
2948
|
+
const verified = await validateOIDCIdToken(tokenResponse.idToken, config.jwksEndpoint, {
|
|
2841
2949
|
audience: config.clientId,
|
|
2842
2950
|
issuer: provider.issuer
|
|
2843
|
-
}).catch((e) => {
|
|
2951
|
+
}, (url) => ctx.context.isTrustedOrigin(url)).catch((e) => {
|
|
2952
|
+
if (e instanceof DiscoveryError) redirectOIDCError("invalid_provider", e.message);
|
|
2844
2953
|
ctx.context.logger.error(e);
|
|
2845
2954
|
return null;
|
|
2846
2955
|
});
|
|
@@ -2849,7 +2958,7 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2849
2958
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, verified.payload[value]])),
|
|
2850
2959
|
id: idToken[mapping.id || "sub"],
|
|
2851
2960
|
email: idToken[mapping.email || "email"],
|
|
2852
|
-
emailVerified: options?.trustEmailVerified ? idToken[mapping.emailVerified || "email_verified"] : false,
|
|
2961
|
+
emailVerified: options?.trustEmailVerified ? parseProviderEmailVerified(idToken[mapping.emailVerified || "email_verified"]) : false,
|
|
2853
2962
|
name: idToken[mapping.name || "name"],
|
|
2854
2963
|
image: idToken[mapping.image || "picture"]
|
|
2855
2964
|
};
|
|
@@ -3287,7 +3396,42 @@ saml.setSchemaValidator({ async validate(xml) {
|
|
|
3287
3396
|
* which won't have a matching Origin header.
|
|
3288
3397
|
*/
|
|
3289
3398
|
const SAML_SKIP_ORIGIN_CHECK_PATHS = ["/sso/saml2/sp/acs", "/sso/saml2/sp/slo"];
|
|
3399
|
+
const SSO_PROVIDER_BUILT_IN_FIELD_KEYS = [
|
|
3400
|
+
"id",
|
|
3401
|
+
"issuer",
|
|
3402
|
+
"oidcConfig",
|
|
3403
|
+
"samlConfig",
|
|
3404
|
+
"userId",
|
|
3405
|
+
"providerId",
|
|
3406
|
+
"organizationId",
|
|
3407
|
+
"domain",
|
|
3408
|
+
"domainVerified"
|
|
3409
|
+
];
|
|
3410
|
+
const SSO_PROVIDER_RESPONSE_FIELD_KEYS = [
|
|
3411
|
+
"type",
|
|
3412
|
+
"spMetadataUrl",
|
|
3413
|
+
"redirectURI",
|
|
3414
|
+
"domainVerificationToken"
|
|
3415
|
+
];
|
|
3416
|
+
const SSO_PROVIDER_BUILT_IN_FIELD_KEY_SET = new Set(SSO_PROVIDER_BUILT_IN_FIELD_KEYS);
|
|
3417
|
+
const SSO_PROVIDER_RESPONSE_FIELD_KEY_SET = new Set(SSO_PROVIDER_RESPONSE_FIELD_KEYS);
|
|
3418
|
+
function getSSOProviderBuiltInFieldName(options, key) {
|
|
3419
|
+
const fieldNames = options?.fields;
|
|
3420
|
+
const schemaFieldNames = options?.schema?.ssoProvider?.fields;
|
|
3421
|
+
return fieldNames?.[key] ?? schemaFieldNames?.[key] ?? key;
|
|
3422
|
+
}
|
|
3423
|
+
function assertNoAdditionalFieldCollisions(options) {
|
|
3424
|
+
const additionalFields = options?.schema?.ssoProvider?.additionalFields ?? {};
|
|
3425
|
+
const builtInFieldNames = new Set(SSO_PROVIDER_BUILT_IN_FIELD_KEYS.map((key) => getSSOProviderBuiltInFieldName(options, key)));
|
|
3426
|
+
for (const [key, field] of Object.entries(additionalFields)) {
|
|
3427
|
+
if (SSO_PROVIDER_BUILT_IN_FIELD_KEY_SET.has(key)) throw new Error(`ssoProvider additional field "${key}" conflicts with a built-in field`);
|
|
3428
|
+
if (SSO_PROVIDER_RESPONSE_FIELD_KEY_SET.has(key)) throw new Error(`ssoProvider additional field "${key}" conflicts with a returned provider field`);
|
|
3429
|
+
const fieldName = field.fieldName ?? key;
|
|
3430
|
+
if (builtInFieldNames.has(fieldName)) throw new Error(`ssoProvider additional field "${key}" maps to built-in field "${fieldName}"`);
|
|
3431
|
+
}
|
|
3432
|
+
}
|
|
3290
3433
|
function sso(options) {
|
|
3434
|
+
assertNoAdditionalFieldCollisions(options);
|
|
3291
3435
|
const optionsWithStore = options;
|
|
3292
3436
|
let endpoints = {
|
|
3293
3437
|
spMetadata: spMetadata(optionsWithStore),
|
|
@@ -3298,8 +3442,8 @@ function sso(options) {
|
|
|
3298
3442
|
acsEndpoint: acsEndpoint(optionsWithStore),
|
|
3299
3443
|
sloEndpoint: sloEndpoint(optionsWithStore),
|
|
3300
3444
|
initiateSLO: initiateSLO(optionsWithStore),
|
|
3301
|
-
listSSOProviders: listSSOProviders(),
|
|
3302
|
-
getSSOProvider: getSSOProvider(),
|
|
3445
|
+
listSSOProviders: listSSOProviders(optionsWithStore),
|
|
3446
|
+
getSSOProvider: getSSOProvider(optionsWithStore),
|
|
3303
3447
|
updateSSOProvider: updateSSOProvider(optionsWithStore),
|
|
3304
3448
|
deleteSSOProvider: deleteSSOProvider()
|
|
3305
3449
|
};
|
|
@@ -3356,22 +3500,22 @@ function sso(options) {
|
|
|
3356
3500
|
}]
|
|
3357
3501
|
},
|
|
3358
3502
|
schema: { ssoProvider: {
|
|
3359
|
-
modelName: options?.modelName ?? "ssoProvider",
|
|
3503
|
+
modelName: options?.modelName ?? options?.schema?.ssoProvider?.modelName ?? "ssoProvider",
|
|
3360
3504
|
fields: {
|
|
3361
3505
|
issuer: {
|
|
3362
3506
|
type: "string",
|
|
3363
3507
|
required: true,
|
|
3364
|
-
fieldName: options?.fields?.issuer ?? "issuer"
|
|
3508
|
+
fieldName: options?.fields?.issuer ?? options?.schema?.ssoProvider?.fields?.issuer ?? "issuer"
|
|
3365
3509
|
},
|
|
3366
3510
|
oidcConfig: {
|
|
3367
3511
|
type: "string",
|
|
3368
3512
|
required: false,
|
|
3369
|
-
fieldName: options?.fields?.oidcConfig ?? "oidcConfig"
|
|
3513
|
+
fieldName: options?.fields?.oidcConfig ?? options?.schema?.ssoProvider?.fields?.oidcConfig ?? "oidcConfig"
|
|
3370
3514
|
},
|
|
3371
3515
|
samlConfig: {
|
|
3372
3516
|
type: "string",
|
|
3373
3517
|
required: false,
|
|
3374
|
-
fieldName: options?.fields?.samlConfig ?? "samlConfig"
|
|
3518
|
+
fieldName: options?.fields?.samlConfig ?? options?.schema?.ssoProvider?.fields?.samlConfig ?? "samlConfig"
|
|
3375
3519
|
},
|
|
3376
3520
|
userId: {
|
|
3377
3521
|
type: "string",
|
|
@@ -3379,30 +3523,33 @@ function sso(options) {
|
|
|
3379
3523
|
model: "user",
|
|
3380
3524
|
field: "id"
|
|
3381
3525
|
},
|
|
3382
|
-
fieldName: options?.fields?.userId ?? "userId"
|
|
3526
|
+
fieldName: options?.fields?.userId ?? options?.schema?.ssoProvider?.fields?.userId ?? "userId"
|
|
3383
3527
|
},
|
|
3384
3528
|
providerId: {
|
|
3385
3529
|
type: "string",
|
|
3386
3530
|
required: true,
|
|
3387
3531
|
unique: true,
|
|
3388
|
-
fieldName: options?.fields?.providerId ?? "providerId"
|
|
3532
|
+
fieldName: options?.fields?.providerId ?? options?.schema?.ssoProvider?.fields?.providerId ?? "providerId"
|
|
3389
3533
|
},
|
|
3390
3534
|
organizationId: {
|
|
3391
3535
|
type: "string",
|
|
3392
3536
|
required: false,
|
|
3393
|
-
fieldName: options?.fields?.organizationId ?? "organizationId"
|
|
3537
|
+
fieldName: options?.fields?.organizationId ?? options?.schema?.ssoProvider?.fields?.organizationId ?? "organizationId"
|
|
3394
3538
|
},
|
|
3395
3539
|
domain: {
|
|
3396
3540
|
type: "string",
|
|
3397
3541
|
required: true,
|
|
3398
|
-
fieldName: options?.fields?.domain ?? "domain"
|
|
3542
|
+
fieldName: options?.fields?.domain ?? options?.schema?.ssoProvider?.fields?.domain ?? "domain"
|
|
3399
3543
|
},
|
|
3400
3544
|
...options?.domainVerification?.enabled ? { domainVerified: {
|
|
3401
3545
|
type: "boolean",
|
|
3402
|
-
required: false
|
|
3403
|
-
|
|
3546
|
+
required: false,
|
|
3547
|
+
fieldName: options?.schema?.ssoProvider?.fields?.domainVerified ?? "domainVerified"
|
|
3548
|
+
} } : {},
|
|
3549
|
+
...options?.schema?.ssoProvider?.additionalFields ?? {}
|
|
3404
3550
|
}
|
|
3405
3551
|
} },
|
|
3552
|
+
$Infer: { SSOProvider: {} },
|
|
3406
3553
|
options
|
|
3407
3554
|
};
|
|
3408
3555
|
}
|