@better-auth/sso 1.7.0-beta.0 → 1.7.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.d.mts +10 -1
- package/dist/client.mjs +1 -1
- package/dist/{index-DVg_iWRX.d.mts → index-A_DJ_AqL.d.mts} +208 -195
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +2420 -1730
- package/dist/{version-CzfTSPRz.mjs → version-C-W6yUfI.mjs} +1 -1
- package/package.json +14 -14
package/dist/index.mjs
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
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-C-W6yUfI.mjs";
|
|
2
|
+
import { APIError, addOAuthServerContext, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
|
|
3
3
|
import { XMLParser, XMLValidator } from "fast-xml-parser";
|
|
4
|
-
import * as saml from "samlify";
|
|
5
4
|
import { X509Certificate } from "node:crypto";
|
|
6
5
|
import { getHostname } from "tldts";
|
|
7
6
|
import { generateRandomString } from "better-auth/crypto";
|
|
8
7
|
import * as z from "zod";
|
|
8
|
+
import { getCurrentAdapter, runWithTransaction } from "@better-auth/core/context";
|
|
9
|
+
import { filterOutputFields } from "@better-auth/core/utils/db";
|
|
10
|
+
import { classifyHost, isPublicRoutableHost } from "@better-auth/core/utils/host";
|
|
11
|
+
import { betterFetch } from "@better-fetch/fetch";
|
|
12
|
+
import { createRemoteJWKSet, customFetch, decodeJwt, jwtVerify } from "jose";
|
|
9
13
|
import { base64 } from "@better-auth/utils/base64";
|
|
10
|
-
import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
|
|
11
|
-
import { ASSERTION_SIGNING_ALGORITHMS, HIDE_METADATA, createAuthorizationURL, generateGenericState, generateState, parseGenericState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
|
|
12
|
-
import { deleteSessionCookie, setSessionCookie } from "better-auth/cookies";
|
|
13
|
-
import { handleOAuthUserInfo } from "better-auth/oauth2";
|
|
14
|
-
import { decodeJwt } from "jose";
|
|
15
14
|
import { defineErrorCodes } from "@better-auth/core/utils/error-codes";
|
|
15
|
+
import { parseInputData, toZodSchema } from "better-auth/db";
|
|
16
|
+
import { isAPIError } from "@better-auth/core/utils/is-api-error";
|
|
17
|
+
import { HIDE_METADATA, PRIVATE_KEY_JWT_SIGNING_ALGORITHMS, authorizationCodeRequest, createAuthorizationURL, createPrivateKeyJwtClientAssertionGetter, generateGenericState, generateState, getOAuth2Tokens, parseGenericState, parseState } from "better-auth";
|
|
18
|
+
import { deleteSessionCookie, setSessionCookie } from "better-auth/cookies";
|
|
19
|
+
import { additionalAuthorizationParamsSchema, handleOAuthUserInfo } from "better-auth/oauth2";
|
|
20
|
+
import * as samlifyNamespace from "samlify";
|
|
21
|
+
import samlifyDefault from "samlify";
|
|
16
22
|
//#region src/constants.ts
|
|
17
23
|
/**
|
|
18
24
|
* SAML Constants
|
|
@@ -21,8 +27,6 @@ import { defineErrorCodes } from "@better-auth/core/utils/error-codes";
|
|
|
21
27
|
*/
|
|
22
28
|
/** Prefix for AuthnRequest IDs used in InResponseTo validation */
|
|
23
29
|
const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
|
|
24
|
-
/** Prefix for used Assertion IDs used in replay protection */
|
|
25
|
-
const USED_ASSERTION_KEY_PREFIX = "saml-used-assertion:";
|
|
26
30
|
/** Prefix for SAML session data (NameID + SessionIndex) for SLO */
|
|
27
31
|
const SAML_SESSION_KEY_PREFIX = "saml-session:";
|
|
28
32
|
/** Prefix for reverse lookup of SAML session by Better Auth session ID */
|
|
@@ -54,7 +58,6 @@ const DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024;
|
|
|
54
58
|
* Protects against oversized metadata documents.
|
|
55
59
|
*/
|
|
56
60
|
const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024;
|
|
57
|
-
const SAML_STATUS_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success";
|
|
58
61
|
//#endregion
|
|
59
62
|
//#region src/utils.ts
|
|
60
63
|
/**
|
|
@@ -80,10 +83,22 @@ function safeJsonParse(value) {
|
|
|
80
83
|
* Checks if a domain matches any domain in a comma-separated list.
|
|
81
84
|
*/
|
|
82
85
|
const domainMatches = (searchDomain, domainList) => {
|
|
83
|
-
const search = searchDomain.toLowerCase();
|
|
84
|
-
|
|
86
|
+
const search = searchDomain.trim().toLowerCase();
|
|
87
|
+
const domains = parseProviderDomains(domainList);
|
|
88
|
+
if (!search || !domains) return false;
|
|
89
|
+
return domains.some((domain) => search === domain || search.endsWith(`.${domain}`));
|
|
85
90
|
};
|
|
86
91
|
/**
|
|
92
|
+
* Strictly parse a provider-supplied email-verification claim.
|
|
93
|
+
*
|
|
94
|
+
* OIDC userInfo, OIDC id-token, and SAML attribute values are frequently
|
|
95
|
+
* strings, so a loose `Boolean(value)` or truthy fallback treats the string
|
|
96
|
+
* `"false"` as verified. Only a boolean `true` or the exact string `"true"`
|
|
97
|
+
* count as verified; every other value, including `"false"`, `"0"`, `""`,
|
|
98
|
+
* numbers, arrays, and objects, is unverified.
|
|
99
|
+
*/
|
|
100
|
+
const parseProviderEmailVerified = (value) => value === true || value === "true";
|
|
101
|
+
/**
|
|
87
102
|
* Validates email domain against allowed domain(s).
|
|
88
103
|
* Supports comma-separated domains for multi-domain SSO.
|
|
89
104
|
*/
|
|
@@ -101,9 +116,31 @@ function parseCertificate(certPem) {
|
|
|
101
116
|
publicKeyAlgorithm: cert.publicKey.asymmetricKeyType?.toUpperCase() || "UNKNOWN"
|
|
102
117
|
};
|
|
103
118
|
}
|
|
119
|
+
function normalizePem(key) {
|
|
120
|
+
if (!key) return key;
|
|
121
|
+
return `${key.split("\n").map((line) => line.trim()).join("\n").trim()}\n`;
|
|
122
|
+
}
|
|
104
123
|
function getHostnameFromDomain(domain) {
|
|
105
124
|
return getHostname(domain) || null;
|
|
106
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Normalize a provider `domain` value to the email domains it authorizes.
|
|
128
|
+
*
|
|
129
|
+
* TODO(next): replace the serialized provider.domain string with a canonical
|
|
130
|
+
* domains array and reject URL/path-shaped values at register/update once main
|
|
131
|
+
* and next provider schemas are reconciled.
|
|
132
|
+
*/
|
|
133
|
+
function parseProviderDomains(domain) {
|
|
134
|
+
const entries = domain.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
135
|
+
if (entries.length === 0) return null;
|
|
136
|
+
const domains = /* @__PURE__ */ new Set();
|
|
137
|
+
for (const entry of entries) {
|
|
138
|
+
const parsedDomain = getHostnameFromDomain(entry)?.toLowerCase();
|
|
139
|
+
if (!parsedDomain) return null;
|
|
140
|
+
domains.add(parsedDomain);
|
|
141
|
+
}
|
|
142
|
+
return [...domains];
|
|
143
|
+
}
|
|
107
144
|
function maskClientId(clientId) {
|
|
108
145
|
if (clientId.length <= 4) return "****";
|
|
109
146
|
return `****${clientId.slice(-4)}`;
|
|
@@ -205,1274 +242,1757 @@ async function assignOrganizationByDomain(ctx, options) {
|
|
|
205
242
|
});
|
|
206
243
|
}
|
|
207
244
|
//#endregion
|
|
208
|
-
//#region src/
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
"404": { description: "Provider not found" },
|
|
224
|
-
"409": { description: "Domain has already been verified" },
|
|
225
|
-
"201": { description: "Domain submitted for verification" }
|
|
226
|
-
}
|
|
227
|
-
} },
|
|
228
|
-
use: [sessionMiddleware]
|
|
229
|
-
}, async (ctx) => {
|
|
230
|
-
const body = ctx.body;
|
|
231
|
-
const provider = await ctx.context.adapter.findOne({
|
|
232
|
-
model: "ssoProvider",
|
|
233
|
-
where: [{
|
|
234
|
-
field: "providerId",
|
|
235
|
-
value: body.providerId
|
|
236
|
-
}]
|
|
237
|
-
});
|
|
238
|
-
if (!provider) throw new APIError("NOT_FOUND", {
|
|
239
|
-
message: "Provider not found",
|
|
240
|
-
code: "PROVIDER_NOT_FOUND"
|
|
241
|
-
});
|
|
242
|
-
const userId = ctx.context.session.user.id;
|
|
243
|
-
let isOrgMember = true;
|
|
244
|
-
if (provider.organizationId) isOrgMember = await ctx.context.adapter.count({
|
|
245
|
-
model: "member",
|
|
246
|
-
where: [{
|
|
247
|
-
field: "userId",
|
|
248
|
-
value: userId
|
|
249
|
-
}, {
|
|
250
|
-
field: "organizationId",
|
|
251
|
-
value: provider.organizationId
|
|
252
|
-
}]
|
|
253
|
-
}) > 0;
|
|
254
|
-
if (provider.userId !== userId || !isOrgMember) throw new APIError("FORBIDDEN", {
|
|
255
|
-
message: "User must be owner of or belong to the SSO provider organization",
|
|
256
|
-
code: "INSUFICCIENT_ACCESS"
|
|
257
|
-
});
|
|
258
|
-
if ("domainVerified" in provider && provider.domainVerified) throw new APIError("CONFLICT", {
|
|
259
|
-
message: "Domain has already been verified",
|
|
260
|
-
code: "DOMAIN_VERIFIED"
|
|
261
|
-
});
|
|
262
|
-
const identifier = getVerificationIdentifier(options, provider.providerId);
|
|
263
|
-
const activeVerification = await ctx.context.internalAdapter.findVerificationValue(identifier);
|
|
264
|
-
if (activeVerification && new Date(activeVerification.expiresAt) > /* @__PURE__ */ new Date()) {
|
|
265
|
-
ctx.setStatus(201);
|
|
266
|
-
return ctx.json({ domainVerificationToken: activeVerification.value });
|
|
267
|
-
}
|
|
268
|
-
const domainVerificationToken = generateRandomString(24);
|
|
269
|
-
await ctx.context.internalAdapter.createVerificationValue({
|
|
270
|
-
identifier,
|
|
271
|
-
value: domainVerificationToken,
|
|
272
|
-
expiresAt: new Date(Date.now() + 3600 * 24 * 7 * 1e3)
|
|
273
|
-
});
|
|
274
|
-
ctx.setStatus(201);
|
|
275
|
-
return ctx.json({ domainVerificationToken });
|
|
276
|
-
});
|
|
277
|
-
};
|
|
278
|
-
const verifyDomain = (options) => {
|
|
279
|
-
return createAuthEndpoint("/sso/verify-domain", {
|
|
280
|
-
method: "POST",
|
|
281
|
-
body: domainVerificationBodySchema,
|
|
282
|
-
metadata: { openapi: {
|
|
283
|
-
summary: "Verify the provider domain ownership",
|
|
284
|
-
description: "Verify the provider domain ownership via DNS records",
|
|
285
|
-
responses: {
|
|
286
|
-
"404": { description: "Provider not found" },
|
|
287
|
-
"409": { description: "Domain has already been verified or no pending verification exists" },
|
|
288
|
-
"502": { description: "Unable to verify domain ownership due to upstream validator error" },
|
|
289
|
-
"204": { description: "Domain ownership was verified" }
|
|
290
|
-
}
|
|
291
|
-
} },
|
|
292
|
-
use: [sessionMiddleware]
|
|
293
|
-
}, async (ctx) => {
|
|
294
|
-
const body = ctx.body;
|
|
295
|
-
const provider = await ctx.context.adapter.findOne({
|
|
296
|
-
model: "ssoProvider",
|
|
297
|
-
where: [{
|
|
298
|
-
field: "providerId",
|
|
299
|
-
value: body.providerId
|
|
300
|
-
}]
|
|
301
|
-
});
|
|
302
|
-
if (!provider) throw new APIError("NOT_FOUND", {
|
|
303
|
-
message: "Provider not found",
|
|
304
|
-
code: "PROVIDER_NOT_FOUND"
|
|
305
|
-
});
|
|
306
|
-
const userId = ctx.context.session.user.id;
|
|
307
|
-
let isOrgMember = true;
|
|
308
|
-
if (provider.organizationId) isOrgMember = await ctx.context.adapter.count({
|
|
309
|
-
model: "member",
|
|
310
|
-
where: [{
|
|
311
|
-
field: "userId",
|
|
312
|
-
value: userId
|
|
313
|
-
}, {
|
|
314
|
-
field: "organizationId",
|
|
315
|
-
value: provider.organizationId
|
|
316
|
-
}]
|
|
317
|
-
}) > 0;
|
|
318
|
-
if (provider.userId !== userId || !isOrgMember) throw new APIError("FORBIDDEN", {
|
|
319
|
-
message: "User must be owner of or belong to the SSO provider organization",
|
|
320
|
-
code: "INSUFICCIENT_ACCESS"
|
|
321
|
-
});
|
|
322
|
-
if ("domainVerified" in provider && provider.domainVerified) throw new APIError("CONFLICT", {
|
|
323
|
-
message: "Domain has already been verified",
|
|
324
|
-
code: "DOMAIN_VERIFIED"
|
|
325
|
-
});
|
|
326
|
-
const identifier = getVerificationIdentifier(options, provider.providerId);
|
|
327
|
-
if (identifier.length > DNS_LABEL_MAX_LENGTH) throw new APIError("BAD_REQUEST", {
|
|
328
|
-
message: `Verification identifier exceeds the DNS label limit of ${DNS_LABEL_MAX_LENGTH} characters`,
|
|
329
|
-
code: "IDENTIFIER_TOO_LONG"
|
|
330
|
-
});
|
|
331
|
-
const activeVerification = await ctx.context.internalAdapter.findVerificationValue(identifier);
|
|
332
|
-
if (!activeVerification || new Date(activeVerification.expiresAt) <= /* @__PURE__ */ new Date()) throw new APIError("NOT_FOUND", {
|
|
333
|
-
message: "No pending domain verification exists",
|
|
334
|
-
code: "NO_PENDING_VERIFICATION"
|
|
335
|
-
});
|
|
336
|
-
let records = [];
|
|
337
|
-
let dns;
|
|
338
|
-
try {
|
|
339
|
-
dns = await import("node:dns/promises");
|
|
340
|
-
} catch (error) {
|
|
341
|
-
ctx.context.logger.error("The core node:dns module is required for the domain verification feature", error);
|
|
342
|
-
throw new APIError("INTERNAL_SERVER_ERROR", {
|
|
343
|
-
message: "Unable to verify domain ownership due to server error",
|
|
344
|
-
code: "DOMAIN_VERIFICATION_FAILED"
|
|
345
|
-
});
|
|
346
|
-
}
|
|
347
|
-
const hostname = getHostnameFromDomain(provider.domain);
|
|
348
|
-
if (!hostname) throw new APIError("BAD_REQUEST", {
|
|
349
|
-
message: "Invalid domain",
|
|
350
|
-
code: "INVALID_DOMAIN"
|
|
351
|
-
});
|
|
352
|
-
try {
|
|
353
|
-
records = (await dns.resolveTxt(`${identifier}.${hostname}`)).flat();
|
|
354
|
-
} catch (error) {
|
|
355
|
-
ctx.context.logger.warn("DNS resolution failure while validating domain ownership", error);
|
|
356
|
-
}
|
|
357
|
-
if (!records.find((record) => record.includes(`${activeVerification.identifier}=${activeVerification.value}`))) throw new APIError("BAD_GATEWAY", {
|
|
358
|
-
message: "Unable to verify domain ownership. Try again later",
|
|
359
|
-
code: "DOMAIN_VERIFICATION_FAILED"
|
|
360
|
-
});
|
|
361
|
-
await ctx.context.adapter.update({
|
|
362
|
-
model: "ssoProvider",
|
|
363
|
-
where: [{
|
|
364
|
-
field: "providerId",
|
|
365
|
-
value: provider.providerId
|
|
366
|
-
}],
|
|
367
|
-
update: { domainVerified: true }
|
|
368
|
-
});
|
|
369
|
-
ctx.setStatus(204);
|
|
370
|
-
});
|
|
245
|
+
//#region src/oidc/types.ts
|
|
246
|
+
/**
|
|
247
|
+
* Custom error class for OIDC discovery failures.
|
|
248
|
+
* Can be caught and mapped to APIError at the edge.
|
|
249
|
+
*/
|
|
250
|
+
var DiscoveryError = class DiscoveryError extends Error {
|
|
251
|
+
code;
|
|
252
|
+
details;
|
|
253
|
+
constructor(code, message, details, options) {
|
|
254
|
+
super(message, options);
|
|
255
|
+
this.name = "DiscoveryError";
|
|
256
|
+
this.code = code;
|
|
257
|
+
this.details = details;
|
|
258
|
+
if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
|
|
259
|
+
}
|
|
371
260
|
};
|
|
261
|
+
/**
|
|
262
|
+
* Required fields that must be present in a valid discovery document.
|
|
263
|
+
*/
|
|
264
|
+
const REQUIRED_DISCOVERY_FIELDS = [
|
|
265
|
+
"issuer",
|
|
266
|
+
"authorization_endpoint",
|
|
267
|
+
"token_endpoint",
|
|
268
|
+
"jwks_uri"
|
|
269
|
+
];
|
|
372
270
|
//#endregion
|
|
373
|
-
//#region src/
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
}
|
|
388
|
-
else if (typeof value === "object" && value !== null) {
|
|
389
|
-
const found = findNode(value, nodeName);
|
|
390
|
-
if (found) return found;
|
|
391
|
-
}
|
|
392
|
-
return null;
|
|
271
|
+
//#region src/oidc/discovery.ts
|
|
272
|
+
/**
|
|
273
|
+
* OIDC Discovery Pipeline
|
|
274
|
+
*
|
|
275
|
+
* Implements OIDC discovery document fetching, validation, and hydration.
|
|
276
|
+
* This module is used both at provider registration time (to persist validated config)
|
|
277
|
+
* and at runtime (to hydrate legacy providers that are missing metadata).
|
|
278
|
+
*
|
|
279
|
+
* @see https://openid.net/specs/openid-connect-discovery-1_0.html
|
|
280
|
+
*/
|
|
281
|
+
/** Default timeout for discovery requests (10 seconds) */
|
|
282
|
+
const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
|
|
283
|
+
function isHttpRedirectStatus(status) {
|
|
284
|
+
return status >= 300 && status < 400;
|
|
393
285
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
286
|
+
/**
|
|
287
|
+
* Main entry point: Discover and hydrate OIDC configuration from an issuer.
|
|
288
|
+
*
|
|
289
|
+
* This function:
|
|
290
|
+
* 1. Computes the discovery URL from the issuer
|
|
291
|
+
* 2. Validates the discovery URL
|
|
292
|
+
* 3. Fetches the discovery document
|
|
293
|
+
* 4. Validates the discovery document (issuer match + required fields)
|
|
294
|
+
* 5. Normalizes URLs
|
|
295
|
+
* 6. Selects token endpoint auth method
|
|
296
|
+
* 7. Merges with existing config (existing values take precedence)
|
|
297
|
+
*
|
|
298
|
+
* @param params - Discovery parameters
|
|
299
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
300
|
+
* @returns Hydrated OIDC configuration ready for persistence
|
|
301
|
+
* @throws DiscoveryError on any failure
|
|
302
|
+
*/
|
|
303
|
+
async function discoverOIDCConfig(params) {
|
|
304
|
+
const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
|
|
305
|
+
const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
|
|
306
|
+
validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
|
|
307
|
+
const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout, params.isTrustedOrigin);
|
|
308
|
+
validateDiscoveryDocument(discoveryDoc, issuer);
|
|
309
|
+
const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
|
|
310
|
+
const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
|
|
311
|
+
return {
|
|
312
|
+
issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
|
|
313
|
+
discoveryEndpoint: existingConfig?.discoveryEndpoint ?? discoveryUrl,
|
|
314
|
+
authorizationEndpoint: existingConfig?.authorizationEndpoint ?? normalizedDoc.authorization_endpoint,
|
|
315
|
+
tokenEndpoint: existingConfig?.tokenEndpoint ?? normalizedDoc.token_endpoint,
|
|
316
|
+
jwksEndpoint: existingConfig?.jwksEndpoint ?? normalizedDoc.jwks_uri,
|
|
317
|
+
userInfoEndpoint: existingConfig?.userInfoEndpoint ?? normalizedDoc.userinfo_endpoint,
|
|
318
|
+
tokenEndpointAuthentication: existingConfig?.tokenEndpointAuthentication ?? tokenEndpointAuth,
|
|
319
|
+
scopesSupported: existingConfig?.scopesSupported ?? normalizedDoc.scopes_supported
|
|
320
|
+
};
|
|
405
321
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
};
|
|
417
|
-
const DigestAlgorithm = {
|
|
418
|
-
SHA1: "http://www.w3.org/2000/09/xmldsig#sha1",
|
|
419
|
-
SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
|
|
420
|
-
SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
|
|
421
|
-
SHA512: "http://www.w3.org/2001/04/xmlenc#sha512"
|
|
422
|
-
};
|
|
423
|
-
const KeyEncryptionAlgorithm = {
|
|
424
|
-
RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
|
|
425
|
-
RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
|
|
426
|
-
RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep"
|
|
427
|
-
};
|
|
428
|
-
const DataEncryptionAlgorithm = {
|
|
429
|
-
TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
|
|
430
|
-
AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
|
|
431
|
-
AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
|
|
432
|
-
AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
|
|
433
|
-
AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
|
|
434
|
-
AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
|
|
435
|
-
AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
|
|
436
|
-
};
|
|
437
|
-
const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
|
|
438
|
-
const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS = [KeyEncryptionAlgorithm.RSA_1_5];
|
|
439
|
-
const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS = [DataEncryptionAlgorithm.TRIPLEDES_CBC];
|
|
440
|
-
const DEPRECATED_DIGEST_ALGORITHMS = [DigestAlgorithm.SHA1];
|
|
441
|
-
const SECURE_SIGNATURE_ALGORITHMS = [
|
|
442
|
-
SignatureAlgorithm.RSA_SHA256,
|
|
443
|
-
SignatureAlgorithm.RSA_SHA384,
|
|
444
|
-
SignatureAlgorithm.RSA_SHA512,
|
|
445
|
-
SignatureAlgorithm.ECDSA_SHA256,
|
|
446
|
-
SignatureAlgorithm.ECDSA_SHA384,
|
|
447
|
-
SignatureAlgorithm.ECDSA_SHA512
|
|
448
|
-
];
|
|
449
|
-
const SECURE_DIGEST_ALGORITHMS = [
|
|
450
|
-
DigestAlgorithm.SHA256,
|
|
451
|
-
DigestAlgorithm.SHA384,
|
|
452
|
-
DigestAlgorithm.SHA512
|
|
453
|
-
];
|
|
454
|
-
const SHORT_FORM_SIGNATURE_TO_URI = {
|
|
455
|
-
sha1: SignatureAlgorithm.RSA_SHA1,
|
|
456
|
-
sha256: SignatureAlgorithm.RSA_SHA256,
|
|
457
|
-
sha384: SignatureAlgorithm.RSA_SHA384,
|
|
458
|
-
sha512: SignatureAlgorithm.RSA_SHA512,
|
|
459
|
-
"rsa-sha1": SignatureAlgorithm.RSA_SHA1,
|
|
460
|
-
"rsa-sha256": SignatureAlgorithm.RSA_SHA256,
|
|
461
|
-
"rsa-sha384": SignatureAlgorithm.RSA_SHA384,
|
|
462
|
-
"rsa-sha512": SignatureAlgorithm.RSA_SHA512,
|
|
463
|
-
"ecdsa-sha256": SignatureAlgorithm.ECDSA_SHA256,
|
|
464
|
-
"ecdsa-sha384": SignatureAlgorithm.ECDSA_SHA384,
|
|
465
|
-
"ecdsa-sha512": SignatureAlgorithm.ECDSA_SHA512
|
|
466
|
-
};
|
|
467
|
-
const SHORT_FORM_DIGEST_TO_URI = {
|
|
468
|
-
sha1: DigestAlgorithm.SHA1,
|
|
469
|
-
sha256: DigestAlgorithm.SHA256,
|
|
470
|
-
sha384: DigestAlgorithm.SHA384,
|
|
471
|
-
sha512: DigestAlgorithm.SHA512
|
|
472
|
-
};
|
|
473
|
-
function normalizeSignatureAlgorithm(alg) {
|
|
474
|
-
return SHORT_FORM_SIGNATURE_TO_URI[alg.toLowerCase()] ?? alg;
|
|
322
|
+
/**
|
|
323
|
+
* Compute the discovery URL from an issuer URL.
|
|
324
|
+
*
|
|
325
|
+
* Per OIDC Discovery spec, the discovery document is located at:
|
|
326
|
+
* <issuer>/.well-known/openid-configuration
|
|
327
|
+
*
|
|
328
|
+
* Handles trailing slashes correctly.
|
|
329
|
+
*/
|
|
330
|
+
function computeDiscoveryUrl(issuer) {
|
|
331
|
+
return `${issuer.endsWith("/") ? issuer.slice(0, -1) : issuer}/.well-known/openid-configuration`;
|
|
475
332
|
}
|
|
476
|
-
|
|
477
|
-
|
|
333
|
+
/**
|
|
334
|
+
* Validate a discovery URL before fetching.
|
|
335
|
+
*
|
|
336
|
+
* @param url - The discovery URL to validate
|
|
337
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
338
|
+
* @throws DiscoveryError if URL is invalid
|
|
339
|
+
*/
|
|
340
|
+
function validateDiscoveryUrl(url, isTrustedOrigin) {
|
|
341
|
+
const discoveryEndpoint = parseURL("discoveryEndpoint", url).toString();
|
|
342
|
+
if (!isTrustedOrigin(discoveryEndpoint)) throw new DiscoveryError("discovery_untrusted_origin", `The main discovery endpoint "${discoveryEndpoint}" is not trusted by your trusted origins configuration.`, { url: discoveryEndpoint });
|
|
478
343
|
}
|
|
479
|
-
|
|
344
|
+
/**
|
|
345
|
+
* Validate that a user-supplied OIDC endpoint URL is safe to fetch.
|
|
346
|
+
*
|
|
347
|
+
* Layered checks (in order):
|
|
348
|
+
* 1. URL parsing + http(s) scheme → discovery_invalid_url
|
|
349
|
+
* 2. Public-routable host (RFC 6890) → allowed
|
|
350
|
+
* 3. Operator-allowlisted via trustedOrigins → allowed (opt-in for internal IdPs)
|
|
351
|
+
* 4. Otherwise → discovery_private_host
|
|
352
|
+
*
|
|
353
|
+
* Step 2 rejects loopback, RFC 1918, link-local, ULA, shared-address,
|
|
354
|
+
* cloud-metadata FQDNs (e.g. `169.254.169.254`, `metadata.google.internal`),
|
|
355
|
+
* multicast, and reserved ranges. See `isPublicRoutableHost` in
|
|
356
|
+
* `@better-auth/core/utils/host`.
|
|
357
|
+
*
|
|
358
|
+
* Step 3 is the documented escape hatch for customers whose IdP runs on a
|
|
359
|
+
* private network or behind a corporate VPN: they add the IdP origin to their
|
|
360
|
+
* `trustedOrigins` configuration.
|
|
361
|
+
*
|
|
362
|
+
* @param name - The endpoint field name, used in error messages
|
|
363
|
+
* @param endpoint - The URL to validate
|
|
364
|
+
* @param isTrustedOrigin - Predicate matching the configured `trustedOrigins`
|
|
365
|
+
* @throws DiscoveryError(discovery_invalid_url) — malformed URL or non-http(s) scheme
|
|
366
|
+
* @throws DiscoveryError(discovery_private_host) — host is not publicly routable and not allowlisted
|
|
367
|
+
*/
|
|
368
|
+
function validateOIDCEndpointUrl(name, endpoint, isTrustedOrigin) {
|
|
369
|
+
const parsed = parseURL(name, endpoint);
|
|
370
|
+
if (isPublicRoutableHost(parsed.hostname)) return;
|
|
371
|
+
if (isTrustedOrigin(parsed.toString())) return;
|
|
372
|
+
throw new DiscoveryError("discovery_private_host", `The ${name} URL (${parsed.toString()}) is not publicly routable: ${parsed.hostname}. If this is an internal IdP, add its origin to trustedOrigins.`, {
|
|
373
|
+
endpoint: name,
|
|
374
|
+
url: endpoint,
|
|
375
|
+
hostname: parsed.hostname
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Validate every present OIDC endpoint URL in a registration or update body.
|
|
380
|
+
*
|
|
381
|
+
* Each provided URL is checked with {@link validateOIDCEndpointUrl}.
|
|
382
|
+
* Omitted (undefined / null / empty) fields are skipped.
|
|
383
|
+
*
|
|
384
|
+
* @param config - OIDC endpoint URLs from the request body
|
|
385
|
+
* @param isTrustedOrigin - Predicate matching the configured `trustedOrigins`
|
|
386
|
+
* @throws DiscoveryError on the first invalid endpoint
|
|
387
|
+
*/
|
|
388
|
+
function validateOIDCEndpointUrls(config, isTrustedOrigin) {
|
|
389
|
+
const fields = [
|
|
390
|
+
["authorizationEndpoint", config.authorizationEndpoint],
|
|
391
|
+
["tokenEndpoint", config.tokenEndpoint],
|
|
392
|
+
["userInfoEndpoint", config.userInfoEndpoint],
|
|
393
|
+
["jwksEndpoint", config.jwksEndpoint],
|
|
394
|
+
["discoveryEndpoint", config.discoveryEndpoint]
|
|
395
|
+
];
|
|
396
|
+
for (const [name, url] of fields) if (url) validateOIDCEndpointUrl(name, url, isTrustedOrigin);
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Re-validate an endpoint by resolving its hostname and rejecting any resolved
|
|
400
|
+
* address that is not publicly routable.
|
|
401
|
+
*
|
|
402
|
+
* {@link validateOIDCEndpointUrl} only classifies the literal hostname, so
|
|
403
|
+
* a host like `idp.example` whose DNS record points at `127.0.0.1`,
|
|
404
|
+
* `169.254.169.254`, or an RFC 1918 address passes that check unchanged. This
|
|
405
|
+
* function closes that gap by performing the same RFC 6890 classification on the
|
|
406
|
+
* addresses the host actually resolves to, right before the server-side fetch.
|
|
407
|
+
*
|
|
408
|
+
* Best-effort by design:
|
|
409
|
+
* - Operator-allowlisted origins (trustedOrigins) are skipped — this is the
|
|
410
|
+
* documented escape hatch for internal IdPs.
|
|
411
|
+
* - IP-literal hosts are already fully covered by the synchronous check.
|
|
412
|
+
* - On runtimes without `node:dns` (e.g. Cloudflare Workers / edge), DNS
|
|
413
|
+
* resolution is unavailable; we fall back to the synchronous host check and
|
|
414
|
+
* the platform's own egress controls.
|
|
415
|
+
*
|
|
416
|
+
* Note: this resolves once and validates the result; it does not pin the address
|
|
417
|
+
* for the subsequent connection, so a change in the resolved address between
|
|
418
|
+
* this lookup and the fetch remains theoretically possible. It nonetheless
|
|
419
|
+
* rejects the common case of a DNS record that statically points at an internal
|
|
420
|
+
* address.
|
|
421
|
+
*
|
|
422
|
+
* @throws DiscoveryError(discovery_private_host) if any resolved address is not public
|
|
423
|
+
*/
|
|
424
|
+
async function assertEndpointResolvesPublic(name, endpoint, isTrustedOrigin) {
|
|
425
|
+
const parsed = parseURL(name, endpoint);
|
|
426
|
+
if (isTrustedOrigin(parsed.toString())) return;
|
|
427
|
+
const host = parsed.hostname;
|
|
428
|
+
if (classifyHost(host).literal !== "fqdn") return;
|
|
429
|
+
let dns;
|
|
480
430
|
try {
|
|
481
|
-
|
|
482
|
-
const keyAlg = (findNode(parsed, "EncryptedKey")?.EncryptionMethod)?.["@_Algorithm"];
|
|
483
|
-
const dataAlg = (findNode(parsed, "EncryptedData")?.EncryptionMethod)?.["@_Algorithm"];
|
|
484
|
-
return {
|
|
485
|
-
keyEncryption: keyAlg || null,
|
|
486
|
-
dataEncryption: dataAlg || null
|
|
487
|
-
};
|
|
431
|
+
dns = await import("node:dns/promises");
|
|
488
432
|
} catch {
|
|
489
|
-
return
|
|
490
|
-
keyEncryption: null,
|
|
491
|
-
dataEncryption: null
|
|
492
|
-
};
|
|
433
|
+
return;
|
|
493
434
|
}
|
|
494
|
-
|
|
495
|
-
function hasEncryptedAssertion(xml) {
|
|
435
|
+
let resolved;
|
|
496
436
|
try {
|
|
497
|
-
|
|
437
|
+
resolved = await dns.lookup(host, { all: true });
|
|
498
438
|
} catch {
|
|
499
|
-
return
|
|
439
|
+
return;
|
|
500
440
|
}
|
|
441
|
+
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.`, {
|
|
442
|
+
endpoint: name,
|
|
443
|
+
url: endpoint,
|
|
444
|
+
hostname: host,
|
|
445
|
+
resolved: address
|
|
446
|
+
});
|
|
501
447
|
}
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
case "warn":
|
|
509
|
-
console.warn(`[SAML Security Warning] ${message}`);
|
|
510
|
-
break;
|
|
511
|
-
case "allow": break;
|
|
512
|
-
}
|
|
448
|
+
/**
|
|
449
|
+
* Validate an OIDC endpoint immediately before a server-side fetch.
|
|
450
|
+
*/
|
|
451
|
+
async function assertOIDCEndpointAllowed(name, endpoint, isTrustedOrigin) {
|
|
452
|
+
validateOIDCEndpointUrl(name, endpoint, isTrustedOrigin);
|
|
453
|
+
await assertEndpointResolvesPublic(name, endpoint, isTrustedOrigin);
|
|
513
454
|
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
455
|
+
/**
|
|
456
|
+
* Re-validate, at fetch time, every OIDC endpoint that is fetched server-side
|
|
457
|
+
* (token, userinfo, jwks). `authorizationEndpoint` is intentionally excluded
|
|
458
|
+
* because it is a browser redirect target, not a server-side fetch.
|
|
459
|
+
*/
|
|
460
|
+
async function assertServerFetchedOIDCEndpointsAllowed(config, isTrustedOrigin) {
|
|
461
|
+
const fields = [
|
|
462
|
+
["tokenEndpoint", config.tokenEndpoint],
|
|
463
|
+
["userInfoEndpoint", config.userInfoEndpoint],
|
|
464
|
+
["jwksEndpoint", config.jwksEndpoint]
|
|
465
|
+
];
|
|
466
|
+
for (const [name, url] of fields) {
|
|
467
|
+
if (!url) continue;
|
|
468
|
+
await assertOIDCEndpointAllowed(name, url, isTrustedOrigin);
|
|
527
469
|
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Convert an explicit HTTP redirect response into the OIDC configuration error
|
|
473
|
+
* used by all server-side endpoint fetches.
|
|
474
|
+
*/
|
|
475
|
+
function throwRedirectError(name, endpoint, status, location) {
|
|
476
|
+
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.`, {
|
|
477
|
+
endpoint: name,
|
|
478
|
+
url: endpoint,
|
|
479
|
+
status,
|
|
480
|
+
location
|
|
531
481
|
});
|
|
532
482
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
}
|
|
551
|
-
}
|
|
483
|
+
/**
|
|
484
|
+
* Fetch a configured OIDC endpoint without following redirects.
|
|
485
|
+
*
|
|
486
|
+
* Every server-side OIDC request goes through this helper so private-host
|
|
487
|
+
* checks and redirect handling stay consistent across discovery, token, and
|
|
488
|
+
* userinfo calls.
|
|
489
|
+
*/
|
|
490
|
+
async function fetchOIDCEndpoint(name, endpoint, options, isTrustedOrigin) {
|
|
491
|
+
await assertOIDCEndpointAllowed(name, endpoint, isTrustedOrigin);
|
|
492
|
+
let redirectLocation = null;
|
|
493
|
+
const { onError, ...fetchOptions } = options;
|
|
494
|
+
const response = await betterFetch(endpoint, {
|
|
495
|
+
...fetchOptions,
|
|
496
|
+
redirect: "manual",
|
|
497
|
+
onError: async (context) => {
|
|
498
|
+
if (isHttpRedirectStatus(context.response.status)) redirectLocation = context.response.headers.get("location");
|
|
499
|
+
await onError?.(context);
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
if (response.error && isHttpRedirectStatus(response.error.status)) throwRedirectError(name, endpoint, response.error.status, redirectLocation);
|
|
503
|
+
return response;
|
|
552
504
|
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
505
|
+
/**
|
|
506
|
+
* Native-fetch variant for libraries that require a `fetch` implementation,
|
|
507
|
+
* such as jose's remote JWKS loader.
|
|
508
|
+
*/
|
|
509
|
+
async function fetchOIDCEndpointResponse(name, endpoint, init, isTrustedOrigin) {
|
|
510
|
+
await assertOIDCEndpointAllowed(name, endpoint, isTrustedOrigin);
|
|
511
|
+
const response = await fetch(endpoint, {
|
|
512
|
+
...init,
|
|
513
|
+
redirect: "manual"
|
|
514
|
+
});
|
|
515
|
+
if (isHttpRedirectStatus(response.status)) throwRedirectError(name, endpoint, response.status, response.headers.get("location"));
|
|
516
|
+
return response;
|
|
556
517
|
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
518
|
+
/**
|
|
519
|
+
* Validate an OIDC ID token using the same endpoint fetch policy as the rest of
|
|
520
|
+
* the SSO OIDC flow.
|
|
521
|
+
*/
|
|
522
|
+
async function validateOIDCIdToken(token, jwksEndpoint, options, isTrustedOrigin) {
|
|
523
|
+
return jwtVerify(token, createRemoteJWKSet(new URL(jwksEndpoint), { [customFetch]: (url, init) => fetchOIDCEndpointResponse("jwksEndpoint", url, init, isTrustedOrigin) }), {
|
|
524
|
+
audience: options.audience,
|
|
525
|
+
issuer: options.issuer
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Fetch the OIDC discovery document from the IdP.
|
|
530
|
+
*
|
|
531
|
+
* @param url - The discovery endpoint URL
|
|
532
|
+
* @param timeout - Request timeout in milliseconds
|
|
533
|
+
* @returns The parsed discovery document
|
|
534
|
+
* @throws DiscoveryError on network errors, timeouts, or invalid responses
|
|
535
|
+
*/
|
|
536
|
+
async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT, isTrustedOrigin = () => false) {
|
|
537
|
+
try {
|
|
538
|
+
const response = await fetchOIDCEndpoint("discoveryEndpoint", url, {
|
|
539
|
+
method: "GET",
|
|
540
|
+
timeout
|
|
541
|
+
}, isTrustedOrigin);
|
|
542
|
+
if (response.error) {
|
|
543
|
+
const { status } = response.error;
|
|
544
|
+
if (status === 404) throw new DiscoveryError("discovery_not_found", "Discovery endpoint not found", {
|
|
545
|
+
url,
|
|
546
|
+
status
|
|
565
547
|
});
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
code: "SAML_UNKNOWN_ALGORITHM"
|
|
570
|
-
});
|
|
571
|
-
}
|
|
572
|
-
if (config.digestAlgorithm) {
|
|
573
|
-
const normalized = normalizeDigestAlgorithm(config.digestAlgorithm);
|
|
574
|
-
if (allowedDigestAlgorithms) {
|
|
575
|
-
if (!allowedDigestAlgorithms.map(normalizeDigestAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
576
|
-
message: `SAML digest algorithm not in allow-list: ${config.digestAlgorithm}`,
|
|
577
|
-
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
548
|
+
if (status === 408) throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
|
|
549
|
+
url,
|
|
550
|
+
timeout
|
|
578
551
|
});
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
552
|
+
throw new DiscoveryError("discovery_unexpected_error", `Unexpected discovery error: ${response.error.statusText}`, {
|
|
553
|
+
url,
|
|
554
|
+
...response.error
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
if (!response.data) throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned an empty response", { url });
|
|
558
|
+
const data = response.data;
|
|
559
|
+
if (typeof data === "string") throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned invalid JSON", {
|
|
560
|
+
url,
|
|
561
|
+
bodyPreview: data.slice(0, 200)
|
|
583
562
|
});
|
|
584
|
-
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
try {
|
|
591
|
-
parsed = xmlParser.parse(xml);
|
|
592
|
-
} catch {
|
|
593
|
-
throw new APIError("BAD_REQUEST", {
|
|
594
|
-
message: "Failed to parse SAML response XML",
|
|
595
|
-
code: "SAML_INVALID_XML"
|
|
563
|
+
return data;
|
|
564
|
+
} catch (error) {
|
|
565
|
+
if (error instanceof DiscoveryError) throw error;
|
|
566
|
+
if (error instanceof Error && error.name === "AbortError") throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
|
|
567
|
+
url,
|
|
568
|
+
timeout
|
|
596
569
|
});
|
|
570
|
+
throw new DiscoveryError("discovery_unexpected_error", `Unexpected error during discovery: ${error instanceof Error ? error.message : String(error)}`, { url }, { cause: error });
|
|
597
571
|
}
|
|
598
|
-
const assertions = countAllNodes(parsed, "Assertion");
|
|
599
|
-
const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
|
|
600
|
-
return {
|
|
601
|
-
assertions,
|
|
602
|
-
encryptedAssertions,
|
|
603
|
-
total: assertions + encryptedAssertions
|
|
604
|
-
};
|
|
605
572
|
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
573
|
+
/**
|
|
574
|
+
* Validate a discovery document.
|
|
575
|
+
*
|
|
576
|
+
* Checks:
|
|
577
|
+
* 1. All required fields are present
|
|
578
|
+
* 2. Issuer matches the configured issuer (case-sensitive, exact match)
|
|
579
|
+
*
|
|
580
|
+
* Invariant: If this function returns without throwing, the document is safe
|
|
581
|
+
* to use for hydrating OIDC config (required fields present, issuer matches
|
|
582
|
+
* configured value, basic structural sanity verified).
|
|
583
|
+
*
|
|
584
|
+
* @param doc - The discovery document to validate
|
|
585
|
+
* @param configuredIssuer - The expected issuer value
|
|
586
|
+
* @throws DiscoveryError if validation fails
|
|
587
|
+
*/
|
|
588
|
+
function validateDiscoveryDocument(doc, configuredIssuer) {
|
|
589
|
+
const missingFields = [];
|
|
590
|
+
for (const field of REQUIRED_DISCOVERY_FIELDS) if (!doc[field]) missingFields.push(field);
|
|
591
|
+
if (missingFields.length > 0) throw new DiscoveryError("discovery_incomplete", `Discovery document is missing required fields: ${missingFields.join(", ")}`, { missingFields });
|
|
592
|
+
if ((doc.issuer.endsWith("/") ? doc.issuer.slice(0, -1) : doc.issuer) !== (configuredIssuer.endsWith("/") ? configuredIssuer.slice(0, -1) : configuredIssuer)) throw new DiscoveryError("issuer_mismatch", `Discovered issuer "${doc.issuer}" does not match configured issuer "${configuredIssuer}"`, {
|
|
593
|
+
discovered: doc.issuer,
|
|
594
|
+
configured: configuredIssuer
|
|
621
595
|
});
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Normalize URLs in the discovery document.
|
|
599
|
+
*
|
|
600
|
+
* @param document - The discovery document
|
|
601
|
+
* @param issuer - The base issuer URL
|
|
602
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
603
|
+
* @returns The normalized discovery document
|
|
604
|
+
*/
|
|
605
|
+
function normalizeDiscoveryUrls(document, issuer, isTrustedOrigin) {
|
|
606
|
+
const doc = { ...document };
|
|
607
|
+
doc.token_endpoint = normalizeAndValidateUrl("token_endpoint", doc.token_endpoint, issuer, isTrustedOrigin);
|
|
608
|
+
doc.authorization_endpoint = normalizeAndValidateUrl("authorization_endpoint", doc.authorization_endpoint, issuer, isTrustedOrigin);
|
|
609
|
+
doc.jwks_uri = normalizeAndValidateUrl("jwks_uri", doc.jwks_uri, issuer, isTrustedOrigin);
|
|
610
|
+
if (doc.userinfo_endpoint) doc.userinfo_endpoint = normalizeAndValidateUrl("userinfo_endpoint", doc.userinfo_endpoint, issuer, isTrustedOrigin);
|
|
611
|
+
if (doc.revocation_endpoint) doc.revocation_endpoint = normalizeAndValidateUrl("revocation_endpoint", doc.revocation_endpoint, issuer, isTrustedOrigin);
|
|
612
|
+
if (doc.end_session_endpoint) doc.end_session_endpoint = normalizeAndValidateUrl("end_session_endpoint", doc.end_session_endpoint, issuer, isTrustedOrigin);
|
|
613
|
+
if (doc.introspection_endpoint) doc.introspection_endpoint = normalizeAndValidateUrl("introspection_endpoint", doc.introspection_endpoint, issuer, isTrustedOrigin);
|
|
614
|
+
return doc;
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Normalizes and validates a single URL endpoint
|
|
618
|
+
* @param name The url name
|
|
619
|
+
* @param endpoint The url to validate
|
|
620
|
+
* @param issuer The issuer base url
|
|
621
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
622
|
+
* @returns
|
|
623
|
+
*/
|
|
624
|
+
function normalizeAndValidateUrl(name, endpoint, issuer, isTrustedOrigin) {
|
|
625
|
+
const url = normalizeUrl(name, endpoint, issuer);
|
|
626
|
+
if (!isTrustedOrigin(url)) throw new DiscoveryError("discovery_untrusted_origin", `The ${name} "${url}" is not trusted by your trusted origins configuration.`, {
|
|
627
|
+
endpoint: name,
|
|
628
|
+
url
|
|
625
629
|
});
|
|
630
|
+
return url;
|
|
626
631
|
}
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
632
|
+
/**
|
|
633
|
+
* Normalize a single URL endpoint.
|
|
634
|
+
*
|
|
635
|
+
* @param name - The endpoint name (e.g token_endpoint)
|
|
636
|
+
* @param endpoint - The endpoint URL to normalize
|
|
637
|
+
* @param issuer - The base issuer URL
|
|
638
|
+
* @returns The normalized endpoint URL
|
|
639
|
+
*/
|
|
640
|
+
function normalizeUrl(name, endpoint, issuer) {
|
|
630
641
|
try {
|
|
631
|
-
|
|
632
|
-
url.searchParams.set("error", error);
|
|
633
|
-
url.searchParams.set("error_description", description);
|
|
634
|
-
return url.toString();
|
|
642
|
+
return parseURL(name, endpoint).toString();
|
|
635
643
|
} catch {
|
|
636
|
-
const
|
|
637
|
-
const
|
|
638
|
-
const
|
|
639
|
-
return
|
|
644
|
+
const issuerURL = parseURL(name, issuer);
|
|
645
|
+
const basePath = issuerURL.pathname.replace(/\/+$/, "");
|
|
646
|
+
const endpointPath = endpoint.replace(/^\/+/, "");
|
|
647
|
+
return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
|
|
640
648
|
}
|
|
641
649
|
}
|
|
642
650
|
/**
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
* This binds the IdP's Response to a specific SP-initiated AuthnRequest,
|
|
646
|
-
* preventing replay attacks, unsolicited response injection, and
|
|
647
|
-
* cross-provider assertion swaps.
|
|
651
|
+
* Parses the given URL or throws in case of invalid or unsupported protocols
|
|
648
652
|
*
|
|
649
|
-
*
|
|
650
|
-
*
|
|
653
|
+
* @param name the url name
|
|
654
|
+
* @param endpoint the endpoint url
|
|
655
|
+
* @param [base] optional base path
|
|
656
|
+
* @returns
|
|
651
657
|
*/
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
if (verification) try {
|
|
660
|
-
storedRequest = JSON.parse(verification.value);
|
|
661
|
-
if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
|
|
662
|
-
} catch {
|
|
663
|
-
storedRequest = null;
|
|
664
|
-
}
|
|
665
|
-
if (!storedRequest) {
|
|
666
|
-
c.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
|
|
667
|
-
inResponseTo,
|
|
668
|
-
providerId: ctx.providerId
|
|
669
|
-
});
|
|
670
|
-
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Unknown or expired request ID"));
|
|
671
|
-
}
|
|
672
|
-
if (storedRequest.providerId !== ctx.providerId) {
|
|
673
|
-
c.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
|
|
674
|
-
inResponseTo,
|
|
675
|
-
expectedProvider: storedRequest.providerId,
|
|
676
|
-
actualProvider: ctx.providerId
|
|
677
|
-
});
|
|
678
|
-
await c.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
679
|
-
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Provider mismatch"));
|
|
680
|
-
}
|
|
681
|
-
await c.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
682
|
-
} else if (!allowIdpInitiated) {
|
|
683
|
-
c.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: ctx.providerId });
|
|
684
|
-
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "unsolicited_response", "IdP-initiated SSO not allowed"));
|
|
658
|
+
function parseURL(name, endpoint, base) {
|
|
659
|
+
let endpointURL;
|
|
660
|
+
try {
|
|
661
|
+
endpointURL = new URL(endpoint, base);
|
|
662
|
+
if (endpointURL.protocol === "http:" || endpointURL.protocol === "https:") return endpointURL;
|
|
663
|
+
} catch (error) {
|
|
664
|
+
throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
|
|
685
665
|
}
|
|
666
|
+
throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must use the http or https supported protocols: ${endpoint}`, {
|
|
667
|
+
url: endpoint,
|
|
668
|
+
protocol: endpointURL.protocol
|
|
669
|
+
});
|
|
686
670
|
}
|
|
687
671
|
/**
|
|
688
|
-
*
|
|
672
|
+
* Select the token endpoint authentication method.
|
|
689
673
|
*
|
|
690
|
-
*
|
|
691
|
-
*
|
|
692
|
-
*
|
|
693
|
-
* could be accepted.
|
|
674
|
+
* @param doc - The discovery document
|
|
675
|
+
* @param existing - Existing authentication method from config
|
|
676
|
+
* @returns The selected authentication method
|
|
694
677
|
*/
|
|
695
|
-
function
|
|
696
|
-
if (
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
678
|
+
function selectTokenEndpointAuthMethod(doc, existing) {
|
|
679
|
+
if (existing === "private_key_jwt") return existing;
|
|
680
|
+
if (existing) return existing;
|
|
681
|
+
const supported = doc.token_endpoint_auth_methods_supported;
|
|
682
|
+
if (!supported || supported.length === 0) return "client_secret_basic";
|
|
683
|
+
if (supported.includes("client_secret_basic")) return "client_secret_basic";
|
|
684
|
+
if (supported.includes("client_secret_post")) return "client_secret_post";
|
|
685
|
+
if (supported.includes("private_key_jwt")) return "private_key_jwt";
|
|
686
|
+
return "client_secret_basic";
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Check if a provider configuration needs runtime discovery.
|
|
690
|
+
*
|
|
691
|
+
* Returns true if we need discovery at runtime to complete the token exchange
|
|
692
|
+
* and validation. Specifically checks for:
|
|
693
|
+
* - `tokenEndpoint` - required for exchanging authorization code for tokens
|
|
694
|
+
* - `jwksEndpoint` - required for validating ID token signatures
|
|
695
|
+
* - `authorizationEndpoint` - required for redirecting users to the IdP for login
|
|
696
|
+
*
|
|
697
|
+
* @param config - Partial OIDC config from the provider
|
|
698
|
+
* @returns true if runtime discovery should be performed
|
|
699
|
+
*/
|
|
700
|
+
function needsRuntimeDiscovery(config) {
|
|
701
|
+
if (!config) return true;
|
|
702
|
+
return !config.tokenEndpoint || !config.jwksEndpoint || !config.authorizationEndpoint;
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Runs runtime OIDC discovery when the stored config is missing required
|
|
706
|
+
* endpoints, and merges the hydrated fields back into the config.
|
|
707
|
+
* Throws if discovery fails.
|
|
708
|
+
*/
|
|
709
|
+
async function ensureRuntimeDiscovery(config, issuer, isTrustedOrigin) {
|
|
710
|
+
let resolved = config;
|
|
711
|
+
if (needsRuntimeDiscovery(config)) {
|
|
712
|
+
const hydrated = await discoverOIDCConfig({
|
|
713
|
+
issuer,
|
|
714
|
+
existingConfig: config,
|
|
715
|
+
isTrustedOrigin
|
|
708
716
|
});
|
|
709
|
-
|
|
717
|
+
resolved = {
|
|
718
|
+
...config,
|
|
719
|
+
authorizationEndpoint: hydrated.authorizationEndpoint,
|
|
720
|
+
tokenEndpoint: hydrated.tokenEndpoint,
|
|
721
|
+
tokenEndpointAuthentication: hydrated.tokenEndpointAuthentication,
|
|
722
|
+
userInfoEndpoint: hydrated.userInfoEndpoint,
|
|
723
|
+
jwksEndpoint: hydrated.jwksEndpoint
|
|
724
|
+
};
|
|
710
725
|
}
|
|
726
|
+
await assertServerFetchedOIDCEndpointsAllowed(resolved, isTrustedOrigin);
|
|
727
|
+
return resolved;
|
|
711
728
|
}
|
|
712
729
|
//#endregion
|
|
713
|
-
//#region src/
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
})
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
730
|
+
//#region src/oidc/errors.ts
|
|
731
|
+
/**
|
|
732
|
+
* OIDC Discovery Error Mapping
|
|
733
|
+
*
|
|
734
|
+
* Maps DiscoveryError codes to appropriate APIError responses.
|
|
735
|
+
* Used at the boundary between the discovery pipeline and HTTP handlers.
|
|
736
|
+
*/
|
|
737
|
+
/**
|
|
738
|
+
* Maps a DiscoveryError to an appropriate APIError for HTTP responses.
|
|
739
|
+
*
|
|
740
|
+
* Error code mapping:
|
|
741
|
+
* - discovery_invalid_url → 400 BAD_REQUEST
|
|
742
|
+
* - discovery_not_found → 400 BAD_REQUEST
|
|
743
|
+
* - discovery_untrusted_origin → 400 BAD_REQUEST
|
|
744
|
+
* - discovery_private_host → 400 BAD_REQUEST
|
|
745
|
+
* - oidc_endpoint_redirect → 400 BAD_REQUEST
|
|
746
|
+
* - discovery_invalid_json → 400 BAD_REQUEST
|
|
747
|
+
* - discovery_incomplete → 400 BAD_REQUEST
|
|
748
|
+
* - issuer_mismatch → 400 BAD_REQUEST
|
|
749
|
+
* - unsupported_token_auth_method → 400 BAD_REQUEST
|
|
750
|
+
* - discovery_timeout → 502 BAD_GATEWAY
|
|
751
|
+
* - discovery_unexpected_error → 502 BAD_GATEWAY
|
|
752
|
+
*
|
|
753
|
+
* @param error - The DiscoveryError to map
|
|
754
|
+
* @returns An APIError with appropriate status and message
|
|
755
|
+
*/
|
|
756
|
+
function mapDiscoveryErrorToAPIError(error) {
|
|
757
|
+
switch (error.code) {
|
|
758
|
+
case "discovery_timeout": return new APIError("BAD_GATEWAY", {
|
|
759
|
+
message: `OIDC discovery timed out: ${error.message}`,
|
|
760
|
+
code: error.code
|
|
761
|
+
});
|
|
762
|
+
case "discovery_unexpected_error": return new APIError("BAD_GATEWAY", {
|
|
763
|
+
message: `OIDC discovery failed: ${error.message}`,
|
|
764
|
+
code: error.code
|
|
765
|
+
});
|
|
766
|
+
case "discovery_not_found": return new APIError("BAD_REQUEST", {
|
|
767
|
+
message: `OIDC discovery endpoint not found. The issuer may not support OIDC discovery, or the URL is incorrect. ${error.message}`,
|
|
768
|
+
code: error.code
|
|
769
|
+
});
|
|
770
|
+
case "discovery_invalid_url": return new APIError("BAD_REQUEST", {
|
|
771
|
+
message: `Invalid OIDC endpoint URL: ${error.message}`,
|
|
772
|
+
code: error.code
|
|
773
|
+
});
|
|
774
|
+
case "discovery_untrusted_origin": return new APIError("BAD_REQUEST", {
|
|
775
|
+
message: `Untrusted OIDC discovery URL: ${error.message}`,
|
|
776
|
+
code: error.code
|
|
777
|
+
});
|
|
778
|
+
case "discovery_private_host": return new APIError("BAD_REQUEST", {
|
|
779
|
+
message: error.message,
|
|
780
|
+
code: error.code
|
|
781
|
+
});
|
|
782
|
+
case "oidc_endpoint_redirect": return new APIError("BAD_REQUEST", {
|
|
783
|
+
message: error.message,
|
|
784
|
+
code: error.code
|
|
785
|
+
});
|
|
786
|
+
case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
|
|
787
|
+
message: `OIDC discovery returned invalid data: ${error.message}`,
|
|
788
|
+
code: error.code
|
|
789
|
+
});
|
|
790
|
+
case "discovery_incomplete": return new APIError("BAD_REQUEST", {
|
|
791
|
+
message: `OIDC discovery document is missing required fields: ${error.message}`,
|
|
792
|
+
code: error.code
|
|
793
|
+
});
|
|
794
|
+
case "issuer_mismatch": return new APIError("BAD_REQUEST", {
|
|
795
|
+
message: `OIDC issuer mismatch: ${error.message}`,
|
|
796
|
+
code: error.code
|
|
797
|
+
});
|
|
798
|
+
case "unsupported_token_auth_method": return new APIError("BAD_REQUEST", {
|
|
799
|
+
message: `Incompatible OIDC provider: ${error.message}`,
|
|
800
|
+
code: error.code
|
|
801
|
+
});
|
|
802
|
+
default:
|
|
803
|
+
error.code;
|
|
804
|
+
return new APIError("INTERNAL_SERVER_ERROR", {
|
|
805
|
+
message: `Unexpected discovery error: ${error.message}`,
|
|
806
|
+
code: "discovery_unexpected_error"
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
//#endregion
|
|
811
|
+
//#region src/saml/parser.ts
|
|
812
|
+
const xmlParser = new XMLParser({
|
|
813
|
+
ignoreAttributes: false,
|
|
814
|
+
attributeNamePrefix: "@_",
|
|
815
|
+
removeNSPrefix: true,
|
|
816
|
+
processEntities: false
|
|
795
817
|
});
|
|
818
|
+
function findNode(obj, nodeName) {
|
|
819
|
+
if (!obj || typeof obj !== "object") return null;
|
|
820
|
+
const record = obj;
|
|
821
|
+
if (nodeName in record) return record[nodeName];
|
|
822
|
+
for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
|
|
823
|
+
const found = findNode(item, nodeName);
|
|
824
|
+
if (found) return found;
|
|
825
|
+
}
|
|
826
|
+
else if (typeof value === "object" && value !== null) {
|
|
827
|
+
const found = findNode(value, nodeName);
|
|
828
|
+
if (found) return found;
|
|
829
|
+
}
|
|
830
|
+
return null;
|
|
831
|
+
}
|
|
832
|
+
function countAllNodes(obj, nodeName) {
|
|
833
|
+
if (!obj || typeof obj !== "object") return 0;
|
|
834
|
+
let count = 0;
|
|
835
|
+
const record = obj;
|
|
836
|
+
if (nodeName in record) {
|
|
837
|
+
const node = record[nodeName];
|
|
838
|
+
count += Array.isArray(node) ? node.length : 1;
|
|
839
|
+
}
|
|
840
|
+
for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) count += countAllNodes(item, nodeName);
|
|
841
|
+
else if (typeof value === "object" && value !== null) count += countAllNodes(value, nodeName);
|
|
842
|
+
return count;
|
|
843
|
+
}
|
|
796
844
|
//#endregion
|
|
797
|
-
//#region src/
|
|
798
|
-
const
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
845
|
+
//#region src/saml/algorithms.ts
|
|
846
|
+
const SignatureAlgorithm = {
|
|
847
|
+
RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
|
|
848
|
+
RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
|
|
849
|
+
RSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
|
|
850
|
+
RSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
|
|
851
|
+
ECDSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
|
|
852
|
+
ECDSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
|
|
853
|
+
ECDSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"
|
|
854
|
+
};
|
|
855
|
+
const DigestAlgorithm = {
|
|
856
|
+
SHA1: "http://www.w3.org/2000/09/xmldsig#sha1",
|
|
857
|
+
SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
|
|
858
|
+
SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
|
|
859
|
+
SHA512: "http://www.w3.org/2001/04/xmlenc#sha512"
|
|
860
|
+
};
|
|
861
|
+
const KeyEncryptionAlgorithm = {
|
|
862
|
+
RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
|
|
863
|
+
RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
|
|
864
|
+
RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep"
|
|
865
|
+
};
|
|
866
|
+
const DataEncryptionAlgorithm = {
|
|
867
|
+
TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
|
|
868
|
+
AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
|
|
869
|
+
AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
|
|
870
|
+
AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
|
|
871
|
+
AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
|
|
872
|
+
AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
|
|
873
|
+
AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
|
|
874
|
+
};
|
|
875
|
+
const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
|
|
876
|
+
const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS = [KeyEncryptionAlgorithm.RSA_1_5];
|
|
877
|
+
const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS = [DataEncryptionAlgorithm.TRIPLEDES_CBC];
|
|
878
|
+
const DEPRECATED_DIGEST_ALGORITHMS = [DigestAlgorithm.SHA1];
|
|
879
|
+
const SECURE_SIGNATURE_ALGORITHMS = [
|
|
880
|
+
SignatureAlgorithm.RSA_SHA256,
|
|
881
|
+
SignatureAlgorithm.RSA_SHA384,
|
|
882
|
+
SignatureAlgorithm.RSA_SHA512,
|
|
883
|
+
SignatureAlgorithm.ECDSA_SHA256,
|
|
884
|
+
SignatureAlgorithm.ECDSA_SHA384,
|
|
885
|
+
SignatureAlgorithm.ECDSA_SHA512
|
|
886
|
+
];
|
|
887
|
+
const SECURE_DIGEST_ALGORITHMS = [
|
|
888
|
+
DigestAlgorithm.SHA256,
|
|
889
|
+
DigestAlgorithm.SHA384,
|
|
890
|
+
DigestAlgorithm.SHA512
|
|
891
|
+
];
|
|
892
|
+
const SHORT_FORM_SIGNATURE_TO_URI = {
|
|
893
|
+
sha1: SignatureAlgorithm.RSA_SHA1,
|
|
894
|
+
sha256: SignatureAlgorithm.RSA_SHA256,
|
|
895
|
+
sha384: SignatureAlgorithm.RSA_SHA384,
|
|
896
|
+
sha512: SignatureAlgorithm.RSA_SHA512,
|
|
897
|
+
"rsa-sha1": SignatureAlgorithm.RSA_SHA1,
|
|
898
|
+
"rsa-sha256": SignatureAlgorithm.RSA_SHA256,
|
|
899
|
+
"rsa-sha384": SignatureAlgorithm.RSA_SHA384,
|
|
900
|
+
"rsa-sha512": SignatureAlgorithm.RSA_SHA512,
|
|
901
|
+
"ecdsa-sha256": SignatureAlgorithm.ECDSA_SHA256,
|
|
902
|
+
"ecdsa-sha384": SignatureAlgorithm.ECDSA_SHA384,
|
|
903
|
+
"ecdsa-sha512": SignatureAlgorithm.ECDSA_SHA512
|
|
904
|
+
};
|
|
905
|
+
const SHORT_FORM_DIGEST_TO_URI = {
|
|
906
|
+
sha1: DigestAlgorithm.SHA1,
|
|
907
|
+
sha256: DigestAlgorithm.SHA256,
|
|
908
|
+
sha384: DigestAlgorithm.SHA384,
|
|
909
|
+
sha512: DigestAlgorithm.SHA512
|
|
910
|
+
};
|
|
911
|
+
function normalizeSignatureAlgorithm(alg) {
|
|
912
|
+
return SHORT_FORM_SIGNATURE_TO_URI[alg.toLowerCase()] ?? alg;
|
|
812
913
|
}
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
const members = await ctx.context.adapter.findMany({
|
|
816
|
-
model: "member",
|
|
817
|
-
where: [{
|
|
818
|
-
field: "userId",
|
|
819
|
-
value: userId
|
|
820
|
-
}, {
|
|
821
|
-
field: "organizationId",
|
|
822
|
-
value: organizationIds,
|
|
823
|
-
operator: "in"
|
|
824
|
-
}]
|
|
825
|
-
});
|
|
826
|
-
const adminOrgIds = /* @__PURE__ */ new Set();
|
|
827
|
-
for (const member of members) if (member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()))) adminOrgIds.add(member.organizationId);
|
|
828
|
-
return adminOrgIds;
|
|
914
|
+
function normalizeDigestAlgorithm(alg) {
|
|
915
|
+
return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
|
|
829
916
|
}
|
|
830
|
-
function
|
|
831
|
-
let oidcConfig = null;
|
|
832
|
-
let samlConfig = null;
|
|
917
|
+
function extractEncryptionAlgorithms(xml) {
|
|
833
918
|
try {
|
|
834
|
-
|
|
919
|
+
const parsed = xmlParser.parse(xml);
|
|
920
|
+
const keyAlg = (findNode(parsed, "EncryptedKey")?.EncryptionMethod)?.["@_Algorithm"];
|
|
921
|
+
const dataAlg = (findNode(parsed, "EncryptedData")?.EncryptionMethod)?.["@_Algorithm"];
|
|
922
|
+
return {
|
|
923
|
+
keyEncryption: keyAlg || null,
|
|
924
|
+
dataEncryption: dataAlg || null
|
|
925
|
+
};
|
|
835
926
|
} catch {
|
|
836
|
-
|
|
927
|
+
return {
|
|
928
|
+
keyEncryption: null,
|
|
929
|
+
dataEncryption: null
|
|
930
|
+
};
|
|
837
931
|
}
|
|
932
|
+
}
|
|
933
|
+
function hasEncryptedAssertion(xml) {
|
|
838
934
|
try {
|
|
839
|
-
|
|
935
|
+
return findNode(xmlParser.parse(xml), "EncryptedAssertion") !== null;
|
|
840
936
|
} catch {
|
|
841
|
-
|
|
937
|
+
return false;
|
|
842
938
|
}
|
|
843
|
-
const type = samlConfig ? "saml" : "oidc";
|
|
844
|
-
return {
|
|
845
|
-
providerId: provider.providerId,
|
|
846
|
-
type,
|
|
847
|
-
issuer: provider.issuer,
|
|
848
|
-
domain: provider.domain,
|
|
849
|
-
organizationId: provider.organizationId || null,
|
|
850
|
-
domainVerified: provider.domainVerified ?? false,
|
|
851
|
-
oidcConfig: oidcConfig ? {
|
|
852
|
-
discoveryEndpoint: oidcConfig.discoveryEndpoint,
|
|
853
|
-
clientIdLastFour: maskClientId(oidcConfig.clientId),
|
|
854
|
-
pkce: oidcConfig.pkce,
|
|
855
|
-
authorizationEndpoint: oidcConfig.authorizationEndpoint,
|
|
856
|
-
tokenEndpoint: oidcConfig.tokenEndpoint,
|
|
857
|
-
userInfoEndpoint: oidcConfig.userInfoEndpoint,
|
|
858
|
-
jwksEndpoint: oidcConfig.jwksEndpoint,
|
|
859
|
-
scopes: oidcConfig.scopes,
|
|
860
|
-
tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication
|
|
861
|
-
} : void 0,
|
|
862
|
-
samlConfig: samlConfig ? {
|
|
863
|
-
entryPoint: samlConfig.entryPoint,
|
|
864
|
-
callbackUrl: samlConfig.callbackUrl,
|
|
865
|
-
audience: samlConfig.audience,
|
|
866
|
-
wantAssertionsSigned: samlConfig.wantAssertionsSigned,
|
|
867
|
-
authnRequestsSigned: samlConfig.authnRequestsSigned,
|
|
868
|
-
identifierFormat: samlConfig.identifierFormat,
|
|
869
|
-
signatureAlgorithm: samlConfig.signatureAlgorithm,
|
|
870
|
-
digestAlgorithm: samlConfig.digestAlgorithm,
|
|
871
|
-
certificate: (() => {
|
|
872
|
-
try {
|
|
873
|
-
return parseCertificate(samlConfig.cert);
|
|
874
|
-
} catch {
|
|
875
|
-
return { error: "Failed to parse certificate" };
|
|
876
|
-
}
|
|
877
|
-
})()
|
|
878
|
-
} : void 0,
|
|
879
|
-
spMetadataUrl: `${baseURL}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(provider.providerId)}`
|
|
880
|
-
};
|
|
881
939
|
}
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
}
|
|
893
|
-
const userId = ctx.context.session.user.id;
|
|
894
|
-
const allProviders = await ctx.context.adapter.findMany({ model: "ssoProvider" });
|
|
895
|
-
const userOwnedProviders = allProviders.filter((p) => p.userId === userId && !p.organizationId);
|
|
896
|
-
const orgProviders = allProviders.filter((p) => p.organizationId !== null && p.organizationId !== void 0);
|
|
897
|
-
const orgPluginEnabled = ctx.context.hasPlugin("organization");
|
|
898
|
-
let accessibleProviders = [...userOwnedProviders];
|
|
899
|
-
if (orgPluginEnabled && orgProviders.length > 0) {
|
|
900
|
-
const adminOrgIds = await batchCheckOrgAdmin(ctx, userId, [...new Set(orgProviders.map((p) => p.organizationId).filter((id) => id !== null && id !== void 0))]);
|
|
901
|
-
const orgAccessibleProviders = orgProviders.filter((provider) => provider.organizationId && adminOrgIds.has(provider.organizationId));
|
|
902
|
-
accessibleProviders = [...accessibleProviders, ...orgAccessibleProviders];
|
|
903
|
-
} else if (!orgPluginEnabled) {
|
|
904
|
-
const userOwnedOrgProviders = orgProviders.filter((p) => p.userId === userId);
|
|
905
|
-
accessibleProviders = [...accessibleProviders, ...userOwnedOrgProviders];
|
|
906
|
-
}
|
|
907
|
-
const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL));
|
|
908
|
-
return ctx.json({ providers });
|
|
909
|
-
});
|
|
910
|
-
};
|
|
911
|
-
const getSSOProviderQuerySchema = z.object({ providerId: z.string() });
|
|
912
|
-
async function checkProviderAccess(ctx, providerId) {
|
|
913
|
-
const userId = ctx.context.session.user.id;
|
|
914
|
-
const provider = await ctx.context.adapter.findOne({
|
|
915
|
-
model: "ssoProvider",
|
|
916
|
-
where: [{
|
|
917
|
-
field: "providerId",
|
|
918
|
-
value: providerId
|
|
919
|
-
}]
|
|
920
|
-
});
|
|
921
|
-
if (!provider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
|
|
922
|
-
let hasAccess = false;
|
|
923
|
-
if (provider.organizationId) if (ctx.context.hasPlugin("organization")) hasAccess = await isOrgAdmin(ctx, userId, provider.organizationId);
|
|
924
|
-
else hasAccess = provider.userId === userId;
|
|
925
|
-
else hasAccess = provider.userId === userId;
|
|
926
|
-
if (!hasAccess) throw new APIError("FORBIDDEN", { message: "You don't have access to this provider" });
|
|
927
|
-
return provider;
|
|
940
|
+
function handleDeprecatedAlgorithm(message, behavior, errorCode) {
|
|
941
|
+
switch (behavior) {
|
|
942
|
+
case "reject": throw new APIError("BAD_REQUEST", {
|
|
943
|
+
message,
|
|
944
|
+
code: errorCode
|
|
945
|
+
});
|
|
946
|
+
case "warn":
|
|
947
|
+
console.warn(`[SAML Security Warning] ${message}`);
|
|
948
|
+
break;
|
|
949
|
+
case "allow": break;
|
|
950
|
+
}
|
|
928
951
|
}
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
const provider = await checkProviderAccess(ctx, providerId);
|
|
947
|
-
return ctx.json(sanitizeProvider(provider, ctx.context.baseURL));
|
|
952
|
+
function validateSignatureAlgorithm(algorithm, options = {}) {
|
|
953
|
+
if (!algorithm) return;
|
|
954
|
+
const { onDeprecated = "warn", allowedSignatureAlgorithms } = options;
|
|
955
|
+
if (allowedSignatureAlgorithms) {
|
|
956
|
+
if (!allowedSignatureAlgorithms.includes(algorithm)) throw new APIError("BAD_REQUEST", {
|
|
957
|
+
message: `SAML signature algorithm not in allow-list: ${algorithm}`,
|
|
958
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
959
|
+
});
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(algorithm)) {
|
|
963
|
+
handleDeprecatedAlgorithm(`SAML response uses deprecated signature algorithm: ${algorithm}. Please configure your IdP to use SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
if (!SECURE_SIGNATURE_ALGORITHMS.includes(algorithm)) throw new APIError("BAD_REQUEST", {
|
|
967
|
+
message: `SAML signature algorithm not recognized: ${algorithm}`,
|
|
968
|
+
code: "SAML_UNKNOWN_ALGORITHM"
|
|
948
969
|
});
|
|
949
|
-
}
|
|
950
|
-
function
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
970
|
+
}
|
|
971
|
+
function validateEncryptionAlgorithms(algorithms, options = {}) {
|
|
972
|
+
const { onDeprecated = "warn", allowedKeyEncryptionAlgorithms, allowedDataEncryptionAlgorithms } = options;
|
|
973
|
+
const { keyEncryption, dataEncryption } = algorithms;
|
|
974
|
+
if (keyEncryption) {
|
|
975
|
+
if (allowedKeyEncryptionAlgorithms) {
|
|
976
|
+
if (!allowedKeyEncryptionAlgorithms.includes(keyEncryption)) throw new APIError("BAD_REQUEST", {
|
|
977
|
+
message: `SAML key encryption algorithm not in allow-list: ${keyEncryption}`,
|
|
978
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
979
|
+
});
|
|
980
|
+
} else if (DEPRECATED_KEY_ENCRYPTION_ALGORITHMS.includes(keyEncryption)) handleDeprecatedAlgorithm(`SAML response uses deprecated key encryption algorithm: ${keyEncryption}. Please configure your IdP to use RSA-OAEP.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
|
|
981
|
+
}
|
|
982
|
+
if (dataEncryption) {
|
|
983
|
+
if (allowedDataEncryptionAlgorithms) {
|
|
984
|
+
if (!allowedDataEncryptionAlgorithms.includes(dataEncryption)) throw new APIError("BAD_REQUEST", {
|
|
985
|
+
message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
|
|
986
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
987
|
+
});
|
|
988
|
+
} else if (DEPRECATED_DATA_ENCRYPTION_ALGORITHMS.includes(dataEncryption)) handleDeprecatedAlgorithm(`SAML response uses deprecated data encryption algorithm: ${dataEncryption}. Please configure your IdP to use AES-GCM.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
|
|
956
989
|
}
|
|
957
|
-
if (!config) throw new APIError("BAD_REQUEST", { message: `Cannot update ${configType} config for a provider that doesn't have ${configType} configured` });
|
|
958
|
-
return config;
|
|
959
990
|
}
|
|
960
|
-
function
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
991
|
+
function validateSAMLAlgorithms(response, options) {
|
|
992
|
+
validateSignatureAlgorithm(response.sigAlg, options);
|
|
993
|
+
if (hasEncryptedAssertion(response.samlContent)) validateEncryptionAlgorithms(extractEncryptionAlgorithms(response.samlContent), options);
|
|
994
|
+
}
|
|
995
|
+
function validateConfigAlgorithms(config, options = {}) {
|
|
996
|
+
const { onDeprecated = "warn", allowedSignatureAlgorithms, allowedDigestAlgorithms } = options;
|
|
997
|
+
if (config.signatureAlgorithm) {
|
|
998
|
+
const normalized = normalizeSignatureAlgorithm(config.signatureAlgorithm);
|
|
999
|
+
if (allowedSignatureAlgorithms) {
|
|
1000
|
+
if (!allowedSignatureAlgorithms.map(normalizeSignatureAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
1001
|
+
message: `SAML signature algorithm not in allow-list: ${config.signatureAlgorithm}`,
|
|
1002
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
1003
|
+
});
|
|
1004
|
+
} else if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(normalized)) handleDeprecatedAlgorithm(`SAML config uses deprecated signature algorithm: ${config.signatureAlgorithm}. Consider using SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_CONFIG_ALGORITHM");
|
|
1005
|
+
else if (!SECURE_SIGNATURE_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
1006
|
+
message: `SAML signature algorithm not recognized: ${config.signatureAlgorithm}`,
|
|
1007
|
+
code: "SAML_UNKNOWN_ALGORITHM"
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
if (config.digestAlgorithm) {
|
|
1011
|
+
const normalized = normalizeDigestAlgorithm(config.digestAlgorithm);
|
|
1012
|
+
if (allowedDigestAlgorithms) {
|
|
1013
|
+
if (!allowedDigestAlgorithms.map(normalizeDigestAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
1014
|
+
message: `SAML digest algorithm not in allow-list: ${config.digestAlgorithm}`,
|
|
1015
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
1016
|
+
});
|
|
1017
|
+
} else if (DEPRECATED_DIGEST_ALGORITHMS.includes(normalized)) handleDeprecatedAlgorithm(`SAML config uses deprecated digest algorithm: ${config.digestAlgorithm}. Consider using SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_CONFIG_ALGORITHM");
|
|
1018
|
+
else if (!SECURE_DIGEST_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
1019
|
+
message: `SAML digest algorithm not recognized: ${config.digestAlgorithm}`,
|
|
1020
|
+
code: "SAML_UNKNOWN_ALGORITHM"
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
978
1023
|
}
|
|
979
|
-
|
|
1024
|
+
//#endregion
|
|
1025
|
+
//#region src/saml/assertions.ts
|
|
1026
|
+
function countAssertions(xml) {
|
|
1027
|
+
let parsed;
|
|
1028
|
+
try {
|
|
1029
|
+
parsed = xmlParser.parse(xml);
|
|
1030
|
+
} catch {
|
|
1031
|
+
throw new APIError("BAD_REQUEST", {
|
|
1032
|
+
message: "Failed to parse SAML response XML",
|
|
1033
|
+
code: "SAML_INVALID_XML"
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
const assertions = countAllNodes(parsed, "Assertion");
|
|
1037
|
+
const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
|
|
980
1038
|
return {
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
pkce: updates.pkce ?? current.pkce ?? true,
|
|
985
|
-
clientId: updates.clientId ?? current.clientId,
|
|
986
|
-
clientSecret: updates.clientSecret ?? current.clientSecret,
|
|
987
|
-
discoveryEndpoint: updates.discoveryEndpoint ?? current.discoveryEndpoint,
|
|
988
|
-
mapping: updates.mapping ?? current.mapping,
|
|
989
|
-
scopes: updates.scopes ?? current.scopes,
|
|
990
|
-
authorizationEndpoint: updates.authorizationEndpoint ?? current.authorizationEndpoint,
|
|
991
|
-
tokenEndpoint: updates.tokenEndpoint ?? current.tokenEndpoint,
|
|
992
|
-
userInfoEndpoint: updates.userInfoEndpoint ?? current.userInfoEndpoint,
|
|
993
|
-
jwksEndpoint: updates.jwksEndpoint ?? current.jwksEndpoint,
|
|
994
|
-
tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication,
|
|
995
|
-
privateKeyId: updates.privateKeyId ?? current.privateKeyId,
|
|
996
|
-
privateKeyAlgorithm: updates.privateKeyAlgorithm ?? current.privateKeyAlgorithm
|
|
1039
|
+
assertions,
|
|
1040
|
+
encryptedAssertions,
|
|
1041
|
+
total: assertions + encryptedAssertions
|
|
997
1042
|
};
|
|
998
1043
|
}
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
responses: {
|
|
1009
|
-
"200": { description: "SSO provider updated successfully" },
|
|
1010
|
-
"404": { description: "Provider not found" },
|
|
1011
|
-
"403": { description: "Access denied" }
|
|
1012
|
-
}
|
|
1013
|
-
} }
|
|
1014
|
-
}, async (ctx) => {
|
|
1015
|
-
const { providerId, ...body } = ctx.body;
|
|
1016
|
-
const { issuer, domain, samlConfig, oidcConfig } = body;
|
|
1017
|
-
if (!issuer && !domain && !samlConfig && !oidcConfig) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
|
|
1018
|
-
const existingProvider = await checkProviderAccess(ctx, providerId);
|
|
1019
|
-
const updateData = {};
|
|
1020
|
-
if (body.issuer !== void 0) updateData.issuer = body.issuer;
|
|
1021
|
-
if (body.domain !== void 0) {
|
|
1022
|
-
updateData.domain = body.domain;
|
|
1023
|
-
if (body.domain !== existingProvider.domain) updateData.domainVerified = false;
|
|
1024
|
-
}
|
|
1025
|
-
if (body.samlConfig) {
|
|
1026
|
-
if (body.samlConfig.idpMetadata?.metadata) {
|
|
1027
|
-
const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
|
|
1028
|
-
if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
|
|
1029
|
-
}
|
|
1030
|
-
if (body.samlConfig.signatureAlgorithm !== void 0 || body.samlConfig.digestAlgorithm !== void 0) validateConfigAlgorithms({
|
|
1031
|
-
signatureAlgorithm: body.samlConfig.signatureAlgorithm,
|
|
1032
|
-
digestAlgorithm: body.samlConfig.digestAlgorithm
|
|
1033
|
-
}, options?.saml?.algorithms);
|
|
1034
|
-
const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
|
|
1035
|
-
const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
|
|
1036
|
-
updateData.samlConfig = JSON.stringify(updatedSamlConfig);
|
|
1037
|
-
}
|
|
1038
|
-
if (body.oidcConfig) {
|
|
1039
|
-
const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
|
|
1040
|
-
const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
|
|
1041
|
-
if (updatedOidcConfig.tokenEndpointAuthentication !== "private_key_jwt" && !updatedOidcConfig.clientSecret) throw new APIError("BAD_REQUEST", { message: "clientSecret is required when using client_secret_basic or client_secret_post authentication" });
|
|
1042
|
-
if (updatedOidcConfig.tokenEndpointAuthentication === "private_key_jwt" && !options?.resolvePrivateKey && !options?.defaultSSO?.some((p) => p.providerId === providerId && "privateKey" in p && p.privateKey)) throw new APIError("BAD_REQUEST", { message: "private_key_jwt authentication requires either a resolvePrivateKey callback or a privateKey in defaultSSO" });
|
|
1043
|
-
updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
|
|
1044
|
-
}
|
|
1045
|
-
await ctx.context.adapter.update({
|
|
1046
|
-
model: "ssoProvider",
|
|
1047
|
-
where: [{
|
|
1048
|
-
field: "providerId",
|
|
1049
|
-
value: providerId
|
|
1050
|
-
}],
|
|
1051
|
-
update: updateData
|
|
1052
|
-
});
|
|
1053
|
-
const fullProvider = await ctx.context.adapter.findOne({
|
|
1054
|
-
model: "ssoProvider",
|
|
1055
|
-
where: [{
|
|
1056
|
-
field: "providerId",
|
|
1057
|
-
value: providerId
|
|
1058
|
-
}]
|
|
1044
|
+
function validateSingleAssertion(samlResponse) {
|
|
1045
|
+
let xml;
|
|
1046
|
+
try {
|
|
1047
|
+
xml = new TextDecoder().decode(base64.decode(samlResponse.replace(/\s+/g, "")));
|
|
1048
|
+
if (!xml.includes("<")) throw new Error("Not XML");
|
|
1049
|
+
} catch {
|
|
1050
|
+
throw new APIError("BAD_REQUEST", {
|
|
1051
|
+
message: "Invalid base64-encoded SAML response",
|
|
1052
|
+
code: "SAML_INVALID_ENCODING"
|
|
1059
1053
|
});
|
|
1060
|
-
|
|
1061
|
-
|
|
1054
|
+
}
|
|
1055
|
+
const counts = countAssertions(xml);
|
|
1056
|
+
if (counts.total === 0) throw new APIError("BAD_REQUEST", {
|
|
1057
|
+
message: "SAML response contains no assertions",
|
|
1058
|
+
code: "SAML_NO_ASSERTION"
|
|
1062
1059
|
});
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
method: "POST",
|
|
1067
|
-
use: [sessionMiddleware],
|
|
1068
|
-
body: z.object({ providerId: z.string() }),
|
|
1069
|
-
metadata: { openapi: {
|
|
1070
|
-
operationId: "deleteSSOProvider",
|
|
1071
|
-
summary: "Delete SSO provider",
|
|
1072
|
-
description: "Deletes an SSO provider",
|
|
1073
|
-
responses: {
|
|
1074
|
-
"200": { description: "SSO provider deleted successfully" },
|
|
1075
|
-
"404": { description: "Provider not found" },
|
|
1076
|
-
"403": { description: "Access denied" }
|
|
1077
|
-
}
|
|
1078
|
-
} }
|
|
1079
|
-
}, async (ctx) => {
|
|
1080
|
-
const { providerId } = ctx.body;
|
|
1081
|
-
await checkProviderAccess(ctx, providerId);
|
|
1082
|
-
await ctx.context.adapter.delete({
|
|
1083
|
-
model: "ssoProvider",
|
|
1084
|
-
where: [{
|
|
1085
|
-
field: "providerId",
|
|
1086
|
-
value: providerId
|
|
1087
|
-
}]
|
|
1088
|
-
});
|
|
1089
|
-
return ctx.json({ success: true });
|
|
1060
|
+
if (counts.total > 1) throw new APIError("BAD_REQUEST", {
|
|
1061
|
+
message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
|
|
1062
|
+
code: "SAML_MULTIPLE_ASSERTIONS"
|
|
1090
1063
|
});
|
|
1091
|
-
}
|
|
1064
|
+
}
|
|
1092
1065
|
//#endregion
|
|
1093
|
-
//#region src/
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
this.name = "DiscoveryError";
|
|
1104
|
-
this.code = code;
|
|
1105
|
-
this.details = details;
|
|
1106
|
-
if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
|
|
1107
|
-
}
|
|
1108
|
-
};
|
|
1109
|
-
/**
|
|
1110
|
-
* Required fields that must be present in a valid discovery document.
|
|
1111
|
-
*/
|
|
1112
|
-
const REQUIRED_DISCOVERY_FIELDS = [
|
|
1113
|
-
"issuer",
|
|
1114
|
-
"authorization_endpoint",
|
|
1115
|
-
"token_endpoint",
|
|
1116
|
-
"jwks_uri"
|
|
1117
|
-
];
|
|
1066
|
+
//#region src/saml/error-codes.ts
|
|
1067
|
+
const SAML_ERROR_CODES = defineErrorCodes({
|
|
1068
|
+
SINGLE_LOGOUT_NOT_ENABLED: "Single Logout is not enabled",
|
|
1069
|
+
INVALID_LOGOUT_RESPONSE: "Invalid LogoutResponse",
|
|
1070
|
+
INVALID_LOGOUT_REQUEST: "Invalid LogoutRequest",
|
|
1071
|
+
LOGOUT_FAILED_AT_IDP: "Logout failed at IdP",
|
|
1072
|
+
IDP_SLO_NOT_SUPPORTED: "IdP does not support Single Logout Service",
|
|
1073
|
+
SAML_PROVIDER_NOT_FOUND: "SAML provider not found",
|
|
1074
|
+
CERT_SOURCE_MISSING: "samlConfig requires either a signing certificate (cert or idpMetadata.cert) or an idpMetadata.metadata XML document."
|
|
1075
|
+
});
|
|
1118
1076
|
//#endregion
|
|
1119
|
-
//#region src/
|
|
1077
|
+
//#region src/saml/cert.ts
|
|
1120
1078
|
/**
|
|
1121
|
-
*
|
|
1122
|
-
*
|
|
1123
|
-
*
|
|
1124
|
-
*
|
|
1125
|
-
* and at runtime (to hydrate legacy providers that are missing metadata).
|
|
1126
|
-
*
|
|
1127
|
-
* @see https://openid.net/specs/openid-connect-discovery-1_0.html
|
|
1079
|
+
* IdP signing-certificate rules for SAML configs. Centralized so the runtime
|
|
1080
|
+
* verification path (`createIdP`), the sanitizer (`getSSOProvider` and
|
|
1081
|
+
* friends), and the registration validator agree on precedence and the
|
|
1082
|
+
* "exactly one cert source" contract.
|
|
1128
1083
|
*/
|
|
1129
|
-
/** Default timeout for discovery requests (10 seconds) */
|
|
1130
|
-
const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
|
|
1131
1084
|
/**
|
|
1132
|
-
*
|
|
1133
|
-
*
|
|
1134
|
-
*
|
|
1135
|
-
*
|
|
1136
|
-
* 2. Validates the discovery URL
|
|
1137
|
-
* 3. Fetches the discovery document
|
|
1138
|
-
* 4. Validates the discovery document (issuer match + required fields)
|
|
1139
|
-
* 5. Normalizes URLs
|
|
1140
|
-
* 6. Selects token endpoint auth method
|
|
1141
|
-
* 7. Merges with existing config (existing values take precedence)
|
|
1142
|
-
*
|
|
1143
|
-
* @param params - Discovery parameters
|
|
1144
|
-
* @param isTrustedOrigin - Origin verification tester function
|
|
1145
|
-
* @returns Hydrated OIDC configuration ready for persistence
|
|
1146
|
-
* @throws DiscoveryError on any failure
|
|
1085
|
+
* Returns the IdP signing certificates Better Auth trusts for this provider
|
|
1086
|
+
* as a list. `idpMetadata.cert` wins when both are set; the top-level `cert`
|
|
1087
|
+
* is the fallback. Returns `undefined` when neither is set (the certs come
|
|
1088
|
+
* from `idpMetadata.metadata` XML instead).
|
|
1147
1089
|
*/
|
|
1148
|
-
|
|
1149
|
-
const
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
|
|
1153
|
-
validateDiscoveryDocument(discoveryDoc, issuer);
|
|
1154
|
-
const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
|
|
1155
|
-
const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
|
|
1156
|
-
return {
|
|
1157
|
-
issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
|
|
1158
|
-
discoveryEndpoint: existingConfig?.discoveryEndpoint ?? discoveryUrl,
|
|
1159
|
-
authorizationEndpoint: existingConfig?.authorizationEndpoint ?? normalizedDoc.authorization_endpoint,
|
|
1160
|
-
tokenEndpoint: existingConfig?.tokenEndpoint ?? normalizedDoc.token_endpoint,
|
|
1161
|
-
jwksEndpoint: existingConfig?.jwksEndpoint ?? normalizedDoc.jwks_uri,
|
|
1162
|
-
userInfoEndpoint: existingConfig?.userInfoEndpoint ?? normalizedDoc.userinfo_endpoint,
|
|
1163
|
-
tokenEndpointAuthentication: existingConfig?.tokenEndpointAuthentication ?? tokenEndpointAuth,
|
|
1164
|
-
scopesSupported: existingConfig?.scopesSupported ?? normalizedDoc.scopes_supported
|
|
1165
|
-
};
|
|
1090
|
+
function resolveSigningCerts(config) {
|
|
1091
|
+
const cert = config.idpMetadata?.cert ?? config.cert;
|
|
1092
|
+
if (cert === void 0) return void 0;
|
|
1093
|
+
return Array.isArray(cert) ? cert : [cert];
|
|
1166
1094
|
}
|
|
1167
1095
|
/**
|
|
1168
|
-
*
|
|
1169
|
-
*
|
|
1170
|
-
*
|
|
1171
|
-
*
|
|
1172
|
-
*
|
|
1173
|
-
* Handles trailing slashes correctly.
|
|
1096
|
+
* Reject SAML configs with no signing-cert source. samlify needs either an
|
|
1097
|
+
* `idpMetadata.metadata` XML document (which embeds the certs) or an explicit
|
|
1098
|
+
* PEM under `cert` or `idpMetadata.cert`; without one of those it has nothing
|
|
1099
|
+
* to verify responses against.
|
|
1174
1100
|
*/
|
|
1175
|
-
function
|
|
1176
|
-
|
|
1101
|
+
function validateCertSources(config) {
|
|
1102
|
+
const hasMetadataXml = !!config.idpMetadata?.metadata;
|
|
1103
|
+
const hasExplicitCert = config.idpMetadata?.cert !== void 0 || config.cert !== void 0;
|
|
1104
|
+
if (!hasMetadataXml && !hasExplicitCert) throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.CERT_SOURCE_MISSING);
|
|
1177
1105
|
}
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
if (
|
|
1106
|
+
//#endregion
|
|
1107
|
+
//#region src/saml/response-binding.ts
|
|
1108
|
+
const SAML_HTTP_POST_BINDING = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
|
|
1109
|
+
const SAML_BEARER_CONFIRMATION_METHOD = "urn:oasis:names:tc:SAML:2.0:cm:bearer";
|
|
1110
|
+
function toNode(value) {
|
|
1111
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
1112
|
+
return value;
|
|
1113
|
+
}
|
|
1114
|
+
function toNodeArray(value) {
|
|
1115
|
+
if (Array.isArray(value)) return value.map(toNode).filter((node) => !!node);
|
|
1116
|
+
const node = toNode(value);
|
|
1117
|
+
return node ? [node] : [];
|
|
1118
|
+
}
|
|
1119
|
+
function firstNode(value) {
|
|
1120
|
+
return toNodeArray(value)[0] ?? null;
|
|
1121
|
+
}
|
|
1122
|
+
function toStringArray(value) {
|
|
1123
|
+
if (typeof value === "string") return [value];
|
|
1124
|
+
if (Array.isArray(value)) return value.flatMap(toStringArray);
|
|
1125
|
+
const text = toNode(value)?.["#text"];
|
|
1126
|
+
return typeof text === "string" ? [text] : [];
|
|
1127
|
+
}
|
|
1128
|
+
function toStringSet(values) {
|
|
1129
|
+
return new Set(values.filter((value) => !!value));
|
|
1130
|
+
}
|
|
1131
|
+
function parseSAMLContent(samlContent) {
|
|
1132
|
+
try {
|
|
1133
|
+
const parsed = toNode(xmlParser.parse(samlContent));
|
|
1134
|
+
if (parsed) return parsed;
|
|
1135
|
+
} catch {}
|
|
1136
|
+
throw new APIError("BAD_REQUEST", {
|
|
1137
|
+
message: "SAML response XML could not be parsed",
|
|
1138
|
+
code: "SAML_RESPONSE_INVALID_XML"
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
function getSAMLPostAssertionConsumerServiceUrls(metadata) {
|
|
1142
|
+
if (!metadata) return [];
|
|
1143
|
+
try {
|
|
1144
|
+
const locations = toNodeArray(findNode(toNode(xmlParser.parse(metadata)), "SPSSODescriptor")).flatMap((descriptor) => toNodeArray(descriptor.AssertionConsumerService).filter((service) => service["@_Binding"] === SAML_HTTP_POST_BINDING).map((service) => service["@_Location"]).filter((location) => typeof location === "string" && !!location));
|
|
1145
|
+
return [...new Set(locations)];
|
|
1146
|
+
} catch {
|
|
1147
|
+
return [];
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
function hasSAMLEncryptedAssertion(samlContent) {
|
|
1151
|
+
try {
|
|
1152
|
+
return countAllNodes(xmlParser.parse(samlContent), "EncryptedAssertion") > 0;
|
|
1153
|
+
} catch {
|
|
1154
|
+
return false;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
function getResponseNode(parsed) {
|
|
1158
|
+
return firstNode(parsed.Response);
|
|
1159
|
+
}
|
|
1160
|
+
function getAssertionNode(parsed, response) {
|
|
1161
|
+
const assertion = firstNode(response?.Assertion) ?? firstNode(parsed.Assertion);
|
|
1162
|
+
if (assertion) return assertion;
|
|
1163
|
+
throw new APIError("BAD_REQUEST", {
|
|
1164
|
+
message: "SAML response is missing an assertion",
|
|
1165
|
+
code: "SAML_ASSERTION_MISSING"
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
function getAudienceRestrictionGroups(assertion) {
|
|
1169
|
+
return toNodeArray(firstNode(assertion.Conditions)?.AudienceRestriction).map((restriction) => toStringArray(restriction.Audience).filter(Boolean));
|
|
1170
|
+
}
|
|
1171
|
+
function validateAudienceRestrictions(assertion, expectedAudiences) {
|
|
1172
|
+
const audienceGroups = getAudienceRestrictionGroups(assertion);
|
|
1173
|
+
if (audienceGroups.length === 0 || audienceGroups.every((group) => !group.length)) throw new APIError("BAD_REQUEST", {
|
|
1174
|
+
message: "SAML assertion is missing an AudienceRestriction",
|
|
1175
|
+
code: "SAML_AUDIENCE_MISSING"
|
|
1176
|
+
});
|
|
1177
|
+
if (audienceGroups.some((group) => !group.some((audience) => expectedAudiences.has(audience)))) throw new APIError("BAD_REQUEST", {
|
|
1178
|
+
message: "SAML assertion audience does not match this Service Provider",
|
|
1179
|
+
code: "SAML_AUDIENCE_MISMATCH"
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
function getBearerSubjectConfirmationData(assertion) {
|
|
1183
|
+
return toNodeArray(firstNode(assertion.Subject)?.SubjectConfirmation).filter((confirmation) => confirmation["@_Method"] === SAML_BEARER_CONFIRMATION_METHOD).map((confirmation) => firstNode(confirmation.SubjectConfirmationData)).filter((data) => !!data);
|
|
1184
|
+
}
|
|
1185
|
+
function validateBearerRecipient(assertion, expectedRecipients) {
|
|
1186
|
+
const confirmationData = getBearerSubjectConfirmationData(assertion);
|
|
1187
|
+
if (!confirmationData.length) throw new APIError("BAD_REQUEST", {
|
|
1188
|
+
message: "SAML assertion is missing bearer SubjectConfirmationData",
|
|
1189
|
+
code: "SAML_BEARER_CONFIRMATION_MISSING"
|
|
1190
|
+
});
|
|
1191
|
+
const recipients = confirmationData.map((data) => data["@_Recipient"]).filter((recipient) => typeof recipient === "string");
|
|
1192
|
+
if (!recipients.length) throw new APIError("BAD_REQUEST", {
|
|
1193
|
+
message: "SAML bearer SubjectConfirmationData is missing a Recipient",
|
|
1194
|
+
code: "SAML_RECIPIENT_MISSING"
|
|
1195
|
+
});
|
|
1196
|
+
if (!recipients.some((recipient) => expectedRecipients.has(recipient))) throw new APIError("BAD_REQUEST", {
|
|
1197
|
+
message: "SAML bearer SubjectConfirmationData Recipient does not match this Service Provider",
|
|
1198
|
+
code: "SAML_RECIPIENT_MISMATCH"
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
function validateResponseDestination(response, expectedRecipients) {
|
|
1202
|
+
const destination = response?.["@_Destination"];
|
|
1203
|
+
if (typeof destination !== "string" || !destination) return;
|
|
1204
|
+
if (!expectedRecipients.has(destination)) throw new APIError("BAD_REQUEST", {
|
|
1205
|
+
message: "SAML response Destination does not match this Service Provider",
|
|
1206
|
+
code: "SAML_DESTINATION_MISMATCH"
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
function validateSAMLResponseBinding(samlContent, options) {
|
|
1210
|
+
const expectedAudiences = toStringSet(options.expectedAudiences);
|
|
1211
|
+
const expectedRecipients = toStringSet(options.expectedRecipients);
|
|
1212
|
+
const parsed = parseSAMLContent(samlContent);
|
|
1213
|
+
const response = getResponseNode(parsed);
|
|
1214
|
+
const assertion = getAssertionNode(parsed, response);
|
|
1215
|
+
validateAudienceRestrictions(assertion, expectedAudiences);
|
|
1216
|
+
validateBearerRecipient(assertion, expectedRecipients);
|
|
1217
|
+
validateResponseDestination(response, expectedRecipients);
|
|
1218
|
+
}
|
|
1219
|
+
//#endregion
|
|
1220
|
+
//#region src/saml/response-validation.ts
|
|
1221
|
+
function errorRedirectUrl(base, error, description) {
|
|
1222
|
+
try {
|
|
1223
|
+
const url = new URL(base);
|
|
1224
|
+
url.searchParams.set("error", error);
|
|
1225
|
+
url.searchParams.set("error_description", description);
|
|
1226
|
+
return url.toString();
|
|
1227
|
+
} catch {
|
|
1228
|
+
const hashIdx = base.indexOf("#");
|
|
1229
|
+
const path = hashIdx >= 0 ? base.slice(0, hashIdx) : base;
|
|
1230
|
+
const hash = hashIdx >= 0 ? base.slice(hashIdx + 1) : void 0;
|
|
1231
|
+
return `${path}${path.includes("?") ? "&" : "?"}${`error=${encodeURIComponent(error)}&error_description=${encodeURIComponent(description)}`}${hash ? `#${hash}` : ""}`;
|
|
1232
|
+
}
|
|
1188
1233
|
}
|
|
1189
1234
|
/**
|
|
1190
|
-
*
|
|
1235
|
+
* Validates the InResponseTo attribute of a SAML Response.
|
|
1191
1236
|
*
|
|
1192
|
-
*
|
|
1193
|
-
*
|
|
1194
|
-
*
|
|
1195
|
-
*
|
|
1237
|
+
* This binds the IdP's Response to a specific SP-initiated AuthnRequest,
|
|
1238
|
+
* preventing replay attacks, unsolicited response injection, and
|
|
1239
|
+
* cross-provider assertion swaps.
|
|
1240
|
+
*
|
|
1241
|
+
* The InResponseTo value lives at `extract.response.inResponseTo` in
|
|
1242
|
+
* samlify's parsed output (not at the top level).
|
|
1196
1243
|
*/
|
|
1197
|
-
async function
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
});
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1244
|
+
async function validateInResponseTo(c, ctx) {
|
|
1245
|
+
if (ctx.options.enableInResponseToValidation === false) return;
|
|
1246
|
+
const inResponseTo = ctx.extract.response?.inResponseTo;
|
|
1247
|
+
const allowIdpInitiated = ctx.options.allowIdpInitiated ?? false;
|
|
1248
|
+
if (inResponseTo) {
|
|
1249
|
+
const consumed = await c.context.internalAdapter.consumeVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
1250
|
+
let storedRequest = null;
|
|
1251
|
+
if (consumed) try {
|
|
1252
|
+
storedRequest = JSON.parse(consumed.value);
|
|
1253
|
+
} catch {
|
|
1254
|
+
storedRequest = null;
|
|
1255
|
+
}
|
|
1256
|
+
if (!storedRequest) {
|
|
1257
|
+
c.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
|
|
1258
|
+
inResponseTo,
|
|
1259
|
+
providerId: ctx.providerId
|
|
1212
1260
|
});
|
|
1213
|
-
throw
|
|
1214
|
-
|
|
1215
|
-
|
|
1261
|
+
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Unknown or expired request ID"));
|
|
1262
|
+
}
|
|
1263
|
+
if (storedRequest.providerId !== ctx.providerId) {
|
|
1264
|
+
c.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
|
|
1265
|
+
inResponseTo,
|
|
1266
|
+
expectedProvider: storedRequest.providerId,
|
|
1267
|
+
actualProvider: ctx.providerId
|
|
1216
1268
|
});
|
|
1269
|
+
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Provider mismatch"));
|
|
1217
1270
|
}
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
url,
|
|
1222
|
-
bodyPreview: data.slice(0, 200)
|
|
1223
|
-
});
|
|
1224
|
-
return data;
|
|
1225
|
-
} catch (error) {
|
|
1226
|
-
if (error instanceof DiscoveryError) throw error;
|
|
1227
|
-
if (error instanceof Error && error.name === "AbortError") throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
|
|
1228
|
-
url,
|
|
1229
|
-
timeout
|
|
1230
|
-
});
|
|
1231
|
-
throw new DiscoveryError("discovery_unexpected_error", `Unexpected error during discovery: ${error instanceof Error ? error.message : String(error)}`, { url }, { cause: error });
|
|
1271
|
+
} else if (!allowIdpInitiated) {
|
|
1272
|
+
c.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: ctx.providerId });
|
|
1273
|
+
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "unsolicited_response", "IdP-initiated SSO not allowed"));
|
|
1232
1274
|
}
|
|
1233
1275
|
}
|
|
1234
1276
|
/**
|
|
1235
|
-
*
|
|
1236
|
-
*
|
|
1237
|
-
* Checks:
|
|
1238
|
-
* 1. All required fields are present
|
|
1239
|
-
* 2. Issuer matches the configured issuer (case-sensitive, exact match)
|
|
1240
|
-
*
|
|
1241
|
-
* Invariant: If this function returns without throwing, the document is safe
|
|
1242
|
-
* to use for hydrating OIDC config (required fields present, issuer matches
|
|
1243
|
-
* configured value, basic structural sanity verified).
|
|
1277
|
+
* Validates the AudienceRestriction of a SAML assertion.
|
|
1244
1278
|
*
|
|
1245
|
-
*
|
|
1246
|
-
*
|
|
1247
|
-
*
|
|
1279
|
+
* Per SAML 2.0 Core §2.5.1, an assertion's Audience element specifies
|
|
1280
|
+
* the intended recipient SP. Without this check, an assertion issued
|
|
1281
|
+
* for a different SP (e.g., another application sharing the same IdP)
|
|
1282
|
+
* could be accepted.
|
|
1248
1283
|
*/
|
|
1249
|
-
function
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1284
|
+
function validateAudience(c, ctx) {
|
|
1285
|
+
if (!ctx.expectedAudience) {
|
|
1286
|
+
c.context.logger.warn("Could not determine SP entity ID for audience validation; skipping", { providerId: ctx.providerId });
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
const audience = ctx.extract.audience;
|
|
1290
|
+
if (!audience) {
|
|
1291
|
+
c.context.logger.error("SAML assertion missing AudienceRestriction but audience is configured — rejecting", { providerId: ctx.providerId });
|
|
1292
|
+
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Audience restriction missing"));
|
|
1293
|
+
}
|
|
1294
|
+
const audiences = Array.isArray(audience) ? audience : [audience];
|
|
1295
|
+
if (!audiences.includes(ctx.expectedAudience)) {
|
|
1296
|
+
c.context.logger.error("SAML audience mismatch: assertion was issued for a different service provider", {
|
|
1297
|
+
expected: ctx.expectedAudience,
|
|
1298
|
+
received: audiences,
|
|
1299
|
+
providerId: ctx.providerId
|
|
1300
|
+
});
|
|
1301
|
+
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Audience mismatch"));
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
//#endregion
|
|
1305
|
+
//#region src/routes/schemas.ts
|
|
1306
|
+
function getSSOProviderAdditionalFields$1(options) {
|
|
1307
|
+
return options?.schema?.ssoProvider?.additionalFields ?? {};
|
|
1308
|
+
}
|
|
1309
|
+
function getSSOProviderAdditionalFieldsSchema(options) {
|
|
1310
|
+
const additionalFields = getSSOProviderAdditionalFields$1(options);
|
|
1311
|
+
const schema = toZodSchema({
|
|
1312
|
+
fields: additionalFields,
|
|
1313
|
+
isClientSide: true
|
|
1256
1314
|
});
|
|
1315
|
+
const blockedInputFields = {};
|
|
1316
|
+
for (const key in additionalFields) if (additionalFields[key]?.input === false) blockedInputFields[key] = z.any().optional();
|
|
1317
|
+
return schema.extend(blockedInputFields);
|
|
1257
1318
|
}
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
*
|
|
1261
|
-
* @param document - The discovery document
|
|
1262
|
-
* @param issuer - The base issuer URL
|
|
1263
|
-
* @param isTrustedOrigin - Origin verification tester function
|
|
1264
|
-
* @returns The normalized discovery document
|
|
1265
|
-
*/
|
|
1266
|
-
function normalizeDiscoveryUrls(document, issuer, isTrustedOrigin) {
|
|
1267
|
-
const doc = { ...document };
|
|
1268
|
-
doc.token_endpoint = normalizeAndValidateUrl("token_endpoint", doc.token_endpoint, issuer, isTrustedOrigin);
|
|
1269
|
-
doc.authorization_endpoint = normalizeAndValidateUrl("authorization_endpoint", doc.authorization_endpoint, issuer, isTrustedOrigin);
|
|
1270
|
-
doc.jwks_uri = normalizeAndValidateUrl("jwks_uri", doc.jwks_uri, issuer, isTrustedOrigin);
|
|
1271
|
-
if (doc.userinfo_endpoint) doc.userinfo_endpoint = normalizeAndValidateUrl("userinfo_endpoint", doc.userinfo_endpoint, issuer, isTrustedOrigin);
|
|
1272
|
-
if (doc.revocation_endpoint) doc.revocation_endpoint = normalizeAndValidateUrl("revocation_endpoint", doc.revocation_endpoint, issuer, isTrustedOrigin);
|
|
1273
|
-
if (doc.end_session_endpoint) doc.end_session_endpoint = normalizeAndValidateUrl("end_session_endpoint", doc.end_session_endpoint, issuer, isTrustedOrigin);
|
|
1274
|
-
if (doc.introspection_endpoint) doc.introspection_endpoint = normalizeAndValidateUrl("introspection_endpoint", doc.introspection_endpoint, issuer, isTrustedOrigin);
|
|
1275
|
-
return doc;
|
|
1319
|
+
function assertNoBlockedAdditionalFieldInput(fields, data) {
|
|
1320
|
+
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` });
|
|
1276
1321
|
}
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1322
|
+
function parseSSOProviderAdditionalFields(options, data, action) {
|
|
1323
|
+
const fields = getSSOProviderAdditionalFields$1(options);
|
|
1324
|
+
assertNoBlockedAdditionalFieldInput(fields, data);
|
|
1325
|
+
return parseInputData(data, {
|
|
1326
|
+
fields,
|
|
1327
|
+
action
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
const oidcMappingSchema = z.object({
|
|
1331
|
+
id: z.string().meta({ description: "Field mapping for user ID (defaults to 'sub')" }),
|
|
1332
|
+
email: z.string().meta({ description: "Field mapping for email (defaults to 'email')" }),
|
|
1333
|
+
emailVerified: z.string().meta({ description: "Field mapping for email verification (defaults to 'email_verified')" }).optional(),
|
|
1334
|
+
name: z.string().meta({ description: "Field mapping for name (defaults to 'name')" }),
|
|
1335
|
+
image: z.string().meta({ description: "Field mapping for image (defaults to 'picture')" }).optional(),
|
|
1336
|
+
extraFields: z.record(z.string(), z.any()).optional()
|
|
1337
|
+
}).optional();
|
|
1338
|
+
const samlMappingSchema = z.object({
|
|
1339
|
+
id: z.string().meta({ description: "Field mapping for user ID (defaults to 'nameID')" }),
|
|
1340
|
+
email: z.string().meta({ description: "Field mapping for email (defaults to 'email')" }),
|
|
1341
|
+
emailVerified: z.string().meta({ description: "Field mapping for email verification" }).optional(),
|
|
1342
|
+
name: z.string().meta({ description: "Field mapping for name (defaults to 'displayName')" }),
|
|
1343
|
+
firstName: z.string().meta({ description: "Field mapping for first name (defaults to 'givenName')" }).optional(),
|
|
1344
|
+
lastName: z.string().meta({ description: "Field mapping for last name (defaults to 'surname')" }).optional(),
|
|
1345
|
+
extraFields: z.record(z.string(), z.any()).optional()
|
|
1346
|
+
}).optional();
|
|
1347
|
+
const signingCertSchema = z.union([z.string(), z.array(z.string()).nonempty()]).meta({ description: "IdP signing certificate(s). Pass a single PEM string or an array for rolling rotation." });
|
|
1348
|
+
const oidcConfigSchema = z.object({
|
|
1349
|
+
clientId: z.string().meta({ description: "The client ID" }),
|
|
1350
|
+
clientSecret: z.string().meta({ description: "The client secret. Required for client_secret_basic/client_secret_post. Optional for private_key_jwt." }).optional(),
|
|
1351
|
+
authorizationEndpoint: z.string().url().meta({ description: "The authorization endpoint" }).optional(),
|
|
1352
|
+
tokenEndpoint: z.string().url().meta({ description: "The token endpoint" }).optional(),
|
|
1353
|
+
userInfoEndpoint: z.string().url().meta({ description: "The user info endpoint" }).optional(),
|
|
1354
|
+
tokenEndpointAuthentication: z.enum([
|
|
1355
|
+
"client_secret_post",
|
|
1356
|
+
"client_secret_basic",
|
|
1357
|
+
"private_key_jwt"
|
|
1358
|
+
]).optional(),
|
|
1359
|
+
privateKeyId: z.string().optional(),
|
|
1360
|
+
privateKeyAlgorithm: z.string().optional(),
|
|
1361
|
+
jwksEndpoint: z.string().url().meta({ description: "The JWKS endpoint" }).optional(),
|
|
1362
|
+
discoveryEndpoint: z.string().url().optional(),
|
|
1363
|
+
skipDiscovery: z.boolean().meta({ description: "Skip OIDC discovery during registration. When true, you must provide authorizationEndpoint, tokenEndpoint, and jwksEndpoint manually." }).optional(),
|
|
1364
|
+
scopes: z.array(z.string()).meta({ description: "The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']" }).optional(),
|
|
1365
|
+
pkce: z.boolean().meta({ description: "Whether to use PKCE for the authorization flow" }).default(true).optional(),
|
|
1366
|
+
overrideUserInfo: z.boolean().optional(),
|
|
1367
|
+
mapping: oidcMappingSchema
|
|
1368
|
+
});
|
|
1369
|
+
const samlConfigSchema = z.object({
|
|
1370
|
+
entryPoint: z.string().url().meta({ description: "The IdP SSO URL (entry point)" }),
|
|
1371
|
+
cert: signingCertSchema.meta({ description: "IdP signing certificate(s). Pass a single PEM string or an array for rolling rotation. Omit when `idpMetadata.metadata` XML carries the certs. When both this and `idpMetadata.cert` are set, `idpMetadata.cert` wins." }).optional(),
|
|
1372
|
+
audience: z.string().optional(),
|
|
1373
|
+
callbackUrl: z.string().refine((url) => !url.includes("#"), { message: "callbackUrl must not contain a fragment" }).optional(),
|
|
1374
|
+
idpMetadata: z.object({
|
|
1375
|
+
metadata: z.string().optional(),
|
|
1376
|
+
entityID: z.string().optional(),
|
|
1377
|
+
cert: signingCertSchema.meta({ description: "IdP signing certificate(s). Pass a single PEM string or an array for rolling rotation. Takes precedence over the top-level `cert`." }).optional(),
|
|
1378
|
+
privateKey: z.string().optional(),
|
|
1379
|
+
privateKeyPass: z.string().optional(),
|
|
1380
|
+
isAssertionEncrypted: z.boolean().optional(),
|
|
1381
|
+
encPrivateKey: z.string().optional(),
|
|
1382
|
+
encPrivateKeyPass: z.string().optional(),
|
|
1383
|
+
singleSignOnService: z.array(z.object({
|
|
1384
|
+
Binding: z.string().meta({ description: "The binding type for the SSO service" }),
|
|
1385
|
+
Location: z.string().url().meta({ description: "The URL for the SSO service" })
|
|
1386
|
+
})).meta({ description: "Single Sign-On service configuration" }).optional(),
|
|
1387
|
+
singleLogoutService: z.array(z.object({
|
|
1388
|
+
Binding: z.string(),
|
|
1389
|
+
Location: z.string().url()
|
|
1390
|
+
})).optional()
|
|
1391
|
+
}).optional(),
|
|
1392
|
+
spMetadata: z.object({
|
|
1393
|
+
metadata: z.string().optional(),
|
|
1394
|
+
entityID: z.string().optional(),
|
|
1395
|
+
binding: z.string().optional(),
|
|
1396
|
+
privateKey: z.string().optional(),
|
|
1397
|
+
privateKeyPass: z.string().optional(),
|
|
1398
|
+
isAssertionEncrypted: z.boolean().optional(),
|
|
1399
|
+
encPrivateKey: z.string().optional(),
|
|
1400
|
+
encPrivateKeyPass: z.string().optional()
|
|
1401
|
+
}).optional(),
|
|
1402
|
+
wantAssertionsSigned: z.boolean().optional(),
|
|
1403
|
+
authnRequestsSigned: z.boolean().optional(),
|
|
1404
|
+
signatureAlgorithm: z.string().optional(),
|
|
1405
|
+
digestAlgorithm: z.string().optional(),
|
|
1406
|
+
identifierFormat: z.string().optional(),
|
|
1407
|
+
privateKey: z.string().optional(),
|
|
1408
|
+
mapping: samlMappingSchema
|
|
1409
|
+
});
|
|
1410
|
+
const registerSSOProviderBodySchema = z.object({
|
|
1411
|
+
providerId: z.string().meta({ description: "The ID of the provider. This is used to identify the provider during login and callback" }),
|
|
1412
|
+
issuer: z.string().url().meta({ description: "The issuer URL of the provider" }),
|
|
1413
|
+
domain: z.string().meta({ description: "The domain(s) of the provider. For enterprise multi-domain SSO where a single IdP serves multiple email domains, use comma-separated values (e.g., 'company.com,subsidiary.com,acquired-company.com')" }),
|
|
1414
|
+
oidcConfig: oidcConfigSchema.optional(),
|
|
1415
|
+
samlConfig: samlConfigSchema.optional(),
|
|
1416
|
+
organizationId: z.string().meta({ description: "If organization plugin is enabled, the organization id to link the provider to" }).optional(),
|
|
1417
|
+
overrideUserInfo: z.boolean().meta({ description: "Override user info with the provider info. Defaults to false" }).default(false).optional()
|
|
1418
|
+
});
|
|
1419
|
+
function getRegisterSSOProviderBodySchema(options) {
|
|
1420
|
+
return registerSSOProviderBodySchema.extend({ ...getSSOProviderAdditionalFieldsSchema(options).shape });
|
|
1421
|
+
}
|
|
1422
|
+
const updateSSOProviderBodySchema = z.object({
|
|
1423
|
+
issuer: z.string().url().optional(),
|
|
1424
|
+
domain: z.string().optional(),
|
|
1425
|
+
oidcConfig: oidcConfigSchema.partial().optional(),
|
|
1426
|
+
samlConfig: samlConfigSchema.partial().optional()
|
|
1427
|
+
});
|
|
1428
|
+
function getUpdateSSOProviderBodySchema(options) {
|
|
1429
|
+
return updateSSOProviderBodySchema.extend({
|
|
1430
|
+
providerId: z.string(),
|
|
1431
|
+
...getSSOProviderAdditionalFieldsSchema(options).partial().shape
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
//#endregion
|
|
1435
|
+
//#region src/routes/providers.ts
|
|
1436
|
+
const ADMIN_ROLES = ["owner", "admin"];
|
|
1437
|
+
const OIDC_IDENTITY_BOUNDARY_FIELDS = [
|
|
1438
|
+
"authorizationEndpoint",
|
|
1439
|
+
"clientId",
|
|
1440
|
+
"discoveryEndpoint",
|
|
1441
|
+
"jwksEndpoint",
|
|
1442
|
+
"tokenEndpoint",
|
|
1443
|
+
"userInfoEndpoint"
|
|
1444
|
+
];
|
|
1445
|
+
const SAML_IDENTITY_BOUNDARY_FIELDS = [
|
|
1446
|
+
"audience",
|
|
1447
|
+
"callbackUrl",
|
|
1448
|
+
"entryPoint",
|
|
1449
|
+
"identifierFormat"
|
|
1450
|
+
];
|
|
1451
|
+
const SAML_IDP_BOUNDARY_FIELDS = [
|
|
1452
|
+
"metadata",
|
|
1453
|
+
"entityID",
|
|
1454
|
+
"singleSignOnService"
|
|
1455
|
+
];
|
|
1456
|
+
const SAML_SP_BOUNDARY_FIELDS = ["metadata", "entityID"];
|
|
1457
|
+
function isRecord(value) {
|
|
1458
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1459
|
+
}
|
|
1460
|
+
function stableStringify(value) {
|
|
1461
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
1462
|
+
if (isRecord(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
|
|
1463
|
+
return JSON.stringify(value) ?? String(value);
|
|
1464
|
+
}
|
|
1465
|
+
function identityValueChanged(current, updated) {
|
|
1466
|
+
return stableStringify(current) !== stableStringify(updated);
|
|
1467
|
+
}
|
|
1468
|
+
function hasChangedField(current, updated, fields) {
|
|
1469
|
+
return fields.some((field) => identityValueChanged(current?.[field], updated?.[field]));
|
|
1470
|
+
}
|
|
1471
|
+
function oidcIdentityBoundaryChanged(current, updated) {
|
|
1472
|
+
return hasChangedField(current, updated, OIDC_IDENTITY_BOUNDARY_FIELDS) || identityValueChanged(current.mapping?.id, updated.mapping?.id);
|
|
1473
|
+
}
|
|
1474
|
+
function samlIdentityBoundaryChanged(current, updated) {
|
|
1475
|
+
return hasChangedField(current, updated, SAML_IDENTITY_BOUNDARY_FIELDS) || identityValueChanged(current.mapping?.id, updated.mapping?.id) || hasChangedField(current.idpMetadata, updated.idpMetadata, SAML_IDP_BOUNDARY_FIELDS) || hasChangedField(current.spMetadata, updated.spMetadata, SAML_SP_BOUNDARY_FIELDS);
|
|
1476
|
+
}
|
|
1477
|
+
function parseConfigSnapshot(config, configType) {
|
|
1478
|
+
if (!config) return;
|
|
1479
|
+
if (typeof config === "string") return parseAndValidateConfig(config, configType);
|
|
1480
|
+
return config;
|
|
1481
|
+
}
|
|
1482
|
+
function ssoProviderIdentityBoundaryChanged(current, updated) {
|
|
1483
|
+
if (identityValueChanged(current.issuer, updated.issuer)) return true;
|
|
1484
|
+
const currentSamlConfig = parseConfigSnapshot(current.samlConfig, "SAML");
|
|
1485
|
+
const updatedSamlConfig = parseConfigSnapshot(updated.samlConfig, "SAML");
|
|
1486
|
+
if (currentSamlConfig && (!updatedSamlConfig || samlIdentityBoundaryChanged(currentSamlConfig, updatedSamlConfig))) return true;
|
|
1487
|
+
const currentOidcConfig = parseConfigSnapshot(current.oidcConfig, "OIDC");
|
|
1488
|
+
const updatedOidcConfig = parseConfigSnapshot(updated.oidcConfig, "OIDC");
|
|
1489
|
+
return Boolean(currentOidcConfig && (!updatedOidcConfig || oidcIdentityBoundaryChanged(currentOidcConfig, updatedOidcConfig)));
|
|
1490
|
+
}
|
|
1491
|
+
async function lockSSOProviderRow(adapter, providerId) {
|
|
1492
|
+
return (await getCurrentAdapter(adapter)).update({
|
|
1493
|
+
model: "ssoProvider",
|
|
1494
|
+
where: [{
|
|
1495
|
+
field: "providerId",
|
|
1496
|
+
value: providerId
|
|
1497
|
+
}],
|
|
1498
|
+
update: { providerId }
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
async function lockSSOProviderForAccountLink(ctx, provider) {
|
|
1502
|
+
const lockedProvider = await lockSSOProviderRow(ctx.context.adapter, provider.providerId);
|
|
1503
|
+
if (!lockedProvider) {
|
|
1504
|
+
if (provider.userId === "default") return;
|
|
1505
|
+
throw new APIError("CONFLICT", {
|
|
1506
|
+
code: "SSO_PROVIDER_CHANGED",
|
|
1507
|
+
message: "SSO provider changed while account linking was in progress"
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
if (ssoProviderIdentityBoundaryChanged(provider, lockedProvider)) throw new APIError("CONFLICT", {
|
|
1511
|
+
code: "SSO_PROVIDER_CHANGED",
|
|
1512
|
+
message: "SSO provider changed while account linking was in progress"
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
function getSSOProviderAdditionalFields(options) {
|
|
1516
|
+
return options?.schema?.ssoProvider?.additionalFields ?? {};
|
|
1517
|
+
}
|
|
1518
|
+
function filterSSOProviderAdditionalFields(provider, options) {
|
|
1519
|
+
return filterOutputFields(provider, getSSOProviderAdditionalFields(options));
|
|
1520
|
+
}
|
|
1521
|
+
function getReturnedSSOProviderAdditionalFields(provider, options) {
|
|
1522
|
+
const additionalFields = getSSOProviderAdditionalFields(options);
|
|
1523
|
+
const result = {};
|
|
1524
|
+
for (const key in additionalFields) {
|
|
1525
|
+
if (additionalFields[key]?.returned === false) continue;
|
|
1526
|
+
if (key in provider) result[key] = provider[key];
|
|
1527
|
+
}
|
|
1528
|
+
return result;
|
|
1529
|
+
}
|
|
1530
|
+
function hasOrgAdminRole(member) {
|
|
1531
|
+
return member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()));
|
|
1532
|
+
}
|
|
1533
|
+
function parseCertOrError(cert) {
|
|
1534
|
+
try {
|
|
1535
|
+
return parseCertificate(cert);
|
|
1536
|
+
} catch {
|
|
1537
|
+
return { error: "Failed to parse certificate" };
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
function sanitizeSigningCerts(config) {
|
|
1541
|
+
const certs = resolveSigningCerts(config);
|
|
1542
|
+
if (certs === void 0) return void 0;
|
|
1543
|
+
return certs.map(parseCertOrError);
|
|
1544
|
+
}
|
|
1545
|
+
async function isOrgAdmin(ctx, userId, organizationId) {
|
|
1546
|
+
const member = await ctx.context.adapter.findOne({
|
|
1547
|
+
model: "member",
|
|
1548
|
+
where: [{
|
|
1549
|
+
field: "userId",
|
|
1550
|
+
value: userId
|
|
1551
|
+
}, {
|
|
1552
|
+
field: "organizationId",
|
|
1553
|
+
value: organizationId
|
|
1554
|
+
}]
|
|
1290
1555
|
});
|
|
1291
|
-
return
|
|
1556
|
+
return member ? hasOrgAdminRole(member) : false;
|
|
1292
1557
|
}
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1558
|
+
async function batchCheckOrgAdmin(ctx, userId, organizationIds) {
|
|
1559
|
+
if (organizationIds.length === 0) return /* @__PURE__ */ new Set();
|
|
1560
|
+
const members = await ctx.context.adapter.findMany({
|
|
1561
|
+
model: "member",
|
|
1562
|
+
where: [{
|
|
1563
|
+
field: "userId",
|
|
1564
|
+
value: userId
|
|
1565
|
+
}, {
|
|
1566
|
+
field: "organizationId",
|
|
1567
|
+
value: organizationIds,
|
|
1568
|
+
operator: "in"
|
|
1569
|
+
}]
|
|
1570
|
+
});
|
|
1571
|
+
const adminOrgIds = /* @__PURE__ */ new Set();
|
|
1572
|
+
for (const member of members) if (hasOrgAdminRole(member)) adminOrgIds.add(member.organizationId);
|
|
1573
|
+
return adminOrgIds;
|
|
1574
|
+
}
|
|
1575
|
+
function sanitizeProvider(provider, baseURL, options) {
|
|
1576
|
+
let oidcConfig = null;
|
|
1577
|
+
let samlConfig = null;
|
|
1302
1578
|
try {
|
|
1303
|
-
|
|
1579
|
+
oidcConfig = safeJsonParse(provider.oidcConfig);
|
|
1304
1580
|
} catch {
|
|
1305
|
-
|
|
1306
|
-
const basePath = issuerURL.pathname.replace(/\/+$/, "");
|
|
1307
|
-
const endpointPath = endpoint.replace(/^\/+/, "");
|
|
1308
|
-
return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
|
|
1581
|
+
oidcConfig = null;
|
|
1309
1582
|
}
|
|
1310
|
-
}
|
|
1311
|
-
/**
|
|
1312
|
-
* Parses the given URL or throws in case of invalid or unsupported protocols
|
|
1313
|
-
*
|
|
1314
|
-
* @param name the url name
|
|
1315
|
-
* @param endpoint the endpoint url
|
|
1316
|
-
* @param [base] optional base path
|
|
1317
|
-
* @returns
|
|
1318
|
-
*/
|
|
1319
|
-
function parseURL(name, endpoint, base) {
|
|
1320
|
-
let endpointURL;
|
|
1321
1583
|
try {
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
|
|
1584
|
+
samlConfig = safeJsonParse(provider.samlConfig);
|
|
1585
|
+
} catch {
|
|
1586
|
+
samlConfig = null;
|
|
1326
1587
|
}
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1588
|
+
const type = samlConfig ? "saml" : "oidc";
|
|
1589
|
+
return {
|
|
1590
|
+
...getReturnedSSOProviderAdditionalFields(provider, options),
|
|
1591
|
+
providerId: provider.providerId,
|
|
1592
|
+
type,
|
|
1593
|
+
issuer: provider.issuer,
|
|
1594
|
+
domain: provider.domain,
|
|
1595
|
+
organizationId: provider.organizationId || null,
|
|
1596
|
+
domainVerified: provider.domainVerified ?? false,
|
|
1597
|
+
oidcConfig: oidcConfig ? {
|
|
1598
|
+
discoveryEndpoint: oidcConfig.discoveryEndpoint,
|
|
1599
|
+
clientIdLastFour: maskClientId(oidcConfig.clientId),
|
|
1600
|
+
pkce: oidcConfig.pkce,
|
|
1601
|
+
authorizationEndpoint: oidcConfig.authorizationEndpoint,
|
|
1602
|
+
tokenEndpoint: oidcConfig.tokenEndpoint,
|
|
1603
|
+
userInfoEndpoint: oidcConfig.userInfoEndpoint,
|
|
1604
|
+
jwksEndpoint: oidcConfig.jwksEndpoint,
|
|
1605
|
+
scopes: oidcConfig.scopes,
|
|
1606
|
+
tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication
|
|
1607
|
+
} : void 0,
|
|
1608
|
+
samlConfig: samlConfig ? {
|
|
1609
|
+
entryPoint: samlConfig.entryPoint,
|
|
1610
|
+
audience: samlConfig.audience,
|
|
1611
|
+
wantAssertionsSigned: samlConfig.wantAssertionsSigned,
|
|
1612
|
+
authnRequestsSigned: samlConfig.authnRequestsSigned,
|
|
1613
|
+
identifierFormat: samlConfig.identifierFormat,
|
|
1614
|
+
signatureAlgorithm: samlConfig.signatureAlgorithm,
|
|
1615
|
+
digestAlgorithm: samlConfig.digestAlgorithm,
|
|
1616
|
+
certificate: sanitizeSigningCerts(samlConfig)
|
|
1617
|
+
} : void 0,
|
|
1618
|
+
spMetadataUrl: `${baseURL}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(provider.providerId)}`
|
|
1619
|
+
};
|
|
1348
1620
|
}
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1621
|
+
const listSSOProviders = (options) => {
|
|
1622
|
+
return createAuthEndpoint("/sso/providers", {
|
|
1623
|
+
method: "GET",
|
|
1624
|
+
use: [sessionMiddleware],
|
|
1625
|
+
metadata: { openapi: {
|
|
1626
|
+
operationId: "listSSOProviders",
|
|
1627
|
+
summary: "List SSO providers",
|
|
1628
|
+
description: "Returns a list of SSO providers the user has access to",
|
|
1629
|
+
responses: { "200": { description: "List of SSO providers. The `certificate` field is an array of parsed certificates for SAML providers, or absent when certs live inside `idpMetadata.metadata`." } }
|
|
1630
|
+
} }
|
|
1631
|
+
}, async (ctx) => {
|
|
1632
|
+
const userId = ctx.context.session.user.id;
|
|
1633
|
+
const allProviders = await ctx.context.adapter.findMany({ model: "ssoProvider" });
|
|
1634
|
+
const userOwnedProviders = allProviders.filter((p) => p.userId === userId && !p.organizationId);
|
|
1635
|
+
const orgProviders = allProviders.filter((p) => p.organizationId !== null && p.organizationId !== void 0);
|
|
1636
|
+
const orgPluginEnabled = ctx.context.hasPlugin("organization");
|
|
1637
|
+
let accessibleProviders = [...userOwnedProviders];
|
|
1638
|
+
if (orgPluginEnabled && orgProviders.length > 0) {
|
|
1639
|
+
const adminOrgIds = await batchCheckOrgAdmin(ctx, userId, [...new Set(orgProviders.map((p) => p.organizationId).filter((id) => id !== null && id !== void 0))]);
|
|
1640
|
+
const orgAccessibleProviders = orgProviders.filter((provider) => provider.organizationId && adminOrgIds.has(provider.organizationId));
|
|
1641
|
+
accessibleProviders = [...accessibleProviders, ...orgAccessibleProviders];
|
|
1642
|
+
} else if (!orgPluginEnabled) {
|
|
1643
|
+
const userOwnedOrgProviders = orgProviders.filter((p) => p.userId === userId);
|
|
1644
|
+
accessibleProviders = [...accessibleProviders, ...userOwnedOrgProviders];
|
|
1645
|
+
}
|
|
1646
|
+
const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL, options));
|
|
1647
|
+
return ctx.json({ providers });
|
|
1648
|
+
});
|
|
1649
|
+
};
|
|
1650
|
+
const getSSOProviderQuerySchema = z.object({ providerId: z.string() });
|
|
1651
|
+
async function checkProviderAccess(ctx, providerId) {
|
|
1652
|
+
const userId = ctx.context.session.user.id;
|
|
1653
|
+
const provider = await ctx.context.adapter.findOne({
|
|
1654
|
+
model: "ssoProvider",
|
|
1655
|
+
where: [{
|
|
1656
|
+
field: "providerId",
|
|
1657
|
+
value: providerId
|
|
1658
|
+
}]
|
|
1659
|
+
});
|
|
1660
|
+
if (!provider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
|
|
1661
|
+
let hasAccess = false;
|
|
1662
|
+
if (provider.organizationId) if (ctx.context.hasPlugin("organization")) hasAccess = await isOrgAdmin(ctx, userId, provider.organizationId);
|
|
1663
|
+
else hasAccess = provider.userId === userId;
|
|
1664
|
+
else hasAccess = provider.userId === userId;
|
|
1665
|
+
if (!hasAccess) throw new APIError("FORBIDDEN", { message: "You don't have access to this provider" });
|
|
1666
|
+
return provider;
|
|
1364
1667
|
}
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1668
|
+
const getSSOProvider = (options) => {
|
|
1669
|
+
return createAuthEndpoint("/sso/get-provider", {
|
|
1670
|
+
method: "GET",
|
|
1671
|
+
use: [sessionMiddleware],
|
|
1672
|
+
query: getSSOProviderQuerySchema,
|
|
1673
|
+
metadata: { openapi: {
|
|
1674
|
+
operationId: "getSSOProvider",
|
|
1675
|
+
summary: "Get SSO provider details",
|
|
1676
|
+
description: "Returns sanitized details for a specific SSO provider",
|
|
1677
|
+
responses: {
|
|
1678
|
+
"200": { description: "SSO provider details. The `certificate` field is an array of parsed certificates for SAML providers, or absent when certs live inside `idpMetadata.metadata`." },
|
|
1679
|
+
"404": { description: "Provider not found" },
|
|
1680
|
+
"403": { description: "Access denied" }
|
|
1681
|
+
}
|
|
1682
|
+
} }
|
|
1683
|
+
}, async (ctx) => {
|
|
1684
|
+
const { providerId } = ctx.query;
|
|
1685
|
+
const provider = await checkProviderAccess(ctx, providerId);
|
|
1686
|
+
return ctx.json(sanitizeProvider(provider, ctx.context.baseURL, options));
|
|
1376
1687
|
});
|
|
1688
|
+
};
|
|
1689
|
+
function parseAndValidateConfig(configString, configType) {
|
|
1690
|
+
let config = null;
|
|
1691
|
+
try {
|
|
1692
|
+
config = safeJsonParse(configString);
|
|
1693
|
+
} catch {
|
|
1694
|
+
config = null;
|
|
1695
|
+
}
|
|
1696
|
+
if (!config) throw new APIError("BAD_REQUEST", { message: `Cannot update ${configType} config for a provider that doesn't have ${configType} configured` });
|
|
1697
|
+
return config;
|
|
1698
|
+
}
|
|
1699
|
+
function mergeSAMLConfig(current, updates, issuer) {
|
|
1377
1700
|
return {
|
|
1378
|
-
...
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1701
|
+
...current,
|
|
1702
|
+
...updates,
|
|
1703
|
+
issuer,
|
|
1704
|
+
entryPoint: updates.entryPoint ?? current.entryPoint,
|
|
1705
|
+
cert: updates.cert ?? current.cert,
|
|
1706
|
+
spMetadata: updates.spMetadata ?? current.spMetadata,
|
|
1707
|
+
idpMetadata: updates.idpMetadata ?? current.idpMetadata,
|
|
1708
|
+
mapping: updates.mapping ?? current.mapping,
|
|
1709
|
+
audience: updates.audience ?? current.audience,
|
|
1710
|
+
callbackUrl: updates.callbackUrl ?? current.callbackUrl,
|
|
1711
|
+
wantAssertionsSigned: updates.wantAssertionsSigned ?? current.wantAssertionsSigned,
|
|
1712
|
+
authnRequestsSigned: updates.authnRequestsSigned ?? current.authnRequestsSigned,
|
|
1713
|
+
identifierFormat: updates.identifierFormat ?? current.identifierFormat,
|
|
1714
|
+
signatureAlgorithm: updates.signatureAlgorithm ?? current.signatureAlgorithm,
|
|
1715
|
+
digestAlgorithm: updates.digestAlgorithm ?? current.digestAlgorithm
|
|
1384
1716
|
};
|
|
1385
1717
|
}
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1718
|
+
function mergeOIDCConfig(current, updates, issuer) {
|
|
1719
|
+
return {
|
|
1720
|
+
...current,
|
|
1721
|
+
...updates,
|
|
1722
|
+
issuer,
|
|
1723
|
+
pkce: updates.pkce ?? current.pkce ?? true,
|
|
1724
|
+
clientId: updates.clientId ?? current.clientId,
|
|
1725
|
+
clientSecret: updates.clientSecret ?? current.clientSecret,
|
|
1726
|
+
discoveryEndpoint: updates.discoveryEndpoint ?? current.discoveryEndpoint,
|
|
1727
|
+
mapping: updates.mapping ?? current.mapping,
|
|
1728
|
+
scopes: updates.scopes ?? current.scopes,
|
|
1729
|
+
authorizationEndpoint: updates.authorizationEndpoint ?? current.authorizationEndpoint,
|
|
1730
|
+
tokenEndpoint: updates.tokenEndpoint ?? current.tokenEndpoint,
|
|
1731
|
+
userInfoEndpoint: updates.userInfoEndpoint ?? current.userInfoEndpoint,
|
|
1732
|
+
jwksEndpoint: updates.jwksEndpoint ?? current.jwksEndpoint,
|
|
1733
|
+
tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication,
|
|
1734
|
+
privateKeyId: updates.privateKeyId ?? current.privateKeyId,
|
|
1735
|
+
privateKeyAlgorithm: updates.privateKeyAlgorithm ?? current.privateKeyAlgorithm
|
|
1736
|
+
};
|
|
1737
|
+
}
|
|
1738
|
+
const updateSSOProvider = (options) => {
|
|
1739
|
+
const updateBodySchema = getUpdateSSOProviderBodySchema(options);
|
|
1740
|
+
return createAuthEndpoint("/sso/update-provider", {
|
|
1741
|
+
method: "POST",
|
|
1742
|
+
use: [sessionMiddleware],
|
|
1743
|
+
body: updateBodySchema,
|
|
1744
|
+
metadata: { openapi: {
|
|
1745
|
+
operationId: "updateSSOProvider",
|
|
1746
|
+
summary: "Update SSO provider",
|
|
1747
|
+
description: "Partially update an SSO provider. Only provided fields are updated. If domain changes, domainVerified is reset to false.",
|
|
1748
|
+
responses: {
|
|
1749
|
+
"200": { description: "SSO provider updated successfully. The `certificate` field is an array of parsed certificates for SAML providers, or absent when certs live inside `idpMetadata.metadata`." },
|
|
1750
|
+
"404": { description: "Provider not found" },
|
|
1751
|
+
"403": { description: "Access denied" }
|
|
1752
|
+
}
|
|
1753
|
+
} }
|
|
1754
|
+
}, async (ctx) => {
|
|
1755
|
+
const { providerId, ...body } = ctx.body;
|
|
1756
|
+
const { issuer, domain, samlConfig, oidcConfig } = body;
|
|
1757
|
+
const additionalFields = parseSSOProviderAdditionalFields(options, body, "update");
|
|
1758
|
+
if (!issuer && !domain && !samlConfig && !oidcConfig && Object.keys(additionalFields).length === 0) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
|
|
1759
|
+
await checkProviderAccess(ctx, providerId);
|
|
1760
|
+
const fullProvider = await runWithTransaction(ctx.context.adapter, async () => {
|
|
1761
|
+
const trx = await getCurrentAdapter(ctx.context.adapter);
|
|
1762
|
+
const existingProvider = await lockSSOProviderRow(ctx.context.adapter, providerId);
|
|
1763
|
+
if (!existingProvider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
|
|
1764
|
+
const updateData = { ...additionalFields };
|
|
1765
|
+
let providerIdentityBoundaryChanged = body.issuer !== void 0 && body.issuer !== existingProvider.issuer;
|
|
1766
|
+
if (body.issuer !== void 0) updateData.issuer = body.issuer;
|
|
1767
|
+
if (body.domain !== void 0) {
|
|
1768
|
+
updateData.domain = body.domain;
|
|
1769
|
+
if (body.domain !== existingProvider.domain) updateData.domainVerified = false;
|
|
1770
|
+
}
|
|
1771
|
+
if (body.samlConfig) {
|
|
1772
|
+
if (body.samlConfig.idpMetadata?.metadata) {
|
|
1773
|
+
const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
|
|
1774
|
+
if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
|
|
1775
|
+
}
|
|
1776
|
+
if (body.samlConfig.signatureAlgorithm !== void 0 || body.samlConfig.digestAlgorithm !== void 0) validateConfigAlgorithms({
|
|
1777
|
+
signatureAlgorithm: body.samlConfig.signatureAlgorithm,
|
|
1778
|
+
digestAlgorithm: body.samlConfig.digestAlgorithm
|
|
1779
|
+
}, options?.saml?.algorithms);
|
|
1780
|
+
const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
|
|
1781
|
+
const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
|
|
1782
|
+
validateCertSources(updatedSamlConfig);
|
|
1783
|
+
if (samlIdentityBoundaryChanged(currentSamlConfig, updatedSamlConfig)) providerIdentityBoundaryChanged = true;
|
|
1784
|
+
updateData.samlConfig = JSON.stringify(updatedSamlConfig);
|
|
1785
|
+
}
|
|
1786
|
+
if (body.oidcConfig) {
|
|
1787
|
+
try {
|
|
1788
|
+
validateOIDCEndpointUrls(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
|
|
1789
|
+
} catch (error) {
|
|
1790
|
+
if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
|
|
1791
|
+
throw error;
|
|
1792
|
+
}
|
|
1793
|
+
const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
|
|
1794
|
+
const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
|
|
1795
|
+
if (updatedOidcConfig.tokenEndpointAuthentication !== "private_key_jwt" && !updatedOidcConfig.clientSecret) throw new APIError("BAD_REQUEST", { message: "clientSecret is required when using client_secret_basic or client_secret_post authentication" });
|
|
1796
|
+
if (updatedOidcConfig.tokenEndpointAuthentication === "private_key_jwt" && !options?.resolvePrivateKey && !options?.defaultSSO?.some((p) => p.providerId === providerId && "privateKey" in p && p.privateKey)) throw new APIError("BAD_REQUEST", { message: "private_key_jwt authentication requires either a resolvePrivateKey callback or a privateKey in defaultSSO" });
|
|
1797
|
+
if (oidcIdentityBoundaryChanged(currentOidcConfig, updatedOidcConfig)) providerIdentityBoundaryChanged = true;
|
|
1798
|
+
updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
|
|
1799
|
+
}
|
|
1800
|
+
if (providerIdentityBoundaryChanged) {
|
|
1801
|
+
if (await trx.findOne({
|
|
1802
|
+
model: "account",
|
|
1803
|
+
where: [{
|
|
1804
|
+
field: "providerId",
|
|
1805
|
+
value: providerId
|
|
1806
|
+
}]
|
|
1807
|
+
})) throw new APIError("CONFLICT", { message: "Cannot change SSO provider identity fields while linked accounts exist" });
|
|
1808
|
+
}
|
|
1809
|
+
await trx.update({
|
|
1810
|
+
model: "ssoProvider",
|
|
1811
|
+
where: [{
|
|
1812
|
+
field: "providerId",
|
|
1813
|
+
value: providerId
|
|
1814
|
+
}],
|
|
1815
|
+
update: updateData
|
|
1816
|
+
});
|
|
1817
|
+
const updatedProvider = await trx.findOne({
|
|
1818
|
+
model: "ssoProvider",
|
|
1819
|
+
where: [{
|
|
1820
|
+
field: "providerId",
|
|
1821
|
+
value: providerId
|
|
1822
|
+
}]
|
|
1823
|
+
});
|
|
1824
|
+
if (!updatedProvider) throw new APIError("NOT_FOUND", { message: "Provider not found after update" });
|
|
1825
|
+
return updatedProvider;
|
|
1419
1826
|
});
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1827
|
+
return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL, options));
|
|
1828
|
+
});
|
|
1829
|
+
};
|
|
1830
|
+
const deleteSSOProvider = () => {
|
|
1831
|
+
return createAuthEndpoint("/sso/delete-provider", {
|
|
1832
|
+
method: "POST",
|
|
1833
|
+
use: [sessionMiddleware],
|
|
1834
|
+
body: z.object({ providerId: z.string() }),
|
|
1835
|
+
metadata: { openapi: {
|
|
1836
|
+
operationId: "deleteSSOProvider",
|
|
1837
|
+
summary: "Delete SSO provider",
|
|
1838
|
+
description: "Deletes an SSO provider",
|
|
1839
|
+
responses: {
|
|
1840
|
+
"200": { description: "SSO provider deleted successfully" },
|
|
1841
|
+
"404": { description: "Provider not found" },
|
|
1842
|
+
"403": { description: "Access denied" }
|
|
1843
|
+
}
|
|
1844
|
+
} }
|
|
1845
|
+
}, async (ctx) => {
|
|
1846
|
+
const { providerId } = ctx.body;
|
|
1847
|
+
await checkProviderAccess(ctx, providerId);
|
|
1848
|
+
await runWithTransaction(ctx.context.adapter, async () => {
|
|
1849
|
+
const trx = await getCurrentAdapter(ctx.context.adapter);
|
|
1850
|
+
await trx.deleteMany({
|
|
1851
|
+
model: "account",
|
|
1852
|
+
where: [{
|
|
1853
|
+
field: "providerId",
|
|
1854
|
+
value: providerId
|
|
1855
|
+
}]
|
|
1856
|
+
});
|
|
1857
|
+
await trx.delete({
|
|
1858
|
+
model: "ssoProvider",
|
|
1859
|
+
where: [{
|
|
1860
|
+
field: "providerId",
|
|
1861
|
+
value: providerId
|
|
1862
|
+
}]
|
|
1863
|
+
});
|
|
1423
1864
|
});
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1865
|
+
return ctx.json({ success: true });
|
|
1866
|
+
});
|
|
1867
|
+
};
|
|
1868
|
+
//#endregion
|
|
1869
|
+
//#region src/routes/domain-verification.ts
|
|
1870
|
+
const DNS_LABEL_MAX_LENGTH = 63;
|
|
1871
|
+
const DEFAULT_TOKEN_PREFIX = "better-auth-token";
|
|
1872
|
+
const domainVerificationBodySchema = z.object({ providerId: z.string() });
|
|
1873
|
+
function getVerificationIdentifier(options, providerId) {
|
|
1874
|
+
return `_${options.domainVerification?.tokenPrefix || DEFAULT_TOKEN_PREFIX}-${providerId}`;
|
|
1875
|
+
}
|
|
1876
|
+
const requestDomainVerification = (options) => {
|
|
1877
|
+
return createAuthEndpoint("/sso/request-domain-verification", {
|
|
1878
|
+
method: "POST",
|
|
1879
|
+
body: domainVerificationBodySchema,
|
|
1880
|
+
metadata: { openapi: {
|
|
1881
|
+
summary: "Request a domain verification",
|
|
1882
|
+
description: "Request a domain verification for the given SSO provider",
|
|
1883
|
+
responses: {
|
|
1884
|
+
"404": { description: "Provider not found" },
|
|
1885
|
+
"409": { description: "Domain has already been verified" },
|
|
1886
|
+
"201": { description: "Domain submitted for verification" }
|
|
1887
|
+
}
|
|
1888
|
+
} },
|
|
1889
|
+
use: [sessionMiddleware]
|
|
1890
|
+
}, async (ctx) => {
|
|
1891
|
+
const body = ctx.body;
|
|
1892
|
+
const provider = await checkProviderAccess(ctx, body.providerId);
|
|
1893
|
+
if (provider.domainVerified) throw new APIError("CONFLICT", {
|
|
1894
|
+
message: "Domain has already been verified",
|
|
1895
|
+
code: "DOMAIN_VERIFIED"
|
|
1427
1896
|
});
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1897
|
+
const identifier = getVerificationIdentifier(options, provider.providerId);
|
|
1898
|
+
const activeVerification = await ctx.context.internalAdapter.findVerificationValue(identifier);
|
|
1899
|
+
if (activeVerification && new Date(activeVerification.expiresAt) > /* @__PURE__ */ new Date()) {
|
|
1900
|
+
ctx.setStatus(201);
|
|
1901
|
+
return ctx.json({ domainVerificationToken: activeVerification.value });
|
|
1902
|
+
}
|
|
1903
|
+
const domainVerificationToken = generateRandomString(24);
|
|
1904
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
1905
|
+
identifier,
|
|
1906
|
+
value: domainVerificationToken,
|
|
1907
|
+
expiresAt: new Date(Date.now() + 3600 * 24 * 7 * 1e3)
|
|
1431
1908
|
});
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1909
|
+
ctx.setStatus(201);
|
|
1910
|
+
return ctx.json({ domainVerificationToken });
|
|
1911
|
+
});
|
|
1912
|
+
};
|
|
1913
|
+
const verifyDomain = (options) => {
|
|
1914
|
+
return createAuthEndpoint("/sso/verify-domain", {
|
|
1915
|
+
method: "POST",
|
|
1916
|
+
body: domainVerificationBodySchema,
|
|
1917
|
+
metadata: { openapi: {
|
|
1918
|
+
summary: "Verify the provider domain ownership",
|
|
1919
|
+
description: "Verify the provider domain ownership via DNS records",
|
|
1920
|
+
responses: {
|
|
1921
|
+
"404": { description: "Provider not found" },
|
|
1922
|
+
"409": { description: "Domain has already been verified or no pending verification exists" },
|
|
1923
|
+
"502": { description: "Unable to verify domain ownership due to upstream validator error" },
|
|
1924
|
+
"204": { description: "Domain ownership was verified" }
|
|
1925
|
+
}
|
|
1926
|
+
} },
|
|
1927
|
+
use: [sessionMiddleware]
|
|
1928
|
+
}, async (ctx) => {
|
|
1929
|
+
const body = ctx.body;
|
|
1930
|
+
const provider = await checkProviderAccess(ctx, body.providerId);
|
|
1931
|
+
if (provider.domainVerified) throw new APIError("CONFLICT", {
|
|
1932
|
+
message: "Domain has already been verified",
|
|
1933
|
+
code: "DOMAIN_VERIFIED"
|
|
1435
1934
|
});
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1935
|
+
const identifier = getVerificationIdentifier(options, provider.providerId);
|
|
1936
|
+
if (identifier.length > DNS_LABEL_MAX_LENGTH) throw new APIError("BAD_REQUEST", {
|
|
1937
|
+
message: `Verification identifier exceeds the DNS label limit of ${DNS_LABEL_MAX_LENGTH} characters`,
|
|
1938
|
+
code: "IDENTIFIER_TOO_LONG"
|
|
1439
1939
|
});
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1940
|
+
const activeVerification = await ctx.context.internalAdapter.findVerificationValue(identifier);
|
|
1941
|
+
if (!activeVerification || new Date(activeVerification.expiresAt) <= /* @__PURE__ */ new Date()) throw new APIError("NOT_FOUND", {
|
|
1942
|
+
message: "No pending domain verification exists",
|
|
1943
|
+
code: "NO_PENDING_VERIFICATION"
|
|
1443
1944
|
});
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1945
|
+
let dns;
|
|
1946
|
+
try {
|
|
1947
|
+
dns = await import("node:dns/promises");
|
|
1948
|
+
} catch (error) {
|
|
1949
|
+
ctx.context.logger.error("The core node:dns module is required for the domain verification feature", error);
|
|
1950
|
+
throw new APIError("INTERNAL_SERVER_ERROR", {
|
|
1951
|
+
message: "Unable to verify domain ownership due to server error",
|
|
1952
|
+
code: "DOMAIN_VERIFICATION_FAILED"
|
|
1953
|
+
});
|
|
1954
|
+
}
|
|
1955
|
+
const domains = parseProviderDomains(provider.domain);
|
|
1956
|
+
if (!domains) throw new APIError("BAD_REQUEST", {
|
|
1957
|
+
message: "Invalid domain",
|
|
1958
|
+
code: "INVALID_DOMAIN"
|
|
1447
1959
|
});
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1960
|
+
for (const domain of domains) {
|
|
1961
|
+
let records = [];
|
|
1962
|
+
try {
|
|
1963
|
+
records = (await dns.resolveTxt(`${identifier}.${domain}`)).map((record) => record.join(""));
|
|
1964
|
+
} catch (error) {
|
|
1965
|
+
ctx.context.logger.warn(`DNS resolution failure while validating domain ownership for ${domain}`, error);
|
|
1966
|
+
}
|
|
1967
|
+
const verificationValue = activeVerification.value;
|
|
1968
|
+
const verificationRecord = `${activeVerification.identifier}=${verificationValue}`;
|
|
1969
|
+
if (!records.find((record) => {
|
|
1970
|
+
const normalizedRecord = record.trim();
|
|
1971
|
+
return normalizedRecord === verificationRecord || normalizedRecord === verificationValue;
|
|
1972
|
+
})) throw new APIError("BAD_GATEWAY", {
|
|
1973
|
+
message: `Unable to verify domain ownership for ${domain}. Try again later`,
|
|
1974
|
+
code: "DOMAIN_VERIFICATION_FAILED"
|
|
1453
1975
|
});
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
}
|
|
1976
|
+
}
|
|
1977
|
+
await ctx.context.adapter.update({
|
|
1978
|
+
model: "ssoProvider",
|
|
1979
|
+
where: [{
|
|
1980
|
+
field: "providerId",
|
|
1981
|
+
value: provider.providerId
|
|
1982
|
+
}],
|
|
1983
|
+
update: { domainVerified: true }
|
|
1984
|
+
});
|
|
1985
|
+
ctx.setStatus(204);
|
|
1986
|
+
});
|
|
1987
|
+
};
|
|
1466
1988
|
//#endregion
|
|
1467
1989
|
//#region src/saml-state.ts
|
|
1468
|
-
async function generateRelayState(c, link
|
|
1990
|
+
async function generateRelayState(c, link) {
|
|
1469
1991
|
const callbackURL = c.body.callbackURL;
|
|
1470
1992
|
if (!callbackURL) throw new APIError("BAD_REQUEST", { message: "callbackURL is required" });
|
|
1471
|
-
const codeVerifier = generateRandomString(128);
|
|
1472
1993
|
const stateData = {
|
|
1473
|
-
...additionalData ? additionalData : {},
|
|
1474
1994
|
callbackURL,
|
|
1475
|
-
codeVerifier,
|
|
1995
|
+
codeVerifier: generateRandomString(128),
|
|
1476
1996
|
errorURL: c.body.errorCallbackURL,
|
|
1477
1997
|
newUserURL: c.body.newUserCallbackURL,
|
|
1478
1998
|
link,
|
|
@@ -1508,8 +2028,17 @@ async function parseRelayState(c) {
|
|
|
1508
2028
|
if (!parsedData.errorURL) parsedData.errorURL = errorURL;
|
|
1509
2029
|
return parsedData;
|
|
1510
2030
|
}
|
|
2031
|
+
const saml = typeof samlifyNamespace.SPMetadata === "function" && typeof samlifyNamespace.setSchemaValidator === "function" ? samlifyNamespace : samlifyDefault ?? samlifyNamespace;
|
|
1511
2032
|
//#endregion
|
|
1512
2033
|
//#region src/routes/helpers.ts
|
|
2034
|
+
/**
|
|
2035
|
+
* Same as `normalizePem`, but applied across the resolved list of IdP signing
|
|
2036
|
+
* certificates so multi-cert rotation configs survive the line-trim step.
|
|
2037
|
+
*/
|
|
2038
|
+
function normalizePemList(certs) {
|
|
2039
|
+
if (!certs) return certs;
|
|
2040
|
+
return certs.map((pem) => normalizePem(pem) ?? pem);
|
|
2041
|
+
}
|
|
1513
2042
|
async function findSAMLProvider(providerId, options, adapter) {
|
|
1514
2043
|
if (options?.defaultSSO?.length) {
|
|
1515
2044
|
const match = options.defaultSSO.find((p) => p.providerId === providerId);
|
|
@@ -1536,41 +2065,47 @@ async function findSAMLProvider(providerId, options, adapter) {
|
|
|
1536
2065
|
function createSP(config, baseURL, providerId, opts) {
|
|
1537
2066
|
const spData = config.spMetadata;
|
|
1538
2067
|
const sloLocation = `${baseURL}/sso/saml2/sp/slo/${providerId}`;
|
|
1539
|
-
const acsUrl =
|
|
1540
|
-
|
|
2068
|
+
const acsUrl = `${baseURL}/sso/saml2/sp/acs/${providerId}`;
|
|
2069
|
+
let metadata = spData?.metadata;
|
|
2070
|
+
if (!metadata) metadata = saml.SPMetadata({
|
|
1541
2071
|
entityID: spData?.entityID || config.issuer,
|
|
1542
|
-
assertionConsumerService:
|
|
2072
|
+
assertionConsumerService: [{
|
|
1543
2073
|
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1544
2074
|
Location: acsUrl
|
|
1545
2075
|
}],
|
|
1546
|
-
singleLogoutService: [{
|
|
2076
|
+
singleLogoutService: opts?.sloOptions ? [{
|
|
1547
2077
|
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1548
2078
|
Location: sloLocation
|
|
1549
2079
|
}, {
|
|
1550
2080
|
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1551
2081
|
Location: sloLocation
|
|
1552
|
-
}],
|
|
2082
|
+
}] : void 0,
|
|
1553
2083
|
wantMessageSigned: config.wantAssertionsSigned || false,
|
|
2084
|
+
authnRequestsSigned: config.authnRequestsSigned || false,
|
|
2085
|
+
nameIDFormat: config.identifierFormat ? [config.identifierFormat] : void 0
|
|
2086
|
+
}).getMetadata() || "";
|
|
2087
|
+
return saml.ServiceProvider({
|
|
2088
|
+
metadata,
|
|
2089
|
+
allowCreate: true,
|
|
1554
2090
|
wantLogoutRequestSigned: opts?.sloOptions?.wantLogoutRequestSigned ?? false,
|
|
1555
2091
|
wantLogoutResponseSigned: opts?.sloOptions?.wantLogoutResponseSigned ?? false,
|
|
1556
|
-
|
|
1557
|
-
privateKey: spData?.privateKey || config.privateKey,
|
|
2092
|
+
privateKey: normalizePem(spData?.privateKey || config.privateKey),
|
|
1558
2093
|
privateKeyPass: spData?.privateKeyPass,
|
|
1559
2094
|
isAssertionEncrypted: spData?.isAssertionEncrypted || false,
|
|
1560
|
-
encPrivateKey: spData?.encPrivateKey,
|
|
2095
|
+
encPrivateKey: normalizePem(spData?.encPrivateKey),
|
|
1561
2096
|
encPrivateKeyPass: spData?.encPrivateKeyPass,
|
|
1562
|
-
|
|
1563
|
-
|
|
2097
|
+
relayState: opts?.relayState,
|
|
2098
|
+
clockDrifts: opts?.clockSkew && opts?.clockSkew !== 0 ? [-opts.clockSkew, opts.clockSkew] : void 0
|
|
1564
2099
|
});
|
|
1565
2100
|
}
|
|
1566
2101
|
function createIdP(config) {
|
|
1567
2102
|
const idpData = config.idpMetadata;
|
|
1568
2103
|
if (idpData?.metadata) return saml.IdentityProvider({
|
|
1569
2104
|
metadata: idpData.metadata,
|
|
1570
|
-
privateKey: idpData.privateKey,
|
|
2105
|
+
privateKey: normalizePem(idpData.privateKey),
|
|
1571
2106
|
privateKeyPass: idpData.privateKeyPass,
|
|
1572
2107
|
isAssertionEncrypted: idpData.isAssertionEncrypted,
|
|
1573
|
-
encPrivateKey: idpData.encPrivateKey,
|
|
2108
|
+
encPrivateKey: normalizePem(idpData.encPrivateKey),
|
|
1574
2109
|
encPrivateKeyPass: idpData.encPrivateKeyPass
|
|
1575
2110
|
});
|
|
1576
2111
|
return saml.IdentityProvider({
|
|
@@ -1580,10 +2115,10 @@ function createIdP(config) {
|
|
|
1580
2115
|
Location: config.entryPoint
|
|
1581
2116
|
}],
|
|
1582
2117
|
singleLogoutService: idpData?.singleLogoutService,
|
|
1583
|
-
signingCert:
|
|
2118
|
+
signingCert: normalizePemList(resolveSigningCerts(config)),
|
|
1584
2119
|
wantAuthnRequestsSigned: config.authnRequestsSigned || false,
|
|
1585
2120
|
isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
|
|
1586
|
-
encPrivateKey: idpData?.encPrivateKey,
|
|
2121
|
+
encPrivateKey: normalizePem(idpData?.encPrivateKey),
|
|
1587
2122
|
encPrivateKeyPass: idpData?.encPrivateKeyPass
|
|
1588
2123
|
});
|
|
1589
2124
|
}
|
|
@@ -1591,7 +2126,17 @@ function escapeHtml(str) {
|
|
|
1591
2126
|
if (!str) return "";
|
|
1592
2127
|
return String(str).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1593
2128
|
}
|
|
2129
|
+
function isSAMLPostBindingLocation(value) {
|
|
2130
|
+
let url;
|
|
2131
|
+
try {
|
|
2132
|
+
url = new URL(value);
|
|
2133
|
+
} catch {
|
|
2134
|
+
return false;
|
|
2135
|
+
}
|
|
2136
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
2137
|
+
}
|
|
1594
2138
|
function createSAMLPostForm(action, samlParam, samlValue, relayState) {
|
|
2139
|
+
if (!isSAMLPostBindingLocation(action)) throw new APIError("BAD_REQUEST", { message: "SAML POST binding location must be an absolute http or https URL" });
|
|
1595
2140
|
const safeAction = escapeHtml(action);
|
|
1596
2141
|
const safeSamlParam = escapeHtml(samlParam);
|
|
1597
2142
|
const safeSamlValue = escapeHtml(samlValue);
|
|
@@ -1661,6 +2206,14 @@ function getSafeRedirectUrl(url, callbackPath, appOrigin, isTrustedOrigin) {
|
|
|
1661
2206
|
}
|
|
1662
2207
|
return url;
|
|
1663
2208
|
}
|
|
2209
|
+
try {
|
|
2210
|
+
const absoluteUrl = new URL(url);
|
|
2211
|
+
if (absoluteUrl.origin === appOrigin) {
|
|
2212
|
+
const callbackPathname = new URL(callbackPath).pathname;
|
|
2213
|
+
if (absoluteUrl.pathname === callbackPathname) return appOrigin;
|
|
2214
|
+
return url;
|
|
2215
|
+
}
|
|
2216
|
+
} catch {}
|
|
1664
2217
|
if (!isTrustedOrigin(url, { allowRelativePaths: false })) return appOrigin;
|
|
1665
2218
|
try {
|
|
1666
2219
|
const callbackPathname = new URL(callbackPath).pathname;
|
|
@@ -1670,6 +2223,39 @@ function getSafeRedirectUrl(url, callbackPath, appOrigin, isTrustedOrigin) {
|
|
|
1670
2223
|
}
|
|
1671
2224
|
return url;
|
|
1672
2225
|
}
|
|
2226
|
+
function buildSAMLRedirectUrl(url, params) {
|
|
2227
|
+
const searchParams = new URLSearchParams(params);
|
|
2228
|
+
try {
|
|
2229
|
+
const isRelativePath = url.startsWith("/") && !url.startsWith("//");
|
|
2230
|
+
const parsedUrl = new URL(url, "http://better-auth.local");
|
|
2231
|
+
for (const [key, value] of searchParams) parsedUrl.searchParams.set(key, value);
|
|
2232
|
+
if (isRelativePath) return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
|
|
2233
|
+
return parsedUrl.toString();
|
|
2234
|
+
} catch {
|
|
2235
|
+
const hashIndex = url.indexOf("#");
|
|
2236
|
+
const urlWithoutFragment = hashIndex === -1 ? url : url.slice(0, hashIndex);
|
|
2237
|
+
const fragment = hashIndex === -1 ? "" : url.slice(hashIndex);
|
|
2238
|
+
return `${urlWithoutFragment}${urlWithoutFragment.includes("?") ? "&" : "?"}${searchParams.toString()}${fragment}`;
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
function toArray(value) {
|
|
2242
|
+
if (Array.isArray(value)) return value;
|
|
2243
|
+
return value ? [value] : [];
|
|
2244
|
+
}
|
|
2245
|
+
function getExpectedSAMLRecipients(config, baseURL, providerId, currentCallbackPath, assertionConsumerServiceUrl) {
|
|
2246
|
+
const configuredPostAssertionConsumerServiceUrls = getSAMLPostAssertionConsumerServiceUrls(config.spMetadata?.metadata);
|
|
2247
|
+
return [
|
|
2248
|
+
currentCallbackPath,
|
|
2249
|
+
`${baseURL}/sso/saml2/sp/acs/${providerId}`,
|
|
2250
|
+
...configuredPostAssertionConsumerServiceUrls,
|
|
2251
|
+
...toArray(assertionConsumerServiceUrl)
|
|
2252
|
+
];
|
|
2253
|
+
}
|
|
2254
|
+
async function getSAMLResponseBindingContent(sp, samlContent) {
|
|
2255
|
+
if (!hasSAMLEncryptedAssertion(samlContent)) return samlContent;
|
|
2256
|
+
const [decryptedContent] = await saml.SamlLib.decryptAssertion(sp, samlContent);
|
|
2257
|
+
return decryptedContent;
|
|
2258
|
+
}
|
|
1673
2259
|
/**
|
|
1674
2260
|
* Extracts the Assertion ID from a SAML response XML.
|
|
1675
2261
|
* Used for replay protection per SAML 2.0 Core section 2.3.3.
|
|
@@ -1694,8 +2280,8 @@ function extractAssertionId(samlContent) {
|
|
|
1694
2280
|
/**
|
|
1695
2281
|
* Unified SAML response processing pipeline.
|
|
1696
2282
|
*
|
|
1697
|
-
*
|
|
1698
|
-
*
|
|
2283
|
+
* The `/sso/saml2/sp/acs/:providerId` endpoint delegates to this function.
|
|
2284
|
+
* It handles the full lifecycle: provider lookup,
|
|
1699
2285
|
* SP/IdP construction, response validation, session creation, and redirect
|
|
1700
2286
|
* URL computation.
|
|
1701
2287
|
*/
|
|
@@ -1716,7 +2302,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1716
2302
|
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
1717
2303
|
const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
|
|
1718
2304
|
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
1719
|
-
const sp = createSP(parsedSamlConfig, ctx.context.baseURL, providerId);
|
|
2305
|
+
const sp = createSP(parsedSamlConfig, ctx.context.baseURL, providerId, { clockSkew: options?.saml?.clockSkew });
|
|
1720
2306
|
const idp = createIdP(parsedSamlConfig);
|
|
1721
2307
|
const samlRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, params.currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
1722
2308
|
validateSingleAssertion(SAMLResponse);
|
|
@@ -1738,12 +2324,47 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1738
2324
|
});
|
|
1739
2325
|
}
|
|
1740
2326
|
const { extract } = parsedResponse;
|
|
2327
|
+
const samlContent = parsedResponse.samlContent;
|
|
1741
2328
|
validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
|
|
1742
2329
|
validateSAMLTimestamp(extract.conditions, {
|
|
1743
2330
|
clockSkew: options?.saml?.clockSkew,
|
|
1744
2331
|
requireTimestamps: options?.saml?.requireTimestamps,
|
|
1745
2332
|
logger: ctx.context.logger
|
|
1746
2333
|
});
|
|
2334
|
+
const expectedAudiences = [sp.entityMeta.getEntityID(), parsedSamlConfig.audience];
|
|
2335
|
+
const assertionConsumerServiceUrl = sp.entityMeta.getAssertionConsumerService(SAML_HTTP_POST_BINDING);
|
|
2336
|
+
const expectedRecipients = getExpectedSAMLRecipients(parsedSamlConfig, ctx.context.baseURL, providerId, currentCallbackPath, assertionConsumerServiceUrl);
|
|
2337
|
+
let samlBindingContent;
|
|
2338
|
+
try {
|
|
2339
|
+
samlBindingContent = await getSAMLResponseBindingContent(sp, samlContent);
|
|
2340
|
+
validateSAMLResponseBinding(samlBindingContent, {
|
|
2341
|
+
expectedAudiences,
|
|
2342
|
+
expectedRecipients
|
|
2343
|
+
});
|
|
2344
|
+
} catch (error) {
|
|
2345
|
+
if (isAPIError(error)) {
|
|
2346
|
+
ctx.context.logger.error("SAML response binding validation failed", {
|
|
2347
|
+
providerId,
|
|
2348
|
+
code: error.body?.code,
|
|
2349
|
+
expectedAudiences: expectedAudiences.filter(Boolean),
|
|
2350
|
+
expectedRecipients: expectedRecipients.filter(Boolean)
|
|
2351
|
+
});
|
|
2352
|
+
throw ctx.redirect(buildSAMLRedirectUrl(samlRedirectUrl, {
|
|
2353
|
+
error: "invalid_saml_response",
|
|
2354
|
+
error_description: error.body?.message || error.message || "Invalid SAML response"
|
|
2355
|
+
}));
|
|
2356
|
+
}
|
|
2357
|
+
ctx.context.logger.error("SAML response binding validation failed", {
|
|
2358
|
+
providerId,
|
|
2359
|
+
error,
|
|
2360
|
+
expectedAudiences: expectedAudiences.filter(Boolean),
|
|
2361
|
+
expectedRecipients: expectedRecipients.filter(Boolean)
|
|
2362
|
+
});
|
|
2363
|
+
throw ctx.redirect(buildSAMLRedirectUrl(samlRedirectUrl, {
|
|
2364
|
+
error: "invalid_saml_response",
|
|
2365
|
+
error_description: "SAML response binding could not be validated"
|
|
2366
|
+
}));
|
|
2367
|
+
}
|
|
1747
2368
|
await validateInResponseTo(ctx, {
|
|
1748
2369
|
extract,
|
|
1749
2370
|
providerId,
|
|
@@ -1755,37 +2376,18 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1755
2376
|
});
|
|
1756
2377
|
validateAudience(ctx, {
|
|
1757
2378
|
extract,
|
|
1758
|
-
expectedAudience: parsedSamlConfig.audience,
|
|
2379
|
+
expectedAudience: parsedSamlConfig.audience || sp.entityMeta.getEntityID(),
|
|
1759
2380
|
providerId,
|
|
1760
2381
|
redirectUrl: samlRedirectUrl
|
|
1761
2382
|
});
|
|
1762
|
-
const
|
|
1763
|
-
const assertionId = samlContent ? extractAssertionId(samlContent) : null;
|
|
2383
|
+
const assertionId = extractAssertionId(samlBindingContent);
|
|
1764
2384
|
if (assertionId) {
|
|
1765
2385
|
const issuer = idp.entityMeta.getEntityID();
|
|
1766
2386
|
const conditions = extract.conditions;
|
|
1767
2387
|
const clockSkew = options?.saml?.clockSkew ?? 3e5;
|
|
1768
2388
|
const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
if (existingAssertion) try {
|
|
1772
|
-
if (JSON.parse(existingAssertion.value).expiresAt >= Date.now()) isReplay = true;
|
|
1773
|
-
} catch (error) {
|
|
1774
|
-
ctx.context.logger.warn("Failed to parse stored assertion record", {
|
|
1775
|
-
assertionId,
|
|
1776
|
-
error
|
|
1777
|
-
});
|
|
1778
|
-
}
|
|
1779
|
-
if (isReplay) {
|
|
1780
|
-
ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
|
|
1781
|
-
assertionId,
|
|
1782
|
-
issuer,
|
|
1783
|
-
providerId
|
|
1784
|
-
});
|
|
1785
|
-
throw ctx.redirect(`${samlRedirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
|
|
1786
|
-
}
|
|
1787
|
-
await ctx.context.internalAdapter.createVerificationValue({
|
|
1788
|
-
identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
|
|
2389
|
+
if (!await ctx.context.internalAdapter.reserveVerificationValue({
|
|
2390
|
+
identifier: `saml-used-assertion:${assertionId}`,
|
|
1789
2391
|
value: JSON.stringify({
|
|
1790
2392
|
assertionId,
|
|
1791
2393
|
issuer,
|
|
@@ -1794,16 +2396,30 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1794
2396
|
expiresAt
|
|
1795
2397
|
}),
|
|
1796
2398
|
expiresAt: new Date(expiresAt)
|
|
1797
|
-
})
|
|
2399
|
+
})) {
|
|
2400
|
+
ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
|
|
2401
|
+
assertionId,
|
|
2402
|
+
issuer,
|
|
2403
|
+
providerId
|
|
2404
|
+
});
|
|
2405
|
+
throw ctx.redirect(buildSAMLRedirectUrl(samlRedirectUrl, {
|
|
2406
|
+
error: "replay_detected",
|
|
2407
|
+
error_description: "SAML assertion has already been used"
|
|
2408
|
+
}));
|
|
2409
|
+
}
|
|
1798
2410
|
} else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
|
|
1799
2411
|
const attributes = extract.attributes || {};
|
|
1800
2412
|
const mapping = parsedSamlConfig.mapping ?? {};
|
|
2413
|
+
const attr = (key) => {
|
|
2414
|
+
const value = attributes[key];
|
|
2415
|
+
return Array.isArray(value) ? value[0] : value;
|
|
2416
|
+
};
|
|
1801
2417
|
const userInfo = {
|
|
1802
2418
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
|
|
1803
|
-
id:
|
|
1804
|
-
email: (
|
|
1805
|
-
name: [
|
|
1806
|
-
emailVerified: options?.trustEmailVerified && mapping.emailVerified ?
|
|
2419
|
+
id: attr(mapping.id || "nameID") || extract.nameID,
|
|
2420
|
+
email: (attr(mapping.email || "email") || extract.nameID || "").toLowerCase(),
|
|
2421
|
+
name: [attr(mapping.firstName || "givenName"), attr(mapping.lastName || "surname")].filter(Boolean).join(" ") || attr(mapping.name || "displayName") || extract.nameID,
|
|
2422
|
+
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? parseProviderEmailVerified(attr(mapping.emailVerified)) : false
|
|
1807
2423
|
};
|
|
1808
2424
|
if (!userInfo.id || !userInfo.email) {
|
|
1809
2425
|
ctx.context.logger.error("Missing essential user info from SAML response", {
|
|
@@ -1814,26 +2430,47 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1814
2430
|
});
|
|
1815
2431
|
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
1816
2432
|
}
|
|
1817
|
-
const isTrustedProvider =
|
|
2433
|
+
const isTrustedProvider = "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
1818
2434
|
const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1819
|
-
const
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
2435
|
+
const errorUrl = relayState?.errorURL || samlRedirectUrl;
|
|
2436
|
+
let result;
|
|
2437
|
+
try {
|
|
2438
|
+
result = await runWithTransaction(ctx.context.adapter, async () => {
|
|
2439
|
+
await lockSSOProviderForAccountLink(ctx, provider);
|
|
2440
|
+
return handleOAuthUserInfo(ctx, {
|
|
2441
|
+
userInfo: {
|
|
2442
|
+
email: userInfo.email,
|
|
2443
|
+
name: userInfo.name || userInfo.email,
|
|
2444
|
+
id: userInfo.id,
|
|
2445
|
+
emailVerified: userInfo.emailVerified
|
|
2446
|
+
},
|
|
2447
|
+
account: {
|
|
2448
|
+
providerId,
|
|
2449
|
+
accountId: userInfo.id,
|
|
2450
|
+
accessToken: "",
|
|
2451
|
+
refreshToken: ""
|
|
2452
|
+
},
|
|
2453
|
+
callbackURL: callbackUrl,
|
|
2454
|
+
disableSignUp: options?.disableImplicitSignUp,
|
|
2455
|
+
source: {
|
|
2456
|
+
method: "sso-saml",
|
|
2457
|
+
sso: {
|
|
2458
|
+
providerId,
|
|
2459
|
+
profile: attributes
|
|
2460
|
+
}
|
|
2461
|
+
},
|
|
2462
|
+
isTrustedProvider,
|
|
2463
|
+
trustProviderByName: false
|
|
2464
|
+
});
|
|
2465
|
+
});
|
|
2466
|
+
} catch (e) {
|
|
2467
|
+
if (isAPIError(e) && e.body?.code) throw ctx.redirect(buildSAMLRedirectUrl(errorUrl, {
|
|
2468
|
+
error: e.body.code,
|
|
2469
|
+
...e.body.message ? { error_description: e.body.message } : {}
|
|
2470
|
+
}));
|
|
2471
|
+
throw e;
|
|
2472
|
+
}
|
|
2473
|
+
if (result.error) throw ctx.redirect(buildSAMLRedirectUrl(callbackUrl, { error: result.error.split(" ").join("_") }));
|
|
1837
2474
|
const { session, user } = result.data;
|
|
1838
2475
|
if (options?.provisionUser && (result.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
|
|
1839
2476
|
user,
|
|
@@ -1847,7 +2484,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1847
2484
|
providerId,
|
|
1848
2485
|
accountId: userInfo.id,
|
|
1849
2486
|
email: userInfo.email,
|
|
1850
|
-
emailVerified:
|
|
2487
|
+
emailVerified: userInfo.emailVerified,
|
|
1851
2488
|
rawAttributes: attributes
|
|
1852
2489
|
},
|
|
1853
2490
|
provider,
|
|
@@ -1861,6 +2498,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1861
2498
|
const samlSessionKey = `${SAML_SESSION_KEY_PREFIX}${providerId}:${extract.nameID}`;
|
|
1862
2499
|
const samlSessionData = {
|
|
1863
2500
|
sessionId: session.id,
|
|
2501
|
+
sessionToken: session.token,
|
|
1864
2502
|
providerId,
|
|
1865
2503
|
nameID: extract.nameID,
|
|
1866
2504
|
sessionIndex: extract.sessionIndex?.sessionIndex
|
|
@@ -1880,6 +2518,14 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1880
2518
|
}
|
|
1881
2519
|
//#endregion
|
|
1882
2520
|
//#region src/routes/sso.ts
|
|
2521
|
+
const BUILT_IN_ACCOUNT_PROVIDER_IDS = [
|
|
2522
|
+
"credential",
|
|
2523
|
+
"email-otp",
|
|
2524
|
+
"magic-link",
|
|
2525
|
+
"phone-number",
|
|
2526
|
+
"anonymous",
|
|
2527
|
+
"siwe"
|
|
2528
|
+
];
|
|
1883
2529
|
/**
|
|
1884
2530
|
* Builds the OIDC redirect URI. Uses the shared `redirectURI` option
|
|
1885
2531
|
* when set, otherwise falls back to `/sso/callback/:providerId`.
|
|
@@ -1893,10 +2539,7 @@ function getOIDCRedirectURI(baseURL, providerId, options) {
|
|
|
1893
2539
|
}
|
|
1894
2540
|
return `${baseURL}/sso/callback/${providerId}`;
|
|
1895
2541
|
}
|
|
1896
|
-
const spMetadataQuerySchema = z.object({
|
|
1897
|
-
providerId: z.string(),
|
|
1898
|
-
format: z.enum(["xml", "json"]).default("xml")
|
|
1899
|
-
});
|
|
2542
|
+
const spMetadataQuerySchema = z.object({ providerId: z.string() });
|
|
1900
2543
|
const spMetadata = (options) => {
|
|
1901
2544
|
return createAuthEndpoint("/sso/saml2/sp/metadata", {
|
|
1902
2545
|
method: "GET",
|
|
@@ -1908,290 +2551,191 @@ const spMetadata = (options) => {
|
|
|
1908
2551
|
responses: { "200": { description: "SAML metadata in XML format" } }
|
|
1909
2552
|
} }
|
|
1910
2553
|
}, async (ctx) => {
|
|
1911
|
-
const provider = await ctx.context.adapter
|
|
1912
|
-
model: "ssoProvider",
|
|
1913
|
-
where: [{
|
|
1914
|
-
field: "providerId",
|
|
1915
|
-
value: ctx.query.providerId
|
|
1916
|
-
}]
|
|
1917
|
-
});
|
|
2554
|
+
const provider = await findSAMLProvider(ctx.query.providerId, options, ctx.context.adapter);
|
|
1918
2555
|
if (!provider) throw new APIError("NOT_FOUND", { message: "No provider found for the given providerId" });
|
|
1919
|
-
const parsedSamlConfig =
|
|
2556
|
+
const parsedSamlConfig = provider.samlConfig;
|
|
1920
2557
|
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
1921
|
-
const
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
}, {
|
|
1926
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1927
|
-
Location: sloLocation
|
|
1928
|
-
}] : void 0;
|
|
1929
|
-
const sp = parsedSamlConfig.spMetadata.metadata ? saml.ServiceProvider({ metadata: parsedSamlConfig.spMetadata.metadata }) : saml.SPMetadata({
|
|
1930
|
-
entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
|
1931
|
-
assertionConsumerService: [{
|
|
1932
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1933
|
-
Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${ctx.query.providerId}`
|
|
1934
|
-
}],
|
|
1935
|
-
singleLogoutService,
|
|
1936
|
-
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1937
|
-
authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
|
|
1938
|
-
nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
|
|
1939
|
-
});
|
|
2558
|
+
const sp = createSP(parsedSamlConfig, ctx.context.baseURL, ctx.query.providerId, options?.saml?.enableSingleLogout ? { sloOptions: {
|
|
2559
|
+
wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned,
|
|
2560
|
+
wantLogoutResponseSigned: options?.saml?.wantLogoutResponseSigned
|
|
2561
|
+
} } : void 0);
|
|
1940
2562
|
return new Response(sp.getMetadata(), { headers: { "Content-Type": "application/xml" } });
|
|
1941
2563
|
});
|
|
1942
2564
|
};
|
|
1943
|
-
const ssoProviderBodySchema = z.object({
|
|
1944
|
-
providerId: z.string({}).meta({ description: "The ID of the provider. This is used to identify the provider during login and callback" }),
|
|
1945
|
-
issuer: z.string({}).meta({ description: "The issuer of the provider" }),
|
|
1946
|
-
domain: z.string({}).meta({ description: "The domain(s) of the provider. For enterprise multi-domain SSO where a single IdP serves multiple email domains, use comma-separated values (e.g., 'company.com,subsidiary.com,acquired-company.com')" }),
|
|
1947
|
-
oidcConfig: z.object({
|
|
1948
|
-
clientId: z.string({}).meta({ description: "The client ID" }),
|
|
1949
|
-
clientSecret: z.string({}).optional().meta({ description: "The client secret. Required for client_secret_basic/client_secret_post. Optional for private_key_jwt." }),
|
|
1950
|
-
authorizationEndpoint: z.string({}).meta({ description: "The authorization endpoint" }).optional(),
|
|
1951
|
-
tokenEndpoint: z.string({}).meta({ description: "The token endpoint" }).optional(),
|
|
1952
|
-
userInfoEndpoint: z.string({}).meta({ description: "The user info endpoint" }).optional(),
|
|
1953
|
-
tokenEndpointAuthentication: z.enum([
|
|
1954
|
-
"client_secret_post",
|
|
1955
|
-
"client_secret_basic",
|
|
1956
|
-
"private_key_jwt"
|
|
1957
|
-
]).optional(),
|
|
1958
|
-
privateKeyId: z.string().optional(),
|
|
1959
|
-
privateKeyAlgorithm: z.string().optional(),
|
|
1960
|
-
jwksEndpoint: z.string({}).meta({ description: "The JWKS endpoint" }).optional(),
|
|
1961
|
-
discoveryEndpoint: z.string().optional(),
|
|
1962
|
-
skipDiscovery: z.boolean().meta({ description: "Skip OIDC discovery during registration. When true, you must provide authorizationEndpoint, tokenEndpoint, and jwksEndpoint manually." }).optional(),
|
|
1963
|
-
scopes: z.array(z.string(), {}).meta({ description: "The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']" }).optional(),
|
|
1964
|
-
pkce: z.boolean({}).meta({ description: "Whether to use PKCE for the authorization flow" }).default(true).optional(),
|
|
1965
|
-
mapping: z.object({
|
|
1966
|
-
id: z.string({}).meta({ description: "Field mapping for user ID (defaults to 'sub')" }),
|
|
1967
|
-
email: z.string({}).meta({ description: "Field mapping for email (defaults to 'email')" }),
|
|
1968
|
-
emailVerified: z.string({}).meta({ description: "Field mapping for email verification (defaults to 'email_verified')" }).optional(),
|
|
1969
|
-
name: z.string({}).meta({ description: "Field mapping for name (defaults to 'name')" }),
|
|
1970
|
-
image: z.string({}).meta({ description: "Field mapping for image (defaults to 'picture')" }).optional(),
|
|
1971
|
-
extraFields: z.record(z.string(), z.any()).optional()
|
|
1972
|
-
}).optional()
|
|
1973
|
-
}).optional(),
|
|
1974
|
-
samlConfig: z.object({
|
|
1975
|
-
entryPoint: z.string({}).meta({ description: "The entry point of the provider" }),
|
|
1976
|
-
cert: z.string({}).meta({ description: "The certificate of the provider" }),
|
|
1977
|
-
callbackUrl: z.string({}).meta({ description: "The callback URL of the provider" }),
|
|
1978
|
-
audience: z.string().optional(),
|
|
1979
|
-
idpMetadata: z.object({
|
|
1980
|
-
metadata: z.string().optional(),
|
|
1981
|
-
entityID: z.string().optional(),
|
|
1982
|
-
cert: z.string().optional(),
|
|
1983
|
-
privateKey: z.string().optional(),
|
|
1984
|
-
privateKeyPass: z.string().optional(),
|
|
1985
|
-
isAssertionEncrypted: z.boolean().optional(),
|
|
1986
|
-
encPrivateKey: z.string().optional(),
|
|
1987
|
-
encPrivateKeyPass: z.string().optional(),
|
|
1988
|
-
singleSignOnService: z.array(z.object({
|
|
1989
|
-
Binding: z.string().meta({ description: "The binding type for the SSO service" }),
|
|
1990
|
-
Location: z.string().meta({ description: "The URL for the SSO service" })
|
|
1991
|
-
})).optional().meta({ description: "Single Sign-On service configuration" })
|
|
1992
|
-
}).optional(),
|
|
1993
|
-
spMetadata: z.object({
|
|
1994
|
-
metadata: z.string().optional(),
|
|
1995
|
-
entityID: z.string().optional(),
|
|
1996
|
-
binding: z.string().optional(),
|
|
1997
|
-
privateKey: z.string().optional(),
|
|
1998
|
-
privateKeyPass: z.string().optional(),
|
|
1999
|
-
isAssertionEncrypted: z.boolean().optional(),
|
|
2000
|
-
encPrivateKey: z.string().optional(),
|
|
2001
|
-
encPrivateKeyPass: z.string().optional()
|
|
2002
|
-
}),
|
|
2003
|
-
wantAssertionsSigned: z.boolean().optional(),
|
|
2004
|
-
authnRequestsSigned: z.boolean().optional(),
|
|
2005
|
-
signatureAlgorithm: z.string().optional(),
|
|
2006
|
-
digestAlgorithm: z.string().optional(),
|
|
2007
|
-
identifierFormat: z.string().optional(),
|
|
2008
|
-
privateKey: z.string().optional(),
|
|
2009
|
-
decryptionPvk: z.string().optional(),
|
|
2010
|
-
additionalParams: z.record(z.string(), z.any()).optional(),
|
|
2011
|
-
mapping: z.object({
|
|
2012
|
-
id: z.string({}).meta({ description: "Field mapping for user ID (defaults to 'nameID')" }),
|
|
2013
|
-
email: z.string({}).meta({ description: "Field mapping for email (defaults to 'email')" }),
|
|
2014
|
-
emailVerified: z.string({}).meta({ description: "Field mapping for email verification" }).optional(),
|
|
2015
|
-
name: z.string({}).meta({ description: "Field mapping for name (defaults to 'displayName')" }),
|
|
2016
|
-
firstName: z.string({}).meta({ description: "Field mapping for first name (defaults to 'givenName')" }).optional(),
|
|
2017
|
-
lastName: z.string({}).meta({ description: "Field mapping for last name (defaults to 'surname')" }).optional(),
|
|
2018
|
-
extraFields: z.record(z.string(), z.any()).optional()
|
|
2019
|
-
}).optional()
|
|
2020
|
-
}).optional(),
|
|
2021
|
-
organizationId: z.string({}).meta({ description: "If organization plugin is enabled, the organization id to link the provider to" }).optional(),
|
|
2022
|
-
overrideUserInfo: z.boolean({}).meta({ description: "Override user info with the provider info. Defaults to false" }).default(false).optional()
|
|
2023
|
-
});
|
|
2024
2565
|
const registerSSOProvider = (options) => {
|
|
2025
2566
|
return createAuthEndpoint("/sso/register", {
|
|
2026
2567
|
method: "POST",
|
|
2027
|
-
body:
|
|
2568
|
+
body: getRegisterSSOProviderBodySchema(options),
|
|
2028
2569
|
use: [sessionMiddleware],
|
|
2029
|
-
metadata: {
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
description: "OIDC provider
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
type: "array",
|
|
2094
|
-
items: { type: "string" },
|
|
2095
|
-
nullable: true,
|
|
2096
|
-
description: "The scopes requested from the provider"
|
|
2097
|
-
},
|
|
2098
|
-
tokenEndpoint: {
|
|
2099
|
-
type: "string",
|
|
2100
|
-
format: "uri",
|
|
2101
|
-
nullable: true,
|
|
2102
|
-
description: "The token endpoint URL"
|
|
2103
|
-
},
|
|
2104
|
-
tokenEndpointAuthentication: {
|
|
2105
|
-
type: "string",
|
|
2106
|
-
enum: ["client_secret_post", "client_secret_basic"],
|
|
2107
|
-
nullable: true,
|
|
2108
|
-
description: "Authentication method for the token endpoint"
|
|
2109
|
-
},
|
|
2110
|
-
jwksEndpoint: {
|
|
2111
|
-
type: "string",
|
|
2112
|
-
format: "uri",
|
|
2113
|
-
nullable: true,
|
|
2114
|
-
description: "The JWKS endpoint URL"
|
|
2115
|
-
},
|
|
2116
|
-
mapping: {
|
|
2117
|
-
type: "object",
|
|
2118
|
-
nullable: true,
|
|
2119
|
-
properties: {
|
|
2120
|
-
id: {
|
|
2121
|
-
type: "string",
|
|
2122
|
-
description: "Field mapping for user ID (defaults to 'sub')"
|
|
2123
|
-
},
|
|
2124
|
-
email: {
|
|
2125
|
-
type: "string",
|
|
2126
|
-
description: "Field mapping for email (defaults to 'email')"
|
|
2127
|
-
},
|
|
2128
|
-
emailVerified: {
|
|
2129
|
-
type: "string",
|
|
2130
|
-
nullable: true,
|
|
2131
|
-
description: "Field mapping for email verification (defaults to 'email_verified')"
|
|
2132
|
-
},
|
|
2133
|
-
name: {
|
|
2134
|
-
type: "string",
|
|
2135
|
-
description: "Field mapping for name (defaults to 'name')"
|
|
2136
|
-
},
|
|
2137
|
-
image: {
|
|
2138
|
-
type: "string",
|
|
2139
|
-
nullable: true,
|
|
2140
|
-
description: "Field mapping for image (defaults to 'picture')"
|
|
2141
|
-
},
|
|
2142
|
-
extraFields: {
|
|
2143
|
-
type: "object",
|
|
2144
|
-
additionalProperties: { type: "string" },
|
|
2145
|
-
nullable: true,
|
|
2146
|
-
description: "Additional field mappings"
|
|
2147
|
-
}
|
|
2570
|
+
metadata: {
|
|
2571
|
+
$Infer: { body: {} },
|
|
2572
|
+
openapi: {
|
|
2573
|
+
operationId: "registerSSOProvider",
|
|
2574
|
+
summary: "Register an OIDC provider",
|
|
2575
|
+
description: "This endpoint is used to register an OIDC provider. This is used to configure the provider and link it to an organization",
|
|
2576
|
+
responses: { "200": {
|
|
2577
|
+
description: "OIDC provider created successfully",
|
|
2578
|
+
content: { "application/json": { schema: {
|
|
2579
|
+
type: "object",
|
|
2580
|
+
properties: {
|
|
2581
|
+
issuer: {
|
|
2582
|
+
type: "string",
|
|
2583
|
+
format: "uri",
|
|
2584
|
+
description: "The issuer URL of the provider"
|
|
2585
|
+
},
|
|
2586
|
+
domain: {
|
|
2587
|
+
type: "string",
|
|
2588
|
+
description: "The domain of the provider, used for email matching"
|
|
2589
|
+
},
|
|
2590
|
+
domainVerified: {
|
|
2591
|
+
type: "boolean",
|
|
2592
|
+
description: "A boolean indicating whether the domain has been verified or not"
|
|
2593
|
+
},
|
|
2594
|
+
domainVerificationToken: {
|
|
2595
|
+
type: "string",
|
|
2596
|
+
description: "Domain verification token. It can be used to prove ownership over the SSO domain"
|
|
2597
|
+
},
|
|
2598
|
+
oidcConfig: {
|
|
2599
|
+
type: "object",
|
|
2600
|
+
properties: {
|
|
2601
|
+
issuer: {
|
|
2602
|
+
type: "string",
|
|
2603
|
+
format: "uri",
|
|
2604
|
+
description: "The issuer URL of the provider"
|
|
2605
|
+
},
|
|
2606
|
+
pkce: {
|
|
2607
|
+
type: "boolean",
|
|
2608
|
+
description: "Whether PKCE is enabled for the authorization flow"
|
|
2609
|
+
},
|
|
2610
|
+
clientId: {
|
|
2611
|
+
type: "string",
|
|
2612
|
+
description: "The client ID for the provider"
|
|
2613
|
+
},
|
|
2614
|
+
clientSecret: {
|
|
2615
|
+
type: "string",
|
|
2616
|
+
description: "The client secret for the provider"
|
|
2617
|
+
},
|
|
2618
|
+
authorizationEndpoint: {
|
|
2619
|
+
type: "string",
|
|
2620
|
+
format: "uri",
|
|
2621
|
+
nullable: true,
|
|
2622
|
+
description: "The authorization endpoint URL"
|
|
2623
|
+
},
|
|
2624
|
+
discoveryEndpoint: {
|
|
2625
|
+
type: "string",
|
|
2626
|
+
format: "uri",
|
|
2627
|
+
description: "The discovery endpoint URL"
|
|
2628
|
+
},
|
|
2629
|
+
userInfoEndpoint: {
|
|
2630
|
+
type: "string",
|
|
2631
|
+
format: "uri",
|
|
2632
|
+
nullable: true,
|
|
2633
|
+
description: "The user info endpoint URL"
|
|
2148
2634
|
},
|
|
2149
|
-
|
|
2150
|
-
"
|
|
2151
|
-
"
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2635
|
+
scopes: {
|
|
2636
|
+
type: "array",
|
|
2637
|
+
items: { type: "string" },
|
|
2638
|
+
nullable: true,
|
|
2639
|
+
description: "The scopes requested from the provider"
|
|
2640
|
+
},
|
|
2641
|
+
tokenEndpoint: {
|
|
2642
|
+
type: "string",
|
|
2643
|
+
format: "uri",
|
|
2644
|
+
nullable: true,
|
|
2645
|
+
description: "The token endpoint URL"
|
|
2646
|
+
},
|
|
2647
|
+
tokenEndpointAuthentication: {
|
|
2648
|
+
type: "string",
|
|
2649
|
+
enum: ["client_secret_post", "client_secret_basic"],
|
|
2650
|
+
nullable: true,
|
|
2651
|
+
description: "Authentication method for the token endpoint"
|
|
2652
|
+
},
|
|
2653
|
+
jwksEndpoint: {
|
|
2654
|
+
type: "string",
|
|
2655
|
+
format: "uri",
|
|
2656
|
+
nullable: true,
|
|
2657
|
+
description: "The JWKS endpoint URL"
|
|
2658
|
+
},
|
|
2659
|
+
mapping: {
|
|
2660
|
+
type: "object",
|
|
2661
|
+
nullable: true,
|
|
2662
|
+
properties: {
|
|
2663
|
+
id: {
|
|
2664
|
+
type: "string",
|
|
2665
|
+
description: "Field mapping for user ID (defaults to 'sub')"
|
|
2666
|
+
},
|
|
2667
|
+
email: {
|
|
2668
|
+
type: "string",
|
|
2669
|
+
description: "Field mapping for email (defaults to 'email')"
|
|
2670
|
+
},
|
|
2671
|
+
emailVerified: {
|
|
2672
|
+
type: "string",
|
|
2673
|
+
nullable: true,
|
|
2674
|
+
description: "Field mapping for email verification (defaults to 'email_verified')"
|
|
2675
|
+
},
|
|
2676
|
+
name: {
|
|
2677
|
+
type: "string",
|
|
2678
|
+
description: "Field mapping for name (defaults to 'name')"
|
|
2679
|
+
},
|
|
2680
|
+
image: {
|
|
2681
|
+
type: "string",
|
|
2682
|
+
nullable: true,
|
|
2683
|
+
description: "Field mapping for image (defaults to 'picture')"
|
|
2684
|
+
},
|
|
2685
|
+
extraFields: {
|
|
2686
|
+
type: "object",
|
|
2687
|
+
additionalProperties: { type: "string" },
|
|
2688
|
+
nullable: true,
|
|
2689
|
+
description: "Additional field mappings"
|
|
2690
|
+
}
|
|
2691
|
+
},
|
|
2692
|
+
required: [
|
|
2693
|
+
"id",
|
|
2694
|
+
"email",
|
|
2695
|
+
"name"
|
|
2696
|
+
]
|
|
2697
|
+
}
|
|
2698
|
+
},
|
|
2699
|
+
required: [
|
|
2700
|
+
"issuer",
|
|
2701
|
+
"pkce",
|
|
2702
|
+
"clientId",
|
|
2703
|
+
"clientSecret",
|
|
2704
|
+
"discoveryEndpoint"
|
|
2705
|
+
],
|
|
2706
|
+
description: "OIDC configuration for the provider"
|
|
2155
2707
|
},
|
|
2156
|
-
|
|
2157
|
-
"
|
|
2158
|
-
|
|
2159
|
-
"
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
providerId: {
|
|
2175
|
-
type: "string",
|
|
2176
|
-
description: "Unique identifier for the provider"
|
|
2708
|
+
organizationId: {
|
|
2709
|
+
type: "string",
|
|
2710
|
+
nullable: true,
|
|
2711
|
+
description: "ID of the linked organization, if any"
|
|
2712
|
+
},
|
|
2713
|
+
userId: {
|
|
2714
|
+
type: "string",
|
|
2715
|
+
description: "ID of the user who registered the provider"
|
|
2716
|
+
},
|
|
2717
|
+
providerId: {
|
|
2718
|
+
type: "string",
|
|
2719
|
+
description: "Unique identifier for the provider"
|
|
2720
|
+
},
|
|
2721
|
+
redirectURI: {
|
|
2722
|
+
type: "string",
|
|
2723
|
+
format: "uri",
|
|
2724
|
+
description: "The redirect URI for the provider callback"
|
|
2725
|
+
}
|
|
2177
2726
|
},
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
"redirectURI"
|
|
2191
|
-
]
|
|
2192
|
-
} } }
|
|
2193
|
-
} }
|
|
2194
|
-
} }
|
|
2727
|
+
required: [
|
|
2728
|
+
"issuer",
|
|
2729
|
+
"domain",
|
|
2730
|
+
"oidcConfig",
|
|
2731
|
+
"userId",
|
|
2732
|
+
"providerId",
|
|
2733
|
+
"redirectURI"
|
|
2734
|
+
]
|
|
2735
|
+
} } }
|
|
2736
|
+
} }
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2195
2739
|
}, async (ctx) => {
|
|
2196
2740
|
const user = ctx.context.session?.user;
|
|
2197
2741
|
if (!user) throw new APIError("UNAUTHORIZED");
|
|
@@ -2205,13 +2749,13 @@ const registerSSOProvider = (options) => {
|
|
|
2205
2749
|
}]
|
|
2206
2750
|
})).length >= limit) throw new APIError("FORBIDDEN", { message: "You have reached the maximum number of SSO providers" });
|
|
2207
2751
|
const body = ctx.body;
|
|
2208
|
-
|
|
2752
|
+
const additionalFields = parseSSOProviderAdditionalFields(options, body, "create");
|
|
2209
2753
|
if (body.samlConfig?.idpMetadata?.metadata) {
|
|
2210
2754
|
const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
|
|
2211
2755
|
if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
|
|
2212
2756
|
}
|
|
2213
2757
|
if (ctx.body.organizationId) {
|
|
2214
|
-
|
|
2758
|
+
const member = await ctx.context.adapter.findOne({
|
|
2215
2759
|
model: "member",
|
|
2216
2760
|
where: [{
|
|
2217
2761
|
field: "userId",
|
|
@@ -2220,7 +2764,31 @@ const registerSSOProvider = (options) => {
|
|
|
2220
2764
|
field: "organizationId",
|
|
2221
2765
|
value: ctx.body.organizationId
|
|
2222
2766
|
}]
|
|
2223
|
-
})
|
|
2767
|
+
});
|
|
2768
|
+
if (!member) throw new APIError("BAD_REQUEST", { message: "You are not a member of the organization" });
|
|
2769
|
+
if (ctx.context.hasPlugin("organization") && !hasOrgAdminRole(member)) throw new APIError("FORBIDDEN", { message: "You must be an organization owner or admin to register SSO providers" });
|
|
2770
|
+
}
|
|
2771
|
+
if (new Set([
|
|
2772
|
+
...BUILT_IN_ACCOUNT_PROVIDER_IDS,
|
|
2773
|
+
...Object.keys(ctx.context.options.socialProviders ?? {}),
|
|
2774
|
+
...ctx.context.socialProviders.map((p) => p.id),
|
|
2775
|
+
...ctx.context.trustedProviders,
|
|
2776
|
+
...options?.defaultSSO?.map((p) => p.providerId) ?? []
|
|
2777
|
+
]).has(body.providerId)) {
|
|
2778
|
+
ctx.context.logger.warn(`SSO provider registration rejected for reserved providerId: ${body.providerId}`);
|
|
2779
|
+
throw new APIError("UNPROCESSABLE_ENTITY", { message: "This providerId is reserved and cannot be used for an SSO provider" });
|
|
2780
|
+
}
|
|
2781
|
+
if (ctx.context.hasPlugin("scim")) {
|
|
2782
|
+
if (await ctx.context.adapter.findOne({
|
|
2783
|
+
model: "scimProvider",
|
|
2784
|
+
where: [{
|
|
2785
|
+
field: "providerId",
|
|
2786
|
+
value: body.providerId
|
|
2787
|
+
}]
|
|
2788
|
+
})) {
|
|
2789
|
+
ctx.context.logger.warn(`SSO provider registration rejected for SCIM providerId: ${body.providerId}`);
|
|
2790
|
+
throw new APIError("UNPROCESSABLE_ENTITY", { message: "This providerId is already used by a SCIM provider and cannot be used for an SSO provider" });
|
|
2791
|
+
}
|
|
2224
2792
|
}
|
|
2225
2793
|
if (await ctx.context.adapter.findOne({
|
|
2226
2794
|
model: "ssoProvider",
|
|
@@ -2232,6 +2800,12 @@ const registerSSOProvider = (options) => {
|
|
|
2232
2800
|
ctx.context.logger.info(`SSO provider creation attempt with existing providerId: ${body.providerId}`);
|
|
2233
2801
|
throw new APIError("UNPROCESSABLE_ENTITY", { message: "SSO provider with this providerId already exists" });
|
|
2234
2802
|
}
|
|
2803
|
+
if (body.oidcConfig) try {
|
|
2804
|
+
validateOIDCEndpointUrls(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
|
|
2805
|
+
} catch (error) {
|
|
2806
|
+
if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
|
|
2807
|
+
throw error;
|
|
2808
|
+
}
|
|
2235
2809
|
let hydratedOIDCConfig = null;
|
|
2236
2810
|
if (body.oidcConfig && !body.oidcConfig.skipDiscovery) try {
|
|
2237
2811
|
hydratedOIDCConfig = await discoverOIDCConfig({
|
|
@@ -2293,6 +2867,7 @@ const registerSSOProvider = (options) => {
|
|
|
2293
2867
|
signatureAlgorithm: body.samlConfig.signatureAlgorithm,
|
|
2294
2868
|
digestAlgorithm: body.samlConfig.digestAlgorithm
|
|
2295
2869
|
}, options?.saml?.algorithms);
|
|
2870
|
+
validateCertSources(body.samlConfig);
|
|
2296
2871
|
const hasIdpMetadata = body.samlConfig.idpMetadata?.metadata;
|
|
2297
2872
|
let hasEntryPoint = false;
|
|
2298
2873
|
if (body.samlConfig.entryPoint) try {
|
|
@@ -2308,6 +2883,7 @@ const registerSSOProvider = (options) => {
|
|
|
2308
2883
|
issuer: body.issuer,
|
|
2309
2884
|
domain: body.domain,
|
|
2310
2885
|
domainVerified: false,
|
|
2886
|
+
...additionalFields,
|
|
2311
2887
|
oidcConfig: (() => {
|
|
2312
2888
|
const config = buildOIDCConfig();
|
|
2313
2889
|
if (config) {
|
|
@@ -2321,8 +2897,8 @@ const registerSSOProvider = (options) => {
|
|
|
2321
2897
|
issuer: body.issuer,
|
|
2322
2898
|
entryPoint: body.samlConfig.entryPoint,
|
|
2323
2899
|
cert: body.samlConfig.cert,
|
|
2324
|
-
callbackUrl: body.samlConfig.callbackUrl,
|
|
2325
2900
|
audience: body.samlConfig.audience,
|
|
2901
|
+
callbackUrl: body.samlConfig.callbackUrl,
|
|
2326
2902
|
idpMetadata: body.samlConfig.idpMetadata,
|
|
2327
2903
|
spMetadata: body.samlConfig.spMetadata,
|
|
2328
2904
|
wantAssertionsSigned: body.samlConfig.wantAssertionsSigned,
|
|
@@ -2331,8 +2907,6 @@ const registerSSOProvider = (options) => {
|
|
|
2331
2907
|
digestAlgorithm: body.samlConfig.digestAlgorithm,
|
|
2332
2908
|
identifierFormat: body.samlConfig.identifierFormat,
|
|
2333
2909
|
privateKey: body.samlConfig.privateKey,
|
|
2334
|
-
decryptionPvk: body.samlConfig.decryptionPvk,
|
|
2335
|
-
additionalParams: body.samlConfig.additionalParams,
|
|
2336
2910
|
mapping: body.samlConfig.mapping
|
|
2337
2911
|
}) : null,
|
|
2338
2912
|
organizationId: body.organizationId,
|
|
@@ -2352,7 +2926,7 @@ const registerSSOProvider = (options) => {
|
|
|
2352
2926
|
});
|
|
2353
2927
|
}
|
|
2354
2928
|
const result = {
|
|
2355
|
-
...provider,
|
|
2929
|
+
...filterSSOProviderAdditionalFields(provider, options),
|
|
2356
2930
|
oidcConfig: safeJsonParse(provider.oidcConfig),
|
|
2357
2931
|
samlConfig: safeJsonParse(provider.samlConfig),
|
|
2358
2932
|
redirectURI: getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options),
|
|
@@ -2363,17 +2937,18 @@ const registerSSOProvider = (options) => {
|
|
|
2363
2937
|
});
|
|
2364
2938
|
};
|
|
2365
2939
|
const signInSSOBodySchema = z.object({
|
|
2366
|
-
email: z.string({}).meta({ description: "The email address to sign in with.
|
|
2367
|
-
organizationSlug: z.string({}).meta({ description: "The slug of the organization to sign in with" }).optional(),
|
|
2368
|
-
providerId: z.string({}).meta({ description: "The ID of the provider to sign in with.
|
|
2369
|
-
domain: z.string({}).meta({ description: "The domain of the provider." }).optional(),
|
|
2370
|
-
callbackURL: z.string({}).meta({ description: "The URL to redirect to after
|
|
2371
|
-
errorCallbackURL: z.string({}).meta({ description: "The URL to redirect to
|
|
2372
|
-
newUserCallbackURL: z.string({}).meta({ description: "The URL to redirect to after
|
|
2940
|
+
email: z.string({}).meta({ description: "The email address to sign in with. Used to resolve the provider via the email domain; optional if providerId, domain, or organizationSlug is provided." }).optional(),
|
|
2941
|
+
organizationSlug: z.string({}).meta({ description: "The slug of the organization to sign in with." }).optional(),
|
|
2942
|
+
providerId: z.string({}).meta({ description: "The ID of the provider to sign in with. Can be provided instead of email." }).optional(),
|
|
2943
|
+
domain: z.string({}).meta({ description: "The email domain of the provider. Can be provided instead of email." }).optional(),
|
|
2944
|
+
callbackURL: z.string({}).meta({ description: "The URL to redirect to after successful sign-in." }),
|
|
2945
|
+
errorCallbackURL: z.string({}).meta({ description: "The URL to redirect to if the sign-in flow fails." }).optional(),
|
|
2946
|
+
newUserCallbackURL: z.string({}).meta({ description: "The URL to redirect to after sign-in if the user is newly registered." }).optional(),
|
|
2373
2947
|
scopes: z.array(z.string(), {}).meta({ description: "Scopes to request from the provider." }).optional(),
|
|
2374
|
-
loginHint: z.string({}).meta({ description: "Login hint to send to the identity provider (e.g., email or identifier). If supported,
|
|
2375
|
-
|
|
2376
|
-
|
|
2948
|
+
loginHint: z.string({}).meta({ description: "Login hint to send to the identity provider (e.g., email or identifier). If supported, sent as 'login_hint'." }).optional(),
|
|
2949
|
+
additionalParams: additionalAuthorizationParamsSchema,
|
|
2950
|
+
requestSignUp: z.boolean({}).meta({ description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider." }).optional(),
|
|
2951
|
+
providerType: z.enum(["oidc", "saml"]).meta({ description: "The provider protocol to sign in with." }).optional()
|
|
2377
2952
|
});
|
|
2378
2953
|
const signInSSO = (options) => {
|
|
2379
2954
|
return createAuthEndpoint("/sign-in/sso", {
|
|
@@ -2388,31 +2963,54 @@ const signInSSO = (options) => {
|
|
|
2388
2963
|
properties: {
|
|
2389
2964
|
email: {
|
|
2390
2965
|
type: "string",
|
|
2391
|
-
description: "The email address to sign in with.
|
|
2966
|
+
description: "The email address to sign in with. Used to resolve the provider via the email domain; optional if providerId, domain, or organizationSlug is provided."
|
|
2392
2967
|
},
|
|
2393
|
-
|
|
2968
|
+
organizationSlug: {
|
|
2394
2969
|
type: "string",
|
|
2395
|
-
description: "The
|
|
2970
|
+
description: "The slug of the organization to sign in with."
|
|
2396
2971
|
},
|
|
2397
2972
|
providerId: {
|
|
2398
2973
|
type: "string",
|
|
2399
|
-
description: "The ID of the provider to sign in with.
|
|
2974
|
+
description: "The ID of the provider to sign in with. Can be provided instead of email."
|
|
2975
|
+
},
|
|
2976
|
+
domain: {
|
|
2977
|
+
type: "string",
|
|
2978
|
+
description: "The email domain of the provider. Can be provided instead of email."
|
|
2400
2979
|
},
|
|
2401
2980
|
callbackURL: {
|
|
2402
2981
|
type: "string",
|
|
2403
|
-
description: "The URL to redirect to after
|
|
2982
|
+
description: "The URL to redirect to after successful sign-in."
|
|
2404
2983
|
},
|
|
2405
2984
|
errorCallbackURL: {
|
|
2406
2985
|
type: "string",
|
|
2407
|
-
description: "The URL to redirect to
|
|
2986
|
+
description: "The URL to redirect to if the sign-in flow fails."
|
|
2408
2987
|
},
|
|
2409
2988
|
newUserCallbackURL: {
|
|
2410
2989
|
type: "string",
|
|
2411
|
-
description: "The URL to redirect to after
|
|
2990
|
+
description: "The URL to redirect to after sign-in if the user is newly registered."
|
|
2991
|
+
},
|
|
2992
|
+
scopes: {
|
|
2993
|
+
type: "array",
|
|
2994
|
+
items: { type: "string" },
|
|
2995
|
+
description: "Scopes to request from the provider."
|
|
2412
2996
|
},
|
|
2413
2997
|
loginHint: {
|
|
2414
2998
|
type: "string",
|
|
2415
2999
|
description: "Login hint to send to the identity provider (e.g., email or identifier). If supported, sent as 'login_hint'."
|
|
3000
|
+
},
|
|
3001
|
+
additionalParams: {
|
|
3002
|
+
type: "object",
|
|
3003
|
+
additionalProperties: { type: "string" },
|
|
3004
|
+
description: "Extra query parameters to append to the OIDC provider authorization URL. RFC 6749 reserved keys (state, client_id, redirect_uri, response_type, code_challenge, code_challenge_method, scope) are rejected. Not supported for SAML providers."
|
|
3005
|
+
},
|
|
3006
|
+
requestSignUp: {
|
|
3007
|
+
type: "boolean",
|
|
3008
|
+
description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider."
|
|
3009
|
+
},
|
|
3010
|
+
providerType: {
|
|
3011
|
+
type: "string",
|
|
3012
|
+
enum: ["oidc", "saml"],
|
|
3013
|
+
description: "The provider protocol to sign in with."
|
|
2416
3014
|
}
|
|
2417
3015
|
},
|
|
2418
3016
|
required: ["callbackURL"]
|
|
@@ -2455,7 +3053,7 @@ const signInSSO = (options) => {
|
|
|
2455
3053
|
});
|
|
2456
3054
|
let provider = null;
|
|
2457
3055
|
if (options?.defaultSSO?.length) {
|
|
2458
|
-
const matchingDefault = providerId ? options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId) : options.defaultSSO.find((defaultProvider) =>
|
|
3056
|
+
const matchingDefault = providerId ? options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId) : options.defaultSSO.find((defaultProvider) => domain && domainMatches(domain, defaultProvider.domain));
|
|
2459
3057
|
if (matchingDefault) provider = {
|
|
2460
3058
|
issuer: matchingDefault.samlConfig?.issuer || matchingDefault.oidcConfig?.issuer || "",
|
|
2461
3059
|
providerId: matchingDefault.providerId,
|
|
@@ -2509,7 +3107,8 @@ const signInSSO = (options) => {
|
|
|
2509
3107
|
throw error;
|
|
2510
3108
|
}
|
|
2511
3109
|
if (!config.authorizationEndpoint) throw new APIError("BAD_REQUEST", { message: "Invalid OIDC configuration. Authorization URL not found." });
|
|
2512
|
-
|
|
3110
|
+
if (options?.redirectURI?.trim()) await addOAuthServerContext({ ssoProviderId: provider.providerId });
|
|
3111
|
+
const state = await generateState(ctx);
|
|
2513
3112
|
const redirectURI = getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options);
|
|
2514
3113
|
const authorizationURL = await createAuthorizationURL({
|
|
2515
3114
|
id: provider.issuer,
|
|
@@ -2527,7 +3126,8 @@ const signInSSO = (options) => {
|
|
|
2527
3126
|
"offline_access"
|
|
2528
3127
|
],
|
|
2529
3128
|
loginHint: ctx.body.loginHint || email,
|
|
2530
|
-
authorizationEndpoint: config.authorizationEndpoint
|
|
3129
|
+
authorizationEndpoint: config.authorizationEndpoint,
|
|
3130
|
+
additionalParams: ctx.body.additionalParams
|
|
2531
3131
|
});
|
|
2532
3132
|
return ctx.json({
|
|
2533
3133
|
url: authorizationURL.toString(),
|
|
@@ -2535,50 +3135,13 @@ const signInSSO = (options) => {
|
|
|
2535
3135
|
});
|
|
2536
3136
|
}
|
|
2537
3137
|
if (provider.samlConfig) {
|
|
3138
|
+
if (ctx.body.additionalParams) throw new APIError("BAD_REQUEST", { message: "additionalParams is not supported for SAML providers; the SAML AuthnRequest is signed and cannot carry caller-supplied query parameters." });
|
|
2538
3139
|
const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
|
|
2539
3140
|
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
2540
3141
|
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" });
|
|
2541
|
-
const { state: relayState } = await generateRelayState(ctx, void 0
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
|
2545
|
-
assertionConsumerService: [{
|
|
2546
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
2547
|
-
Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.providerId}`
|
|
2548
|
-
}],
|
|
2549
|
-
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
2550
|
-
authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
|
|
2551
|
-
nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
|
|
2552
|
-
}).getMetadata() || "";
|
|
2553
|
-
const sp = saml.ServiceProvider({
|
|
2554
|
-
metadata,
|
|
2555
|
-
allowCreate: true,
|
|
2556
|
-
privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
|
|
2557
|
-
privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
|
|
2558
|
-
relayState
|
|
2559
|
-
});
|
|
2560
|
-
const idpData = parsedSamlConfig.idpMetadata;
|
|
2561
|
-
let idp;
|
|
2562
|
-
if (!idpData?.metadata) idp = saml.IdentityProvider({
|
|
2563
|
-
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
|
2564
|
-
singleSignOnService: idpData?.singleSignOnService || [{
|
|
2565
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
2566
|
-
Location: parsedSamlConfig.entryPoint
|
|
2567
|
-
}],
|
|
2568
|
-
signingCert: idpData?.cert || parsedSamlConfig.cert,
|
|
2569
|
-
wantAuthnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
|
|
2570
|
-
isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
|
|
2571
|
-
encPrivateKey: idpData?.encPrivateKey,
|
|
2572
|
-
encPrivateKeyPass: idpData?.encPrivateKeyPass
|
|
2573
|
-
});
|
|
2574
|
-
else idp = saml.IdentityProvider({
|
|
2575
|
-
metadata: idpData.metadata,
|
|
2576
|
-
privateKey: idpData.privateKey,
|
|
2577
|
-
privateKeyPass: idpData.privateKeyPass,
|
|
2578
|
-
isAssertionEncrypted: idpData.isAssertionEncrypted,
|
|
2579
|
-
encPrivateKey: idpData.encPrivateKey,
|
|
2580
|
-
encPrivateKeyPass: idpData.encPrivateKeyPass
|
|
2581
|
-
});
|
|
3142
|
+
const { state: relayState } = await generateRelayState(ctx, void 0);
|
|
3143
|
+
const sp = createSP(parsedSamlConfig, ctx.context.baseURL, provider.providerId, { relayState });
|
|
3144
|
+
const idp = createIdP(parsedSamlConfig);
|
|
2582
3145
|
const loginRequest = sp.createLoginRequest(idp, "redirect");
|
|
2583
3146
|
if (!loginRequest) throw new APIError("BAD_REQUEST", { message: "Invalid SAML request" });
|
|
2584
3147
|
if (loginRequest.id && options?.saml?.enableInResponseToValidation !== false) {
|
|
@@ -2605,10 +3168,26 @@ const signInSSO = (options) => {
|
|
|
2605
3168
|
};
|
|
2606
3169
|
const callbackSSOQuerySchema = z.object({
|
|
2607
3170
|
code: z.string().optional(),
|
|
2608
|
-
state: z.string(),
|
|
3171
|
+
state: z.string().optional(),
|
|
2609
3172
|
error: z.string().optional(),
|
|
2610
3173
|
error_description: z.string().optional()
|
|
2611
3174
|
});
|
|
3175
|
+
function getStringErrorField(value, field) {
|
|
3176
|
+
if (!value || typeof value !== "object") return;
|
|
3177
|
+
const fieldValue = value[field];
|
|
3178
|
+
return typeof fieldValue === "string" && fieldValue.length > 0 ? fieldValue : void 0;
|
|
3179
|
+
}
|
|
3180
|
+
function getOIDCErrorDescription(error, fallback) {
|
|
3181
|
+
const nestedError = error && typeof error === "object" ? error.error : void 0;
|
|
3182
|
+
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");
|
|
3183
|
+
if (description) return description;
|
|
3184
|
+
if (error && typeof error === "object") {
|
|
3185
|
+
const status = error.status;
|
|
3186
|
+
if (typeof status === "number") return `HTTP ${status}`;
|
|
3187
|
+
if (typeof status === "string" && status.length > 0) return status;
|
|
3188
|
+
}
|
|
3189
|
+
return fallback;
|
|
3190
|
+
}
|
|
2612
3191
|
/**
|
|
2613
3192
|
* Core OIDC callback handler logic, shared between the per-provider and
|
|
2614
3193
|
* shared callback endpoints. Resolves the provider, exchanges the
|
|
@@ -2625,30 +3204,17 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2625
3204
|
throw ctx.redirect(`${errorURL}?error=invalid_state`);
|
|
2626
3205
|
}
|
|
2627
3206
|
const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
if (!provider) provider = await ctx.context.adapter.findOne({
|
|
2640
|
-
model: "ssoProvider",
|
|
2641
|
-
where: [{
|
|
2642
|
-
field: "providerId",
|
|
2643
|
-
value: providerId
|
|
2644
|
-
}]
|
|
2645
|
-
}).then((res) => {
|
|
2646
|
-
if (!res) return null;
|
|
2647
|
-
return {
|
|
2648
|
-
...res,
|
|
2649
|
-
oidcConfig: safeJsonParse(res.oidcConfig) || void 0
|
|
2650
|
-
};
|
|
2651
|
-
});
|
|
3207
|
+
const redirectOIDCError = (error, description) => {
|
|
3208
|
+
const baseURL = errorURL || callbackURL;
|
|
3209
|
+
const params = new URLSearchParams({
|
|
3210
|
+
error,
|
|
3211
|
+
error_description: description
|
|
3212
|
+
});
|
|
3213
|
+
const separator = baseURL.includes("?") ? "&" : "?";
|
|
3214
|
+
throw ctx.redirect(`${baseURL}${separator}${params.toString()}`);
|
|
3215
|
+
};
|
|
3216
|
+
if (!code || error) redirectOIDCError(error || "invalid_request", error_description || (error ? error : "authorization_code_not_found"));
|
|
3217
|
+
const provider = await resolveOIDCProvider(ctx, options, providerId);
|
|
2652
3218
|
if (!provider) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
|
|
2653
3219
|
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
2654
3220
|
let config = provider.oidcConfig;
|
|
@@ -2669,11 +3235,9 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2669
3235
|
]
|
|
2670
3236
|
};
|
|
2671
3237
|
if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_endpoint_not_found`);
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
let clientAssertionConfig;
|
|
2676
|
-
if (authMethod === "private_key_jwt") {
|
|
3238
|
+
const tokenEndpoint = config.tokenEndpoint;
|
|
3239
|
+
let tokenEndpointAuth = config.tokenEndpointAuthentication === "client_secret_post" ? { method: "client_secret_post" } : { method: "client_secret_basic" };
|
|
3240
|
+
if (config.tokenEndpointAuthentication === "private_key_jwt") {
|
|
2677
3241
|
let resolved;
|
|
2678
3242
|
const matchingDefault = options?.defaultSSO?.find((p) => p.providerId === provider.providerId && "privateKey" in p && p.privateKey);
|
|
2679
3243
|
if (matchingDefault && "privateKey" in matchingDefault) resolved = matchingDefault.privateKey;
|
|
@@ -2682,55 +3246,73 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2682
3246
|
keyId: config.privateKeyId,
|
|
2683
3247
|
issuer: config.issuer
|
|
2684
3248
|
});
|
|
2685
|
-
if (!resolved) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=no_private_key_available`);
|
|
3249
|
+
if (!resolved || !resolved.privateKeyJwk && !resolved.privateKeyPem) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=no_private_key_available`);
|
|
2686
3250
|
const rawAlg = config.privateKeyAlgorithm ?? resolved.algorithm;
|
|
2687
|
-
const algorithm = rawAlg &&
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
3251
|
+
const algorithm = rawAlg && PRIVATE_KEY_JWT_SIGNING_ALGORITHMS.includes(rawAlg) ? rawAlg : void 0;
|
|
3252
|
+
tokenEndpointAuth = {
|
|
3253
|
+
method: "private_key_jwt",
|
|
3254
|
+
getClientAssertion: createPrivateKeyJwtClientAssertionGetter({
|
|
3255
|
+
privateKeyJwk: resolved.privateKeyJwk,
|
|
3256
|
+
privateKeyPem: resolved.privateKeyPem,
|
|
3257
|
+
kid: config.privateKeyId ?? resolved.kid,
|
|
3258
|
+
algorithm
|
|
3259
|
+
})
|
|
2694
3260
|
};
|
|
2695
3261
|
}
|
|
2696
|
-
const
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
3262
|
+
const tokenRequestOptions = { clientId: config.clientId };
|
|
3263
|
+
if (tokenEndpointAuth.method !== "private_key_jwt") tokenRequestOptions.clientSecret = config.clientSecret;
|
|
3264
|
+
const tokenResponse = await (async () => {
|
|
3265
|
+
const { body, headers } = await authorizationCodeRequest({
|
|
3266
|
+
code,
|
|
3267
|
+
codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
|
|
3268
|
+
redirectURI: getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options),
|
|
3269
|
+
options: tokenRequestOptions,
|
|
3270
|
+
tokenEndpoint,
|
|
3271
|
+
tokenEndpointAuth
|
|
3272
|
+
});
|
|
3273
|
+
const { data, error } = await fetchOIDCEndpoint("tokenEndpoint", tokenEndpoint, {
|
|
3274
|
+
method: "POST",
|
|
3275
|
+
body,
|
|
3276
|
+
headers
|
|
3277
|
+
}, (url) => ctx.context.isTrustedOrigin(url));
|
|
3278
|
+
if (error) redirectOIDCError("invalid_provider", getOIDCErrorDescription(error, "token_response_error"));
|
|
3279
|
+
if (!data) throw new Error("Token endpoint returned an empty response");
|
|
3280
|
+
return getOAuth2Tokens(data);
|
|
3281
|
+
})().catch((e) => {
|
|
3282
|
+
if (isAPIError(e)) throw e;
|
|
2708
3283
|
ctx.context.logger.error("Error validating authorization code", e);
|
|
2709
|
-
if (e instanceof
|
|
2710
|
-
|
|
3284
|
+
if (e instanceof DiscoveryError) redirectOIDCError("invalid_provider", e.message);
|
|
3285
|
+
redirectOIDCError("invalid_provider", getOIDCErrorDescription(e, "token_response_error"));
|
|
2711
3286
|
});
|
|
2712
3287
|
if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_response_not_found`);
|
|
2713
3288
|
let userInfo = null;
|
|
2714
3289
|
const mapping = config.mapping || {};
|
|
3290
|
+
let rawProfile;
|
|
2715
3291
|
if (config.userInfoEndpoint) {
|
|
2716
|
-
const userInfoResponse = await
|
|
2717
|
-
|
|
2718
|
-
|
|
3292
|
+
const userInfoResponse = await fetchOIDCEndpoint("userInfoEndpoint", config.userInfoEndpoint, { headers: { Authorization: `Bearer ${tokenResponse.accessToken}` } }, (url) => ctx.context.isTrustedOrigin(url)).catch((e) => {
|
|
3293
|
+
if (e instanceof DiscoveryError) redirectOIDCError("invalid_provider", e.message);
|
|
3294
|
+
throw e;
|
|
3295
|
+
});
|
|
3296
|
+
if (userInfoResponse.error) redirectOIDCError("invalid_provider", userInfoResponse.error.message || userInfoResponse.error.statusText || "userinfo_response_error");
|
|
3297
|
+
const rawUserInfo = userInfoResponse.data ?? redirectOIDCError("invalid_provider", "userinfo_response_not_found");
|
|
3298
|
+
rawProfile = rawUserInfo;
|
|
2719
3299
|
userInfo = {
|
|
2720
3300
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, rawUserInfo[value]])),
|
|
2721
3301
|
id: rawUserInfo[mapping.id || "sub"],
|
|
2722
3302
|
email: rawUserInfo[mapping.email || "email"],
|
|
2723
|
-
emailVerified: options?.trustEmailVerified ? rawUserInfo[mapping.emailVerified || "email_verified"] : false,
|
|
3303
|
+
emailVerified: options?.trustEmailVerified ? parseProviderEmailVerified(rawUserInfo[mapping.emailVerified || "email_verified"]) : false,
|
|
2724
3304
|
name: rawUserInfo[mapping.name || "name"],
|
|
2725
3305
|
image: rawUserInfo[mapping.image || "picture"]
|
|
2726
3306
|
};
|
|
2727
3307
|
} else if (tokenResponse.idToken) {
|
|
2728
3308
|
const idToken = decodeJwt(tokenResponse.idToken);
|
|
3309
|
+
rawProfile = idToken;
|
|
2729
3310
|
if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=jwks_endpoint_not_found`);
|
|
2730
|
-
const verified = await
|
|
3311
|
+
const verified = await validateOIDCIdToken(tokenResponse.idToken, config.jwksEndpoint, {
|
|
2731
3312
|
audience: config.clientId,
|
|
2732
3313
|
issuer: provider.issuer
|
|
2733
|
-
}).catch((e) => {
|
|
3314
|
+
}, (url) => ctx.context.isTrustedOrigin(url)).catch((e) => {
|
|
3315
|
+
if (e instanceof DiscoveryError) redirectOIDCError("invalid_provider", e.message);
|
|
2734
3316
|
ctx.context.logger.error(e);
|
|
2735
3317
|
return null;
|
|
2736
3318
|
});
|
|
@@ -2739,37 +3321,67 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2739
3321
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, verified.payload[value]])),
|
|
2740
3322
|
id: idToken[mapping.id || "sub"],
|
|
2741
3323
|
email: idToken[mapping.email || "email"],
|
|
2742
|
-
emailVerified: options?.trustEmailVerified ? idToken[mapping.emailVerified || "email_verified"] : false,
|
|
3324
|
+
emailVerified: options?.trustEmailVerified ? parseProviderEmailVerified(idToken[mapping.emailVerified || "email_verified"]) : false,
|
|
2743
3325
|
name: idToken[mapping.name || "name"],
|
|
2744
3326
|
image: idToken[mapping.image || "picture"]
|
|
2745
3327
|
};
|
|
2746
3328
|
} else throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=user_info_endpoint_not_found`);
|
|
2747
3329
|
if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=missing_user_info`);
|
|
2748
|
-
const
|
|
2749
|
-
const
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
3330
|
+
const userInfoEmail = userInfo.email;
|
|
3331
|
+
const userInfoId = userInfo.id;
|
|
3332
|
+
const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfoEmail, provider.domain);
|
|
3333
|
+
let linked;
|
|
3334
|
+
try {
|
|
3335
|
+
linked = await runWithTransaction(ctx.context.adapter, async () => {
|
|
3336
|
+
await lockSSOProviderForAccountLink(ctx, provider);
|
|
3337
|
+
return handleOAuthUserInfo(ctx, {
|
|
3338
|
+
userInfo: {
|
|
3339
|
+
email: userInfoEmail,
|
|
3340
|
+
name: userInfo.name || "",
|
|
3341
|
+
id: userInfoId,
|
|
3342
|
+
image: userInfo.image,
|
|
3343
|
+
emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
|
|
3344
|
+
},
|
|
3345
|
+
account: {
|
|
3346
|
+
idToken: tokenResponse.idToken,
|
|
3347
|
+
accessToken: tokenResponse.accessToken,
|
|
3348
|
+
refreshToken: tokenResponse.refreshToken,
|
|
3349
|
+
accountId: userInfoId,
|
|
3350
|
+
providerId: provider.providerId,
|
|
3351
|
+
accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
|
|
3352
|
+
refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
|
|
3353
|
+
scope: tokenResponse.scopes?.join(",")
|
|
3354
|
+
},
|
|
3355
|
+
callbackURL,
|
|
3356
|
+
disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
|
|
3357
|
+
overrideUserInfo: config.overrideUserInfo,
|
|
3358
|
+
source: {
|
|
3359
|
+
method: "sso-oidc",
|
|
3360
|
+
sso: {
|
|
3361
|
+
providerId: provider.providerId,
|
|
3362
|
+
profile: rawProfile
|
|
3363
|
+
}
|
|
3364
|
+
},
|
|
3365
|
+
isTrustedProvider,
|
|
3366
|
+
trustProviderByName: false
|
|
3367
|
+
});
|
|
3368
|
+
});
|
|
3369
|
+
} catch (e) {
|
|
3370
|
+
if (isAPIError(e) && e.body?.code) {
|
|
3371
|
+
const baseURL = errorURL || callbackURL;
|
|
3372
|
+
const params = new URLSearchParams({ error: e.body.code });
|
|
3373
|
+
if (e.body.message) params.set("error_description", e.body.message);
|
|
3374
|
+
const sep = baseURL.includes("?") ? "&" : "?";
|
|
3375
|
+
throw ctx.redirect(`${baseURL}${sep}${params.toString()}`);
|
|
3376
|
+
}
|
|
3377
|
+
throw e;
|
|
3378
|
+
}
|
|
3379
|
+
if (linked.error) {
|
|
3380
|
+
const baseURL = errorURL || callbackURL;
|
|
3381
|
+
const params = new URLSearchParams({ error: linked.error });
|
|
3382
|
+
const sep = baseURL.includes("?") ? "&" : "?";
|
|
3383
|
+
throw ctx.redirect(`${baseURL}${sep}${params.toString()}`);
|
|
3384
|
+
}
|
|
2773
3385
|
const { session, user } = linked.data;
|
|
2774
3386
|
if (options?.provisionUser && (linked.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
|
|
2775
3387
|
user,
|
|
@@ -2782,8 +3394,8 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2782
3394
|
profile: {
|
|
2783
3395
|
providerType: "oidc",
|
|
2784
3396
|
providerId: provider.providerId,
|
|
2785
|
-
accountId:
|
|
2786
|
-
email:
|
|
3397
|
+
accountId: userInfoId,
|
|
3398
|
+
email: userInfoEmail,
|
|
2787
3399
|
emailVerified: Boolean(userInfo.emailVerified),
|
|
2788
3400
|
rawAttributes: userInfo
|
|
2789
3401
|
},
|
|
@@ -2817,9 +3429,86 @@ const callbackSSOEndpointConfig = {
|
|
|
2817
3429
|
}
|
|
2818
3430
|
}
|
|
2819
3431
|
};
|
|
3432
|
+
/**
|
|
3433
|
+
* Resolves an SSO provider by `providerId`, first checking `options.defaultSSO`
|
|
3434
|
+
* and falling back to the `ssoProvider` table. Returns `null` when no match is
|
|
3435
|
+
* found so the caller can decide how to react (redirect, silently skip, etc.).
|
|
3436
|
+
*/
|
|
3437
|
+
async function resolveOIDCProvider(ctx, options, providerId) {
|
|
3438
|
+
const matchingDefault = options?.defaultSSO?.find((defaultProvider) => defaultProvider.providerId === providerId);
|
|
3439
|
+
if (matchingDefault) return {
|
|
3440
|
+
...matchingDefault,
|
|
3441
|
+
issuer: matchingDefault.oidcConfig?.issuer || "",
|
|
3442
|
+
userId: "default",
|
|
3443
|
+
...options?.domainVerification?.enabled ? { domainVerified: true } : {}
|
|
3444
|
+
};
|
|
3445
|
+
return ctx.context.adapter.findOne({
|
|
3446
|
+
model: "ssoProvider",
|
|
3447
|
+
where: [{
|
|
3448
|
+
field: "providerId",
|
|
3449
|
+
value: providerId
|
|
3450
|
+
}]
|
|
3451
|
+
}).then((res) => {
|
|
3452
|
+
if (!res) return null;
|
|
3453
|
+
return {
|
|
3454
|
+
...res,
|
|
3455
|
+
oidcConfig: safeJsonParse(res.oidcConfig) || void 0
|
|
3456
|
+
};
|
|
3457
|
+
});
|
|
3458
|
+
}
|
|
3459
|
+
/**
|
|
3460
|
+
* Restarts the OAuth flow server-side when a stateless callback arrives for
|
|
3461
|
+
* an OIDC provider that opted into IDP-initiated flows. Silently returns
|
|
3462
|
+
* otherwise, letting the normal handler produce its error redirect.
|
|
3463
|
+
*/
|
|
3464
|
+
async function bounceIfIdpInitiated(ctx, options, providerId) {
|
|
3465
|
+
const provider = await resolveOIDCProvider(ctx, options, providerId);
|
|
3466
|
+
if (!provider?.oidcConfig?.allowIdpInitiated) return;
|
|
3467
|
+
let config = provider.oidcConfig;
|
|
3468
|
+
try {
|
|
3469
|
+
config = await ensureRuntimeDiscovery(config, provider.issuer, (url) => ctx.context.isTrustedOrigin(url));
|
|
3470
|
+
} catch (error) {
|
|
3471
|
+
ctx.context.logger.error("IDP-initiated bounce skipped: OIDC discovery failed", {
|
|
3472
|
+
providerId: provider.providerId,
|
|
3473
|
+
issuer: provider.issuer,
|
|
3474
|
+
error
|
|
3475
|
+
});
|
|
3476
|
+
return;
|
|
3477
|
+
}
|
|
3478
|
+
if (!config.authorizationEndpoint) {
|
|
3479
|
+
ctx.context.logger.error("IDP-initiated bounce skipped: authorizationEndpoint missing after discovery", {
|
|
3480
|
+
providerId: provider.providerId,
|
|
3481
|
+
issuer: provider.issuer
|
|
3482
|
+
});
|
|
3483
|
+
return;
|
|
3484
|
+
}
|
|
3485
|
+
if (options?.redirectURI?.trim()) await addOAuthServerContext({ ssoProviderId: provider.providerId });
|
|
3486
|
+
const state = await generateState(ctx);
|
|
3487
|
+
const redirectURI = getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options);
|
|
3488
|
+
const authorizationURL = await createAuthorizationURL({
|
|
3489
|
+
id: provider.issuer,
|
|
3490
|
+
options: {
|
|
3491
|
+
clientId: config.clientId,
|
|
3492
|
+
clientSecret: config.clientSecret
|
|
3493
|
+
},
|
|
3494
|
+
redirectURI,
|
|
3495
|
+
state: state.state,
|
|
3496
|
+
codeVerifier: config.pkce ? state.codeVerifier : void 0,
|
|
3497
|
+
scopes: config.scopes || [
|
|
3498
|
+
"openid",
|
|
3499
|
+
"email",
|
|
3500
|
+
"profile",
|
|
3501
|
+
"offline_access"
|
|
3502
|
+
],
|
|
3503
|
+
authorizationEndpoint: config.authorizationEndpoint
|
|
3504
|
+
});
|
|
3505
|
+
throw ctx.redirect(authorizationURL.toString());
|
|
3506
|
+
}
|
|
2820
3507
|
const callbackSSO = (options) => {
|
|
2821
3508
|
return createAuthEndpoint("/sso/callback/:providerId", callbackSSOEndpointConfig, async (ctx) => {
|
|
2822
|
-
|
|
3509
|
+
const providerId = ctx.params.providerId;
|
|
3510
|
+
if (ctx.query.state === void 0 && ctx.query.code) await bounceIfIdpInitiated(ctx, options, providerId);
|
|
3511
|
+
return handleOIDCCallback(ctx, options, providerId);
|
|
2823
3512
|
});
|
|
2824
3513
|
};
|
|
2825
3514
|
/**
|
|
@@ -2845,7 +3534,7 @@ const callbackSSOShared = (options) => {
|
|
|
2845
3534
|
const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
|
|
2846
3535
|
throw ctx.redirect(`${errorURL}?error=invalid_state`);
|
|
2847
3536
|
}
|
|
2848
|
-
const providerId = stateData.ssoProviderId;
|
|
3537
|
+
const providerId = stateData.serverContext?.ssoProviderId;
|
|
2849
3538
|
if (!providerId) {
|
|
2850
3539
|
const errorURL = stateData.errorURL || stateData.callbackURL;
|
|
2851
3540
|
throw ctx.redirect(`${errorURL}?error=invalid_state&error_description=missing_provider_id`);
|
|
@@ -2853,72 +3542,42 @@ const callbackSSOShared = (options) => {
|
|
|
2853
3542
|
return handleOIDCCallback(ctx, options, providerId, stateData);
|
|
2854
3543
|
});
|
|
2855
3544
|
};
|
|
2856
|
-
const
|
|
3545
|
+
const acsEndpointBodySchema = z.object({
|
|
2857
3546
|
SAMLResponse: z.string(),
|
|
2858
3547
|
RelayState: z.string().optional()
|
|
2859
3548
|
});
|
|
2860
|
-
const
|
|
2861
|
-
return createAuthEndpoint("/sso/saml2/
|
|
3549
|
+
const acsEndpoint = (options) => {
|
|
3550
|
+
return createAuthEndpoint("/sso/saml2/sp/acs/:providerId", {
|
|
2862
3551
|
method: ["GET", "POST"],
|
|
2863
|
-
body:
|
|
3552
|
+
body: acsEndpointBodySchema.optional(),
|
|
2864
3553
|
query: z.object({ RelayState: z.string().optional() }).optional(),
|
|
2865
3554
|
metadata: {
|
|
2866
3555
|
...HIDE_METADATA,
|
|
2867
3556
|
allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
|
|
2868
3557
|
openapi: {
|
|
2869
|
-
operationId: "
|
|
2870
|
-
summary: "
|
|
2871
|
-
description: "
|
|
3558
|
+
operationId: "handleSAMLAssertionConsumerService",
|
|
3559
|
+
summary: "SAML Assertion Consumer Service",
|
|
3560
|
+
description: "Handles SAML responses from IdP after successful authentication. Supports GET for post-auth redirects and POST for SAML response processing.",
|
|
2872
3561
|
responses: {
|
|
2873
|
-
"302": { description: "Redirects
|
|
2874
|
-
"400": { description: "
|
|
2875
|
-
"
|
|
3562
|
+
"302": { description: "Redirects after authentication (success or error with query params)" },
|
|
3563
|
+
"400": { description: "Missing SAMLResponse in POST body" },
|
|
3564
|
+
"404": { description: "SAML provider not found" }
|
|
2876
3565
|
}
|
|
2877
3566
|
}
|
|
2878
3567
|
}
|
|
2879
3568
|
}, async (ctx) => {
|
|
2880
3569
|
const { providerId } = ctx.params;
|
|
3570
|
+
const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`;
|
|
2881
3571
|
const appOrigin = new URL(ctx.context.baseURL).origin;
|
|
2882
|
-
const errorURL = ctx.context.options.onAPIError?.errorURL || `${appOrigin}/error`;
|
|
2883
|
-
const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/callback/${providerId}`;
|
|
2884
3572
|
if (ctx.method === "GET" && !ctx.body?.SAMLResponse) {
|
|
2885
|
-
if (!(await getSessionFromCtx(ctx))?.session)
|
|
3573
|
+
if (!(await getSessionFromCtx(ctx))?.session) {
|
|
3574
|
+
const errorURL = ctx.context.options.onAPIError?.errorURL || `${appOrigin}/error`;
|
|
3575
|
+
throw ctx.redirect(`${errorURL}?error=invalid_request`);
|
|
3576
|
+
}
|
|
2886
3577
|
const relayState = ctx.query?.RelayState;
|
|
2887
|
-
|
|
2888
|
-
throw ctx.redirect(safeRedirectUrl);
|
|
3578
|
+
throw ctx.redirect(getSafeRedirectUrl(relayState, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings)));
|
|
2889
3579
|
}
|
|
2890
3580
|
if (!ctx.body?.SAMLResponse) throw new APIError("BAD_REQUEST", { message: "SAMLResponse is required for POST requests" });
|
|
2891
|
-
const safeRedirectUrl = await processSAMLResponse(ctx, {
|
|
2892
|
-
SAMLResponse: ctx.body.SAMLResponse,
|
|
2893
|
-
RelayState: ctx.body.RelayState,
|
|
2894
|
-
providerId,
|
|
2895
|
-
currentCallbackPath
|
|
2896
|
-
}, options);
|
|
2897
|
-
throw ctx.redirect(safeRedirectUrl);
|
|
2898
|
-
});
|
|
2899
|
-
};
|
|
2900
|
-
const acsEndpointBodySchema = z.object({
|
|
2901
|
-
SAMLResponse: z.string(),
|
|
2902
|
-
RelayState: z.string().optional()
|
|
2903
|
-
});
|
|
2904
|
-
const acsEndpoint = (options) => {
|
|
2905
|
-
return createAuthEndpoint("/sso/saml2/sp/acs/:providerId", {
|
|
2906
|
-
method: "POST",
|
|
2907
|
-
body: acsEndpointBodySchema,
|
|
2908
|
-
metadata: {
|
|
2909
|
-
...HIDE_METADATA,
|
|
2910
|
-
allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
|
|
2911
|
-
openapi: {
|
|
2912
|
-
operationId: "handleSAMLAssertionConsumerService",
|
|
2913
|
-
summary: "SAML Assertion Consumer Service",
|
|
2914
|
-
description: "Handles SAML responses from IdP after successful authentication",
|
|
2915
|
-
responses: { "302": { description: "Redirects to the callback URL after successful authentication" } }
|
|
2916
|
-
}
|
|
2917
|
-
}
|
|
2918
|
-
}, async (ctx) => {
|
|
2919
|
-
const { providerId } = ctx.params;
|
|
2920
|
-
const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`;
|
|
2921
|
-
const appOrigin = new URL(ctx.context.baseURL).origin;
|
|
2922
3581
|
try {
|
|
2923
3582
|
const safeRedirectUrl = await processSAMLResponse(ctx, {
|
|
2924
3583
|
SAMLResponse: ctx.body.SAMLResponse,
|
|
@@ -2930,9 +3589,8 @@ const acsEndpoint = (options) => {
|
|
|
2930
3589
|
} catch (error) {
|
|
2931
3590
|
if (error instanceof Response || error && typeof error === "object" && "status" in error && error.status === 302) throw error;
|
|
2932
3591
|
if (error instanceof APIError && error.statusCode === 400) {
|
|
2933
|
-
const
|
|
2934
|
-
const
|
|
2935
|
-
const redirectUrl = getSafeRedirectUrl(ctx.body.RelayState || void 0, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
3592
|
+
const errorCode = (error.body?.code || "saml_error").toLowerCase();
|
|
3593
|
+
const redirectUrl = getSafeRedirectUrl(ctx.body?.RelayState || void 0, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
2936
3594
|
throw ctx.redirect(`${redirectUrl}${redirectUrl.includes("?") ? "&" : "?"}error=${encodeURIComponent(errorCode)}&error_description=${encodeURIComponent(error.message)}`);
|
|
2937
3595
|
}
|
|
2938
3596
|
throw error;
|
|
@@ -3025,7 +3683,7 @@ async function handleLogoutRequest(ctx, sp, idp, relayState, providerId) {
|
|
|
3025
3683
|
if (stored) {
|
|
3026
3684
|
const data = safeJsonParse(stored.value);
|
|
3027
3685
|
if (data) if (!sessionIndex || !data.sessionIndex || sessionIndex === data.sessionIndex) {
|
|
3028
|
-
await ctx.context.internalAdapter.deleteSession(data.
|
|
3686
|
+
await ctx.context.internalAdapter.deleteSession(data.sessionToken).catch((e) => ctx.context.logger.warn("Failed to delete session during SLO", { error: e }));
|
|
3029
3687
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${SAML_SESSION_BY_ID_PREFIX}${data.sessionId}`).catch((e) => ctx.context.logger.warn("Failed to delete SAML session lookup during SLO", e));
|
|
3030
3688
|
} else ctx.context.logger.warn("SessionIndex mismatch in LogoutRequest - skipping session deletion", {
|
|
3031
3689
|
providerId,
|
|
@@ -3035,10 +3693,9 @@ async function handleLogoutRequest(ctx, sp, idp, relayState, providerId) {
|
|
|
3035
3693
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(key).catch((e) => ctx.context.logger.warn("Failed to delete SAML session key during SLO", e));
|
|
3036
3694
|
}
|
|
3037
3695
|
const currentSession = await getSessionFromCtx(ctx);
|
|
3038
|
-
if (currentSession?.session) await ctx.context.internalAdapter.deleteSession(currentSession.session.
|
|
3696
|
+
if (currentSession?.session) await ctx.context.internalAdapter.deleteSession(currentSession.session.token);
|
|
3039
3697
|
deleteSessionCookie(ctx);
|
|
3040
|
-
const
|
|
3041
|
-
const res = sp.createLogoutResponse(idp, null, binding, relayState || "", (template) => template.replace("{InResponseTo}", requestId).replace("{StatusCode}", SAML_STATUS_SUCCESS));
|
|
3698
|
+
const res = sp.createLogoutResponse(idp, parsed, binding, relayState || "");
|
|
3042
3699
|
if (binding === "post" && res.entityEndpoint) return createSAMLPostForm(res.entityEndpoint, "SAMLResponse", res.context, relayState);
|
|
3043
3700
|
throw ctx.redirect(res.context);
|
|
3044
3701
|
}
|
|
@@ -3091,7 +3748,7 @@ const initiateSLO = (options) => {
|
|
|
3091
3748
|
});
|
|
3092
3749
|
if (samlSessionKey) await ctx.context.internalAdapter.deleteVerificationByIdentifier(samlSessionKey).catch((e) => ctx.context.logger.warn("Failed to delete SAML session key during logout", e));
|
|
3093
3750
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(sessionLookupKey).catch((e) => ctx.context.logger.warn("Failed to delete session lookup key during logout", e));
|
|
3094
|
-
await ctx.context.internalAdapter.deleteSession(session.session.
|
|
3751
|
+
await ctx.context.internalAdapter.deleteSession(session.session.token);
|
|
3095
3752
|
deleteSessionCookie(ctx);
|
|
3096
3753
|
throw ctx.redirect(logoutRequest.context);
|
|
3097
3754
|
});
|
|
@@ -3107,12 +3764,43 @@ saml.setSchemaValidator({ async validate(xml) {
|
|
|
3107
3764
|
* These endpoints receive POST requests from external Identity Providers,
|
|
3108
3765
|
* which won't have a matching Origin header.
|
|
3109
3766
|
*/
|
|
3110
|
-
const SAML_SKIP_ORIGIN_CHECK_PATHS = [
|
|
3111
|
-
|
|
3112
|
-
"
|
|
3113
|
-
"
|
|
3767
|
+
const SAML_SKIP_ORIGIN_CHECK_PATHS = ["/sso/saml2/sp/acs", "/sso/saml2/sp/slo"];
|
|
3768
|
+
const SSO_PROVIDER_BUILT_IN_FIELD_KEYS = [
|
|
3769
|
+
"id",
|
|
3770
|
+
"issuer",
|
|
3771
|
+
"oidcConfig",
|
|
3772
|
+
"samlConfig",
|
|
3773
|
+
"userId",
|
|
3774
|
+
"providerId",
|
|
3775
|
+
"organizationId",
|
|
3776
|
+
"domain",
|
|
3777
|
+
"domainVerified"
|
|
3778
|
+
];
|
|
3779
|
+
const SSO_PROVIDER_RESPONSE_FIELD_KEYS = [
|
|
3780
|
+
"type",
|
|
3781
|
+
"spMetadataUrl",
|
|
3782
|
+
"redirectURI",
|
|
3783
|
+
"domainVerificationToken"
|
|
3114
3784
|
];
|
|
3785
|
+
const SSO_PROVIDER_BUILT_IN_FIELD_KEY_SET = new Set(SSO_PROVIDER_BUILT_IN_FIELD_KEYS);
|
|
3786
|
+
const SSO_PROVIDER_RESPONSE_FIELD_KEY_SET = new Set(SSO_PROVIDER_RESPONSE_FIELD_KEYS);
|
|
3787
|
+
function getSSOProviderBuiltInFieldName(options, key) {
|
|
3788
|
+
const fieldNames = options?.fields;
|
|
3789
|
+
const schemaFieldNames = options?.schema?.ssoProvider?.fields;
|
|
3790
|
+
return fieldNames?.[key] ?? schemaFieldNames?.[key] ?? key;
|
|
3791
|
+
}
|
|
3792
|
+
function assertNoAdditionalFieldCollisions(options) {
|
|
3793
|
+
const additionalFields = options?.schema?.ssoProvider?.additionalFields ?? {};
|
|
3794
|
+
const builtInFieldNames = new Set(SSO_PROVIDER_BUILT_IN_FIELD_KEYS.map((key) => getSSOProviderBuiltInFieldName(options, key)));
|
|
3795
|
+
for (const [key, field] of Object.entries(additionalFields)) {
|
|
3796
|
+
if (SSO_PROVIDER_BUILT_IN_FIELD_KEY_SET.has(key)) throw new Error(`ssoProvider additional field "${key}" conflicts with a built-in field`);
|
|
3797
|
+
if (SSO_PROVIDER_RESPONSE_FIELD_KEY_SET.has(key)) throw new Error(`ssoProvider additional field "${key}" conflicts with a returned provider field`);
|
|
3798
|
+
const fieldName = field.fieldName ?? key;
|
|
3799
|
+
if (builtInFieldNames.has(fieldName)) throw new Error(`ssoProvider additional field "${key}" maps to built-in field "${fieldName}"`);
|
|
3800
|
+
}
|
|
3801
|
+
}
|
|
3115
3802
|
function sso(options) {
|
|
3803
|
+
assertNoAdditionalFieldCollisions(options);
|
|
3116
3804
|
const optionsWithStore = options;
|
|
3117
3805
|
let endpoints = {
|
|
3118
3806
|
spMetadata: spMetadata(optionsWithStore),
|
|
@@ -3120,12 +3808,11 @@ function sso(options) {
|
|
|
3120
3808
|
signInSSO: signInSSO(optionsWithStore),
|
|
3121
3809
|
callbackSSO: callbackSSO(optionsWithStore),
|
|
3122
3810
|
callbackSSOShared: callbackSSOShared(optionsWithStore),
|
|
3123
|
-
callbackSSOSAML: callbackSSOSAML(optionsWithStore),
|
|
3124
3811
|
acsEndpoint: acsEndpoint(optionsWithStore),
|
|
3125
3812
|
sloEndpoint: sloEndpoint(optionsWithStore),
|
|
3126
3813
|
initiateSLO: initiateSLO(optionsWithStore),
|
|
3127
|
-
listSSOProviders: listSSOProviders(),
|
|
3128
|
-
getSSOProvider: getSSOProvider(),
|
|
3814
|
+
listSSOProviders: listSSOProviders(optionsWithStore),
|
|
3815
|
+
getSSOProvider: getSSOProvider(optionsWithStore),
|
|
3129
3816
|
updateSSOProvider: updateSSOProvider(optionsWithStore),
|
|
3130
3817
|
deleteSSOProvider: deleteSSOProvider()
|
|
3131
3818
|
};
|
|
@@ -3182,22 +3869,22 @@ function sso(options) {
|
|
|
3182
3869
|
}]
|
|
3183
3870
|
},
|
|
3184
3871
|
schema: { ssoProvider: {
|
|
3185
|
-
modelName: options?.modelName ?? "ssoProvider",
|
|
3872
|
+
modelName: options?.modelName ?? options?.schema?.ssoProvider?.modelName ?? "ssoProvider",
|
|
3186
3873
|
fields: {
|
|
3187
3874
|
issuer: {
|
|
3188
3875
|
type: "string",
|
|
3189
3876
|
required: true,
|
|
3190
|
-
fieldName: options?.fields?.issuer ?? "issuer"
|
|
3877
|
+
fieldName: options?.fields?.issuer ?? options?.schema?.ssoProvider?.fields?.issuer ?? "issuer"
|
|
3191
3878
|
},
|
|
3192
3879
|
oidcConfig: {
|
|
3193
3880
|
type: "string",
|
|
3194
3881
|
required: false,
|
|
3195
|
-
fieldName: options?.fields?.oidcConfig ?? "oidcConfig"
|
|
3882
|
+
fieldName: options?.fields?.oidcConfig ?? options?.schema?.ssoProvider?.fields?.oidcConfig ?? "oidcConfig"
|
|
3196
3883
|
},
|
|
3197
3884
|
samlConfig: {
|
|
3198
3885
|
type: "string",
|
|
3199
3886
|
required: false,
|
|
3200
|
-
fieldName: options?.fields?.samlConfig ?? "samlConfig"
|
|
3887
|
+
fieldName: options?.fields?.samlConfig ?? options?.schema?.ssoProvider?.fields?.samlConfig ?? "samlConfig"
|
|
3201
3888
|
},
|
|
3202
3889
|
userId: {
|
|
3203
3890
|
type: "string",
|
|
@@ -3205,30 +3892,33 @@ function sso(options) {
|
|
|
3205
3892
|
model: "user",
|
|
3206
3893
|
field: "id"
|
|
3207
3894
|
},
|
|
3208
|
-
fieldName: options?.fields?.userId ?? "userId"
|
|
3895
|
+
fieldName: options?.fields?.userId ?? options?.schema?.ssoProvider?.fields?.userId ?? "userId"
|
|
3209
3896
|
},
|
|
3210
3897
|
providerId: {
|
|
3211
3898
|
type: "string",
|
|
3212
3899
|
required: true,
|
|
3213
3900
|
unique: true,
|
|
3214
|
-
fieldName: options?.fields?.providerId ?? "providerId"
|
|
3901
|
+
fieldName: options?.fields?.providerId ?? options?.schema?.ssoProvider?.fields?.providerId ?? "providerId"
|
|
3215
3902
|
},
|
|
3216
3903
|
organizationId: {
|
|
3217
3904
|
type: "string",
|
|
3218
3905
|
required: false,
|
|
3219
|
-
fieldName: options?.fields?.organizationId ?? "organizationId"
|
|
3906
|
+
fieldName: options?.fields?.organizationId ?? options?.schema?.ssoProvider?.fields?.organizationId ?? "organizationId"
|
|
3220
3907
|
},
|
|
3221
3908
|
domain: {
|
|
3222
3909
|
type: "string",
|
|
3223
3910
|
required: true,
|
|
3224
|
-
fieldName: options?.fields?.domain ?? "domain"
|
|
3911
|
+
fieldName: options?.fields?.domain ?? options?.schema?.ssoProvider?.fields?.domain ?? "domain"
|
|
3225
3912
|
},
|
|
3226
3913
|
...options?.domainVerification?.enabled ? { domainVerified: {
|
|
3227
3914
|
type: "boolean",
|
|
3228
|
-
required: false
|
|
3229
|
-
|
|
3915
|
+
required: false,
|
|
3916
|
+
fieldName: options?.schema?.ssoProvider?.fields?.domainVerified ?? "domainVerified"
|
|
3917
|
+
} } : {},
|
|
3918
|
+
...options?.schema?.ssoProvider?.additionalFields ?? {}
|
|
3230
3919
|
}
|
|
3231
3920
|
} },
|
|
3921
|
+
$Infer: { SSOProvider: {} },
|
|
3232
3922
|
options
|
|
3233
3923
|
};
|
|
3234
3924
|
}
|