@better-auth/sso 1.7.0-beta.4 → 1.7.0-beta.6
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-
|
|
2
|
-
import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
|
|
1
|
+
import { t as PACKAGE_VERSION } from "./version-BTlyLl-N.mjs";
|
|
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 {
|
|
9
|
-
import {
|
|
8
|
+
import { filterOutputFields } from "@better-auth/core/utils/db";
|
|
9
|
+
import { classifyHost, isPublicRoutableHost } from "@better-auth/core/utils/host";
|
|
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
|
-
import { additionalAuthorizationParamsSchema,
|
|
16
|
-
import { decodeJwt } from "jose";
|
|
18
|
+
import { additionalAuthorizationParamsSchema, signInWithOAuthIdentity } from "better-auth/oauth2";
|
|
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,7 +372,137 @@ 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);
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Re-validate an endpoint by resolving its hostname and rejecting any resolved
|
|
379
|
+
* address that is not publicly routable.
|
|
380
|
+
*
|
|
381
|
+
* {@link validateOIDCEndpointUrl} only classifies the literal hostname, so
|
|
382
|
+
* a host like `idp.example` whose DNS record points at `127.0.0.1`,
|
|
383
|
+
* `169.254.169.254`, or an RFC 1918 address passes that check unchanged. This
|
|
384
|
+
* function closes that gap by performing the same RFC 6890 classification on the
|
|
385
|
+
* addresses the host actually resolves to, right before the server-side fetch.
|
|
386
|
+
*
|
|
387
|
+
* Best-effort by design:
|
|
388
|
+
* - Operator-allowlisted origins (trustedOrigins) are skipped — this is the
|
|
389
|
+
* documented escape hatch for internal IdPs.
|
|
390
|
+
* - IP-literal hosts are already fully covered by the synchronous check.
|
|
391
|
+
* - On runtimes without `node:dns` (e.g. Cloudflare Workers / edge), DNS
|
|
392
|
+
* resolution is unavailable; we fall back to the synchronous host check and
|
|
393
|
+
* the platform's own egress controls.
|
|
394
|
+
*
|
|
395
|
+
* Note: this resolves once and validates the result; it does not pin the address
|
|
396
|
+
* for the subsequent connection, so a change in the resolved address between
|
|
397
|
+
* this lookup and the fetch remains theoretically possible. It nonetheless
|
|
398
|
+
* rejects the common case of a DNS record that statically points at an internal
|
|
399
|
+
* address.
|
|
400
|
+
*
|
|
401
|
+
* @throws DiscoveryError(discovery_private_host) if any resolved address is not public
|
|
402
|
+
*/
|
|
403
|
+
async function assertEndpointResolvesPublic(name, endpoint, isTrustedOrigin) {
|
|
404
|
+
const parsed = parseURL(name, endpoint);
|
|
405
|
+
if (isTrustedOrigin(parsed.toString())) return;
|
|
406
|
+
const host = parsed.hostname;
|
|
407
|
+
if (classifyHost(host).literal !== "fqdn") return;
|
|
408
|
+
let dns;
|
|
409
|
+
try {
|
|
410
|
+
dns = await import("node:dns/promises");
|
|
411
|
+
} catch {
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
let resolved;
|
|
415
|
+
try {
|
|
416
|
+
resolved = await dns.lookup(host, { all: true });
|
|
417
|
+
} catch {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
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.`, {
|
|
421
|
+
endpoint: name,
|
|
422
|
+
url: endpoint,
|
|
423
|
+
hostname: host,
|
|
424
|
+
resolved: address
|
|
425
|
+
});
|
|
426
|
+
}
|
|
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
|
+
/**
|
|
435
|
+
* Re-validate, at fetch time, every OIDC endpoint that is fetched server-side
|
|
436
|
+
* (token, userinfo, jwks). `authorizationEndpoint` is intentionally excluded
|
|
437
|
+
* because it is a browser redirect target, not a server-side fetch.
|
|
438
|
+
*/
|
|
439
|
+
async function assertServerFetchedOIDCEndpointsAllowed(config, isTrustedOrigin) {
|
|
440
|
+
const fields = [
|
|
441
|
+
["tokenEndpoint", config.tokenEndpoint],
|
|
442
|
+
["userInfoEndpoint", config.userInfoEndpoint],
|
|
443
|
+
["jwksEndpoint", config.jwksEndpoint]
|
|
444
|
+
];
|
|
445
|
+
for (const [name, url] of fields) {
|
|
446
|
+
if (!url) continue;
|
|
447
|
+
await assertOIDCEndpointAllowed(name, url, isTrustedOrigin);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
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
|
+
});
|
|
528
506
|
}
|
|
529
507
|
/**
|
|
530
508
|
* Fetch the OIDC discovery document from the IdP.
|
|
@@ -534,12 +512,12 @@ function validateSkipDiscoveryEndpoints(config, isTrustedOrigin) {
|
|
|
534
512
|
* @returns The parsed discovery document
|
|
535
513
|
* @throws DiscoveryError on network errors, timeouts, or invalid responses
|
|
536
514
|
*/
|
|
537
|
-
async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
|
|
515
|
+
async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT, isTrustedOrigin = () => false) {
|
|
538
516
|
try {
|
|
539
|
-
const response = await
|
|
517
|
+
const response = await fetchOIDCEndpoint("discoveryEndpoint", url, {
|
|
540
518
|
method: "GET",
|
|
541
519
|
timeout
|
|
542
|
-
});
|
|
520
|
+
}, isTrustedOrigin);
|
|
543
521
|
if (response.error) {
|
|
544
522
|
const { status } = response.error;
|
|
545
523
|
if (status === 404) throw new DiscoveryError("discovery_not_found", "Discovery endpoint not found", {
|
|
@@ -708,20 +686,24 @@ function needsRuntimeDiscovery(config) {
|
|
|
708
686
|
* Throws if discovery fails.
|
|
709
687
|
*/
|
|
710
688
|
async function ensureRuntimeDiscovery(config, issuer, isTrustedOrigin) {
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
689
|
+
let resolved = config;
|
|
690
|
+
if (needsRuntimeDiscovery(config)) {
|
|
691
|
+
const hydrated = await discoverOIDCConfig({
|
|
692
|
+
issuer,
|
|
693
|
+
existingConfig: config,
|
|
694
|
+
isTrustedOrigin
|
|
695
|
+
});
|
|
696
|
+
resolved = {
|
|
697
|
+
...config,
|
|
698
|
+
authorizationEndpoint: hydrated.authorizationEndpoint,
|
|
699
|
+
tokenEndpoint: hydrated.tokenEndpoint,
|
|
700
|
+
tokenEndpointAuthentication: hydrated.tokenEndpointAuthentication,
|
|
701
|
+
userInfoEndpoint: hydrated.userInfoEndpoint,
|
|
702
|
+
jwksEndpoint: hydrated.jwksEndpoint
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
await assertServerFetchedOIDCEndpointsAllowed(resolved, isTrustedOrigin);
|
|
706
|
+
return resolved;
|
|
725
707
|
}
|
|
726
708
|
//#endregion
|
|
727
709
|
//#region src/oidc/errors.ts
|
|
@@ -739,6 +721,7 @@ async function ensureRuntimeDiscovery(config, issuer, isTrustedOrigin) {
|
|
|
739
721
|
* - discovery_not_found → 400 BAD_REQUEST
|
|
740
722
|
* - discovery_untrusted_origin → 400 BAD_REQUEST
|
|
741
723
|
* - discovery_private_host → 400 BAD_REQUEST
|
|
724
|
+
* - oidc_endpoint_redirect → 400 BAD_REQUEST
|
|
742
725
|
* - discovery_invalid_json → 400 BAD_REQUEST
|
|
743
726
|
* - discovery_incomplete → 400 BAD_REQUEST
|
|
744
727
|
* - issuer_mismatch → 400 BAD_REQUEST
|
|
@@ -775,6 +758,10 @@ function mapDiscoveryErrorToAPIError(error) {
|
|
|
775
758
|
message: error.message,
|
|
776
759
|
code: error.code
|
|
777
760
|
});
|
|
761
|
+
case "oidc_endpoint_redirect": return new APIError("BAD_REQUEST", {
|
|
762
|
+
message: error.message,
|
|
763
|
+
code: error.code
|
|
764
|
+
});
|
|
778
765
|
case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
|
|
779
766
|
message: `OIDC discovery returned invalid data: ${error.message}`,
|
|
780
767
|
code: error.code
|
|
@@ -1125,11 +1112,10 @@ async function validateInResponseTo(c, ctx) {
|
|
|
1125
1112
|
const inResponseTo = ctx.extract.response?.inResponseTo;
|
|
1126
1113
|
const allowIdpInitiated = ctx.options.allowIdpInitiated ?? false;
|
|
1127
1114
|
if (inResponseTo) {
|
|
1115
|
+
const consumed = await c.context.internalAdapter.consumeVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
1128
1116
|
let storedRequest = null;
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
storedRequest = JSON.parse(verification.value);
|
|
1132
|
-
if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
|
|
1117
|
+
if (consumed) try {
|
|
1118
|
+
storedRequest = JSON.parse(consumed.value);
|
|
1133
1119
|
} catch {
|
|
1134
1120
|
storedRequest = null;
|
|
1135
1121
|
}
|
|
@@ -1146,10 +1132,8 @@ async function validateInResponseTo(c, ctx) {
|
|
|
1146
1132
|
expectedProvider: storedRequest.providerId,
|
|
1147
1133
|
actualProvider: ctx.providerId
|
|
1148
1134
|
});
|
|
1149
|
-
await c.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
1150
1135
|
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Provider mismatch"));
|
|
1151
1136
|
}
|
|
1152
|
-
await c.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
1153
1137
|
} else if (!allowIdpInitiated) {
|
|
1154
1138
|
c.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: ctx.providerId });
|
|
1155
1139
|
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "unsolicited_response", "IdP-initiated SSO not allowed"));
|
|
@@ -1185,6 +1169,30 @@ function validateAudience(c, ctx) {
|
|
|
1185
1169
|
}
|
|
1186
1170
|
//#endregion
|
|
1187
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
|
+
}
|
|
1188
1196
|
const oidcMappingSchema = z.object({
|
|
1189
1197
|
id: z.string().meta({ description: "Field mapping for user ID (defaults to 'sub')" }),
|
|
1190
1198
|
email: z.string().meta({ description: "Field mapping for email (defaults to 'email')" }),
|
|
@@ -1273,15 +1281,39 @@ const registerSSOProviderBodySchema = z.object({
|
|
|
1273
1281
|
organizationId: z.string().meta({ description: "If organization plugin is enabled, the organization id to link the provider to" }).optional(),
|
|
1274
1282
|
overrideUserInfo: z.boolean().meta({ description: "Override user info with the provider info. Defaults to false" }).default(false).optional()
|
|
1275
1283
|
});
|
|
1284
|
+
function getRegisterSSOProviderBodySchema(options) {
|
|
1285
|
+
return registerSSOProviderBodySchema.extend({ ...getSSOProviderAdditionalFieldsSchema(options).shape });
|
|
1286
|
+
}
|
|
1276
1287
|
const updateSSOProviderBodySchema = z.object({
|
|
1277
1288
|
issuer: z.string().url().optional(),
|
|
1278
1289
|
domain: z.string().optional(),
|
|
1279
1290
|
oidcConfig: oidcConfigSchema.partial().optional(),
|
|
1280
1291
|
samlConfig: samlConfigSchema.partial().optional()
|
|
1281
1292
|
});
|
|
1293
|
+
function getUpdateSSOProviderBodySchema(options) {
|
|
1294
|
+
return updateSSOProviderBodySchema.extend({
|
|
1295
|
+
providerId: z.string(),
|
|
1296
|
+
...getSSOProviderAdditionalFieldsSchema(options).partial().shape
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1282
1299
|
//#endregion
|
|
1283
1300
|
//#region src/routes/providers.ts
|
|
1284
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
|
+
}
|
|
1285
1317
|
function hasOrgAdminRole(member) {
|
|
1286
1318
|
return member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()));
|
|
1287
1319
|
}
|
|
@@ -1327,7 +1359,7 @@ async function batchCheckOrgAdmin(ctx, userId, organizationIds) {
|
|
|
1327
1359
|
for (const member of members) if (hasOrgAdminRole(member)) adminOrgIds.add(member.organizationId);
|
|
1328
1360
|
return adminOrgIds;
|
|
1329
1361
|
}
|
|
1330
|
-
function sanitizeProvider(provider, baseURL) {
|
|
1362
|
+
function sanitizeProvider(provider, baseURL, options) {
|
|
1331
1363
|
let oidcConfig = null;
|
|
1332
1364
|
let samlConfig = null;
|
|
1333
1365
|
try {
|
|
@@ -1342,6 +1374,7 @@ function sanitizeProvider(provider, baseURL) {
|
|
|
1342
1374
|
}
|
|
1343
1375
|
const type = samlConfig ? "saml" : "oidc";
|
|
1344
1376
|
return {
|
|
1377
|
+
...getReturnedSSOProviderAdditionalFields(provider, options),
|
|
1345
1378
|
providerId: provider.providerId,
|
|
1346
1379
|
type,
|
|
1347
1380
|
issuer: provider.issuer,
|
|
@@ -1372,7 +1405,7 @@ function sanitizeProvider(provider, baseURL) {
|
|
|
1372
1405
|
spMetadataUrl: `${baseURL}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(provider.providerId)}`
|
|
1373
1406
|
};
|
|
1374
1407
|
}
|
|
1375
|
-
const listSSOProviders = () => {
|
|
1408
|
+
const listSSOProviders = (options) => {
|
|
1376
1409
|
return createAuthEndpoint("/sso/providers", {
|
|
1377
1410
|
method: "GET",
|
|
1378
1411
|
use: [sessionMiddleware],
|
|
@@ -1397,7 +1430,7 @@ const listSSOProviders = () => {
|
|
|
1397
1430
|
const userOwnedOrgProviders = orgProviders.filter((p) => p.userId === userId);
|
|
1398
1431
|
accessibleProviders = [...accessibleProviders, ...userOwnedOrgProviders];
|
|
1399
1432
|
}
|
|
1400
|
-
const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL));
|
|
1433
|
+
const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL, options));
|
|
1401
1434
|
return ctx.json({ providers });
|
|
1402
1435
|
});
|
|
1403
1436
|
};
|
|
@@ -1419,7 +1452,7 @@ async function checkProviderAccess(ctx, providerId) {
|
|
|
1419
1452
|
if (!hasAccess) throw new APIError("FORBIDDEN", { message: "You don't have access to this provider" });
|
|
1420
1453
|
return provider;
|
|
1421
1454
|
}
|
|
1422
|
-
const getSSOProvider = () => {
|
|
1455
|
+
const getSSOProvider = (options) => {
|
|
1423
1456
|
return createAuthEndpoint("/sso/get-provider", {
|
|
1424
1457
|
method: "GET",
|
|
1425
1458
|
use: [sessionMiddleware],
|
|
@@ -1437,7 +1470,7 @@ const getSSOProvider = () => {
|
|
|
1437
1470
|
}, async (ctx) => {
|
|
1438
1471
|
const { providerId } = ctx.query;
|
|
1439
1472
|
const provider = await checkProviderAccess(ctx, providerId);
|
|
1440
|
-
return ctx.json(sanitizeProvider(provider, ctx.context.baseURL));
|
|
1473
|
+
return ctx.json(sanitizeProvider(provider, ctx.context.baseURL, options));
|
|
1441
1474
|
});
|
|
1442
1475
|
};
|
|
1443
1476
|
function parseAndValidateConfig(configString, configType) {
|
|
@@ -1489,10 +1522,11 @@ function mergeOIDCConfig(current, updates, issuer) {
|
|
|
1489
1522
|
};
|
|
1490
1523
|
}
|
|
1491
1524
|
const updateSSOProvider = (options) => {
|
|
1525
|
+
const updateBodySchema = getUpdateSSOProviderBodySchema(options);
|
|
1492
1526
|
return createAuthEndpoint("/sso/update-provider", {
|
|
1493
1527
|
method: "POST",
|
|
1494
1528
|
use: [sessionMiddleware],
|
|
1495
|
-
body:
|
|
1529
|
+
body: updateBodySchema,
|
|
1496
1530
|
metadata: { openapi: {
|
|
1497
1531
|
operationId: "updateSSOProvider",
|
|
1498
1532
|
summary: "Update SSO provider",
|
|
@@ -1506,9 +1540,10 @@ const updateSSOProvider = (options) => {
|
|
|
1506
1540
|
}, async (ctx) => {
|
|
1507
1541
|
const { providerId, ...body } = ctx.body;
|
|
1508
1542
|
const { issuer, domain, samlConfig, oidcConfig } = body;
|
|
1509
|
-
|
|
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" });
|
|
1510
1545
|
const existingProvider = await checkProviderAccess(ctx, providerId);
|
|
1511
|
-
const updateData = {};
|
|
1546
|
+
const updateData = { ...additionalFields };
|
|
1512
1547
|
if (body.issuer !== void 0) updateData.issuer = body.issuer;
|
|
1513
1548
|
if (body.domain !== void 0) {
|
|
1514
1549
|
updateData.domain = body.domain;
|
|
@@ -1530,7 +1565,7 @@ const updateSSOProvider = (options) => {
|
|
|
1530
1565
|
}
|
|
1531
1566
|
if (body.oidcConfig) {
|
|
1532
1567
|
try {
|
|
1533
|
-
|
|
1568
|
+
validateOIDCEndpointUrls(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
|
|
1534
1569
|
} catch (error) {
|
|
1535
1570
|
if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
|
|
1536
1571
|
throw error;
|
|
@@ -1557,7 +1592,7 @@ const updateSSOProvider = (options) => {
|
|
|
1557
1592
|
}]
|
|
1558
1593
|
});
|
|
1559
1594
|
if (!fullProvider) throw new APIError("NOT_FOUND", { message: "Provider not found after update" });
|
|
1560
|
-
return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL));
|
|
1595
|
+
return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL, options));
|
|
1561
1596
|
});
|
|
1562
1597
|
};
|
|
1563
1598
|
const deleteSSOProvider = () => {
|
|
@@ -1589,15 +1624,126 @@ const deleteSSOProvider = () => {
|
|
|
1589
1624
|
});
|
|
1590
1625
|
};
|
|
1591
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
|
|
1592
1740
|
//#region src/saml-state.ts
|
|
1593
|
-
async function generateRelayState(c, link
|
|
1741
|
+
async function generateRelayState(c, link) {
|
|
1594
1742
|
const callbackURL = c.body.callbackURL;
|
|
1595
1743
|
if (!callbackURL) throw new APIError("BAD_REQUEST", { message: "callbackURL is required" });
|
|
1596
|
-
const codeVerifier = generateRandomString(128);
|
|
1597
1744
|
const stateData = {
|
|
1598
|
-
...additionalData ? additionalData : {},
|
|
1599
1745
|
callbackURL,
|
|
1600
|
-
codeVerifier,
|
|
1746
|
+
codeVerifier: generateRandomString(128),
|
|
1601
1747
|
errorURL: c.body.errorCallbackURL,
|
|
1602
1748
|
newUserURL: c.body.newUserCallbackURL,
|
|
1603
1749
|
link,
|
|
@@ -1699,7 +1845,8 @@ function createSP(config, baseURL, providerId, opts) {
|
|
|
1699
1845
|
isAssertionEncrypted: spData?.isAssertionEncrypted || false,
|
|
1700
1846
|
encPrivateKey: normalizePem(spData?.encPrivateKey),
|
|
1701
1847
|
encPrivateKeyPass: spData?.encPrivateKeyPass,
|
|
1702
|
-
relayState: opts?.relayState
|
|
1848
|
+
relayState: opts?.relayState,
|
|
1849
|
+
clockDrifts: opts?.clockSkew && opts?.clockSkew !== 0 ? [-opts.clockSkew, opts.clockSkew] : void 0
|
|
1703
1850
|
});
|
|
1704
1851
|
}
|
|
1705
1852
|
function createIdP(config) {
|
|
@@ -1855,7 +2002,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1855
2002
|
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
1856
2003
|
const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
|
|
1857
2004
|
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
1858
|
-
const sp = createSP(parsedSamlConfig, ctx.context.baseURL, providerId);
|
|
2005
|
+
const sp = createSP(parsedSamlConfig, ctx.context.baseURL, providerId, { clockSkew: options?.saml?.clockSkew });
|
|
1859
2006
|
const idp = createIdP(parsedSamlConfig);
|
|
1860
2007
|
const samlRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL, params.currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
1861
2008
|
validateSingleAssertion(SAMLResponse);
|
|
@@ -1905,26 +2052,8 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1905
2052
|
const conditions = extract.conditions;
|
|
1906
2053
|
const clockSkew = options?.saml?.clockSkew ?? 3e5;
|
|
1907
2054
|
const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
if (existingAssertion) try {
|
|
1911
|
-
if (JSON.parse(existingAssertion.value).expiresAt >= Date.now()) isReplay = true;
|
|
1912
|
-
} catch (error) {
|
|
1913
|
-
ctx.context.logger.warn("Failed to parse stored assertion record", {
|
|
1914
|
-
assertionId,
|
|
1915
|
-
error
|
|
1916
|
-
});
|
|
1917
|
-
}
|
|
1918
|
-
if (isReplay) {
|
|
1919
|
-
ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
|
|
1920
|
-
assertionId,
|
|
1921
|
-
issuer,
|
|
1922
|
-
providerId
|
|
1923
|
-
});
|
|
1924
|
-
throw ctx.redirect(`${samlRedirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
|
|
1925
|
-
}
|
|
1926
|
-
await ctx.context.internalAdapter.createVerificationValue({
|
|
1927
|
-
identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
|
|
2055
|
+
if (!await ctx.context.internalAdapter.reserveVerificationValue({
|
|
2056
|
+
identifier: `saml-used-assertion:${assertionId}`,
|
|
1928
2057
|
value: JSON.stringify({
|
|
1929
2058
|
assertionId,
|
|
1930
2059
|
issuer,
|
|
@@ -1933,7 +2062,14 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1933
2062
|
expiresAt
|
|
1934
2063
|
}),
|
|
1935
2064
|
expiresAt: new Date(expiresAt)
|
|
1936
|
-
})
|
|
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
|
+
}
|
|
1937
2073
|
} else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
|
|
1938
2074
|
const attributes = extract.attributes || {};
|
|
1939
2075
|
const mapping = parsedSamlConfig.mapping ?? {};
|
|
@@ -1946,7 +2082,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1946
2082
|
id: attr(mapping.id || "nameID") || extract.nameID,
|
|
1947
2083
|
email: (attr(mapping.email || "email") || extract.nameID || "").toLowerCase(),
|
|
1948
2084
|
name: [attr(mapping.firstName || "givenName"), attr(mapping.lastName || "surname")].filter(Boolean).join(" ") || attr(mapping.name || "displayName") || extract.nameID,
|
|
1949
|
-
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attr(mapping.emailVerified)
|
|
2085
|
+
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? parseProviderEmailVerified(attr(mapping.emailVerified)) : false
|
|
1950
2086
|
};
|
|
1951
2087
|
if (!userInfo.id || !userInfo.email) {
|
|
1952
2088
|
ctx.context.logger.error("Missing essential user info from SAML response", {
|
|
@@ -1957,27 +2093,32 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1957
2093
|
});
|
|
1958
2094
|
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
1959
2095
|
}
|
|
1960
|
-
const isTrustedProvider =
|
|
2096
|
+
const isTrustedProvider = "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
1961
2097
|
const postAuthRedirect = relayState?.callbackURL || ctx.context.baseURL;
|
|
1962
2098
|
const errorUrl = relayState?.errorURL || samlRedirectUrl;
|
|
1963
2099
|
let result;
|
|
1964
2100
|
try {
|
|
1965
|
-
result = await
|
|
2101
|
+
result = await signInWithOAuthIdentity(ctx, {
|
|
1966
2102
|
userInfo: {
|
|
1967
2103
|
email: userInfo.email,
|
|
1968
2104
|
name: userInfo.name || userInfo.email,
|
|
1969
2105
|
id: userInfo.id,
|
|
1970
|
-
emailVerified:
|
|
1971
|
-
},
|
|
1972
|
-
account: {
|
|
1973
|
-
providerId,
|
|
1974
|
-
accountId: userInfo.id,
|
|
1975
|
-
accessToken: "",
|
|
1976
|
-
refreshToken: ""
|
|
2106
|
+
emailVerified: userInfo.emailVerified
|
|
1977
2107
|
},
|
|
2108
|
+
providerId,
|
|
2109
|
+
accountId: userInfo.id,
|
|
2110
|
+
tokens: {},
|
|
1978
2111
|
callbackURL: postAuthRedirect,
|
|
1979
2112
|
disableSignUp: options?.disableImplicitSignUp,
|
|
1980
|
-
|
|
2113
|
+
source: {
|
|
2114
|
+
method: "sso-saml",
|
|
2115
|
+
sso: {
|
|
2116
|
+
providerId,
|
|
2117
|
+
profile: attributes
|
|
2118
|
+
}
|
|
2119
|
+
},
|
|
2120
|
+
isTrustedProvider,
|
|
2121
|
+
trustProviderByName: false
|
|
1981
2122
|
});
|
|
1982
2123
|
} catch (e) {
|
|
1983
2124
|
if (isAPIError(e) && e.body?.code) {
|
|
@@ -2002,7 +2143,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2002
2143
|
providerId,
|
|
2003
2144
|
accountId: userInfo.id,
|
|
2004
2145
|
email: userInfo.email,
|
|
2005
|
-
emailVerified:
|
|
2146
|
+
emailVerified: userInfo.emailVerified,
|
|
2006
2147
|
rawAttributes: attributes
|
|
2007
2148
|
},
|
|
2008
2149
|
provider,
|
|
@@ -2075,174 +2216,177 @@ const spMetadata = (options) => {
|
|
|
2075
2216
|
const registerSSOProvider = (options) => {
|
|
2076
2217
|
return createAuthEndpoint("/sso/register", {
|
|
2077
2218
|
method: "POST",
|
|
2078
|
-
body:
|
|
2219
|
+
body: getRegisterSSOProviderBodySchema(options),
|
|
2079
2220
|
use: [sessionMiddleware],
|
|
2080
|
-
metadata: {
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
description: "OIDC provider
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
type: "array",
|
|
2145
|
-
items: { type: "string" },
|
|
2146
|
-
nullable: true,
|
|
2147
|
-
description: "The scopes requested from the provider"
|
|
2148
|
-
},
|
|
2149
|
-
tokenEndpoint: {
|
|
2150
|
-
type: "string",
|
|
2151
|
-
format: "uri",
|
|
2152
|
-
nullable: true,
|
|
2153
|
-
description: "The token endpoint URL"
|
|
2154
|
-
},
|
|
2155
|
-
tokenEndpointAuthentication: {
|
|
2156
|
-
type: "string",
|
|
2157
|
-
enum: ["client_secret_post", "client_secret_basic"],
|
|
2158
|
-
nullable: true,
|
|
2159
|
-
description: "Authentication method for the token endpoint"
|
|
2160
|
-
},
|
|
2161
|
-
jwksEndpoint: {
|
|
2162
|
-
type: "string",
|
|
2163
|
-
format: "uri",
|
|
2164
|
-
nullable: true,
|
|
2165
|
-
description: "The JWKS endpoint URL"
|
|
2166
|
-
},
|
|
2167
|
-
mapping: {
|
|
2168
|
-
type: "object",
|
|
2169
|
-
nullable: true,
|
|
2170
|
-
properties: {
|
|
2171
|
-
id: {
|
|
2172
|
-
type: "string",
|
|
2173
|
-
description: "Field mapping for user ID (defaults to 'sub')"
|
|
2174
|
-
},
|
|
2175
|
-
email: {
|
|
2176
|
-
type: "string",
|
|
2177
|
-
description: "Field mapping for email (defaults to 'email')"
|
|
2178
|
-
},
|
|
2179
|
-
emailVerified: {
|
|
2180
|
-
type: "string",
|
|
2181
|
-
nullable: true,
|
|
2182
|
-
description: "Field mapping for email verification (defaults to 'email_verified')"
|
|
2183
|
-
},
|
|
2184
|
-
name: {
|
|
2185
|
-
type: "string",
|
|
2186
|
-
description: "Field mapping for name (defaults to 'name')"
|
|
2187
|
-
},
|
|
2188
|
-
image: {
|
|
2189
|
-
type: "string",
|
|
2190
|
-
nullable: true,
|
|
2191
|
-
description: "Field mapping for image (defaults to 'picture')"
|
|
2192
|
-
},
|
|
2193
|
-
extraFields: {
|
|
2194
|
-
type: "object",
|
|
2195
|
-
additionalProperties: { type: "string" },
|
|
2196
|
-
nullable: true,
|
|
2197
|
-
description: "Additional field mappings"
|
|
2198
|
-
}
|
|
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"
|
|
2199
2285
|
},
|
|
2200
|
-
|
|
2201
|
-
"
|
|
2202
|
-
"
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
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"
|
|
2297
|
+
},
|
|
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"
|
|
2206
2358
|
},
|
|
2207
|
-
|
|
2208
|
-
"
|
|
2209
|
-
|
|
2210
|
-
"
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
providerId: {
|
|
2226
|
-
type: "string",
|
|
2227
|
-
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
|
+
}
|
|
2228
2377
|
},
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
"redirectURI"
|
|
2242
|
-
]
|
|
2243
|
-
} } }
|
|
2244
|
-
} }
|
|
2245
|
-
} }
|
|
2378
|
+
required: [
|
|
2379
|
+
"issuer",
|
|
2380
|
+
"domain",
|
|
2381
|
+
"oidcConfig",
|
|
2382
|
+
"userId",
|
|
2383
|
+
"providerId",
|
|
2384
|
+
"redirectURI"
|
|
2385
|
+
]
|
|
2386
|
+
} } }
|
|
2387
|
+
} }
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2246
2390
|
}, async (ctx) => {
|
|
2247
2391
|
const user = ctx.context.session?.user;
|
|
2248
2392
|
if (!user) throw new APIError("UNAUTHORIZED");
|
|
@@ -2256,6 +2400,7 @@ const registerSSOProvider = (options) => {
|
|
|
2256
2400
|
}]
|
|
2257
2401
|
})).length >= limit) throw new APIError("FORBIDDEN", { message: "You have reached the maximum number of SSO providers" });
|
|
2258
2402
|
const body = ctx.body;
|
|
2403
|
+
const additionalFields = parseSSOProviderAdditionalFields(options, body, "create");
|
|
2259
2404
|
if (body.samlConfig?.idpMetadata?.metadata) {
|
|
2260
2405
|
const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
|
|
2261
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)` });
|
|
@@ -2274,6 +2419,14 @@ const registerSSOProvider = (options) => {
|
|
|
2274
2419
|
if (!member) throw new APIError("BAD_REQUEST", { message: "You are not a member of the organization" });
|
|
2275
2420
|
if (ctx.context.hasPlugin("organization") && !hasOrgAdminRole(member)) throw new APIError("FORBIDDEN", { message: "You must be an organization owner or admin to register SSO providers" });
|
|
2276
2421
|
}
|
|
2422
|
+
if (new Set([
|
|
2423
|
+
"credential",
|
|
2424
|
+
...ctx.context.socialProviders.map((p) => p.id),
|
|
2425
|
+
...ctx.context.trustedProviders
|
|
2426
|
+
]).has(body.providerId)) {
|
|
2427
|
+
ctx.context.logger.warn(`SSO provider registration rejected for reserved providerId: ${body.providerId}`);
|
|
2428
|
+
throw new APIError("UNPROCESSABLE_ENTITY", { message: "This providerId is reserved and cannot be used for an SSO provider" });
|
|
2429
|
+
}
|
|
2277
2430
|
if (await ctx.context.adapter.findOne({
|
|
2278
2431
|
model: "ssoProvider",
|
|
2279
2432
|
where: [{
|
|
@@ -2285,7 +2438,7 @@ const registerSSOProvider = (options) => {
|
|
|
2285
2438
|
throw new APIError("UNPROCESSABLE_ENTITY", { message: "SSO provider with this providerId already exists" });
|
|
2286
2439
|
}
|
|
2287
2440
|
if (body.oidcConfig) try {
|
|
2288
|
-
|
|
2441
|
+
validateOIDCEndpointUrls(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
|
|
2289
2442
|
} catch (error) {
|
|
2290
2443
|
if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
|
|
2291
2444
|
throw error;
|
|
@@ -2367,6 +2520,7 @@ const registerSSOProvider = (options) => {
|
|
|
2367
2520
|
issuer: body.issuer,
|
|
2368
2521
|
domain: body.domain,
|
|
2369
2522
|
domainVerified: false,
|
|
2523
|
+
...additionalFields,
|
|
2370
2524
|
oidcConfig: (() => {
|
|
2371
2525
|
const config = buildOIDCConfig();
|
|
2372
2526
|
if (config) {
|
|
@@ -2408,7 +2562,7 @@ const registerSSOProvider = (options) => {
|
|
|
2408
2562
|
});
|
|
2409
2563
|
}
|
|
2410
2564
|
const result = {
|
|
2411
|
-
...provider,
|
|
2565
|
+
...filterSSOProviderAdditionalFields(provider, options),
|
|
2412
2566
|
oidcConfig: safeJsonParse(provider.oidcConfig),
|
|
2413
2567
|
samlConfig: safeJsonParse(provider.samlConfig),
|
|
2414
2568
|
redirectURI: getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options),
|
|
@@ -2589,9 +2743,16 @@ const signInSSO = (options) => {
|
|
|
2589
2743
|
throw error;
|
|
2590
2744
|
}
|
|
2591
2745
|
if (!config.authorizationEndpoint) throw new APIError("BAD_REQUEST", { message: "Invalid OIDC configuration. Authorization URL not found." });
|
|
2592
|
-
const
|
|
2746
|
+
const requestedScopes = ctx.body.scopes || config.scopes || [
|
|
2747
|
+
"openid",
|
|
2748
|
+
"email",
|
|
2749
|
+
"profile",
|
|
2750
|
+
"offline_access"
|
|
2751
|
+
];
|
|
2752
|
+
if (options?.redirectURI?.trim()) await addOAuthServerContext({ ssoProviderId: provider.providerId });
|
|
2753
|
+
const state = await generateState(ctx, { requestedScopes });
|
|
2593
2754
|
const redirectURI = getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options);
|
|
2594
|
-
const authorizationURL = await createAuthorizationURL({
|
|
2755
|
+
const { url: authorizationURL } = await createAuthorizationURL({
|
|
2595
2756
|
id: provider.issuer,
|
|
2596
2757
|
options: {
|
|
2597
2758
|
clientId: config.clientId,
|
|
@@ -2600,12 +2761,7 @@ const signInSSO = (options) => {
|
|
|
2600
2761
|
redirectURI,
|
|
2601
2762
|
state: state.state,
|
|
2602
2763
|
codeVerifier: config.pkce ? state.codeVerifier : void 0,
|
|
2603
|
-
scopes:
|
|
2604
|
-
"openid",
|
|
2605
|
-
"email",
|
|
2606
|
-
"profile",
|
|
2607
|
-
"offline_access"
|
|
2608
|
-
],
|
|
2764
|
+
scopes: requestedScopes,
|
|
2609
2765
|
loginHint: ctx.body.loginHint || email,
|
|
2610
2766
|
authorizationEndpoint: config.authorizationEndpoint,
|
|
2611
2767
|
additionalParams: ctx.body.additionalParams
|
|
@@ -2620,7 +2776,7 @@ const signInSSO = (options) => {
|
|
|
2620
2776
|
const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
|
|
2621
2777
|
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
2622
2778
|
if (parsedSamlConfig.authnRequestsSigned && !parsedSamlConfig.spMetadata?.privateKey && !parsedSamlConfig.privateKey) throw new APIError("BAD_REQUEST", { message: "authnRequestsSigned is enabled but no privateKey provided in spMetadata or samlConfig" });
|
|
2623
|
-
const { state: relayState } = await generateRelayState(ctx, void 0
|
|
2779
|
+
const { state: relayState } = await generateRelayState(ctx, void 0);
|
|
2624
2780
|
const sp = createSP(parsedSamlConfig, ctx.context.baseURL, provider.providerId, { relayState });
|
|
2625
2781
|
const idp = createIdP(parsedSamlConfig);
|
|
2626
2782
|
const loginRequest = sp.createLoginRequest(idp, "redirect");
|
|
@@ -2653,6 +2809,22 @@ const callbackSSOQuerySchema = z.object({
|
|
|
2653
2809
|
error: z.string().optional(),
|
|
2654
2810
|
error_description: z.string().optional()
|
|
2655
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
|
+
}
|
|
2656
2828
|
/**
|
|
2657
2829
|
* Core OIDC callback handler logic, shared between the per-provider and
|
|
2658
2830
|
* shared callback endpoints. Resolves the provider, exchanges the
|
|
@@ -2668,8 +2840,17 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2668
2840
|
const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
|
|
2669
2841
|
throw ctx.redirect(`${errorURL}?error=invalid_state`);
|
|
2670
2842
|
}
|
|
2671
|
-
const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
|
|
2672
|
-
|
|
2843
|
+
const { callbackURL, errorURL, newUserURL, requestSignUp, requestedScopes } = stateData;
|
|
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"));
|
|
2673
2854
|
const provider = await resolveOIDCProvider(ctx, options, providerId);
|
|
2674
2855
|
if (!provider) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
|
|
2675
2856
|
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
@@ -2691,6 +2872,7 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2691
2872
|
]
|
|
2692
2873
|
};
|
|
2693
2874
|
if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_endpoint_not_found`);
|
|
2875
|
+
const tokenEndpoint = config.tokenEndpoint;
|
|
2694
2876
|
let tokenEndpointAuth = config.tokenEndpointAuthentication === "client_secret_post" ? { method: "client_secret_post" } : { method: "client_secret_basic" };
|
|
2695
2877
|
if (config.tokenEndpointAuthentication === "private_key_jwt") {
|
|
2696
2878
|
let resolved;
|
|
@@ -2716,40 +2898,58 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2716
2898
|
}
|
|
2717
2899
|
const tokenRequestOptions = { clientId: config.clientId };
|
|
2718
2900
|
if (tokenEndpointAuth.method !== "private_key_jwt") tokenRequestOptions.clientSecret = config.clientSecret;
|
|
2719
|
-
const tokenResponse = await
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
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;
|
|
2727
2920
|
ctx.context.logger.error("Error validating authorization code", e);
|
|
2728
|
-
if (e instanceof
|
|
2729
|
-
|
|
2921
|
+
if (e instanceof DiscoveryError) redirectOIDCError("invalid_provider", e.message);
|
|
2922
|
+
redirectOIDCError("invalid_provider", getOIDCErrorDescription(e, "token_response_error"));
|
|
2730
2923
|
});
|
|
2731
2924
|
if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_response_not_found`);
|
|
2732
2925
|
let userInfo = null;
|
|
2733
2926
|
const mapping = config.mapping || {};
|
|
2927
|
+
let rawProfile;
|
|
2734
2928
|
if (config.userInfoEndpoint) {
|
|
2735
|
-
const userInfoResponse = await
|
|
2736
|
-
|
|
2737
|
-
|
|
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;
|
|
2932
|
+
});
|
|
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");
|
|
2935
|
+
rawProfile = rawUserInfo;
|
|
2738
2936
|
userInfo = {
|
|
2739
2937
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, rawUserInfo[value]])),
|
|
2740
2938
|
id: rawUserInfo[mapping.id || "sub"],
|
|
2741
2939
|
email: rawUserInfo[mapping.email || "email"],
|
|
2742
|
-
emailVerified: options?.trustEmailVerified ? rawUserInfo[mapping.emailVerified || "email_verified"] : false,
|
|
2940
|
+
emailVerified: options?.trustEmailVerified ? parseProviderEmailVerified(rawUserInfo[mapping.emailVerified || "email_verified"]) : false,
|
|
2743
2941
|
name: rawUserInfo[mapping.name || "name"],
|
|
2744
2942
|
image: rawUserInfo[mapping.image || "picture"]
|
|
2745
2943
|
};
|
|
2746
2944
|
} else if (tokenResponse.idToken) {
|
|
2747
2945
|
const idToken = decodeJwt(tokenResponse.idToken);
|
|
2946
|
+
rawProfile = idToken;
|
|
2748
2947
|
if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=jwks_endpoint_not_found`);
|
|
2749
|
-
const verified = await
|
|
2948
|
+
const verified = await validateOIDCIdToken(tokenResponse.idToken, config.jwksEndpoint, {
|
|
2750
2949
|
audience: config.clientId,
|
|
2751
2950
|
issuer: provider.issuer
|
|
2752
|
-
}).catch((e) => {
|
|
2951
|
+
}, (url) => ctx.context.isTrustedOrigin(url)).catch((e) => {
|
|
2952
|
+
if (e instanceof DiscoveryError) redirectOIDCError("invalid_provider", e.message);
|
|
2753
2953
|
ctx.context.logger.error(e);
|
|
2754
2954
|
return null;
|
|
2755
2955
|
});
|
|
@@ -2758,7 +2958,7 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2758
2958
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, verified.payload[value]])),
|
|
2759
2959
|
id: idToken[mapping.id || "sub"],
|
|
2760
2960
|
email: idToken[mapping.email || "email"],
|
|
2761
|
-
emailVerified: options?.trustEmailVerified ? idToken[mapping.emailVerified || "email_verified"] : false,
|
|
2961
|
+
emailVerified: options?.trustEmailVerified ? parseProviderEmailVerified(idToken[mapping.emailVerified || "email_verified"]) : false,
|
|
2762
2962
|
name: idToken[mapping.name || "name"],
|
|
2763
2963
|
image: idToken[mapping.image || "picture"]
|
|
2764
2964
|
};
|
|
@@ -2767,7 +2967,7 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2767
2967
|
const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfo.email, provider.domain);
|
|
2768
2968
|
let linked;
|
|
2769
2969
|
try {
|
|
2770
|
-
linked = await
|
|
2970
|
+
linked = await signInWithOAuthIdentity(ctx, {
|
|
2771
2971
|
userInfo: {
|
|
2772
2972
|
email: userInfo.email,
|
|
2773
2973
|
name: userInfo.name || "",
|
|
@@ -2775,20 +2975,22 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2775
2975
|
image: userInfo.image,
|
|
2776
2976
|
emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
|
|
2777
2977
|
},
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
accountId: userInfo.id,
|
|
2783
|
-
providerId: provider.providerId,
|
|
2784
|
-
accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
|
|
2785
|
-
refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
|
|
2786
|
-
scope: tokenResponse.scopes?.join(",")
|
|
2787
|
-
},
|
|
2978
|
+
providerId: provider.providerId,
|
|
2979
|
+
accountId: userInfo.id,
|
|
2980
|
+
tokens: tokenResponse,
|
|
2981
|
+
requestedScopes,
|
|
2788
2982
|
callbackURL,
|
|
2789
2983
|
disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
|
|
2790
2984
|
overrideUserInfo: config.overrideUserInfo,
|
|
2791
|
-
|
|
2985
|
+
source: {
|
|
2986
|
+
method: "sso-oidc",
|
|
2987
|
+
sso: {
|
|
2988
|
+
providerId: provider.providerId,
|
|
2989
|
+
profile: rawProfile
|
|
2990
|
+
}
|
|
2991
|
+
},
|
|
2992
|
+
isTrustedProvider,
|
|
2993
|
+
trustProviderByName: false
|
|
2792
2994
|
});
|
|
2793
2995
|
} catch (e) {
|
|
2794
2996
|
if (isAPIError(e) && e.body?.code) {
|
|
@@ -2906,9 +3108,15 @@ async function bounceIfIdpInitiated(ctx, options, providerId) {
|
|
|
2906
3108
|
});
|
|
2907
3109
|
return;
|
|
2908
3110
|
}
|
|
2909
|
-
|
|
3111
|
+
if (options?.redirectURI?.trim()) await addOAuthServerContext({ ssoProviderId: provider.providerId });
|
|
3112
|
+
const state = await generateState(ctx, { requestedScopes: config.scopes || [
|
|
3113
|
+
"openid",
|
|
3114
|
+
"email",
|
|
3115
|
+
"profile",
|
|
3116
|
+
"offline_access"
|
|
3117
|
+
] });
|
|
2910
3118
|
const redirectURI = getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options);
|
|
2911
|
-
const authorizationURL = await createAuthorizationURL({
|
|
3119
|
+
const { url: authorizationURL } = await createAuthorizationURL({
|
|
2912
3120
|
id: provider.issuer,
|
|
2913
3121
|
options: {
|
|
2914
3122
|
clientId: config.clientId,
|
|
@@ -2957,7 +3165,7 @@ const callbackSSOShared = (options) => {
|
|
|
2957
3165
|
const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
|
|
2958
3166
|
throw ctx.redirect(`${errorURL}?error=invalid_state`);
|
|
2959
3167
|
}
|
|
2960
|
-
const providerId = stateData.ssoProviderId;
|
|
3168
|
+
const providerId = stateData.serverContext?.ssoProviderId;
|
|
2961
3169
|
if (!providerId) {
|
|
2962
3170
|
const errorURL = stateData.errorURL || stateData.callbackURL;
|
|
2963
3171
|
throw ctx.redirect(`${errorURL}?error=invalid_state&error_description=missing_provider_id`);
|
|
@@ -3188,7 +3396,42 @@ saml.setSchemaValidator({ async validate(xml) {
|
|
|
3188
3396
|
* which won't have a matching Origin header.
|
|
3189
3397
|
*/
|
|
3190
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
|
+
}
|
|
3191
3433
|
function sso(options) {
|
|
3434
|
+
assertNoAdditionalFieldCollisions(options);
|
|
3192
3435
|
const optionsWithStore = options;
|
|
3193
3436
|
let endpoints = {
|
|
3194
3437
|
spMetadata: spMetadata(optionsWithStore),
|
|
@@ -3199,8 +3442,8 @@ function sso(options) {
|
|
|
3199
3442
|
acsEndpoint: acsEndpoint(optionsWithStore),
|
|
3200
3443
|
sloEndpoint: sloEndpoint(optionsWithStore),
|
|
3201
3444
|
initiateSLO: initiateSLO(optionsWithStore),
|
|
3202
|
-
listSSOProviders: listSSOProviders(),
|
|
3203
|
-
getSSOProvider: getSSOProvider(),
|
|
3445
|
+
listSSOProviders: listSSOProviders(optionsWithStore),
|
|
3446
|
+
getSSOProvider: getSSOProvider(optionsWithStore),
|
|
3204
3447
|
updateSSOProvider: updateSSOProvider(optionsWithStore),
|
|
3205
3448
|
deleteSSOProvider: deleteSSOProvider()
|
|
3206
3449
|
};
|
|
@@ -3257,22 +3500,22 @@ function sso(options) {
|
|
|
3257
3500
|
}]
|
|
3258
3501
|
},
|
|
3259
3502
|
schema: { ssoProvider: {
|
|
3260
|
-
modelName: options?.modelName ?? "ssoProvider",
|
|
3503
|
+
modelName: options?.modelName ?? options?.schema?.ssoProvider?.modelName ?? "ssoProvider",
|
|
3261
3504
|
fields: {
|
|
3262
3505
|
issuer: {
|
|
3263
3506
|
type: "string",
|
|
3264
3507
|
required: true,
|
|
3265
|
-
fieldName: options?.fields?.issuer ?? "issuer"
|
|
3508
|
+
fieldName: options?.fields?.issuer ?? options?.schema?.ssoProvider?.fields?.issuer ?? "issuer"
|
|
3266
3509
|
},
|
|
3267
3510
|
oidcConfig: {
|
|
3268
3511
|
type: "string",
|
|
3269
3512
|
required: false,
|
|
3270
|
-
fieldName: options?.fields?.oidcConfig ?? "oidcConfig"
|
|
3513
|
+
fieldName: options?.fields?.oidcConfig ?? options?.schema?.ssoProvider?.fields?.oidcConfig ?? "oidcConfig"
|
|
3271
3514
|
},
|
|
3272
3515
|
samlConfig: {
|
|
3273
3516
|
type: "string",
|
|
3274
3517
|
required: false,
|
|
3275
|
-
fieldName: options?.fields?.samlConfig ?? "samlConfig"
|
|
3518
|
+
fieldName: options?.fields?.samlConfig ?? options?.schema?.ssoProvider?.fields?.samlConfig ?? "samlConfig"
|
|
3276
3519
|
},
|
|
3277
3520
|
userId: {
|
|
3278
3521
|
type: "string",
|
|
@@ -3280,30 +3523,33 @@ function sso(options) {
|
|
|
3280
3523
|
model: "user",
|
|
3281
3524
|
field: "id"
|
|
3282
3525
|
},
|
|
3283
|
-
fieldName: options?.fields?.userId ?? "userId"
|
|
3526
|
+
fieldName: options?.fields?.userId ?? options?.schema?.ssoProvider?.fields?.userId ?? "userId"
|
|
3284
3527
|
},
|
|
3285
3528
|
providerId: {
|
|
3286
3529
|
type: "string",
|
|
3287
3530
|
required: true,
|
|
3288
3531
|
unique: true,
|
|
3289
|
-
fieldName: options?.fields?.providerId ?? "providerId"
|
|
3532
|
+
fieldName: options?.fields?.providerId ?? options?.schema?.ssoProvider?.fields?.providerId ?? "providerId"
|
|
3290
3533
|
},
|
|
3291
3534
|
organizationId: {
|
|
3292
3535
|
type: "string",
|
|
3293
3536
|
required: false,
|
|
3294
|
-
fieldName: options?.fields?.organizationId ?? "organizationId"
|
|
3537
|
+
fieldName: options?.fields?.organizationId ?? options?.schema?.ssoProvider?.fields?.organizationId ?? "organizationId"
|
|
3295
3538
|
},
|
|
3296
3539
|
domain: {
|
|
3297
3540
|
type: "string",
|
|
3298
3541
|
required: true,
|
|
3299
|
-
fieldName: options?.fields?.domain ?? "domain"
|
|
3542
|
+
fieldName: options?.fields?.domain ?? options?.schema?.ssoProvider?.fields?.domain ?? "domain"
|
|
3300
3543
|
},
|
|
3301
3544
|
...options?.domainVerification?.enabled ? { domainVerified: {
|
|
3302
3545
|
type: "boolean",
|
|
3303
|
-
required: false
|
|
3304
|
-
|
|
3546
|
+
required: false,
|
|
3547
|
+
fieldName: options?.schema?.ssoProvider?.fields?.domainVerified ?? "domainVerified"
|
|
3548
|
+
} } : {},
|
|
3549
|
+
...options?.schema?.ssoProvider?.additionalFields ?? {}
|
|
3305
3550
|
}
|
|
3306
3551
|
} },
|
|
3552
|
+
$Infer: { SSOProvider: {} },
|
|
3307
3553
|
options
|
|
3308
3554
|
};
|
|
3309
3555
|
}
|