@better-auth/sso 1.7.0-beta.1 → 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-CagV4mMx.d.mts → index-A_DJ_AqL.d.mts} +183 -156
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +2379 -1604
- package/dist/{version-C22JHwcK.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,1272 +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
|
-
if (
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
|
711
716
|
});
|
|
712
|
-
|
|
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
|
+
};
|
|
713
725
|
}
|
|
726
|
+
await assertServerFetchedOIDCEndpointsAllowed(resolved, isTrustedOrigin);
|
|
727
|
+
return resolved;
|
|
714
728
|
}
|
|
715
729
|
//#endregion
|
|
716
|
-
//#region src/
|
|
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
|
-
audience: samlConfig.audience,
|
|
865
|
-
wantAssertionsSigned: samlConfig.wantAssertionsSigned,
|
|
866
|
-
authnRequestsSigned: samlConfig.authnRequestsSigned,
|
|
867
|
-
identifierFormat: samlConfig.identifierFormat,
|
|
868
|
-
signatureAlgorithm: samlConfig.signatureAlgorithm,
|
|
869
|
-
digestAlgorithm: samlConfig.digestAlgorithm,
|
|
870
|
-
certificate: (() => {
|
|
871
|
-
try {
|
|
872
|
-
return parseCertificate(samlConfig.cert);
|
|
873
|
-
} catch {
|
|
874
|
-
return { error: "Failed to parse certificate" };
|
|
875
|
-
}
|
|
876
|
-
})()
|
|
877
|
-
} : void 0,
|
|
878
|
-
spMetadataUrl: `${baseURL}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(provider.providerId)}`
|
|
879
|
-
};
|
|
880
939
|
}
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
const getSSOProviderQuerySchema = z.object({ providerId: z.string() });
|
|
911
|
-
async function checkProviderAccess(ctx, providerId) {
|
|
912
|
-
const userId = ctx.context.session.user.id;
|
|
913
|
-
const provider = await ctx.context.adapter.findOne({
|
|
914
|
-
model: "ssoProvider",
|
|
915
|
-
where: [{
|
|
916
|
-
field: "providerId",
|
|
917
|
-
value: providerId
|
|
918
|
-
}]
|
|
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
|
+
}
|
|
951
|
+
}
|
|
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"
|
|
919
969
|
});
|
|
920
|
-
if (!provider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
|
|
921
|
-
let hasAccess = false;
|
|
922
|
-
if (provider.organizationId) if (ctx.context.hasPlugin("organization")) hasAccess = await isOrgAdmin(ctx, userId, provider.organizationId);
|
|
923
|
-
else hasAccess = provider.userId === userId;
|
|
924
|
-
else hasAccess = provider.userId === userId;
|
|
925
|
-
if (!hasAccess) throw new APIError("FORBIDDEN", { message: "You don't have access to this provider" });
|
|
926
|
-
return provider;
|
|
927
970
|
}
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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");
|
|
989
|
+
}
|
|
990
|
+
}
|
|
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
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
//#endregion
|
|
1025
|
+
//#region src/saml/assertions.ts
|
|
1026
|
+
function countAssertions(xml) {
|
|
1027
|
+
let parsed;
|
|
951
1028
|
try {
|
|
952
|
-
|
|
1029
|
+
parsed = xmlParser.parse(xml);
|
|
953
1030
|
} catch {
|
|
954
|
-
|
|
1031
|
+
throw new APIError("BAD_REQUEST", {
|
|
1032
|
+
message: "Failed to parse SAML response XML",
|
|
1033
|
+
code: "SAML_INVALID_XML"
|
|
1034
|
+
});
|
|
955
1035
|
}
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
}
|
|
959
|
-
function mergeSAMLConfig(current, updates, issuer) {
|
|
960
|
-
return {
|
|
961
|
-
...current,
|
|
962
|
-
...updates,
|
|
963
|
-
issuer,
|
|
964
|
-
entryPoint: updates.entryPoint ?? current.entryPoint,
|
|
965
|
-
cert: updates.cert ?? current.cert,
|
|
966
|
-
spMetadata: updates.spMetadata ?? current.spMetadata,
|
|
967
|
-
idpMetadata: updates.idpMetadata ?? current.idpMetadata,
|
|
968
|
-
mapping: updates.mapping ?? current.mapping,
|
|
969
|
-
audience: updates.audience ?? current.audience,
|
|
970
|
-
wantAssertionsSigned: updates.wantAssertionsSigned ?? current.wantAssertionsSigned,
|
|
971
|
-
authnRequestsSigned: updates.authnRequestsSigned ?? current.authnRequestsSigned,
|
|
972
|
-
identifierFormat: updates.identifierFormat ?? current.identifierFormat,
|
|
973
|
-
signatureAlgorithm: updates.signatureAlgorithm ?? current.signatureAlgorithm,
|
|
974
|
-
digestAlgorithm: updates.digestAlgorithm ?? current.digestAlgorithm
|
|
975
|
-
};
|
|
976
|
-
}
|
|
977
|
-
function mergeOIDCConfig(current, updates, issuer) {
|
|
1036
|
+
const assertions = countAllNodes(parsed, "Assertion");
|
|
1037
|
+
const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
|
|
978
1038
|
return {
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
pkce: updates.pkce ?? current.pkce ?? true,
|
|
983
|
-
clientId: updates.clientId ?? current.clientId,
|
|
984
|
-
clientSecret: updates.clientSecret ?? current.clientSecret,
|
|
985
|
-
discoveryEndpoint: updates.discoveryEndpoint ?? current.discoveryEndpoint,
|
|
986
|
-
mapping: updates.mapping ?? current.mapping,
|
|
987
|
-
scopes: updates.scopes ?? current.scopes,
|
|
988
|
-
authorizationEndpoint: updates.authorizationEndpoint ?? current.authorizationEndpoint,
|
|
989
|
-
tokenEndpoint: updates.tokenEndpoint ?? current.tokenEndpoint,
|
|
990
|
-
userInfoEndpoint: updates.userInfoEndpoint ?? current.userInfoEndpoint,
|
|
991
|
-
jwksEndpoint: updates.jwksEndpoint ?? current.jwksEndpoint,
|
|
992
|
-
tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication,
|
|
993
|
-
privateKeyId: updates.privateKeyId ?? current.privateKeyId,
|
|
994
|
-
privateKeyAlgorithm: updates.privateKeyAlgorithm ?? current.privateKeyAlgorithm
|
|
1039
|
+
assertions,
|
|
1040
|
+
encryptedAssertions,
|
|
1041
|
+
total: assertions + encryptedAssertions
|
|
995
1042
|
};
|
|
996
1043
|
}
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
responses: {
|
|
1007
|
-
"200": { description: "SSO provider updated successfully" },
|
|
1008
|
-
"404": { description: "Provider not found" },
|
|
1009
|
-
"403": { description: "Access denied" }
|
|
1010
|
-
}
|
|
1011
|
-
} }
|
|
1012
|
-
}, async (ctx) => {
|
|
1013
|
-
const { providerId, ...body } = ctx.body;
|
|
1014
|
-
const { issuer, domain, samlConfig, oidcConfig } = body;
|
|
1015
|
-
if (!issuer && !domain && !samlConfig && !oidcConfig) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
|
|
1016
|
-
const existingProvider = await checkProviderAccess(ctx, providerId);
|
|
1017
|
-
const updateData = {};
|
|
1018
|
-
if (body.issuer !== void 0) updateData.issuer = body.issuer;
|
|
1019
|
-
if (body.domain !== void 0) {
|
|
1020
|
-
updateData.domain = body.domain;
|
|
1021
|
-
if (body.domain !== existingProvider.domain) updateData.domainVerified = false;
|
|
1022
|
-
}
|
|
1023
|
-
if (body.samlConfig) {
|
|
1024
|
-
if (body.samlConfig.idpMetadata?.metadata) {
|
|
1025
|
-
const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
|
|
1026
|
-
if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
|
|
1027
|
-
}
|
|
1028
|
-
if (body.samlConfig.signatureAlgorithm !== void 0 || body.samlConfig.digestAlgorithm !== void 0) validateConfigAlgorithms({
|
|
1029
|
-
signatureAlgorithm: body.samlConfig.signatureAlgorithm,
|
|
1030
|
-
digestAlgorithm: body.samlConfig.digestAlgorithm
|
|
1031
|
-
}, options?.saml?.algorithms);
|
|
1032
|
-
const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
|
|
1033
|
-
const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
|
|
1034
|
-
updateData.samlConfig = JSON.stringify(updatedSamlConfig);
|
|
1035
|
-
}
|
|
1036
|
-
if (body.oidcConfig) {
|
|
1037
|
-
const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
|
|
1038
|
-
const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
|
|
1039
|
-
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" });
|
|
1040
|
-
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" });
|
|
1041
|
-
updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
|
|
1042
|
-
}
|
|
1043
|
-
await ctx.context.adapter.update({
|
|
1044
|
-
model: "ssoProvider",
|
|
1045
|
-
where: [{
|
|
1046
|
-
field: "providerId",
|
|
1047
|
-
value: providerId
|
|
1048
|
-
}],
|
|
1049
|
-
update: updateData
|
|
1050
|
-
});
|
|
1051
|
-
const fullProvider = await ctx.context.adapter.findOne({
|
|
1052
|
-
model: "ssoProvider",
|
|
1053
|
-
where: [{
|
|
1054
|
-
field: "providerId",
|
|
1055
|
-
value: providerId
|
|
1056
|
-
}]
|
|
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"
|
|
1057
1053
|
});
|
|
1058
|
-
|
|
1059
|
-
|
|
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"
|
|
1060
1059
|
});
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
method: "POST",
|
|
1065
|
-
use: [sessionMiddleware],
|
|
1066
|
-
body: z.object({ providerId: z.string() }),
|
|
1067
|
-
metadata: { openapi: {
|
|
1068
|
-
operationId: "deleteSSOProvider",
|
|
1069
|
-
summary: "Delete SSO provider",
|
|
1070
|
-
description: "Deletes an SSO provider",
|
|
1071
|
-
responses: {
|
|
1072
|
-
"200": { description: "SSO provider deleted successfully" },
|
|
1073
|
-
"404": { description: "Provider not found" },
|
|
1074
|
-
"403": { description: "Access denied" }
|
|
1075
|
-
}
|
|
1076
|
-
} }
|
|
1077
|
-
}, async (ctx) => {
|
|
1078
|
-
const { providerId } = ctx.body;
|
|
1079
|
-
await checkProviderAccess(ctx, providerId);
|
|
1080
|
-
await ctx.context.adapter.delete({
|
|
1081
|
-
model: "ssoProvider",
|
|
1082
|
-
where: [{
|
|
1083
|
-
field: "providerId",
|
|
1084
|
-
value: providerId
|
|
1085
|
-
}]
|
|
1086
|
-
});
|
|
1087
|
-
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"
|
|
1088
1063
|
});
|
|
1089
|
-
};
|
|
1090
|
-
//#endregion
|
|
1091
|
-
//#region src/oidc/types.ts
|
|
1092
|
-
/**
|
|
1093
|
-
* Custom error class for OIDC discovery failures.
|
|
1094
|
-
* Can be caught and mapped to APIError at the edge.
|
|
1095
|
-
*/
|
|
1096
|
-
var DiscoveryError = class DiscoveryError extends Error {
|
|
1097
|
-
code;
|
|
1098
|
-
details;
|
|
1099
|
-
constructor(code, message, details, options) {
|
|
1100
|
-
super(message, options);
|
|
1101
|
-
this.name = "DiscoveryError";
|
|
1102
|
-
this.code = code;
|
|
1103
|
-
this.details = details;
|
|
1104
|
-
if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
|
|
1105
|
-
}
|
|
1106
|
-
};
|
|
1107
|
-
/**
|
|
1108
|
-
* Required fields that must be present in a valid discovery document.
|
|
1109
|
-
*/
|
|
1110
|
-
const REQUIRED_DISCOVERY_FIELDS = [
|
|
1111
|
-
"issuer",
|
|
1112
|
-
"authorization_endpoint",
|
|
1113
|
-
"token_endpoint",
|
|
1114
|
-
"jwks_uri"
|
|
1115
|
-
];
|
|
1116
|
-
//#endregion
|
|
1117
|
-
//#region src/oidc/discovery.ts
|
|
1118
|
-
/**
|
|
1119
|
-
* OIDC Discovery Pipeline
|
|
1120
|
-
*
|
|
1121
|
-
* Implements OIDC discovery document fetching, validation, and hydration.
|
|
1122
|
-
* This module is used both at provider registration time (to persist validated config)
|
|
1123
|
-
* and at runtime (to hydrate legacy providers that are missing metadata).
|
|
1124
|
-
*
|
|
1125
|
-
* @see https://openid.net/specs/openid-connect-discovery-1_0.html
|
|
1126
|
-
*/
|
|
1127
|
-
/** Default timeout for discovery requests (10 seconds) */
|
|
1128
|
-
const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
|
|
1129
|
-
/**
|
|
1130
|
-
* Main entry point: Discover and hydrate OIDC configuration from an issuer.
|
|
1131
|
-
*
|
|
1132
|
-
* This function:
|
|
1133
|
-
* 1. Computes the discovery URL from the issuer
|
|
1134
|
-
* 2. Validates the discovery URL
|
|
1135
|
-
* 3. Fetches the discovery document
|
|
1136
|
-
* 4. Validates the discovery document (issuer match + required fields)
|
|
1137
|
-
* 5. Normalizes URLs
|
|
1138
|
-
* 6. Selects token endpoint auth method
|
|
1139
|
-
* 7. Merges with existing config (existing values take precedence)
|
|
1140
|
-
*
|
|
1141
|
-
* @param params - Discovery parameters
|
|
1142
|
-
* @param isTrustedOrigin - Origin verification tester function
|
|
1143
|
-
* @returns Hydrated OIDC configuration ready for persistence
|
|
1144
|
-
* @throws DiscoveryError on any failure
|
|
1145
|
-
*/
|
|
1146
|
-
async function discoverOIDCConfig(params) {
|
|
1147
|
-
const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
|
|
1148
|
-
const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
|
|
1149
|
-
validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
|
|
1150
|
-
const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
|
|
1151
|
-
validateDiscoveryDocument(discoveryDoc, issuer);
|
|
1152
|
-
const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
|
|
1153
|
-
const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
|
|
1154
|
-
return {
|
|
1155
|
-
issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
|
|
1156
|
-
discoveryEndpoint: existingConfig?.discoveryEndpoint ?? discoveryUrl,
|
|
1157
|
-
authorizationEndpoint: existingConfig?.authorizationEndpoint ?? normalizedDoc.authorization_endpoint,
|
|
1158
|
-
tokenEndpoint: existingConfig?.tokenEndpoint ?? normalizedDoc.token_endpoint,
|
|
1159
|
-
jwksEndpoint: existingConfig?.jwksEndpoint ?? normalizedDoc.jwks_uri,
|
|
1160
|
-
userInfoEndpoint: existingConfig?.userInfoEndpoint ?? normalizedDoc.userinfo_endpoint,
|
|
1161
|
-
tokenEndpointAuthentication: existingConfig?.tokenEndpointAuthentication ?? tokenEndpointAuth,
|
|
1162
|
-
scopesSupported: existingConfig?.scopesSupported ?? normalizedDoc.scopes_supported
|
|
1163
|
-
};
|
|
1164
1064
|
}
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1065
|
+
//#endregion
|
|
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
|
+
});
|
|
1076
|
+
//#endregion
|
|
1077
|
+
//#region src/saml/cert.ts
|
|
1078
|
+
/**
|
|
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.
|
|
1172
1083
|
*/
|
|
1173
|
-
|
|
1174
|
-
|
|
1084
|
+
/**
|
|
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).
|
|
1089
|
+
*/
|
|
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];
|
|
1175
1094
|
}
|
|
1176
1095
|
/**
|
|
1177
|
-
*
|
|
1178
|
-
*
|
|
1179
|
-
*
|
|
1180
|
-
*
|
|
1181
|
-
* @throws DiscoveryError if URL is invalid
|
|
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.
|
|
1182
1100
|
*/
|
|
1183
|
-
function
|
|
1184
|
-
const
|
|
1185
|
-
|
|
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);
|
|
1105
|
+
}
|
|
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
|
+
}
|
|
1186
1233
|
}
|
|
1187
1234
|
/**
|
|
1188
|
-
*
|
|
1235
|
+
* Validates the InResponseTo attribute of a SAML Response.
|
|
1189
1236
|
*
|
|
1190
|
-
*
|
|
1191
|
-
*
|
|
1192
|
-
*
|
|
1193
|
-
*
|
|
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).
|
|
1194
1243
|
*/
|
|
1195
|
-
async function
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
});
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
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
|
|
1210
1260
|
});
|
|
1211
|
-
throw
|
|
1212
|
-
|
|
1213
|
-
|
|
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
|
|
1214
1268
|
});
|
|
1269
|
+
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Provider mismatch"));
|
|
1215
1270
|
}
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
url,
|
|
1220
|
-
bodyPreview: data.slice(0, 200)
|
|
1221
|
-
});
|
|
1222
|
-
return data;
|
|
1223
|
-
} catch (error) {
|
|
1224
|
-
if (error instanceof DiscoveryError) throw error;
|
|
1225
|
-
if (error instanceof Error && error.name === "AbortError") throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
|
|
1226
|
-
url,
|
|
1227
|
-
timeout
|
|
1228
|
-
});
|
|
1229
|
-
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"));
|
|
1230
1274
|
}
|
|
1231
1275
|
}
|
|
1232
1276
|
/**
|
|
1233
|
-
*
|
|
1234
|
-
*
|
|
1235
|
-
* Checks:
|
|
1236
|
-
* 1. All required fields are present
|
|
1237
|
-
* 2. Issuer matches the configured issuer (case-sensitive, exact match)
|
|
1238
|
-
*
|
|
1239
|
-
* Invariant: If this function returns without throwing, the document is safe
|
|
1240
|
-
* to use for hydrating OIDC config (required fields present, issuer matches
|
|
1241
|
-
* configured value, basic structural sanity verified).
|
|
1277
|
+
* Validates the AudienceRestriction of a SAML assertion.
|
|
1242
1278
|
*
|
|
1243
|
-
*
|
|
1244
|
-
*
|
|
1245
|
-
*
|
|
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.
|
|
1246
1283
|
*/
|
|
1247
|
-
function
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
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
|
|
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);
|
|
1318
|
+
}
|
|
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` });
|
|
1321
|
+
}
|
|
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 }
|
|
1254
1499
|
});
|
|
1255
1500
|
}
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
doc.jwks_uri = normalizeAndValidateUrl("jwks_uri", doc.jwks_uri, issuer, isTrustedOrigin);
|
|
1269
|
-
if (doc.userinfo_endpoint) doc.userinfo_endpoint = normalizeAndValidateUrl("userinfo_endpoint", doc.userinfo_endpoint, issuer, isTrustedOrigin);
|
|
1270
|
-
if (doc.revocation_endpoint) doc.revocation_endpoint = normalizeAndValidateUrl("revocation_endpoint", doc.revocation_endpoint, issuer, isTrustedOrigin);
|
|
1271
|
-
if (doc.end_session_endpoint) doc.end_session_endpoint = normalizeAndValidateUrl("end_session_endpoint", doc.end_session_endpoint, issuer, isTrustedOrigin);
|
|
1272
|
-
if (doc.introspection_endpoint) doc.introspection_endpoint = normalizeAndValidateUrl("introspection_endpoint", doc.introspection_endpoint, issuer, isTrustedOrigin);
|
|
1273
|
-
return doc;
|
|
1274
|
-
}
|
|
1275
|
-
/**
|
|
1276
|
-
* Normalizes and validates a single URL endpoint
|
|
1277
|
-
* @param name The url name
|
|
1278
|
-
* @param endpoint The url to validate
|
|
1279
|
-
* @param issuer The issuer base url
|
|
1280
|
-
* @param isTrustedOrigin - Origin verification tester function
|
|
1281
|
-
* @returns
|
|
1282
|
-
*/
|
|
1283
|
-
function normalizeAndValidateUrl(name, endpoint, issuer, isTrustedOrigin) {
|
|
1284
|
-
const url = normalizeUrl(name, endpoint, issuer);
|
|
1285
|
-
if (!isTrustedOrigin(url)) throw new DiscoveryError("discovery_untrusted_origin", `The ${name} "${url}" is not trusted by your trusted origins configuration.`, {
|
|
1286
|
-
endpoint: name,
|
|
1287
|
-
url
|
|
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"
|
|
1288
1513
|
});
|
|
1289
|
-
return url;
|
|
1290
1514
|
}
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
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) {
|
|
1300
1534
|
try {
|
|
1301
|
-
return
|
|
1535
|
+
return parseCertificate(cert);
|
|
1302
1536
|
} catch {
|
|
1303
|
-
|
|
1304
|
-
const basePath = issuerURL.pathname.replace(/\/+$/, "");
|
|
1305
|
-
const endpointPath = endpoint.replace(/^\/+/, "");
|
|
1306
|
-
return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
|
|
1537
|
+
return { error: "Failed to parse certificate" };
|
|
1307
1538
|
}
|
|
1308
1539
|
}
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
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
|
+
}]
|
|
1555
|
+
});
|
|
1556
|
+
return member ? hasOrgAdminRole(member) : false;
|
|
1557
|
+
}
|
|
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;
|
|
1319
1578
|
try {
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
|
|
1579
|
+
oidcConfig = safeJsonParse(provider.oidcConfig);
|
|
1580
|
+
} catch {
|
|
1581
|
+
oidcConfig = null;
|
|
1324
1582
|
}
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1583
|
+
try {
|
|
1584
|
+
samlConfig = safeJsonParse(provider.samlConfig);
|
|
1585
|
+
} catch {
|
|
1586
|
+
samlConfig = null;
|
|
1587
|
+
}
|
|
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
|
+
};
|
|
1329
1620
|
}
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
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;
|
|
1346
1667
|
}
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
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));
|
|
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;
|
|
1362
1698
|
}
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
*/
|
|
1368
|
-
async function ensureRuntimeDiscovery(config, issuer, isTrustedOrigin) {
|
|
1369
|
-
if (!needsRuntimeDiscovery(config)) return config;
|
|
1370
|
-
const hydrated = await discoverOIDCConfig({
|
|
1699
|
+
function mergeSAMLConfig(current, updates, issuer) {
|
|
1700
|
+
return {
|
|
1701
|
+
...current,
|
|
1702
|
+
...updates,
|
|
1371
1703
|
issuer,
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
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
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
function mergeOIDCConfig(current, updates, issuer) {
|
|
1375
1719
|
return {
|
|
1376
|
-
...
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
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
|
|
1382
1736
|
};
|
|
1383
1737
|
}
|
|
1384
|
-
|
|
1385
|
-
|
|
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
|
-
|
|
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;
|
|
1417
1826
|
});
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
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
|
+
});
|
|
1421
1864
|
});
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
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"
|
|
1425
1896
|
});
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
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)
|
|
1429
1908
|
});
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
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"
|
|
1433
1934
|
});
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
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"
|
|
1437
1939
|
});
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
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"
|
|
1441
1944
|
});
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
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"
|
|
1445
1959
|
});
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
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"
|
|
1451
1975
|
});
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
}
|
|
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
|
+
};
|
|
1464
1988
|
//#endregion
|
|
1465
1989
|
//#region src/saml-state.ts
|
|
1466
|
-
async function generateRelayState(c, link
|
|
1990
|
+
async function generateRelayState(c, link) {
|
|
1467
1991
|
const callbackURL = c.body.callbackURL;
|
|
1468
1992
|
if (!callbackURL) throw new APIError("BAD_REQUEST", { message: "callbackURL is required" });
|
|
1469
|
-
const codeVerifier = generateRandomString(128);
|
|
1470
1993
|
const stateData = {
|
|
1471
|
-
...additionalData ? additionalData : {},
|
|
1472
1994
|
callbackURL,
|
|
1473
|
-
codeVerifier,
|
|
1995
|
+
codeVerifier: generateRandomString(128),
|
|
1474
1996
|
errorURL: c.body.errorCallbackURL,
|
|
1475
1997
|
newUserURL: c.body.newUserCallbackURL,
|
|
1476
1998
|
link,
|
|
@@ -1506,17 +2028,16 @@ async function parseRelayState(c) {
|
|
|
1506
2028
|
if (!parsedData.errorURL) parsedData.errorURL = errorURL;
|
|
1507
2029
|
return parsedData;
|
|
1508
2030
|
}
|
|
2031
|
+
const saml = typeof samlifyNamespace.SPMetadata === "function" && typeof samlifyNamespace.setSchemaValidator === "function" ? samlifyNamespace : samlifyDefault ?? samlifyNamespace;
|
|
1509
2032
|
//#endregion
|
|
1510
2033
|
//#region src/routes/helpers.ts
|
|
1511
2034
|
/**
|
|
1512
|
-
*
|
|
1513
|
-
*
|
|
1514
|
-
* blocks with leading whitespace, which is common when keys are stored in
|
|
1515
|
-
* indented config files, environment variables, or JSON.
|
|
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.
|
|
1516
2037
|
*/
|
|
1517
|
-
function
|
|
1518
|
-
if (!
|
|
1519
|
-
return
|
|
2038
|
+
function normalizePemList(certs) {
|
|
2039
|
+
if (!certs) return certs;
|
|
2040
|
+
return certs.map((pem) => normalizePem(pem) ?? pem);
|
|
1520
2041
|
}
|
|
1521
2042
|
async function findSAMLProvider(providerId, options, adapter) {
|
|
1522
2043
|
if (options?.defaultSSO?.length) {
|
|
@@ -1573,7 +2094,8 @@ function createSP(config, baseURL, providerId, opts) {
|
|
|
1573
2094
|
isAssertionEncrypted: spData?.isAssertionEncrypted || false,
|
|
1574
2095
|
encPrivateKey: normalizePem(spData?.encPrivateKey),
|
|
1575
2096
|
encPrivateKeyPass: spData?.encPrivateKeyPass,
|
|
1576
|
-
relayState: opts?.relayState
|
|
2097
|
+
relayState: opts?.relayState,
|
|
2098
|
+
clockDrifts: opts?.clockSkew && opts?.clockSkew !== 0 ? [-opts.clockSkew, opts.clockSkew] : void 0
|
|
1577
2099
|
});
|
|
1578
2100
|
}
|
|
1579
2101
|
function createIdP(config) {
|
|
@@ -1593,7 +2115,7 @@ function createIdP(config) {
|
|
|
1593
2115
|
Location: config.entryPoint
|
|
1594
2116
|
}],
|
|
1595
2117
|
singleLogoutService: idpData?.singleLogoutService,
|
|
1596
|
-
signingCert:
|
|
2118
|
+
signingCert: normalizePemList(resolveSigningCerts(config)),
|
|
1597
2119
|
wantAuthnRequestsSigned: config.authnRequestsSigned || false,
|
|
1598
2120
|
isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
|
|
1599
2121
|
encPrivateKey: normalizePem(idpData?.encPrivateKey),
|
|
@@ -1604,7 +2126,17 @@ function escapeHtml(str) {
|
|
|
1604
2126
|
if (!str) return "";
|
|
1605
2127
|
return String(str).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1606
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
|
+
}
|
|
1607
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" });
|
|
1608
2140
|
const safeAction = escapeHtml(action);
|
|
1609
2141
|
const safeSamlParam = escapeHtml(samlParam);
|
|
1610
2142
|
const safeSamlValue = escapeHtml(samlValue);
|
|
@@ -1674,6 +2206,14 @@ function getSafeRedirectUrl(url, callbackPath, appOrigin, isTrustedOrigin) {
|
|
|
1674
2206
|
}
|
|
1675
2207
|
return url;
|
|
1676
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 {}
|
|
1677
2217
|
if (!isTrustedOrigin(url, { allowRelativePaths: false })) return appOrigin;
|
|
1678
2218
|
try {
|
|
1679
2219
|
const callbackPathname = new URL(callbackPath).pathname;
|
|
@@ -1683,6 +2223,39 @@ function getSafeRedirectUrl(url, callbackPath, appOrigin, isTrustedOrigin) {
|
|
|
1683
2223
|
}
|
|
1684
2224
|
return url;
|
|
1685
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
|
+
}
|
|
1686
2259
|
/**
|
|
1687
2260
|
* Extracts the Assertion ID from a SAML response XML.
|
|
1688
2261
|
* Used for replay protection per SAML 2.0 Core section 2.3.3.
|
|
@@ -1729,9 +2302,9 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1729
2302
|
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
1730
2303
|
const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
|
|
1731
2304
|
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
1732
|
-
const sp = createSP(parsedSamlConfig, ctx.context.baseURL, providerId);
|
|
2305
|
+
const sp = createSP(parsedSamlConfig, ctx.context.baseURL, providerId, { clockSkew: options?.saml?.clockSkew });
|
|
1733
2306
|
const idp = createIdP(parsedSamlConfig);
|
|
1734
|
-
const samlRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL, params.currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
2307
|
+
const samlRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, params.currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
1735
2308
|
validateSingleAssertion(SAMLResponse);
|
|
1736
2309
|
let parsedResponse;
|
|
1737
2310
|
try {
|
|
@@ -1751,12 +2324,47 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1751
2324
|
});
|
|
1752
2325
|
}
|
|
1753
2326
|
const { extract } = parsedResponse;
|
|
2327
|
+
const samlContent = parsedResponse.samlContent;
|
|
1754
2328
|
validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
|
|
1755
2329
|
validateSAMLTimestamp(extract.conditions, {
|
|
1756
2330
|
clockSkew: options?.saml?.clockSkew,
|
|
1757
2331
|
requireTimestamps: options?.saml?.requireTimestamps,
|
|
1758
2332
|
logger: ctx.context.logger
|
|
1759
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
|
+
}
|
|
1760
2368
|
await validateInResponseTo(ctx, {
|
|
1761
2369
|
extract,
|
|
1762
2370
|
providerId,
|
|
@@ -1772,33 +2380,14 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1772
2380
|
providerId,
|
|
1773
2381
|
redirectUrl: samlRedirectUrl
|
|
1774
2382
|
});
|
|
1775
|
-
const
|
|
1776
|
-
const assertionId = samlContent ? extractAssertionId(samlContent) : null;
|
|
2383
|
+
const assertionId = extractAssertionId(samlBindingContent);
|
|
1777
2384
|
if (assertionId) {
|
|
1778
2385
|
const issuer = idp.entityMeta.getEntityID();
|
|
1779
2386
|
const conditions = extract.conditions;
|
|
1780
2387
|
const clockSkew = options?.saml?.clockSkew ?? 3e5;
|
|
1781
2388
|
const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
if (existingAssertion) try {
|
|
1785
|
-
if (JSON.parse(existingAssertion.value).expiresAt >= Date.now()) isReplay = true;
|
|
1786
|
-
} catch (error) {
|
|
1787
|
-
ctx.context.logger.warn("Failed to parse stored assertion record", {
|
|
1788
|
-
assertionId,
|
|
1789
|
-
error
|
|
1790
|
-
});
|
|
1791
|
-
}
|
|
1792
|
-
if (isReplay) {
|
|
1793
|
-
ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
|
|
1794
|
-
assertionId,
|
|
1795
|
-
issuer,
|
|
1796
|
-
providerId
|
|
1797
|
-
});
|
|
1798
|
-
throw ctx.redirect(`${samlRedirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
|
|
1799
|
-
}
|
|
1800
|
-
await ctx.context.internalAdapter.createVerificationValue({
|
|
1801
|
-
identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
|
|
2389
|
+
if (!await ctx.context.internalAdapter.reserveVerificationValue({
|
|
2390
|
+
identifier: `saml-used-assertion:${assertionId}`,
|
|
1802
2391
|
value: JSON.stringify({
|
|
1803
2392
|
assertionId,
|
|
1804
2393
|
issuer,
|
|
@@ -1807,16 +2396,30 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1807
2396
|
expiresAt
|
|
1808
2397
|
}),
|
|
1809
2398
|
expiresAt: new Date(expiresAt)
|
|
1810
|
-
})
|
|
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
|
+
}
|
|
1811
2410
|
} else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
|
|
1812
2411
|
const attributes = extract.attributes || {};
|
|
1813
2412
|
const mapping = parsedSamlConfig.mapping ?? {};
|
|
2413
|
+
const attr = (key) => {
|
|
2414
|
+
const value = attributes[key];
|
|
2415
|
+
return Array.isArray(value) ? value[0] : value;
|
|
2416
|
+
};
|
|
1814
2417
|
const userInfo = {
|
|
1815
2418
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
|
|
1816
|
-
id:
|
|
1817
|
-
email: (
|
|
1818
|
-
name: [
|
|
1819
|
-
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
|
|
1820
2423
|
};
|
|
1821
2424
|
if (!userInfo.id || !userInfo.email) {
|
|
1822
2425
|
ctx.context.logger.error("Missing essential user info from SAML response", {
|
|
@@ -1827,26 +2430,47 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1827
2430
|
});
|
|
1828
2431
|
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
1829
2432
|
}
|
|
1830
|
-
const isTrustedProvider =
|
|
1831
|
-
const
|
|
1832
|
-
const
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
2433
|
+
const isTrustedProvider = "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
2434
|
+
const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
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("_") }));
|
|
1850
2474
|
const { session, user } = result.data;
|
|
1851
2475
|
if (options?.provisionUser && (result.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
|
|
1852
2476
|
user,
|
|
@@ -1860,7 +2484,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1860
2484
|
providerId,
|
|
1861
2485
|
accountId: userInfo.id,
|
|
1862
2486
|
email: userInfo.email,
|
|
1863
|
-
emailVerified:
|
|
2487
|
+
emailVerified: userInfo.emailVerified,
|
|
1864
2488
|
rawAttributes: attributes
|
|
1865
2489
|
},
|
|
1866
2490
|
provider,
|
|
@@ -1874,6 +2498,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1874
2498
|
const samlSessionKey = `${SAML_SESSION_KEY_PREFIX}${providerId}:${extract.nameID}`;
|
|
1875
2499
|
const samlSessionData = {
|
|
1876
2500
|
sessionId: session.id,
|
|
2501
|
+
sessionToken: session.token,
|
|
1877
2502
|
providerId,
|
|
1878
2503
|
nameID: extract.nameID,
|
|
1879
2504
|
sessionIndex: extract.sessionIndex?.sessionIndex
|
|
@@ -1889,10 +2514,18 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
1889
2514
|
expiresAt: session.expiresAt
|
|
1890
2515
|
}).catch((e) => ctx.context.logger.warn("Failed to create SAML session lookup record", e));
|
|
1891
2516
|
}
|
|
1892
|
-
return getSafeRedirectUrl(relayState?.callbackURL, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
2517
|
+
return getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
1893
2518
|
}
|
|
1894
2519
|
//#endregion
|
|
1895
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
|
+
];
|
|
1896
2529
|
/**
|
|
1897
2530
|
* Builds the OIDC redirect URI. Uses the shared `redirectURI` option
|
|
1898
2531
|
* when set, otherwise falls back to `/sso/callback/:providerId`.
|
|
@@ -1918,15 +2551,9 @@ const spMetadata = (options) => {
|
|
|
1918
2551
|
responses: { "200": { description: "SAML metadata in XML format" } }
|
|
1919
2552
|
} }
|
|
1920
2553
|
}, async (ctx) => {
|
|
1921
|
-
const provider = await ctx.context.adapter
|
|
1922
|
-
model: "ssoProvider",
|
|
1923
|
-
where: [{
|
|
1924
|
-
field: "providerId",
|
|
1925
|
-
value: ctx.query.providerId
|
|
1926
|
-
}]
|
|
1927
|
-
});
|
|
2554
|
+
const provider = await findSAMLProvider(ctx.query.providerId, options, ctx.context.adapter);
|
|
1928
2555
|
if (!provider) throw new APIError("NOT_FOUND", { message: "No provider found for the given providerId" });
|
|
1929
|
-
const parsedSamlConfig =
|
|
2556
|
+
const parsedSamlConfig = provider.samlConfig;
|
|
1930
2557
|
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
1931
2558
|
const sp = createSP(parsedSamlConfig, ctx.context.baseURL, ctx.query.providerId, options?.saml?.enableSingleLogout ? { sloOptions: {
|
|
1932
2559
|
wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned,
|
|
@@ -1935,255 +2562,180 @@ const spMetadata = (options) => {
|
|
|
1935
2562
|
return new Response(sp.getMetadata(), { headers: { "Content-Type": "application/xml" } });
|
|
1936
2563
|
});
|
|
1937
2564
|
};
|
|
1938
|
-
const ssoProviderBodySchema = z.object({
|
|
1939
|
-
providerId: z.string({}).meta({ description: "The ID of the provider. This is used to identify the provider during login and callback" }),
|
|
1940
|
-
issuer: z.string({}).meta({ description: "The issuer of the provider" }),
|
|
1941
|
-
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')" }),
|
|
1942
|
-
oidcConfig: z.object({
|
|
1943
|
-
clientId: z.string({}).meta({ description: "The client ID" }),
|
|
1944
|
-
clientSecret: z.string({}).optional().meta({ description: "The client secret. Required for client_secret_basic/client_secret_post. Optional for private_key_jwt." }),
|
|
1945
|
-
authorizationEndpoint: z.string({}).meta({ description: "The authorization endpoint" }).optional(),
|
|
1946
|
-
tokenEndpoint: z.string({}).meta({ description: "The token endpoint" }).optional(),
|
|
1947
|
-
userInfoEndpoint: z.string({}).meta({ description: "The user info endpoint" }).optional(),
|
|
1948
|
-
tokenEndpointAuthentication: z.enum([
|
|
1949
|
-
"client_secret_post",
|
|
1950
|
-
"client_secret_basic",
|
|
1951
|
-
"private_key_jwt"
|
|
1952
|
-
]).optional(),
|
|
1953
|
-
privateKeyId: z.string().optional(),
|
|
1954
|
-
privateKeyAlgorithm: z.string().optional(),
|
|
1955
|
-
jwksEndpoint: z.string({}).meta({ description: "The JWKS endpoint" }).optional(),
|
|
1956
|
-
discoveryEndpoint: z.string().optional(),
|
|
1957
|
-
skipDiscovery: z.boolean().meta({ description: "Skip OIDC discovery during registration. When true, you must provide authorizationEndpoint, tokenEndpoint, and jwksEndpoint manually." }).optional(),
|
|
1958
|
-
scopes: z.array(z.string(), {}).meta({ description: "The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']" }).optional(),
|
|
1959
|
-
pkce: z.boolean({}).meta({ description: "Whether to use PKCE for the authorization flow" }).default(true).optional(),
|
|
1960
|
-
mapping: z.object({
|
|
1961
|
-
id: z.string({}).meta({ description: "Field mapping for user ID (defaults to 'sub')" }),
|
|
1962
|
-
email: z.string({}).meta({ description: "Field mapping for email (defaults to 'email')" }),
|
|
1963
|
-
emailVerified: z.string({}).meta({ description: "Field mapping for email verification (defaults to 'email_verified')" }).optional(),
|
|
1964
|
-
name: z.string({}).meta({ description: "Field mapping for name (defaults to 'name')" }),
|
|
1965
|
-
image: z.string({}).meta({ description: "Field mapping for image (defaults to 'picture')" }).optional(),
|
|
1966
|
-
extraFields: z.record(z.string(), z.any()).optional()
|
|
1967
|
-
}).optional()
|
|
1968
|
-
}).optional(),
|
|
1969
|
-
samlConfig: z.object({
|
|
1970
|
-
entryPoint: z.string({}).meta({ description: "The entry point of the provider" }),
|
|
1971
|
-
cert: z.string({}).meta({ description: "The certificate of the provider" }),
|
|
1972
|
-
audience: z.string().optional(),
|
|
1973
|
-
idpMetadata: z.object({
|
|
1974
|
-
metadata: z.string().optional(),
|
|
1975
|
-
entityID: z.string().optional(),
|
|
1976
|
-
cert: z.string().optional(),
|
|
1977
|
-
privateKey: z.string().optional(),
|
|
1978
|
-
privateKeyPass: z.string().optional(),
|
|
1979
|
-
isAssertionEncrypted: z.boolean().optional(),
|
|
1980
|
-
encPrivateKey: z.string().optional(),
|
|
1981
|
-
encPrivateKeyPass: z.string().optional(),
|
|
1982
|
-
singleSignOnService: z.array(z.object({
|
|
1983
|
-
Binding: z.string().meta({ description: "The binding type for the SSO service" }),
|
|
1984
|
-
Location: z.string().meta({ description: "The URL for the SSO service" })
|
|
1985
|
-
})).optional().meta({ description: "Single Sign-On service configuration" })
|
|
1986
|
-
}).optional(),
|
|
1987
|
-
spMetadata: z.object({
|
|
1988
|
-
metadata: z.string().optional(),
|
|
1989
|
-
entityID: z.string().optional(),
|
|
1990
|
-
binding: z.string().optional(),
|
|
1991
|
-
privateKey: z.string().optional(),
|
|
1992
|
-
privateKeyPass: z.string().optional(),
|
|
1993
|
-
isAssertionEncrypted: z.boolean().optional(),
|
|
1994
|
-
encPrivateKey: z.string().optional(),
|
|
1995
|
-
encPrivateKeyPass: z.string().optional()
|
|
1996
|
-
}).optional(),
|
|
1997
|
-
wantAssertionsSigned: z.boolean().optional(),
|
|
1998
|
-
authnRequestsSigned: z.boolean().optional(),
|
|
1999
|
-
signatureAlgorithm: z.string().optional(),
|
|
2000
|
-
digestAlgorithm: z.string().optional(),
|
|
2001
|
-
identifierFormat: z.string().optional(),
|
|
2002
|
-
privateKey: z.string().optional(),
|
|
2003
|
-
mapping: z.object({
|
|
2004
|
-
id: z.string({}).meta({ description: "Field mapping for user ID (defaults to 'nameID')" }),
|
|
2005
|
-
email: z.string({}).meta({ description: "Field mapping for email (defaults to 'email')" }),
|
|
2006
|
-
emailVerified: z.string({}).meta({ description: "Field mapping for email verification" }).optional(),
|
|
2007
|
-
name: z.string({}).meta({ description: "Field mapping for name (defaults to 'displayName')" }),
|
|
2008
|
-
firstName: z.string({}).meta({ description: "Field mapping for first name (defaults to 'givenName')" }).optional(),
|
|
2009
|
-
lastName: z.string({}).meta({ description: "Field mapping for last name (defaults to 'surname')" }).optional(),
|
|
2010
|
-
extraFields: z.record(z.string(), z.any()).optional()
|
|
2011
|
-
}).optional()
|
|
2012
|
-
}).optional(),
|
|
2013
|
-
organizationId: z.string({}).meta({ description: "If organization plugin is enabled, the organization id to link the provider to" }).optional(),
|
|
2014
|
-
overrideUserInfo: z.boolean({}).meta({ description: "Override user info with the provider info. Defaults to false" }).default(false).optional()
|
|
2015
|
-
});
|
|
2016
2565
|
const registerSSOProvider = (options) => {
|
|
2017
2566
|
return createAuthEndpoint("/sso/register", {
|
|
2018
2567
|
method: "POST",
|
|
2019
|
-
body:
|
|
2568
|
+
body: getRegisterSSOProviderBodySchema(options),
|
|
2020
2569
|
use: [sessionMiddleware],
|
|
2021
|
-
metadata: {
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
description: "OIDC provider
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
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
|
-
type: "string",
|
|
2065
|
-
description: "The client secret for the provider"
|
|
2066
|
-
},
|
|
2067
|
-
authorizationEndpoint: {
|
|
2068
|
-
type: "string",
|
|
2069
|
-
format: "uri",
|
|
2070
|
-
nullable: true,
|
|
2071
|
-
description: "The authorization endpoint URL"
|
|
2072
|
-
},
|
|
2073
|
-
discoveryEndpoint: {
|
|
2074
|
-
type: "string",
|
|
2075
|
-
format: "uri",
|
|
2076
|
-
description: "The discovery endpoint URL"
|
|
2077
|
-
},
|
|
2078
|
-
userInfoEndpoint: {
|
|
2079
|
-
type: "string",
|
|
2080
|
-
format: "uri",
|
|
2081
|
-
nullable: true,
|
|
2082
|
-
description: "The user info endpoint URL"
|
|
2083
|
-
},
|
|
2084
|
-
scopes: {
|
|
2085
|
-
type: "array",
|
|
2086
|
-
items: { type: "string" },
|
|
2087
|
-
nullable: true,
|
|
2088
|
-
description: "The scopes requested from the provider"
|
|
2089
|
-
},
|
|
2090
|
-
tokenEndpoint: {
|
|
2091
|
-
type: "string",
|
|
2092
|
-
format: "uri",
|
|
2093
|
-
nullable: true,
|
|
2094
|
-
description: "The token endpoint URL"
|
|
2095
|
-
},
|
|
2096
|
-
tokenEndpointAuthentication: {
|
|
2097
|
-
type: "string",
|
|
2098
|
-
enum: ["client_secret_post", "client_secret_basic"],
|
|
2099
|
-
nullable: true,
|
|
2100
|
-
description: "Authentication method for the token endpoint"
|
|
2101
|
-
},
|
|
2102
|
-
jwksEndpoint: {
|
|
2103
|
-
type: "string",
|
|
2104
|
-
format: "uri",
|
|
2105
|
-
nullable: true,
|
|
2106
|
-
description: "The JWKS endpoint URL"
|
|
2107
|
-
},
|
|
2108
|
-
mapping: {
|
|
2109
|
-
type: "object",
|
|
2110
|
-
nullable: true,
|
|
2111
|
-
properties: {
|
|
2112
|
-
id: {
|
|
2113
|
-
type: "string",
|
|
2114
|
-
description: "Field mapping for user ID (defaults to 'sub')"
|
|
2115
|
-
},
|
|
2116
|
-
email: {
|
|
2117
|
-
type: "string",
|
|
2118
|
-
description: "Field mapping for email (defaults to 'email')"
|
|
2119
|
-
},
|
|
2120
|
-
emailVerified: {
|
|
2121
|
-
type: "string",
|
|
2122
|
-
nullable: true,
|
|
2123
|
-
description: "Field mapping for email verification (defaults to 'email_verified')"
|
|
2124
|
-
},
|
|
2125
|
-
name: {
|
|
2126
|
-
type: "string",
|
|
2127
|
-
description: "Field mapping for name (defaults to 'name')"
|
|
2128
|
-
},
|
|
2129
|
-
image: {
|
|
2130
|
-
type: "string",
|
|
2131
|
-
nullable: true,
|
|
2132
|
-
description: "Field mapping for image (defaults to 'picture')"
|
|
2133
|
-
},
|
|
2134
|
-
extraFields: {
|
|
2135
|
-
type: "object",
|
|
2136
|
-
additionalProperties: { type: "string" },
|
|
2137
|
-
nullable: true,
|
|
2138
|
-
description: "Additional field mappings"
|
|
2139
|
-
}
|
|
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"
|
|
2140
2613
|
},
|
|
2141
|
-
|
|
2142
|
-
"
|
|
2143
|
-
"
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
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"
|
|
2634
|
+
},
|
|
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"
|
|
2147
2707
|
},
|
|
2148
|
-
|
|
2149
|
-
"
|
|
2150
|
-
|
|
2151
|
-
"
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
providerId: {
|
|
2167
|
-
type: "string",
|
|
2168
|
-
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
|
+
}
|
|
2169
2726
|
},
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
"redirectURI"
|
|
2183
|
-
]
|
|
2184
|
-
} } }
|
|
2185
|
-
} }
|
|
2186
|
-
} }
|
|
2727
|
+
required: [
|
|
2728
|
+
"issuer",
|
|
2729
|
+
"domain",
|
|
2730
|
+
"oidcConfig",
|
|
2731
|
+
"userId",
|
|
2732
|
+
"providerId",
|
|
2733
|
+
"redirectURI"
|
|
2734
|
+
]
|
|
2735
|
+
} } }
|
|
2736
|
+
} }
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2187
2739
|
}, async (ctx) => {
|
|
2188
2740
|
const user = ctx.context.session?.user;
|
|
2189
2741
|
if (!user) throw new APIError("UNAUTHORIZED");
|
|
@@ -2197,13 +2749,13 @@ const registerSSOProvider = (options) => {
|
|
|
2197
2749
|
}]
|
|
2198
2750
|
})).length >= limit) throw new APIError("FORBIDDEN", { message: "You have reached the maximum number of SSO providers" });
|
|
2199
2751
|
const body = ctx.body;
|
|
2200
|
-
|
|
2752
|
+
const additionalFields = parseSSOProviderAdditionalFields(options, body, "create");
|
|
2201
2753
|
if (body.samlConfig?.idpMetadata?.metadata) {
|
|
2202
2754
|
const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
|
|
2203
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)` });
|
|
2204
2756
|
}
|
|
2205
2757
|
if (ctx.body.organizationId) {
|
|
2206
|
-
|
|
2758
|
+
const member = await ctx.context.adapter.findOne({
|
|
2207
2759
|
model: "member",
|
|
2208
2760
|
where: [{
|
|
2209
2761
|
field: "userId",
|
|
@@ -2212,7 +2764,31 @@ const registerSSOProvider = (options) => {
|
|
|
2212
2764
|
field: "organizationId",
|
|
2213
2765
|
value: ctx.body.organizationId
|
|
2214
2766
|
}]
|
|
2215
|
-
})
|
|
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
|
+
}
|
|
2216
2792
|
}
|
|
2217
2793
|
if (await ctx.context.adapter.findOne({
|
|
2218
2794
|
model: "ssoProvider",
|
|
@@ -2224,6 +2800,12 @@ const registerSSOProvider = (options) => {
|
|
|
2224
2800
|
ctx.context.logger.info(`SSO provider creation attempt with existing providerId: ${body.providerId}`);
|
|
2225
2801
|
throw new APIError("UNPROCESSABLE_ENTITY", { message: "SSO provider with this providerId already exists" });
|
|
2226
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
|
+
}
|
|
2227
2809
|
let hydratedOIDCConfig = null;
|
|
2228
2810
|
if (body.oidcConfig && !body.oidcConfig.skipDiscovery) try {
|
|
2229
2811
|
hydratedOIDCConfig = await discoverOIDCConfig({
|
|
@@ -2285,6 +2867,7 @@ const registerSSOProvider = (options) => {
|
|
|
2285
2867
|
signatureAlgorithm: body.samlConfig.signatureAlgorithm,
|
|
2286
2868
|
digestAlgorithm: body.samlConfig.digestAlgorithm
|
|
2287
2869
|
}, options?.saml?.algorithms);
|
|
2870
|
+
validateCertSources(body.samlConfig);
|
|
2288
2871
|
const hasIdpMetadata = body.samlConfig.idpMetadata?.metadata;
|
|
2289
2872
|
let hasEntryPoint = false;
|
|
2290
2873
|
if (body.samlConfig.entryPoint) try {
|
|
@@ -2300,6 +2883,7 @@ const registerSSOProvider = (options) => {
|
|
|
2300
2883
|
issuer: body.issuer,
|
|
2301
2884
|
domain: body.domain,
|
|
2302
2885
|
domainVerified: false,
|
|
2886
|
+
...additionalFields,
|
|
2303
2887
|
oidcConfig: (() => {
|
|
2304
2888
|
const config = buildOIDCConfig();
|
|
2305
2889
|
if (config) {
|
|
@@ -2314,6 +2898,7 @@ const registerSSOProvider = (options) => {
|
|
|
2314
2898
|
entryPoint: body.samlConfig.entryPoint,
|
|
2315
2899
|
cert: body.samlConfig.cert,
|
|
2316
2900
|
audience: body.samlConfig.audience,
|
|
2901
|
+
callbackUrl: body.samlConfig.callbackUrl,
|
|
2317
2902
|
idpMetadata: body.samlConfig.idpMetadata,
|
|
2318
2903
|
spMetadata: body.samlConfig.spMetadata,
|
|
2319
2904
|
wantAssertionsSigned: body.samlConfig.wantAssertionsSigned,
|
|
@@ -2341,7 +2926,7 @@ const registerSSOProvider = (options) => {
|
|
|
2341
2926
|
});
|
|
2342
2927
|
}
|
|
2343
2928
|
const result = {
|
|
2344
|
-
...provider,
|
|
2929
|
+
...filterSSOProviderAdditionalFields(provider, options),
|
|
2345
2930
|
oidcConfig: safeJsonParse(provider.oidcConfig),
|
|
2346
2931
|
samlConfig: safeJsonParse(provider.samlConfig),
|
|
2347
2932
|
redirectURI: getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options),
|
|
@@ -2352,17 +2937,18 @@ const registerSSOProvider = (options) => {
|
|
|
2352
2937
|
});
|
|
2353
2938
|
};
|
|
2354
2939
|
const signInSSOBodySchema = z.object({
|
|
2355
|
-
email: z.string({}).meta({ description: "The email address to sign in with.
|
|
2356
|
-
organizationSlug: z.string({}).meta({ description: "The slug of the organization to sign in with" }).optional(),
|
|
2357
|
-
providerId: z.string({}).meta({ description: "The ID of the provider to sign in with.
|
|
2358
|
-
domain: z.string({}).meta({ description: "The domain of the provider." }).optional(),
|
|
2359
|
-
callbackURL: z.string({}).meta({ description: "The URL to redirect to after
|
|
2360
|
-
errorCallbackURL: z.string({}).meta({ description: "The URL to redirect to
|
|
2361
|
-
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(),
|
|
2362
2947
|
scopes: z.array(z.string(), {}).meta({ description: "Scopes to request from the provider." }).optional(),
|
|
2363
|
-
loginHint: z.string({}).meta({ description: "Login hint to send to the identity provider (e.g., email or identifier). If supported,
|
|
2364
|
-
|
|
2365
|
-
|
|
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()
|
|
2366
2952
|
});
|
|
2367
2953
|
const signInSSO = (options) => {
|
|
2368
2954
|
return createAuthEndpoint("/sign-in/sso", {
|
|
@@ -2377,31 +2963,54 @@ const signInSSO = (options) => {
|
|
|
2377
2963
|
properties: {
|
|
2378
2964
|
email: {
|
|
2379
2965
|
type: "string",
|
|
2380
|
-
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."
|
|
2381
2967
|
},
|
|
2382
|
-
|
|
2968
|
+
organizationSlug: {
|
|
2383
2969
|
type: "string",
|
|
2384
|
-
description: "The
|
|
2970
|
+
description: "The slug of the organization to sign in with."
|
|
2385
2971
|
},
|
|
2386
2972
|
providerId: {
|
|
2387
2973
|
type: "string",
|
|
2388
|
-
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."
|
|
2389
2979
|
},
|
|
2390
2980
|
callbackURL: {
|
|
2391
2981
|
type: "string",
|
|
2392
|
-
description: "The URL to redirect to after
|
|
2982
|
+
description: "The URL to redirect to after successful sign-in."
|
|
2393
2983
|
},
|
|
2394
2984
|
errorCallbackURL: {
|
|
2395
2985
|
type: "string",
|
|
2396
|
-
description: "The URL to redirect to
|
|
2986
|
+
description: "The URL to redirect to if the sign-in flow fails."
|
|
2397
2987
|
},
|
|
2398
2988
|
newUserCallbackURL: {
|
|
2399
2989
|
type: "string",
|
|
2400
|
-
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."
|
|
2401
2996
|
},
|
|
2402
2997
|
loginHint: {
|
|
2403
2998
|
type: "string",
|
|
2404
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."
|
|
2405
3014
|
}
|
|
2406
3015
|
},
|
|
2407
3016
|
required: ["callbackURL"]
|
|
@@ -2444,7 +3053,7 @@ const signInSSO = (options) => {
|
|
|
2444
3053
|
});
|
|
2445
3054
|
let provider = null;
|
|
2446
3055
|
if (options?.defaultSSO?.length) {
|
|
2447
|
-
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));
|
|
2448
3057
|
if (matchingDefault) provider = {
|
|
2449
3058
|
issuer: matchingDefault.samlConfig?.issuer || matchingDefault.oidcConfig?.issuer || "",
|
|
2450
3059
|
providerId: matchingDefault.providerId,
|
|
@@ -2498,7 +3107,8 @@ const signInSSO = (options) => {
|
|
|
2498
3107
|
throw error;
|
|
2499
3108
|
}
|
|
2500
3109
|
if (!config.authorizationEndpoint) throw new APIError("BAD_REQUEST", { message: "Invalid OIDC configuration. Authorization URL not found." });
|
|
2501
|
-
|
|
3110
|
+
if (options?.redirectURI?.trim()) await addOAuthServerContext({ ssoProviderId: provider.providerId });
|
|
3111
|
+
const state = await generateState(ctx);
|
|
2502
3112
|
const redirectURI = getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options);
|
|
2503
3113
|
const authorizationURL = await createAuthorizationURL({
|
|
2504
3114
|
id: provider.issuer,
|
|
@@ -2516,7 +3126,8 @@ const signInSSO = (options) => {
|
|
|
2516
3126
|
"offline_access"
|
|
2517
3127
|
],
|
|
2518
3128
|
loginHint: ctx.body.loginHint || email,
|
|
2519
|
-
authorizationEndpoint: config.authorizationEndpoint
|
|
3129
|
+
authorizationEndpoint: config.authorizationEndpoint,
|
|
3130
|
+
additionalParams: ctx.body.additionalParams
|
|
2520
3131
|
});
|
|
2521
3132
|
return ctx.json({
|
|
2522
3133
|
url: authorizationURL.toString(),
|
|
@@ -2524,10 +3135,11 @@ const signInSSO = (options) => {
|
|
|
2524
3135
|
});
|
|
2525
3136
|
}
|
|
2526
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." });
|
|
2527
3139
|
const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
|
|
2528
3140
|
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
2529
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" });
|
|
2530
|
-
const { state: relayState } = await generateRelayState(ctx, void 0
|
|
3142
|
+
const { state: relayState } = await generateRelayState(ctx, void 0);
|
|
2531
3143
|
const sp = createSP(parsedSamlConfig, ctx.context.baseURL, provider.providerId, { relayState });
|
|
2532
3144
|
const idp = createIdP(parsedSamlConfig);
|
|
2533
3145
|
const loginRequest = sp.createLoginRequest(idp, "redirect");
|
|
@@ -2556,10 +3168,26 @@ const signInSSO = (options) => {
|
|
|
2556
3168
|
};
|
|
2557
3169
|
const callbackSSOQuerySchema = z.object({
|
|
2558
3170
|
code: z.string().optional(),
|
|
2559
|
-
state: z.string(),
|
|
3171
|
+
state: z.string().optional(),
|
|
2560
3172
|
error: z.string().optional(),
|
|
2561
3173
|
error_description: z.string().optional()
|
|
2562
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
|
+
}
|
|
2563
3191
|
/**
|
|
2564
3192
|
* Core OIDC callback handler logic, shared between the per-provider and
|
|
2565
3193
|
* shared callback endpoints. Resolves the provider, exchanges the
|
|
@@ -2576,30 +3204,17 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2576
3204
|
throw ctx.redirect(`${errorURL}?error=invalid_state`);
|
|
2577
3205
|
}
|
|
2578
3206
|
const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
if (!provider) provider = await ctx.context.adapter.findOne({
|
|
2591
|
-
model: "ssoProvider",
|
|
2592
|
-
where: [{
|
|
2593
|
-
field: "providerId",
|
|
2594
|
-
value: providerId
|
|
2595
|
-
}]
|
|
2596
|
-
}).then((res) => {
|
|
2597
|
-
if (!res) return null;
|
|
2598
|
-
return {
|
|
2599
|
-
...res,
|
|
2600
|
-
oidcConfig: safeJsonParse(res.oidcConfig) || void 0
|
|
2601
|
-
};
|
|
2602
|
-
});
|
|
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);
|
|
2603
3218
|
if (!provider) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
|
|
2604
3219
|
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
2605
3220
|
let config = provider.oidcConfig;
|
|
@@ -2620,11 +3235,9 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2620
3235
|
]
|
|
2621
3236
|
};
|
|
2622
3237
|
if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_endpoint_not_found`);
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
let clientAssertionConfig;
|
|
2627
|
-
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") {
|
|
2628
3241
|
let resolved;
|
|
2629
3242
|
const matchingDefault = options?.defaultSSO?.find((p) => p.providerId === provider.providerId && "privateKey" in p && p.privateKey);
|
|
2630
3243
|
if (matchingDefault && "privateKey" in matchingDefault) resolved = matchingDefault.privateKey;
|
|
@@ -2633,55 +3246,73 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2633
3246
|
keyId: config.privateKeyId,
|
|
2634
3247
|
issuer: config.issuer
|
|
2635
3248
|
});
|
|
2636
|
-
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`);
|
|
2637
3250
|
const rawAlg = config.privateKeyAlgorithm ?? resolved.algorithm;
|
|
2638
|
-
const algorithm = rawAlg &&
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
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
|
+
})
|
|
2645
3260
|
};
|
|
2646
3261
|
}
|
|
2647
|
-
const
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
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;
|
|
2659
3283
|
ctx.context.logger.error("Error validating authorization code", e);
|
|
2660
|
-
if (e instanceof
|
|
2661
|
-
|
|
3284
|
+
if (e instanceof DiscoveryError) redirectOIDCError("invalid_provider", e.message);
|
|
3285
|
+
redirectOIDCError("invalid_provider", getOIDCErrorDescription(e, "token_response_error"));
|
|
2662
3286
|
});
|
|
2663
3287
|
if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_response_not_found`);
|
|
2664
3288
|
let userInfo = null;
|
|
2665
3289
|
const mapping = config.mapping || {};
|
|
3290
|
+
let rawProfile;
|
|
2666
3291
|
if (config.userInfoEndpoint) {
|
|
2667
|
-
const userInfoResponse = await
|
|
2668
|
-
|
|
2669
|
-
|
|
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;
|
|
2670
3299
|
userInfo = {
|
|
2671
3300
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, rawUserInfo[value]])),
|
|
2672
3301
|
id: rawUserInfo[mapping.id || "sub"],
|
|
2673
3302
|
email: rawUserInfo[mapping.email || "email"],
|
|
2674
|
-
emailVerified: options?.trustEmailVerified ? rawUserInfo[mapping.emailVerified || "email_verified"] : false,
|
|
3303
|
+
emailVerified: options?.trustEmailVerified ? parseProviderEmailVerified(rawUserInfo[mapping.emailVerified || "email_verified"]) : false,
|
|
2675
3304
|
name: rawUserInfo[mapping.name || "name"],
|
|
2676
3305
|
image: rawUserInfo[mapping.image || "picture"]
|
|
2677
3306
|
};
|
|
2678
3307
|
} else if (tokenResponse.idToken) {
|
|
2679
3308
|
const idToken = decodeJwt(tokenResponse.idToken);
|
|
3309
|
+
rawProfile = idToken;
|
|
2680
3310
|
if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=jwks_endpoint_not_found`);
|
|
2681
|
-
const verified = await
|
|
3311
|
+
const verified = await validateOIDCIdToken(tokenResponse.idToken, config.jwksEndpoint, {
|
|
2682
3312
|
audience: config.clientId,
|
|
2683
3313
|
issuer: provider.issuer
|
|
2684
|
-
}).catch((e) => {
|
|
3314
|
+
}, (url) => ctx.context.isTrustedOrigin(url)).catch((e) => {
|
|
3315
|
+
if (e instanceof DiscoveryError) redirectOIDCError("invalid_provider", e.message);
|
|
2685
3316
|
ctx.context.logger.error(e);
|
|
2686
3317
|
return null;
|
|
2687
3318
|
});
|
|
@@ -2690,37 +3321,67 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2690
3321
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, verified.payload[value]])),
|
|
2691
3322
|
id: idToken[mapping.id || "sub"],
|
|
2692
3323
|
email: idToken[mapping.email || "email"],
|
|
2693
|
-
emailVerified: options?.trustEmailVerified ? idToken[mapping.emailVerified || "email_verified"] : false,
|
|
3324
|
+
emailVerified: options?.trustEmailVerified ? parseProviderEmailVerified(idToken[mapping.emailVerified || "email_verified"]) : false,
|
|
2694
3325
|
name: idToken[mapping.name || "name"],
|
|
2695
3326
|
image: idToken[mapping.image || "picture"]
|
|
2696
3327
|
};
|
|
2697
3328
|
} else throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=user_info_endpoint_not_found`);
|
|
2698
3329
|
if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=missing_user_info`);
|
|
2699
|
-
const
|
|
2700
|
-
const
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
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
|
+
}
|
|
2724
3385
|
const { session, user } = linked.data;
|
|
2725
3386
|
if (options?.provisionUser && (linked.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
|
|
2726
3387
|
user,
|
|
@@ -2733,8 +3394,8 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2733
3394
|
profile: {
|
|
2734
3395
|
providerType: "oidc",
|
|
2735
3396
|
providerId: provider.providerId,
|
|
2736
|
-
accountId:
|
|
2737
|
-
email:
|
|
3397
|
+
accountId: userInfoId,
|
|
3398
|
+
email: userInfoEmail,
|
|
2738
3399
|
emailVerified: Boolean(userInfo.emailVerified),
|
|
2739
3400
|
rawAttributes: userInfo
|
|
2740
3401
|
},
|
|
@@ -2768,9 +3429,86 @@ const callbackSSOEndpointConfig = {
|
|
|
2768
3429
|
}
|
|
2769
3430
|
}
|
|
2770
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
|
+
}
|
|
2771
3507
|
const callbackSSO = (options) => {
|
|
2772
3508
|
return createAuthEndpoint("/sso/callback/:providerId", callbackSSOEndpointConfig, async (ctx) => {
|
|
2773
|
-
|
|
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);
|
|
2774
3512
|
});
|
|
2775
3513
|
};
|
|
2776
3514
|
/**
|
|
@@ -2796,7 +3534,7 @@ const callbackSSOShared = (options) => {
|
|
|
2796
3534
|
const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
|
|
2797
3535
|
throw ctx.redirect(`${errorURL}?error=invalid_state`);
|
|
2798
3536
|
}
|
|
2799
|
-
const providerId = stateData.ssoProviderId;
|
|
3537
|
+
const providerId = stateData.serverContext?.ssoProviderId;
|
|
2800
3538
|
if (!providerId) {
|
|
2801
3539
|
const errorURL = stateData.errorURL || stateData.callbackURL;
|
|
2802
3540
|
throw ctx.redirect(`${errorURL}?error=invalid_state&error_description=missing_provider_id`);
|
|
@@ -2945,7 +3683,7 @@ async function handleLogoutRequest(ctx, sp, idp, relayState, providerId) {
|
|
|
2945
3683
|
if (stored) {
|
|
2946
3684
|
const data = safeJsonParse(stored.value);
|
|
2947
3685
|
if (data) if (!sessionIndex || !data.sessionIndex || sessionIndex === data.sessionIndex) {
|
|
2948
|
-
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 }));
|
|
2949
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));
|
|
2950
3688
|
} else ctx.context.logger.warn("SessionIndex mismatch in LogoutRequest - skipping session deletion", {
|
|
2951
3689
|
providerId,
|
|
@@ -2955,10 +3693,9 @@ async function handleLogoutRequest(ctx, sp, idp, relayState, providerId) {
|
|
|
2955
3693
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(key).catch((e) => ctx.context.logger.warn("Failed to delete SAML session key during SLO", e));
|
|
2956
3694
|
}
|
|
2957
3695
|
const currentSession = await getSessionFromCtx(ctx);
|
|
2958
|
-
if (currentSession?.session) await ctx.context.internalAdapter.deleteSession(currentSession.session.
|
|
3696
|
+
if (currentSession?.session) await ctx.context.internalAdapter.deleteSession(currentSession.session.token);
|
|
2959
3697
|
deleteSessionCookie(ctx);
|
|
2960
|
-
const
|
|
2961
|
-
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 || "");
|
|
2962
3699
|
if (binding === "post" && res.entityEndpoint) return createSAMLPostForm(res.entityEndpoint, "SAMLResponse", res.context, relayState);
|
|
2963
3700
|
throw ctx.redirect(res.context);
|
|
2964
3701
|
}
|
|
@@ -3011,7 +3748,7 @@ const initiateSLO = (options) => {
|
|
|
3011
3748
|
});
|
|
3012
3749
|
if (samlSessionKey) await ctx.context.internalAdapter.deleteVerificationByIdentifier(samlSessionKey).catch((e) => ctx.context.logger.warn("Failed to delete SAML session key during logout", e));
|
|
3013
3750
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(sessionLookupKey).catch((e) => ctx.context.logger.warn("Failed to delete session lookup key during logout", e));
|
|
3014
|
-
await ctx.context.internalAdapter.deleteSession(session.session.
|
|
3751
|
+
await ctx.context.internalAdapter.deleteSession(session.session.token);
|
|
3015
3752
|
deleteSessionCookie(ctx);
|
|
3016
3753
|
throw ctx.redirect(logoutRequest.context);
|
|
3017
3754
|
});
|
|
@@ -3028,7 +3765,42 @@ saml.setSchemaValidator({ async validate(xml) {
|
|
|
3028
3765
|
* which won't have a matching Origin header.
|
|
3029
3766
|
*/
|
|
3030
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"
|
|
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
|
+
}
|
|
3031
3802
|
function sso(options) {
|
|
3803
|
+
assertNoAdditionalFieldCollisions(options);
|
|
3032
3804
|
const optionsWithStore = options;
|
|
3033
3805
|
let endpoints = {
|
|
3034
3806
|
spMetadata: spMetadata(optionsWithStore),
|
|
@@ -3039,8 +3811,8 @@ function sso(options) {
|
|
|
3039
3811
|
acsEndpoint: acsEndpoint(optionsWithStore),
|
|
3040
3812
|
sloEndpoint: sloEndpoint(optionsWithStore),
|
|
3041
3813
|
initiateSLO: initiateSLO(optionsWithStore),
|
|
3042
|
-
listSSOProviders: listSSOProviders(),
|
|
3043
|
-
getSSOProvider: getSSOProvider(),
|
|
3814
|
+
listSSOProviders: listSSOProviders(optionsWithStore),
|
|
3815
|
+
getSSOProvider: getSSOProvider(optionsWithStore),
|
|
3044
3816
|
updateSSOProvider: updateSSOProvider(optionsWithStore),
|
|
3045
3817
|
deleteSSOProvider: deleteSSOProvider()
|
|
3046
3818
|
};
|
|
@@ -3097,22 +3869,22 @@ function sso(options) {
|
|
|
3097
3869
|
}]
|
|
3098
3870
|
},
|
|
3099
3871
|
schema: { ssoProvider: {
|
|
3100
|
-
modelName: options?.modelName ?? "ssoProvider",
|
|
3872
|
+
modelName: options?.modelName ?? options?.schema?.ssoProvider?.modelName ?? "ssoProvider",
|
|
3101
3873
|
fields: {
|
|
3102
3874
|
issuer: {
|
|
3103
3875
|
type: "string",
|
|
3104
3876
|
required: true,
|
|
3105
|
-
fieldName: options?.fields?.issuer ?? "issuer"
|
|
3877
|
+
fieldName: options?.fields?.issuer ?? options?.schema?.ssoProvider?.fields?.issuer ?? "issuer"
|
|
3106
3878
|
},
|
|
3107
3879
|
oidcConfig: {
|
|
3108
3880
|
type: "string",
|
|
3109
3881
|
required: false,
|
|
3110
|
-
fieldName: options?.fields?.oidcConfig ?? "oidcConfig"
|
|
3882
|
+
fieldName: options?.fields?.oidcConfig ?? options?.schema?.ssoProvider?.fields?.oidcConfig ?? "oidcConfig"
|
|
3111
3883
|
},
|
|
3112
3884
|
samlConfig: {
|
|
3113
3885
|
type: "string",
|
|
3114
3886
|
required: false,
|
|
3115
|
-
fieldName: options?.fields?.samlConfig ?? "samlConfig"
|
|
3887
|
+
fieldName: options?.fields?.samlConfig ?? options?.schema?.ssoProvider?.fields?.samlConfig ?? "samlConfig"
|
|
3116
3888
|
},
|
|
3117
3889
|
userId: {
|
|
3118
3890
|
type: "string",
|
|
@@ -3120,30 +3892,33 @@ function sso(options) {
|
|
|
3120
3892
|
model: "user",
|
|
3121
3893
|
field: "id"
|
|
3122
3894
|
},
|
|
3123
|
-
fieldName: options?.fields?.userId ?? "userId"
|
|
3895
|
+
fieldName: options?.fields?.userId ?? options?.schema?.ssoProvider?.fields?.userId ?? "userId"
|
|
3124
3896
|
},
|
|
3125
3897
|
providerId: {
|
|
3126
3898
|
type: "string",
|
|
3127
3899
|
required: true,
|
|
3128
3900
|
unique: true,
|
|
3129
|
-
fieldName: options?.fields?.providerId ?? "providerId"
|
|
3901
|
+
fieldName: options?.fields?.providerId ?? options?.schema?.ssoProvider?.fields?.providerId ?? "providerId"
|
|
3130
3902
|
},
|
|
3131
3903
|
organizationId: {
|
|
3132
3904
|
type: "string",
|
|
3133
3905
|
required: false,
|
|
3134
|
-
fieldName: options?.fields?.organizationId ?? "organizationId"
|
|
3906
|
+
fieldName: options?.fields?.organizationId ?? options?.schema?.ssoProvider?.fields?.organizationId ?? "organizationId"
|
|
3135
3907
|
},
|
|
3136
3908
|
domain: {
|
|
3137
3909
|
type: "string",
|
|
3138
3910
|
required: true,
|
|
3139
|
-
fieldName: options?.fields?.domain ?? "domain"
|
|
3911
|
+
fieldName: options?.fields?.domain ?? options?.schema?.ssoProvider?.fields?.domain ?? "domain"
|
|
3140
3912
|
},
|
|
3141
3913
|
...options?.domainVerification?.enabled ? { domainVerified: {
|
|
3142
3914
|
type: "boolean",
|
|
3143
|
-
required: false
|
|
3144
|
-
|
|
3915
|
+
required: false,
|
|
3916
|
+
fieldName: options?.schema?.ssoProvider?.fields?.domainVerified ?? "domainVerified"
|
|
3917
|
+
} } : {},
|
|
3918
|
+
...options?.schema?.ssoProvider?.additionalFields ?? {}
|
|
3145
3919
|
}
|
|
3146
3920
|
} },
|
|
3921
|
+
$Infer: { SSOProvider: {} },
|
|
3147
3922
|
options
|
|
3148
3923
|
};
|
|
3149
3924
|
}
|