@better-auth/sso 1.4.17 → 1.4.19
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/.turbo/turbo-build.log +10 -8
- package/dist/client.d.mts +7 -2
- package/dist/client.mjs +7 -2
- package/dist/client.mjs.map +1 -0
- package/dist/{index-XUgmj4eH.d.mts → index-D-VInsst.d.mts} +387 -8
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1214 -657
- package/dist/index.mjs.map +1 -0
- package/package.json +4 -3
- package/src/client.ts +5 -1
- package/src/domain-verification.test.ts +46 -4
- package/src/index.ts +45 -6
- package/src/linking/org-assignment.ts +30 -15
- package/src/oidc.test.ts +1 -3
- package/src/providers.test.ts +1326 -0
- package/src/routes/domain-verification.ts +34 -12
- package/src/routes/providers.ts +567 -0
- package/src/routes/schemas.ts +95 -0
- package/src/routes/sso.ts +302 -118
- package/src/saml-state.ts +78 -0
- package/src/saml.test.ts +1660 -229
- package/src/types.ts +13 -2
- package/src/utils.test.ts +103 -0
- package/src/utils.ts +45 -5
- package/tsdown.config.ts +1 -0
package/dist/index.mjs
CHANGED
|
@@ -1,16 +1,68 @@
|
|
|
1
|
-
import { APIError, createAuthEndpoint, createAuthMiddleware, sessionMiddleware } from "better-auth/api";
|
|
1
|
+
import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
|
|
2
2
|
import { XMLParser, XMLValidator } from "fast-xml-parser";
|
|
3
3
|
import * as saml from "samlify";
|
|
4
|
+
import { X509Certificate } from "node:crypto";
|
|
4
5
|
import { generateRandomString } from "better-auth/crypto";
|
|
5
6
|
import * as z$1 from "zod/v4";
|
|
6
7
|
import z from "zod/v4";
|
|
8
|
+
import { base64 } from "@better-auth/utils/base64";
|
|
7
9
|
import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
|
|
8
|
-
import { HIDE_METADATA, createAuthorizationURL, generateState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
|
|
10
|
+
import { HIDE_METADATA, createAuthorizationURL, generateGenericState, generateState, parseGenericState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
|
|
9
11
|
import { setSessionCookie } from "better-auth/cookies";
|
|
10
12
|
import { handleOAuthUserInfo } from "better-auth/oauth2";
|
|
11
13
|
import { decodeJwt } from "jose";
|
|
12
|
-
import { base64 } from "@better-auth/utils/base64";
|
|
13
14
|
|
|
15
|
+
//#region src/utils.ts
|
|
16
|
+
/**
|
|
17
|
+
* Safely parses a value that might be a JSON string or already a parsed object.
|
|
18
|
+
* This handles cases where ORMs like Drizzle might return already parsed objects
|
|
19
|
+
* instead of JSON strings from TEXT/JSON columns.
|
|
20
|
+
*
|
|
21
|
+
* @param value - The value to parse (string, object, null, or undefined)
|
|
22
|
+
* @returns The parsed object or null
|
|
23
|
+
* @throws Error if string parsing fails
|
|
24
|
+
*/
|
|
25
|
+
function safeJsonParse(value) {
|
|
26
|
+
if (!value) return null;
|
|
27
|
+
if (typeof value === "object") return value;
|
|
28
|
+
if (typeof value === "string") try {
|
|
29
|
+
return JSON.parse(value);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
throw new Error(`Failed to parse JSON: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Checks if a domain matches any domain in a comma-separated list.
|
|
37
|
+
*/
|
|
38
|
+
const domainMatches = (searchDomain, domainList) => {
|
|
39
|
+
const search = searchDomain.toLowerCase();
|
|
40
|
+
return domainList.split(",").map((d) => d.trim().toLowerCase()).filter(Boolean).some((d) => search === d || search.endsWith(`.${d}`));
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Validates email domain against allowed domain(s).
|
|
44
|
+
* Supports comma-separated domains for multi-domain SSO.
|
|
45
|
+
*/
|
|
46
|
+
const validateEmailDomain = (email, domain) => {
|
|
47
|
+
const emailDomain = email.split("@")[1]?.toLowerCase();
|
|
48
|
+
if (!emailDomain || !domain) return false;
|
|
49
|
+
return domainMatches(emailDomain, domain);
|
|
50
|
+
};
|
|
51
|
+
function parseCertificate(certPem) {
|
|
52
|
+
const cert = new X509Certificate(certPem.includes("-----BEGIN") ? certPem : `-----BEGIN CERTIFICATE-----\n${certPem}\n-----END CERTIFICATE-----`);
|
|
53
|
+
return {
|
|
54
|
+
fingerprintSha256: cert.fingerprint256,
|
|
55
|
+
notBefore: cert.validFrom,
|
|
56
|
+
notAfter: cert.validTo,
|
|
57
|
+
publicKeyAlgorithm: cert.publicKey.asymmetricKeyType?.toUpperCase() || "UNKNOWN"
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function maskClientId(clientId) {
|
|
61
|
+
if (clientId.length <= 4) return "****";
|
|
62
|
+
return `****${clientId.slice(-4)}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
//#endregion
|
|
14
66
|
//#region src/linking/org-assignment.ts
|
|
15
67
|
/**
|
|
16
68
|
* Assigns a user to an organization based on the SSO provider's organizationId.
|
|
@@ -20,7 +72,7 @@ async function assignOrganizationFromProvider(ctx, options) {
|
|
|
20
72
|
const { user, profile, provider, token, provisioningOptions } = options;
|
|
21
73
|
if (!provider.organizationId) return;
|
|
22
74
|
if (provisioningOptions?.disabled) return;
|
|
23
|
-
if (!ctx.context.options.plugins?.
|
|
75
|
+
if (!(ctx.context.options.plugins?.some((plugin) => plugin.id === "organization") ?? false)) return;
|
|
24
76
|
if (await ctx.context.adapter.findOne({
|
|
25
77
|
model: "member",
|
|
26
78
|
where: [{
|
|
@@ -58,7 +110,7 @@ async function assignOrganizationFromProvider(ctx, options) {
|
|
|
58
110
|
async function assignOrganizationByDomain(ctx, options) {
|
|
59
111
|
const { user, provisioningOptions, domainVerification } = options;
|
|
60
112
|
if (provisioningOptions?.disabled) return;
|
|
61
|
-
if (!ctx.context.options.plugins?.
|
|
113
|
+
if (!(ctx.context.options.plugins?.some((plugin) => plugin.id === "organization") ?? false)) return;
|
|
62
114
|
const domain = user.email.split("@")[1];
|
|
63
115
|
if (!domain) return;
|
|
64
116
|
const whereClause = [{
|
|
@@ -69,10 +121,17 @@ async function assignOrganizationByDomain(ctx, options) {
|
|
|
69
121
|
field: "domainVerified",
|
|
70
122
|
value: true
|
|
71
123
|
});
|
|
72
|
-
|
|
124
|
+
let ssoProvider = await ctx.context.adapter.findOne({
|
|
73
125
|
model: "ssoProvider",
|
|
74
126
|
where: whereClause
|
|
75
127
|
});
|
|
128
|
+
if (!ssoProvider) ssoProvider = (await ctx.context.adapter.findMany({
|
|
129
|
+
model: "ssoProvider",
|
|
130
|
+
where: domainVerification?.enabled ? [{
|
|
131
|
+
field: "domainVerified",
|
|
132
|
+
value: true
|
|
133
|
+
}] : []
|
|
134
|
+
})).find((p) => domainMatches(domain, p.domain)) ?? null;
|
|
76
135
|
if (!ssoProvider || !ssoProvider.organizationId) return;
|
|
77
136
|
if (await ctx.context.adapter.findOne({
|
|
78
137
|
model: "member",
|
|
@@ -102,7 +161,12 @@ async function assignOrganizationByDomain(ctx, options) {
|
|
|
102
161
|
|
|
103
162
|
//#endregion
|
|
104
163
|
//#region src/routes/domain-verification.ts
|
|
164
|
+
const DNS_LABEL_MAX_LENGTH = 63;
|
|
165
|
+
const DEFAULT_TOKEN_PREFIX = "better-auth-token";
|
|
105
166
|
const domainVerificationBodySchema = z$1.object({ providerId: z$1.string() });
|
|
167
|
+
function getVerificationIdentifier(options, providerId) {
|
|
168
|
+
return `_${options.domainVerification?.tokenPrefix || DEFAULT_TOKEN_PREFIX}-${providerId}`;
|
|
169
|
+
}
|
|
106
170
|
const requestDomainVerification = (options) => {
|
|
107
171
|
return createAuthEndpoint("/sso/request-domain-verification", {
|
|
108
172
|
method: "POST",
|
|
@@ -150,11 +214,12 @@ const requestDomainVerification = (options) => {
|
|
|
150
214
|
message: "Domain has already been verified",
|
|
151
215
|
code: "DOMAIN_VERIFIED"
|
|
152
216
|
});
|
|
217
|
+
const identifier = getVerificationIdentifier(options, provider.providerId);
|
|
153
218
|
const activeVerification = await ctx.context.adapter.findOne({
|
|
154
219
|
model: "verification",
|
|
155
220
|
where: [{
|
|
156
221
|
field: "identifier",
|
|
157
|
-
value:
|
|
222
|
+
value: identifier
|
|
158
223
|
}, {
|
|
159
224
|
field: "expiresAt",
|
|
160
225
|
value: /* @__PURE__ */ new Date(),
|
|
@@ -169,7 +234,7 @@ const requestDomainVerification = (options) => {
|
|
|
169
234
|
await ctx.context.adapter.create({
|
|
170
235
|
model: "verification",
|
|
171
236
|
data: {
|
|
172
|
-
identifier
|
|
237
|
+
identifier,
|
|
173
238
|
createdAt: /* @__PURE__ */ new Date(),
|
|
174
239
|
updatedAt: /* @__PURE__ */ new Date(),
|
|
175
240
|
value: domainVerificationToken,
|
|
@@ -228,11 +293,16 @@ const verifyDomain = (options) => {
|
|
|
228
293
|
message: "Domain has already been verified",
|
|
229
294
|
code: "DOMAIN_VERIFIED"
|
|
230
295
|
});
|
|
296
|
+
const identifier = getVerificationIdentifier(options, provider.providerId);
|
|
297
|
+
if (identifier.length > DNS_LABEL_MAX_LENGTH) throw new APIError("BAD_REQUEST", {
|
|
298
|
+
message: `Verification identifier exceeds the DNS label limit of ${DNS_LABEL_MAX_LENGTH} characters`,
|
|
299
|
+
code: "IDENTIFIER_TOO_LONG"
|
|
300
|
+
});
|
|
231
301
|
const activeVerification = await ctx.context.adapter.findOne({
|
|
232
302
|
model: "verification",
|
|
233
303
|
where: [{
|
|
234
304
|
field: "identifier",
|
|
235
|
-
value:
|
|
305
|
+
value: identifier
|
|
236
306
|
}, {
|
|
237
307
|
field: "expiresAt",
|
|
238
308
|
value: /* @__PURE__ */ new Date(),
|
|
@@ -255,7 +325,8 @@ const verifyDomain = (options) => {
|
|
|
255
325
|
});
|
|
256
326
|
}
|
|
257
327
|
try {
|
|
258
|
-
|
|
328
|
+
const hostname = new URL(provider.domain).hostname;
|
|
329
|
+
records = (await dns.resolveTxt(`${identifier}.${hostname}`)).flat();
|
|
259
330
|
} catch (error) {
|
|
260
331
|
ctx.context.logger.warn("DNS resolution failure while validating domain ownership", error);
|
|
261
332
|
}
|
|
@@ -318,637 +389,1023 @@ const DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024;
|
|
|
318
389
|
const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024;
|
|
319
390
|
|
|
320
391
|
//#endregion
|
|
321
|
-
//#region src/
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
if (
|
|
392
|
+
//#region src/saml/parser.ts
|
|
393
|
+
const xmlParser = new XMLParser({
|
|
394
|
+
ignoreAttributes: false,
|
|
395
|
+
attributeNamePrefix: "@_",
|
|
396
|
+
removeNSPrefix: true,
|
|
397
|
+
processEntities: false
|
|
398
|
+
});
|
|
399
|
+
function findNode(obj, nodeName) {
|
|
400
|
+
if (!obj || typeof obj !== "object") return null;
|
|
401
|
+
const record = obj;
|
|
402
|
+
if (nodeName in record) return record[nodeName];
|
|
403
|
+
for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
|
|
404
|
+
const found = findNode(item, nodeName);
|
|
405
|
+
if (found) return found;
|
|
335
406
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
"
|
|
344
|
-
|
|
345
|
-
|
|
407
|
+
else if (typeof value === "object" && value !== null) {
|
|
408
|
+
const found = findNode(value, nodeName);
|
|
409
|
+
if (found) return found;
|
|
410
|
+
}
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
function countAllNodes(obj, nodeName) {
|
|
414
|
+
if (!obj || typeof obj !== "object") return 0;
|
|
415
|
+
let count = 0;
|
|
416
|
+
const record = obj;
|
|
417
|
+
if (nodeName in record) {
|
|
418
|
+
const node = record[nodeName];
|
|
419
|
+
count += Array.isArray(node) ? node.length : 1;
|
|
420
|
+
}
|
|
421
|
+
for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) count += countAllNodes(item, nodeName);
|
|
422
|
+
else if (typeof value === "object" && value !== null) count += countAllNodes(value, nodeName);
|
|
423
|
+
return count;
|
|
424
|
+
}
|
|
346
425
|
|
|
347
426
|
//#endregion
|
|
348
|
-
//#region src/
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
427
|
+
//#region src/saml/algorithms.ts
|
|
428
|
+
const SignatureAlgorithm = {
|
|
429
|
+
RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
|
|
430
|
+
RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
|
|
431
|
+
RSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
|
|
432
|
+
RSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
|
|
433
|
+
ECDSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
|
|
434
|
+
ECDSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
|
|
435
|
+
ECDSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"
|
|
436
|
+
};
|
|
437
|
+
const DigestAlgorithm = {
|
|
438
|
+
SHA1: "http://www.w3.org/2000/09/xmldsig#sha1",
|
|
439
|
+
SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
|
|
440
|
+
SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
|
|
441
|
+
SHA512: "http://www.w3.org/2001/04/xmlenc#sha512"
|
|
442
|
+
};
|
|
443
|
+
const KeyEncryptionAlgorithm = {
|
|
444
|
+
RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
|
|
445
|
+
RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
|
|
446
|
+
RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep"
|
|
447
|
+
};
|
|
448
|
+
const DataEncryptionAlgorithm = {
|
|
449
|
+
TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
|
|
450
|
+
AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
|
|
451
|
+
AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
|
|
452
|
+
AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
|
|
453
|
+
AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
|
|
454
|
+
AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
|
|
455
|
+
AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
|
|
456
|
+
};
|
|
457
|
+
const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
|
|
458
|
+
const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS = [KeyEncryptionAlgorithm.RSA_1_5];
|
|
459
|
+
const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS = [DataEncryptionAlgorithm.TRIPLEDES_CBC];
|
|
460
|
+
const DEPRECATED_DIGEST_ALGORITHMS = [DigestAlgorithm.SHA1];
|
|
461
|
+
const SECURE_SIGNATURE_ALGORITHMS = [
|
|
462
|
+
SignatureAlgorithm.RSA_SHA256,
|
|
463
|
+
SignatureAlgorithm.RSA_SHA384,
|
|
464
|
+
SignatureAlgorithm.RSA_SHA512,
|
|
465
|
+
SignatureAlgorithm.ECDSA_SHA256,
|
|
466
|
+
SignatureAlgorithm.ECDSA_SHA384,
|
|
467
|
+
SignatureAlgorithm.ECDSA_SHA512
|
|
468
|
+
];
|
|
469
|
+
const SECURE_DIGEST_ALGORITHMS = [
|
|
470
|
+
DigestAlgorithm.SHA256,
|
|
471
|
+
DigestAlgorithm.SHA384,
|
|
472
|
+
DigestAlgorithm.SHA512
|
|
473
|
+
];
|
|
474
|
+
const SHORT_FORM_SIGNATURE_TO_URI = {
|
|
475
|
+
sha1: SignatureAlgorithm.RSA_SHA1,
|
|
476
|
+
sha256: SignatureAlgorithm.RSA_SHA256,
|
|
477
|
+
sha384: SignatureAlgorithm.RSA_SHA384,
|
|
478
|
+
sha512: SignatureAlgorithm.RSA_SHA512,
|
|
479
|
+
"rsa-sha1": SignatureAlgorithm.RSA_SHA1,
|
|
480
|
+
"rsa-sha256": SignatureAlgorithm.RSA_SHA256,
|
|
481
|
+
"rsa-sha384": SignatureAlgorithm.RSA_SHA384,
|
|
482
|
+
"rsa-sha512": SignatureAlgorithm.RSA_SHA512,
|
|
483
|
+
"ecdsa-sha256": SignatureAlgorithm.ECDSA_SHA256,
|
|
484
|
+
"ecdsa-sha384": SignatureAlgorithm.ECDSA_SHA384,
|
|
485
|
+
"ecdsa-sha512": SignatureAlgorithm.ECDSA_SHA512
|
|
486
|
+
};
|
|
487
|
+
const SHORT_FORM_DIGEST_TO_URI = {
|
|
488
|
+
sha1: DigestAlgorithm.SHA1,
|
|
489
|
+
sha256: DigestAlgorithm.SHA256,
|
|
490
|
+
sha384: DigestAlgorithm.SHA384,
|
|
491
|
+
sha512: DigestAlgorithm.SHA512
|
|
492
|
+
};
|
|
493
|
+
function normalizeSignatureAlgorithm(alg) {
|
|
494
|
+
return SHORT_FORM_SIGNATURE_TO_URI[alg.toLowerCase()] ?? alg;
|
|
406
495
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
*
|
|
410
|
-
* @param url - The discovery URL to validate
|
|
411
|
-
* @param isTrustedOrigin - Origin verification tester function
|
|
412
|
-
* @throws DiscoveryError if URL is invalid
|
|
413
|
-
*/
|
|
414
|
-
function validateDiscoveryUrl(url, isTrustedOrigin) {
|
|
415
|
-
const discoveryEndpoint = parseURL("discoveryEndpoint", url).toString();
|
|
416
|
-
if (!isTrustedOrigin(discoveryEndpoint)) throw new DiscoveryError("discovery_untrusted_origin", `The main discovery endpoint "${discoveryEndpoint}" is not trusted by your trusted origins configuration.`, { url: discoveryEndpoint });
|
|
496
|
+
function normalizeDigestAlgorithm(alg) {
|
|
497
|
+
return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
|
|
417
498
|
}
|
|
418
|
-
|
|
419
|
-
* Fetch the OIDC discovery document from the IdP.
|
|
420
|
-
*
|
|
421
|
-
* @param url - The discovery endpoint URL
|
|
422
|
-
* @param timeout - Request timeout in milliseconds
|
|
423
|
-
* @returns The parsed discovery document
|
|
424
|
-
* @throws DiscoveryError on network errors, timeouts, or invalid responses
|
|
425
|
-
*/
|
|
426
|
-
async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
|
|
499
|
+
function extractEncryptionAlgorithms(xml) {
|
|
427
500
|
try {
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
} catch (error) {
|
|
455
|
-
if (error instanceof DiscoveryError) throw error;
|
|
456
|
-
if (error instanceof Error && error.name === "AbortError") throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
|
|
457
|
-
url,
|
|
458
|
-
timeout
|
|
501
|
+
const parsed = xmlParser.parse(xml);
|
|
502
|
+
const keyAlg = (findNode(parsed, "EncryptedKey")?.EncryptionMethod)?.["@_Algorithm"];
|
|
503
|
+
const dataAlg = (findNode(parsed, "EncryptedData")?.EncryptionMethod)?.["@_Algorithm"];
|
|
504
|
+
return {
|
|
505
|
+
keyEncryption: keyAlg || null,
|
|
506
|
+
dataEncryption: dataAlg || null
|
|
507
|
+
};
|
|
508
|
+
} catch {
|
|
509
|
+
return {
|
|
510
|
+
keyEncryption: null,
|
|
511
|
+
dataEncryption: null
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
function hasEncryptedAssertion(xml) {
|
|
516
|
+
try {
|
|
517
|
+
return findNode(xmlParser.parse(xml), "EncryptedAssertion") !== null;
|
|
518
|
+
} catch {
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
function handleDeprecatedAlgorithm(message, behavior, errorCode) {
|
|
523
|
+
switch (behavior) {
|
|
524
|
+
case "reject": throw new APIError("BAD_REQUEST", {
|
|
525
|
+
message,
|
|
526
|
+
code: errorCode
|
|
459
527
|
});
|
|
460
|
-
|
|
528
|
+
case "warn":
|
|
529
|
+
console.warn(`[SAML Security Warning] ${message}`);
|
|
530
|
+
break;
|
|
531
|
+
case "allow": break;
|
|
461
532
|
}
|
|
462
533
|
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
for (const field of REQUIRED_DISCOVERY_FIELDS) if (!doc[field]) missingFields.push(field);
|
|
481
|
-
if (missingFields.length > 0) throw new DiscoveryError("discovery_incomplete", `Discovery document is missing required fields: ${missingFields.join(", ")}`, { missingFields });
|
|
482
|
-
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}"`, {
|
|
483
|
-
discovered: doc.issuer,
|
|
484
|
-
configured: configuredIssuer
|
|
534
|
+
function validateSignatureAlgorithm(algorithm, options = {}) {
|
|
535
|
+
if (!algorithm) return;
|
|
536
|
+
const { onDeprecated = "warn", allowedSignatureAlgorithms } = options;
|
|
537
|
+
if (allowedSignatureAlgorithms) {
|
|
538
|
+
if (!allowedSignatureAlgorithms.includes(algorithm)) throw new APIError("BAD_REQUEST", {
|
|
539
|
+
message: `SAML signature algorithm not in allow-list: ${algorithm}`,
|
|
540
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
541
|
+
});
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(algorithm)) {
|
|
545
|
+
handleDeprecatedAlgorithm(`SAML response uses deprecated signature algorithm: ${algorithm}. Please configure your IdP to use SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (!SECURE_SIGNATURE_ALGORITHMS.includes(algorithm)) throw new APIError("BAD_REQUEST", {
|
|
549
|
+
message: `SAML signature algorithm not recognized: ${algorithm}`,
|
|
550
|
+
code: "SAML_UNKNOWN_ALGORITHM"
|
|
485
551
|
});
|
|
486
552
|
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
553
|
+
function validateEncryptionAlgorithms(algorithms, options = {}) {
|
|
554
|
+
const { onDeprecated = "warn", allowedKeyEncryptionAlgorithms, allowedDataEncryptionAlgorithms } = options;
|
|
555
|
+
const { keyEncryption, dataEncryption } = algorithms;
|
|
556
|
+
if (keyEncryption) {
|
|
557
|
+
if (allowedKeyEncryptionAlgorithms) {
|
|
558
|
+
if (!allowedKeyEncryptionAlgorithms.includes(keyEncryption)) throw new APIError("BAD_REQUEST", {
|
|
559
|
+
message: `SAML key encryption algorithm not in allow-list: ${keyEncryption}`,
|
|
560
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
561
|
+
});
|
|
562
|
+
} 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");
|
|
563
|
+
}
|
|
564
|
+
if (dataEncryption) {
|
|
565
|
+
if (allowedDataEncryptionAlgorithms) {
|
|
566
|
+
if (!allowedDataEncryptionAlgorithms.includes(dataEncryption)) throw new APIError("BAD_REQUEST", {
|
|
567
|
+
message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
|
|
568
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
569
|
+
});
|
|
570
|
+
} 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");
|
|
571
|
+
}
|
|
505
572
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
* @param endpoint The url to validate
|
|
510
|
-
* @param issuer The issuer base url
|
|
511
|
-
* @param isTrustedOrigin - Origin verification tester function
|
|
512
|
-
* @returns
|
|
513
|
-
*/
|
|
514
|
-
function normalizeAndValidateUrl(name, endpoint, issuer, isTrustedOrigin) {
|
|
515
|
-
const url = normalizeUrl(name, endpoint, issuer);
|
|
516
|
-
if (!isTrustedOrigin(url)) throw new DiscoveryError("discovery_untrusted_origin", `The ${name} "${url}" is not trusted by your trusted origins configuration.`, {
|
|
517
|
-
endpoint: name,
|
|
518
|
-
url
|
|
519
|
-
});
|
|
520
|
-
return url;
|
|
573
|
+
function validateSAMLAlgorithms(response, options) {
|
|
574
|
+
validateSignatureAlgorithm(response.sigAlg, options);
|
|
575
|
+
if (hasEncryptedAssertion(response.samlContent)) validateEncryptionAlgorithms(extractEncryptionAlgorithms(response.samlContent), options);
|
|
521
576
|
}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
577
|
+
function validateConfigAlgorithms(config, options = {}) {
|
|
578
|
+
const { onDeprecated = "warn", allowedSignatureAlgorithms, allowedDigestAlgorithms } = options;
|
|
579
|
+
if (config.signatureAlgorithm) {
|
|
580
|
+
const normalized = normalizeSignatureAlgorithm(config.signatureAlgorithm);
|
|
581
|
+
if (allowedSignatureAlgorithms) {
|
|
582
|
+
if (!allowedSignatureAlgorithms.map(normalizeSignatureAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
583
|
+
message: `SAML signature algorithm not in allow-list: ${config.signatureAlgorithm}`,
|
|
584
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
585
|
+
});
|
|
586
|
+
} 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");
|
|
587
|
+
else if (!SECURE_SIGNATURE_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
588
|
+
message: `SAML signature algorithm not recognized: ${config.signatureAlgorithm}`,
|
|
589
|
+
code: "SAML_UNKNOWN_ALGORITHM"
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
if (config.digestAlgorithm) {
|
|
593
|
+
const normalized = normalizeDigestAlgorithm(config.digestAlgorithm);
|
|
594
|
+
if (allowedDigestAlgorithms) {
|
|
595
|
+
if (!allowedDigestAlgorithms.map(normalizeDigestAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
596
|
+
message: `SAML digest algorithm not in allow-list: ${config.digestAlgorithm}`,
|
|
597
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
598
|
+
});
|
|
599
|
+
} 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");
|
|
600
|
+
else if (!SECURE_DIGEST_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
601
|
+
message: `SAML digest algorithm not recognized: ${config.digestAlgorithm}`,
|
|
602
|
+
code: "SAML_UNKNOWN_ALGORITHM"
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
//#endregion
|
|
608
|
+
//#region src/saml/assertions.ts
|
|
609
|
+
/** @lintignore used in tests */
|
|
610
|
+
function countAssertions(xml) {
|
|
611
|
+
let parsed;
|
|
531
612
|
try {
|
|
532
|
-
|
|
613
|
+
parsed = xmlParser.parse(xml);
|
|
533
614
|
} catch {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
615
|
+
throw new APIError("BAD_REQUEST", {
|
|
616
|
+
message: "Failed to parse SAML response XML",
|
|
617
|
+
code: "SAML_INVALID_XML"
|
|
618
|
+
});
|
|
538
619
|
}
|
|
620
|
+
const assertions = countAllNodes(parsed, "Assertion");
|
|
621
|
+
const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
|
|
622
|
+
return {
|
|
623
|
+
assertions,
|
|
624
|
+
encryptedAssertions,
|
|
625
|
+
total: assertions + encryptedAssertions
|
|
626
|
+
};
|
|
539
627
|
}
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
*
|
|
543
|
-
* @param name the url name
|
|
544
|
-
* @param endpoint the endpoint url
|
|
545
|
-
* @param [base] optional base path
|
|
546
|
-
* @returns
|
|
547
|
-
*/
|
|
548
|
-
function parseURL(name, endpoint, base) {
|
|
549
|
-
let endpointURL;
|
|
628
|
+
function validateSingleAssertion(samlResponse) {
|
|
629
|
+
let xml;
|
|
550
630
|
try {
|
|
551
|
-
|
|
552
|
-
if (
|
|
553
|
-
} catch
|
|
554
|
-
throw new
|
|
631
|
+
xml = new TextDecoder().decode(base64.decode(samlResponse));
|
|
632
|
+
if (!xml.includes("<")) throw new Error("Not XML");
|
|
633
|
+
} catch {
|
|
634
|
+
throw new APIError("BAD_REQUEST", {
|
|
635
|
+
message: "Invalid base64-encoded SAML response",
|
|
636
|
+
code: "SAML_INVALID_ENCODING"
|
|
637
|
+
});
|
|
555
638
|
}
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
639
|
+
const counts = countAssertions(xml);
|
|
640
|
+
if (counts.total === 0) throw new APIError("BAD_REQUEST", {
|
|
641
|
+
message: "SAML response contains no assertions",
|
|
642
|
+
code: "SAML_NO_ASSERTION"
|
|
643
|
+
});
|
|
644
|
+
if (counts.total > 1) throw new APIError("BAD_REQUEST", {
|
|
645
|
+
message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
|
|
646
|
+
code: "SAML_MULTIPLE_ASSERTIONS"
|
|
559
647
|
});
|
|
560
|
-
}
|
|
561
|
-
/**
|
|
562
|
-
* Select the token endpoint authentication method.
|
|
563
|
-
*
|
|
564
|
-
* @param doc - The discovery document
|
|
565
|
-
* @param existing - Existing authentication method from config
|
|
566
|
-
* @returns The selected authentication method
|
|
567
|
-
*/
|
|
568
|
-
function selectTokenEndpointAuthMethod(doc, existing) {
|
|
569
|
-
if (existing) return existing;
|
|
570
|
-
const supported = doc.token_endpoint_auth_methods_supported;
|
|
571
|
-
if (!supported || supported.length === 0) return "client_secret_basic";
|
|
572
|
-
if (supported.includes("client_secret_basic")) return "client_secret_basic";
|
|
573
|
-
if (supported.includes("client_secret_post")) return "client_secret_post";
|
|
574
|
-
return "client_secret_basic";
|
|
575
|
-
}
|
|
576
|
-
/**
|
|
577
|
-
* Check if a provider configuration needs runtime discovery.
|
|
578
|
-
*
|
|
579
|
-
* Returns true if we need discovery at runtime to complete the token exchange
|
|
580
|
-
* and validation. Specifically checks for:
|
|
581
|
-
* - `tokenEndpoint` - required for exchanging authorization code for tokens
|
|
582
|
-
* - `jwksEndpoint` - required for validating ID token signatures
|
|
583
|
-
*
|
|
584
|
-
* Note: `authorizationEndpoint` is handled separately in the sign-in flow,
|
|
585
|
-
* so it's not checked here.
|
|
586
|
-
*
|
|
587
|
-
* @param config - Partial OIDC config from the provider
|
|
588
|
-
* @returns true if runtime discovery should be performed
|
|
589
|
-
*/
|
|
590
|
-
function needsRuntimeDiscovery(config) {
|
|
591
|
-
if (!config) return true;
|
|
592
|
-
return !config.tokenEndpoint || !config.jwksEndpoint;
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
//#endregion
|
|
596
|
-
//#region src/oidc/errors.ts
|
|
597
|
-
/**
|
|
598
|
-
* OIDC Discovery Error Mapping
|
|
599
|
-
*
|
|
600
|
-
* Maps DiscoveryError codes to appropriate APIError responses.
|
|
601
|
-
* Used at the boundary between the discovery pipeline and HTTP handlers.
|
|
602
|
-
*/
|
|
603
|
-
/**
|
|
604
|
-
* Maps a DiscoveryError to an appropriate APIError for HTTP responses.
|
|
605
|
-
*
|
|
606
|
-
* Error code mapping:
|
|
607
|
-
* - discovery_invalid_url → 400 BAD_REQUEST
|
|
608
|
-
* - discovery_not_found → 400 BAD_REQUEST
|
|
609
|
-
* - discovery_invalid_json → 400 BAD_REQUEST
|
|
610
|
-
* - discovery_incomplete → 400 BAD_REQUEST
|
|
611
|
-
* - issuer_mismatch → 400 BAD_REQUEST
|
|
612
|
-
* - unsupported_token_auth_method → 400 BAD_REQUEST
|
|
613
|
-
* - discovery_timeout → 502 BAD_GATEWAY
|
|
614
|
-
* - discovery_unexpected_error → 502 BAD_GATEWAY
|
|
615
|
-
*
|
|
616
|
-
* @param error - The DiscoveryError to map
|
|
617
|
-
* @returns An APIError with appropriate status and message
|
|
618
|
-
*/
|
|
619
|
-
function mapDiscoveryErrorToAPIError(error) {
|
|
620
|
-
switch (error.code) {
|
|
621
|
-
case "discovery_timeout": return new APIError("BAD_GATEWAY", {
|
|
622
|
-
message: `OIDC discovery timed out: ${error.message}`,
|
|
623
|
-
code: error.code
|
|
624
|
-
});
|
|
625
|
-
case "discovery_unexpected_error": return new APIError("BAD_GATEWAY", {
|
|
626
|
-
message: `OIDC discovery failed: ${error.message}`,
|
|
627
|
-
code: error.code
|
|
628
|
-
});
|
|
629
|
-
case "discovery_not_found": return new APIError("BAD_REQUEST", {
|
|
630
|
-
message: `OIDC discovery endpoint not found. The issuer may not support OIDC discovery, or the URL is incorrect. ${error.message}`,
|
|
631
|
-
code: error.code
|
|
632
|
-
});
|
|
633
|
-
case "discovery_invalid_url": return new APIError("BAD_REQUEST", {
|
|
634
|
-
message: `Invalid OIDC discovery URL: ${error.message}`,
|
|
635
|
-
code: error.code
|
|
636
|
-
});
|
|
637
|
-
case "discovery_untrusted_origin": return new APIError("BAD_REQUEST", {
|
|
638
|
-
message: `Untrusted OIDC discovery URL: ${error.message}`,
|
|
639
|
-
code: error.code
|
|
640
|
-
});
|
|
641
|
-
case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
|
|
642
|
-
message: `OIDC discovery returned invalid data: ${error.message}`,
|
|
643
|
-
code: error.code
|
|
644
|
-
});
|
|
645
|
-
case "discovery_incomplete": return new APIError("BAD_REQUEST", {
|
|
646
|
-
message: `OIDC discovery document is missing required fields: ${error.message}`,
|
|
647
|
-
code: error.code
|
|
648
|
-
});
|
|
649
|
-
case "issuer_mismatch": return new APIError("BAD_REQUEST", {
|
|
650
|
-
message: `OIDC issuer mismatch: ${error.message}`,
|
|
651
|
-
code: error.code
|
|
652
|
-
});
|
|
653
|
-
case "unsupported_token_auth_method": return new APIError("BAD_REQUEST", {
|
|
654
|
-
message: `Incompatible OIDC provider: ${error.message}`,
|
|
655
|
-
code: error.code
|
|
656
|
-
});
|
|
657
|
-
default:
|
|
658
|
-
error.code;
|
|
659
|
-
return new APIError("INTERNAL_SERVER_ERROR", {
|
|
660
|
-
message: `Unexpected discovery error: ${error.message}`,
|
|
661
|
-
code: "discovery_unexpected_error"
|
|
662
|
-
});
|
|
663
|
-
}
|
|
664
648
|
}
|
|
665
649
|
|
|
666
650
|
//#endregion
|
|
667
|
-
//#region src/
|
|
668
|
-
const
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
651
|
+
//#region src/routes/schemas.ts
|
|
652
|
+
const oidcMappingSchema = z.object({
|
|
653
|
+
id: z.string().optional(),
|
|
654
|
+
email: z.string().optional(),
|
|
655
|
+
emailVerified: z.string().optional(),
|
|
656
|
+
name: z.string().optional(),
|
|
657
|
+
image: z.string().optional(),
|
|
658
|
+
extraFields: z.record(z.string(), z.any()).optional()
|
|
659
|
+
}).optional();
|
|
660
|
+
const samlMappingSchema = z.object({
|
|
661
|
+
id: z.string().optional(),
|
|
662
|
+
email: z.string().optional(),
|
|
663
|
+
emailVerified: z.string().optional(),
|
|
664
|
+
name: z.string().optional(),
|
|
665
|
+
firstName: z.string().optional(),
|
|
666
|
+
lastName: z.string().optional(),
|
|
667
|
+
extraFields: z.record(z.string(), z.any()).optional()
|
|
668
|
+
}).optional();
|
|
669
|
+
const oidcConfigSchema = z.object({
|
|
670
|
+
clientId: z.string().optional(),
|
|
671
|
+
clientSecret: z.string().optional(),
|
|
672
|
+
authorizationEndpoint: z.string().url().optional(),
|
|
673
|
+
tokenEndpoint: z.string().url().optional(),
|
|
674
|
+
userInfoEndpoint: z.string().url().optional(),
|
|
675
|
+
tokenEndpointAuthentication: z.enum(["client_secret_post", "client_secret_basic"]).optional(),
|
|
676
|
+
jwksEndpoint: z.string().url().optional(),
|
|
677
|
+
discoveryEndpoint: z.string().url().optional(),
|
|
678
|
+
scopes: z.array(z.string()).optional(),
|
|
679
|
+
pkce: z.boolean().optional(),
|
|
680
|
+
overrideUserInfo: z.boolean().optional(),
|
|
681
|
+
mapping: oidcMappingSchema
|
|
682
|
+
});
|
|
683
|
+
const samlConfigSchema = z.object({
|
|
684
|
+
entryPoint: z.string().url().optional(),
|
|
685
|
+
cert: z.string().optional(),
|
|
686
|
+
callbackUrl: z.string().url().optional(),
|
|
687
|
+
audience: z.string().optional(),
|
|
688
|
+
idpMetadata: z.object({
|
|
689
|
+
metadata: z.string().optional(),
|
|
690
|
+
entityID: z.string().optional(),
|
|
691
|
+
cert: z.string().optional(),
|
|
692
|
+
privateKey: z.string().optional(),
|
|
693
|
+
privateKeyPass: z.string().optional(),
|
|
694
|
+
isAssertionEncrypted: z.boolean().optional(),
|
|
695
|
+
encPrivateKey: z.string().optional(),
|
|
696
|
+
encPrivateKeyPass: z.string().optional(),
|
|
697
|
+
singleSignOnService: z.array(z.object({
|
|
698
|
+
Binding: z.string(),
|
|
699
|
+
Location: z.string().url()
|
|
700
|
+
})).optional()
|
|
701
|
+
}).optional(),
|
|
702
|
+
spMetadata: z.object({
|
|
703
|
+
metadata: z.string().optional(),
|
|
704
|
+
entityID: z.string().optional(),
|
|
705
|
+
binding: z.string().optional(),
|
|
706
|
+
privateKey: z.string().optional(),
|
|
707
|
+
privateKeyPass: z.string().optional(),
|
|
708
|
+
isAssertionEncrypted: z.boolean().optional(),
|
|
709
|
+
encPrivateKey: z.string().optional(),
|
|
710
|
+
encPrivateKeyPass: z.string().optional()
|
|
711
|
+
}).optional(),
|
|
712
|
+
wantAssertionsSigned: z.boolean().optional(),
|
|
713
|
+
signatureAlgorithm: z.string().optional(),
|
|
714
|
+
digestAlgorithm: z.string().optional(),
|
|
715
|
+
identifierFormat: z.string().optional(),
|
|
716
|
+
privateKey: z.string().optional(),
|
|
717
|
+
decryptionPvk: z.string().optional(),
|
|
718
|
+
additionalParams: z.record(z.string(), z.any()).optional(),
|
|
719
|
+
mapping: samlMappingSchema
|
|
720
|
+
});
|
|
721
|
+
const updateSSOProviderBodySchema = z.object({
|
|
722
|
+
issuer: z.string().url().optional(),
|
|
723
|
+
domain: z.string().optional(),
|
|
724
|
+
oidcConfig: oidcConfigSchema.optional(),
|
|
725
|
+
samlConfig: samlConfigSchema.optional()
|
|
673
726
|
});
|
|
674
|
-
function findNode(obj, nodeName) {
|
|
675
|
-
if (!obj || typeof obj !== "object") return null;
|
|
676
|
-
const record = obj;
|
|
677
|
-
if (nodeName in record) return record[nodeName];
|
|
678
|
-
for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
|
|
679
|
-
const found = findNode(item, nodeName);
|
|
680
|
-
if (found) return found;
|
|
681
|
-
}
|
|
682
|
-
else if (typeof value === "object" && value !== null) {
|
|
683
|
-
const found = findNode(value, nodeName);
|
|
684
|
-
if (found) return found;
|
|
685
|
-
}
|
|
686
|
-
return null;
|
|
687
|
-
}
|
|
688
|
-
function countAllNodes(obj, nodeName) {
|
|
689
|
-
if (!obj || typeof obj !== "object") return 0;
|
|
690
|
-
let count = 0;
|
|
691
|
-
const record = obj;
|
|
692
|
-
if (nodeName in record) {
|
|
693
|
-
const node = record[nodeName];
|
|
694
|
-
count += Array.isArray(node) ? node.length : 1;
|
|
695
|
-
}
|
|
696
|
-
for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) count += countAllNodes(item, nodeName);
|
|
697
|
-
else if (typeof value === "object" && value !== null) count += countAllNodes(value, nodeName);
|
|
698
|
-
return count;
|
|
699
|
-
}
|
|
700
727
|
|
|
701
728
|
//#endregion
|
|
702
|
-
//#region src/
|
|
703
|
-
const
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
};
|
|
718
|
-
const KeyEncryptionAlgorithm = {
|
|
719
|
-
RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
|
|
720
|
-
RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
|
|
721
|
-
RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep"
|
|
722
|
-
};
|
|
723
|
-
const DataEncryptionAlgorithm = {
|
|
724
|
-
TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
|
|
725
|
-
AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
|
|
726
|
-
AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
|
|
727
|
-
AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
|
|
728
|
-
AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
|
|
729
|
-
AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
|
|
730
|
-
AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
|
|
731
|
-
};
|
|
732
|
-
const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
|
|
733
|
-
const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS = [KeyEncryptionAlgorithm.RSA_1_5];
|
|
734
|
-
const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS = [DataEncryptionAlgorithm.TRIPLEDES_CBC];
|
|
735
|
-
const DEPRECATED_DIGEST_ALGORITHMS = [DigestAlgorithm.SHA1];
|
|
736
|
-
const SECURE_SIGNATURE_ALGORITHMS = [
|
|
737
|
-
SignatureAlgorithm.RSA_SHA256,
|
|
738
|
-
SignatureAlgorithm.RSA_SHA384,
|
|
739
|
-
SignatureAlgorithm.RSA_SHA512,
|
|
740
|
-
SignatureAlgorithm.ECDSA_SHA256,
|
|
741
|
-
SignatureAlgorithm.ECDSA_SHA384,
|
|
742
|
-
SignatureAlgorithm.ECDSA_SHA512
|
|
743
|
-
];
|
|
744
|
-
const SECURE_DIGEST_ALGORITHMS = [
|
|
745
|
-
DigestAlgorithm.SHA256,
|
|
746
|
-
DigestAlgorithm.SHA384,
|
|
747
|
-
DigestAlgorithm.SHA512
|
|
748
|
-
];
|
|
749
|
-
const SHORT_FORM_SIGNATURE_TO_URI = {
|
|
750
|
-
sha1: SignatureAlgorithm.RSA_SHA1,
|
|
751
|
-
sha256: SignatureAlgorithm.RSA_SHA256,
|
|
752
|
-
sha384: SignatureAlgorithm.RSA_SHA384,
|
|
753
|
-
sha512: SignatureAlgorithm.RSA_SHA512,
|
|
754
|
-
"rsa-sha1": SignatureAlgorithm.RSA_SHA1,
|
|
755
|
-
"rsa-sha256": SignatureAlgorithm.RSA_SHA256,
|
|
756
|
-
"rsa-sha384": SignatureAlgorithm.RSA_SHA384,
|
|
757
|
-
"rsa-sha512": SignatureAlgorithm.RSA_SHA512,
|
|
758
|
-
"ecdsa-sha256": SignatureAlgorithm.ECDSA_SHA256,
|
|
759
|
-
"ecdsa-sha384": SignatureAlgorithm.ECDSA_SHA384,
|
|
760
|
-
"ecdsa-sha512": SignatureAlgorithm.ECDSA_SHA512
|
|
761
|
-
};
|
|
762
|
-
const SHORT_FORM_DIGEST_TO_URI = {
|
|
763
|
-
sha1: DigestAlgorithm.SHA1,
|
|
764
|
-
sha256: DigestAlgorithm.SHA256,
|
|
765
|
-
sha384: DigestAlgorithm.SHA384,
|
|
766
|
-
sha512: DigestAlgorithm.SHA512
|
|
767
|
-
};
|
|
768
|
-
function normalizeSignatureAlgorithm(alg) {
|
|
769
|
-
return SHORT_FORM_SIGNATURE_TO_URI[alg.toLowerCase()] ?? alg;
|
|
729
|
+
//#region src/routes/providers.ts
|
|
730
|
+
const ADMIN_ROLES = ["owner", "admin"];
|
|
731
|
+
async function isOrgAdmin(ctx, userId, organizationId) {
|
|
732
|
+
const member = await ctx.context.adapter.findOne({
|
|
733
|
+
model: "member",
|
|
734
|
+
where: [{
|
|
735
|
+
field: "userId",
|
|
736
|
+
value: userId
|
|
737
|
+
}, {
|
|
738
|
+
field: "organizationId",
|
|
739
|
+
value: organizationId
|
|
740
|
+
}]
|
|
741
|
+
});
|
|
742
|
+
if (!member) return false;
|
|
743
|
+
return member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()));
|
|
770
744
|
}
|
|
771
|
-
function
|
|
772
|
-
|
|
745
|
+
async function batchCheckOrgAdmin(ctx, userId, organizationIds) {
|
|
746
|
+
if (organizationIds.length === 0) return /* @__PURE__ */ new Set();
|
|
747
|
+
const members = await ctx.context.adapter.findMany({
|
|
748
|
+
model: "member",
|
|
749
|
+
where: [{
|
|
750
|
+
field: "userId",
|
|
751
|
+
value: userId
|
|
752
|
+
}, {
|
|
753
|
+
field: "organizationId",
|
|
754
|
+
value: organizationIds,
|
|
755
|
+
operator: "in"
|
|
756
|
+
}]
|
|
757
|
+
});
|
|
758
|
+
const adminOrgIds = /* @__PURE__ */ new Set();
|
|
759
|
+
for (const member of members) if (member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()))) adminOrgIds.add(member.organizationId);
|
|
760
|
+
return adminOrgIds;
|
|
773
761
|
}
|
|
774
|
-
function
|
|
762
|
+
function sanitizeProvider(provider, baseURL) {
|
|
763
|
+
let oidcConfig = null;
|
|
764
|
+
let samlConfig = null;
|
|
775
765
|
try {
|
|
776
|
-
|
|
777
|
-
const keyAlg = (findNode(parsed, "EncryptedKey")?.EncryptionMethod)?.["@_Algorithm"];
|
|
778
|
-
const dataAlg = (findNode(parsed, "EncryptedData")?.EncryptionMethod)?.["@_Algorithm"];
|
|
779
|
-
return {
|
|
780
|
-
keyEncryption: keyAlg || null,
|
|
781
|
-
dataEncryption: dataAlg || null
|
|
782
|
-
};
|
|
766
|
+
oidcConfig = safeJsonParse(provider.oidcConfig);
|
|
783
767
|
} catch {
|
|
784
|
-
|
|
785
|
-
keyEncryption: null,
|
|
786
|
-
dataEncryption: null
|
|
787
|
-
};
|
|
768
|
+
oidcConfig = null;
|
|
788
769
|
}
|
|
789
|
-
}
|
|
790
|
-
function hasEncryptedAssertion(xml) {
|
|
791
770
|
try {
|
|
792
|
-
|
|
771
|
+
samlConfig = safeJsonParse(provider.samlConfig);
|
|
793
772
|
} catch {
|
|
794
|
-
|
|
773
|
+
samlConfig = null;
|
|
795
774
|
}
|
|
775
|
+
const type = samlConfig ? "saml" : "oidc";
|
|
776
|
+
return {
|
|
777
|
+
providerId: provider.providerId,
|
|
778
|
+
type,
|
|
779
|
+
issuer: provider.issuer,
|
|
780
|
+
domain: provider.domain,
|
|
781
|
+
organizationId: provider.organizationId || null,
|
|
782
|
+
domainVerified: provider.domainVerified ?? false,
|
|
783
|
+
oidcConfig: oidcConfig ? {
|
|
784
|
+
discoveryEndpoint: oidcConfig.discoveryEndpoint,
|
|
785
|
+
clientIdLastFour: maskClientId(oidcConfig.clientId),
|
|
786
|
+
pkce: oidcConfig.pkce,
|
|
787
|
+
authorizationEndpoint: oidcConfig.authorizationEndpoint,
|
|
788
|
+
tokenEndpoint: oidcConfig.tokenEndpoint,
|
|
789
|
+
userInfoEndpoint: oidcConfig.userInfoEndpoint,
|
|
790
|
+
jwksEndpoint: oidcConfig.jwksEndpoint,
|
|
791
|
+
scopes: oidcConfig.scopes,
|
|
792
|
+
tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication
|
|
793
|
+
} : void 0,
|
|
794
|
+
samlConfig: samlConfig ? {
|
|
795
|
+
entryPoint: samlConfig.entryPoint,
|
|
796
|
+
callbackUrl: samlConfig.callbackUrl,
|
|
797
|
+
audience: samlConfig.audience,
|
|
798
|
+
wantAssertionsSigned: samlConfig.wantAssertionsSigned,
|
|
799
|
+
identifierFormat: samlConfig.identifierFormat,
|
|
800
|
+
signatureAlgorithm: samlConfig.signatureAlgorithm,
|
|
801
|
+
digestAlgorithm: samlConfig.digestAlgorithm,
|
|
802
|
+
certificate: (() => {
|
|
803
|
+
try {
|
|
804
|
+
return parseCertificate(samlConfig.cert);
|
|
805
|
+
} catch {
|
|
806
|
+
return { error: "Failed to parse certificate" };
|
|
807
|
+
}
|
|
808
|
+
})()
|
|
809
|
+
} : void 0,
|
|
810
|
+
spMetadataUrl: `${baseURL}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(provider.providerId)}`
|
|
811
|
+
};
|
|
796
812
|
}
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
}
|
|
813
|
+
const listSSOProviders = () => {
|
|
814
|
+
return createAuthEndpoint("/sso/providers", {
|
|
815
|
+
method: "GET",
|
|
816
|
+
use: [sessionMiddleware],
|
|
817
|
+
metadata: { openapi: {
|
|
818
|
+
operationId: "listSSOProviders",
|
|
819
|
+
summary: "List SSO providers",
|
|
820
|
+
description: "Returns a list of SSO providers the user has access to",
|
|
821
|
+
responses: { "200": { description: "List of SSO providers" } }
|
|
822
|
+
} }
|
|
823
|
+
}, async (ctx) => {
|
|
824
|
+
const userId = ctx.context.session.user.id;
|
|
825
|
+
const allProviders = await ctx.context.adapter.findMany({ model: "ssoProvider" });
|
|
826
|
+
const userOwnedProviders = allProviders.filter((p) => p.userId === userId && !p.organizationId);
|
|
827
|
+
const orgProviders = allProviders.filter((p) => p.organizationId !== null && p.organizationId !== void 0);
|
|
828
|
+
const orgPluginEnabled = ctx.context.options.plugins?.some(({ id }) => id === "organization") ?? false;
|
|
829
|
+
let accessibleProviders = [...userOwnedProviders];
|
|
830
|
+
if (orgPluginEnabled && orgProviders.length > 0) {
|
|
831
|
+
const adminOrgIds = await batchCheckOrgAdmin(ctx, userId, [...new Set(orgProviders.map((p) => p.organizationId).filter((id) => id !== null && id !== void 0))]);
|
|
832
|
+
const orgAccessibleProviders = orgProviders.filter((provider) => provider.organizationId && adminOrgIds.has(provider.organizationId));
|
|
833
|
+
accessibleProviders = [...accessibleProviders, ...orgAccessibleProviders];
|
|
834
|
+
} else if (!orgPluginEnabled) {
|
|
835
|
+
const userOwnedOrgProviders = orgProviders.filter((p) => p.userId === userId);
|
|
836
|
+
accessibleProviders = [...accessibleProviders, ...userOwnedOrgProviders];
|
|
837
|
+
}
|
|
838
|
+
const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL));
|
|
839
|
+
return ctx.json({ providers });
|
|
840
|
+
});
|
|
841
|
+
};
|
|
842
|
+
const getSSOProviderParamsSchema = z.object({ providerId: z.string() });
|
|
843
|
+
async function checkProviderAccess(ctx, providerId) {
|
|
844
|
+
const userId = ctx.context.session.user.id;
|
|
845
|
+
const provider = await ctx.context.adapter.findOne({
|
|
846
|
+
model: "ssoProvider",
|
|
847
|
+
where: [{
|
|
848
|
+
field: "providerId",
|
|
849
|
+
value: providerId
|
|
850
|
+
}]
|
|
851
|
+
});
|
|
852
|
+
if (!provider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
|
|
853
|
+
let hasAccess = false;
|
|
854
|
+
if (provider.organizationId) if (ctx.context.options.plugins?.some(({ id }) => id === "organization") ?? false) hasAccess = await isOrgAdmin(ctx, userId, provider.organizationId);
|
|
855
|
+
else hasAccess = provider.userId === userId;
|
|
856
|
+
else hasAccess = provider.userId === userId;
|
|
857
|
+
if (!hasAccess) throw new APIError("FORBIDDEN", { message: "You don't have access to this provider" });
|
|
858
|
+
return provider;
|
|
808
859
|
}
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
860
|
+
const getSSOProvider = () => {
|
|
861
|
+
return createAuthEndpoint("/sso/providers/:providerId", {
|
|
862
|
+
method: "GET",
|
|
863
|
+
use: [sessionMiddleware],
|
|
864
|
+
params: getSSOProviderParamsSchema,
|
|
865
|
+
metadata: { openapi: {
|
|
866
|
+
operationId: "getSSOProvider",
|
|
867
|
+
summary: "Get SSO provider details",
|
|
868
|
+
description: "Returns sanitized details for a specific SSO provider",
|
|
869
|
+
responses: {
|
|
870
|
+
"200": { description: "SSO provider details" },
|
|
871
|
+
"404": { description: "Provider not found" },
|
|
872
|
+
"403": { description: "Access denied" }
|
|
873
|
+
}
|
|
874
|
+
} }
|
|
875
|
+
}, async (ctx) => {
|
|
876
|
+
const { providerId } = ctx.params;
|
|
877
|
+
const provider = await checkProviderAccess(ctx, providerId);
|
|
878
|
+
return ctx.json(sanitizeProvider(provider, ctx.context.baseURL));
|
|
879
|
+
});
|
|
880
|
+
};
|
|
881
|
+
function parseAndValidateConfig(configString, configType) {
|
|
882
|
+
let config = null;
|
|
883
|
+
try {
|
|
884
|
+
config = safeJsonParse(configString);
|
|
885
|
+
} catch {
|
|
886
|
+
config = null;
|
|
887
|
+
}
|
|
888
|
+
if (!config) throw new APIError("BAD_REQUEST", { message: `Cannot update ${configType} config for a provider that doesn't have ${configType} configured` });
|
|
889
|
+
return config;
|
|
890
|
+
}
|
|
891
|
+
function mergeSAMLConfig(current, updates, issuer) {
|
|
892
|
+
return {
|
|
893
|
+
...current,
|
|
894
|
+
...updates,
|
|
895
|
+
issuer,
|
|
896
|
+
entryPoint: updates.entryPoint ?? current.entryPoint,
|
|
897
|
+
cert: updates.cert ?? current.cert,
|
|
898
|
+
callbackUrl: updates.callbackUrl ?? current.callbackUrl,
|
|
899
|
+
spMetadata: updates.spMetadata ?? current.spMetadata,
|
|
900
|
+
idpMetadata: updates.idpMetadata ?? current.idpMetadata,
|
|
901
|
+
mapping: updates.mapping ?? current.mapping,
|
|
902
|
+
audience: updates.audience ?? current.audience,
|
|
903
|
+
wantAssertionsSigned: updates.wantAssertionsSigned ?? current.wantAssertionsSigned,
|
|
904
|
+
identifierFormat: updates.identifierFormat ?? current.identifierFormat,
|
|
905
|
+
signatureAlgorithm: updates.signatureAlgorithm ?? current.signatureAlgorithm,
|
|
906
|
+
digestAlgorithm: updates.digestAlgorithm ?? current.digestAlgorithm
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
function mergeOIDCConfig(current, updates, issuer) {
|
|
910
|
+
return {
|
|
911
|
+
...current,
|
|
912
|
+
...updates,
|
|
913
|
+
issuer,
|
|
914
|
+
pkce: updates.pkce ?? current.pkce ?? true,
|
|
915
|
+
clientId: updates.clientId ?? current.clientId,
|
|
916
|
+
clientSecret: updates.clientSecret ?? current.clientSecret,
|
|
917
|
+
discoveryEndpoint: updates.discoveryEndpoint ?? current.discoveryEndpoint,
|
|
918
|
+
mapping: updates.mapping ?? current.mapping,
|
|
919
|
+
scopes: updates.scopes ?? current.scopes,
|
|
920
|
+
authorizationEndpoint: updates.authorizationEndpoint ?? current.authorizationEndpoint,
|
|
921
|
+
tokenEndpoint: updates.tokenEndpoint ?? current.tokenEndpoint,
|
|
922
|
+
userInfoEndpoint: updates.userInfoEndpoint ?? current.userInfoEndpoint,
|
|
923
|
+
jwksEndpoint: updates.jwksEndpoint ?? current.jwksEndpoint,
|
|
924
|
+
tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
const updateSSOProvider = (options) => {
|
|
928
|
+
return createAuthEndpoint("/sso/providers/:providerId", {
|
|
929
|
+
method: "PATCH",
|
|
930
|
+
use: [sessionMiddleware],
|
|
931
|
+
params: getSSOProviderParamsSchema,
|
|
932
|
+
body: updateSSOProviderBodySchema,
|
|
933
|
+
metadata: { openapi: {
|
|
934
|
+
operationId: "updateSSOProvider",
|
|
935
|
+
summary: "Update SSO provider",
|
|
936
|
+
description: "Partially update an SSO provider. Only provided fields are updated. If domain changes, domainVerified is reset to false.",
|
|
937
|
+
responses: {
|
|
938
|
+
"200": { description: "SSO provider updated successfully" },
|
|
939
|
+
"404": { description: "Provider not found" },
|
|
940
|
+
"403": { description: "Access denied" }
|
|
941
|
+
}
|
|
942
|
+
} }
|
|
943
|
+
}, async (ctx) => {
|
|
944
|
+
const { providerId } = ctx.params;
|
|
945
|
+
const body = ctx.body;
|
|
946
|
+
const { issuer, domain, samlConfig, oidcConfig } = body;
|
|
947
|
+
if (!issuer && !domain && !samlConfig && !oidcConfig) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
|
|
948
|
+
const existingProvider = await checkProviderAccess(ctx, providerId);
|
|
949
|
+
const updateData = {};
|
|
950
|
+
if (body.issuer !== void 0) updateData.issuer = body.issuer;
|
|
951
|
+
if (body.domain !== void 0) {
|
|
952
|
+
updateData.domain = body.domain;
|
|
953
|
+
if (body.domain !== existingProvider.domain) updateData.domainVerified = false;
|
|
954
|
+
}
|
|
955
|
+
if (body.samlConfig) {
|
|
956
|
+
if (body.samlConfig.idpMetadata?.metadata) {
|
|
957
|
+
const maxMetadataSize = options?.saml?.maxMetadataSize ?? DEFAULT_MAX_SAML_METADATA_SIZE;
|
|
958
|
+
if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
|
|
959
|
+
}
|
|
960
|
+
if (body.samlConfig.signatureAlgorithm !== void 0 || body.samlConfig.digestAlgorithm !== void 0) validateConfigAlgorithms({
|
|
961
|
+
signatureAlgorithm: body.samlConfig.signatureAlgorithm,
|
|
962
|
+
digestAlgorithm: body.samlConfig.digestAlgorithm
|
|
963
|
+
}, options?.saml?.algorithms);
|
|
964
|
+
const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
|
|
965
|
+
const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
|
|
966
|
+
updateData.samlConfig = JSON.stringify(updatedSamlConfig);
|
|
967
|
+
}
|
|
968
|
+
if (body.oidcConfig) {
|
|
969
|
+
const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
|
|
970
|
+
const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
|
|
971
|
+
updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
|
|
972
|
+
}
|
|
973
|
+
await ctx.context.adapter.update({
|
|
974
|
+
model: "ssoProvider",
|
|
975
|
+
where: [{
|
|
976
|
+
field: "providerId",
|
|
977
|
+
value: providerId
|
|
978
|
+
}],
|
|
979
|
+
update: updateData
|
|
816
980
|
});
|
|
817
|
-
|
|
981
|
+
const fullProvider = await ctx.context.adapter.findOne({
|
|
982
|
+
model: "ssoProvider",
|
|
983
|
+
where: [{
|
|
984
|
+
field: "providerId",
|
|
985
|
+
value: providerId
|
|
986
|
+
}]
|
|
987
|
+
});
|
|
988
|
+
if (!fullProvider) throw new APIError("NOT_FOUND", { message: "Provider not found after update" });
|
|
989
|
+
return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL));
|
|
990
|
+
});
|
|
991
|
+
};
|
|
992
|
+
const deleteSSOProvider = () => {
|
|
993
|
+
return createAuthEndpoint("/sso/providers/:providerId", {
|
|
994
|
+
method: "DELETE",
|
|
995
|
+
use: [sessionMiddleware],
|
|
996
|
+
params: getSSOProviderParamsSchema,
|
|
997
|
+
metadata: { openapi: {
|
|
998
|
+
operationId: "deleteSSOProvider",
|
|
999
|
+
summary: "Delete SSO provider",
|
|
1000
|
+
description: "Deletes an SSO provider",
|
|
1001
|
+
responses: {
|
|
1002
|
+
"200": { description: "SSO provider deleted successfully" },
|
|
1003
|
+
"404": { description: "Provider not found" },
|
|
1004
|
+
"403": { description: "Access denied" }
|
|
1005
|
+
}
|
|
1006
|
+
} }
|
|
1007
|
+
}, async (ctx) => {
|
|
1008
|
+
const { providerId } = ctx.params;
|
|
1009
|
+
await checkProviderAccess(ctx, providerId);
|
|
1010
|
+
await ctx.context.adapter.delete({
|
|
1011
|
+
model: "ssoProvider",
|
|
1012
|
+
where: [{
|
|
1013
|
+
field: "providerId",
|
|
1014
|
+
value: providerId
|
|
1015
|
+
}]
|
|
1016
|
+
});
|
|
1017
|
+
return ctx.json({ success: true });
|
|
1018
|
+
});
|
|
1019
|
+
};
|
|
1020
|
+
|
|
1021
|
+
//#endregion
|
|
1022
|
+
//#region src/oidc/types.ts
|
|
1023
|
+
/**
|
|
1024
|
+
* Custom error class for OIDC discovery failures.
|
|
1025
|
+
* Can be caught and mapped to APIError at the edge.
|
|
1026
|
+
*/
|
|
1027
|
+
var DiscoveryError = class DiscoveryError extends Error {
|
|
1028
|
+
code;
|
|
1029
|
+
details;
|
|
1030
|
+
constructor(code, message, details, options) {
|
|
1031
|
+
super(message, options);
|
|
1032
|
+
this.name = "DiscoveryError";
|
|
1033
|
+
this.code = code;
|
|
1034
|
+
this.details = details;
|
|
1035
|
+
if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
|
|
818
1036
|
}
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
1037
|
+
};
|
|
1038
|
+
/**
|
|
1039
|
+
* Required fields that must be present in a valid discovery document.
|
|
1040
|
+
*/
|
|
1041
|
+
const REQUIRED_DISCOVERY_FIELDS = [
|
|
1042
|
+
"issuer",
|
|
1043
|
+
"authorization_endpoint",
|
|
1044
|
+
"token_endpoint",
|
|
1045
|
+
"jwks_uri"
|
|
1046
|
+
];
|
|
1047
|
+
|
|
1048
|
+
//#endregion
|
|
1049
|
+
//#region src/oidc/discovery.ts
|
|
1050
|
+
/**
|
|
1051
|
+
* OIDC Discovery Pipeline
|
|
1052
|
+
*
|
|
1053
|
+
* Implements OIDC discovery document fetching, validation, and hydration.
|
|
1054
|
+
* This module is used both at provider registration time (to persist validated config)
|
|
1055
|
+
* and at runtime (to hydrate legacy providers that are missing metadata).
|
|
1056
|
+
*
|
|
1057
|
+
* @see https://openid.net/specs/openid-connect-discovery-1_0.html
|
|
1058
|
+
*/
|
|
1059
|
+
/** Default timeout for discovery requests (10 seconds) */
|
|
1060
|
+
const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
|
|
1061
|
+
/**
|
|
1062
|
+
* Main entry point: Discover and hydrate OIDC configuration from an issuer.
|
|
1063
|
+
*
|
|
1064
|
+
* This function:
|
|
1065
|
+
* 1. Computes the discovery URL from the issuer
|
|
1066
|
+
* 2. Validates the discovery URL
|
|
1067
|
+
* 3. Fetches the discovery document
|
|
1068
|
+
* 4. Validates the discovery document (issuer match + required fields)
|
|
1069
|
+
* 5. Normalizes URLs
|
|
1070
|
+
* 6. Selects token endpoint auth method
|
|
1071
|
+
* 7. Merges with existing config (existing values take precedence)
|
|
1072
|
+
*
|
|
1073
|
+
* @param params - Discovery parameters
|
|
1074
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
1075
|
+
* @returns Hydrated OIDC configuration ready for persistence
|
|
1076
|
+
* @throws DiscoveryError on any failure
|
|
1077
|
+
*/
|
|
1078
|
+
async function discoverOIDCConfig(params) {
|
|
1079
|
+
const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
|
|
1080
|
+
const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
|
|
1081
|
+
validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
|
|
1082
|
+
const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
|
|
1083
|
+
validateDiscoveryDocument(discoveryDoc, issuer);
|
|
1084
|
+
const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
|
|
1085
|
+
const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
|
|
1086
|
+
return {
|
|
1087
|
+
issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
|
|
1088
|
+
discoveryEndpoint: existingConfig?.discoveryEndpoint ?? discoveryUrl,
|
|
1089
|
+
authorizationEndpoint: existingConfig?.authorizationEndpoint ?? normalizedDoc.authorization_endpoint,
|
|
1090
|
+
tokenEndpoint: existingConfig?.tokenEndpoint ?? normalizedDoc.token_endpoint,
|
|
1091
|
+
jwksEndpoint: existingConfig?.jwksEndpoint ?? normalizedDoc.jwks_uri,
|
|
1092
|
+
userInfoEndpoint: existingConfig?.userInfoEndpoint ?? normalizedDoc.userinfo_endpoint,
|
|
1093
|
+
tokenEndpointAuthentication: existingConfig?.tokenEndpointAuthentication ?? tokenEndpointAuth,
|
|
1094
|
+
scopesSupported: existingConfig?.scopesSupported ?? normalizedDoc.scopes_supported
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Compute the discovery URL from an issuer URL.
|
|
1099
|
+
*
|
|
1100
|
+
* Per OIDC Discovery spec, the discovery document is located at:
|
|
1101
|
+
* <issuer>/.well-known/openid-configuration
|
|
1102
|
+
*
|
|
1103
|
+
* Handles trailing slashes correctly.
|
|
1104
|
+
*/
|
|
1105
|
+
function computeDiscoveryUrl(issuer) {
|
|
1106
|
+
return `${issuer.endsWith("/") ? issuer.slice(0, -1) : issuer}/.well-known/openid-configuration`;
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Validate a discovery URL before fetching.
|
|
1110
|
+
*
|
|
1111
|
+
* @param url - The discovery URL to validate
|
|
1112
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
1113
|
+
* @throws DiscoveryError if URL is invalid
|
|
1114
|
+
*/
|
|
1115
|
+
function validateDiscoveryUrl(url, isTrustedOrigin) {
|
|
1116
|
+
const discoveryEndpoint = parseURL("discoveryEndpoint", url).toString();
|
|
1117
|
+
if (!isTrustedOrigin(discoveryEndpoint)) throw new DiscoveryError("discovery_untrusted_origin", `The main discovery endpoint "${discoveryEndpoint}" is not trusted by your trusted origins configuration.`, { url: discoveryEndpoint });
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Fetch the OIDC discovery document from the IdP.
|
|
1121
|
+
*
|
|
1122
|
+
* @param url - The discovery endpoint URL
|
|
1123
|
+
* @param timeout - Request timeout in milliseconds
|
|
1124
|
+
* @returns The parsed discovery document
|
|
1125
|
+
* @throws DiscoveryError on network errors, timeouts, or invalid responses
|
|
1126
|
+
*/
|
|
1127
|
+
async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
|
|
1128
|
+
try {
|
|
1129
|
+
const response = await betterFetch(url, {
|
|
1130
|
+
method: "GET",
|
|
1131
|
+
timeout
|
|
1132
|
+
});
|
|
1133
|
+
if (response.error) {
|
|
1134
|
+
const { status } = response.error;
|
|
1135
|
+
if (status === 404) throw new DiscoveryError("discovery_not_found", "Discovery endpoint not found", {
|
|
1136
|
+
url,
|
|
1137
|
+
status
|
|
1138
|
+
});
|
|
1139
|
+
if (status === 408) throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
|
|
1140
|
+
url,
|
|
1141
|
+
timeout
|
|
1142
|
+
});
|
|
1143
|
+
throw new DiscoveryError("discovery_unexpected_error", `Unexpected discovery error: ${response.error.statusText}`, {
|
|
1144
|
+
url,
|
|
1145
|
+
...response.error
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
if (!response.data) throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned an empty response", { url });
|
|
1149
|
+
const data = response.data;
|
|
1150
|
+
if (typeof data === "string") throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned invalid JSON", {
|
|
1151
|
+
url,
|
|
1152
|
+
bodyPreview: data.slice(0, 200)
|
|
1153
|
+
});
|
|
1154
|
+
return data;
|
|
1155
|
+
} catch (error) {
|
|
1156
|
+
if (error instanceof DiscoveryError) throw error;
|
|
1157
|
+
if (error instanceof Error && error.name === "AbortError") throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
|
|
1158
|
+
url,
|
|
1159
|
+
timeout
|
|
1160
|
+
});
|
|
1161
|
+
throw new DiscoveryError("discovery_unexpected_error", `Unexpected error during discovery: ${error instanceof Error ? error.message : String(error)}`, { url }, { cause: error });
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Validate a discovery document.
|
|
1166
|
+
*
|
|
1167
|
+
* Checks:
|
|
1168
|
+
* 1. All required fields are present
|
|
1169
|
+
* 2. Issuer matches the configured issuer (case-sensitive, exact match)
|
|
1170
|
+
*
|
|
1171
|
+
* Invariant: If this function returns without throwing, the document is safe
|
|
1172
|
+
* to use for hydrating OIDC config (required fields present, issuer matches
|
|
1173
|
+
* configured value, basic structural sanity verified).
|
|
1174
|
+
*
|
|
1175
|
+
* @param doc - The discovery document to validate
|
|
1176
|
+
* @param configuredIssuer - The expected issuer value
|
|
1177
|
+
* @throws DiscoveryError if validation fails
|
|
1178
|
+
*/
|
|
1179
|
+
function validateDiscoveryDocument(doc, configuredIssuer) {
|
|
1180
|
+
const missingFields = [];
|
|
1181
|
+
for (const field of REQUIRED_DISCOVERY_FIELDS) if (!doc[field]) missingFields.push(field);
|
|
1182
|
+
if (missingFields.length > 0) throw new DiscoveryError("discovery_incomplete", `Discovery document is missing required fields: ${missingFields.join(", ")}`, { missingFields });
|
|
1183
|
+
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}"`, {
|
|
1184
|
+
discovered: doc.issuer,
|
|
1185
|
+
configured: configuredIssuer
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* Normalize URLs in the discovery document.
|
|
1190
|
+
*
|
|
1191
|
+
* @param document - The discovery document
|
|
1192
|
+
* @param issuer - The base issuer URL
|
|
1193
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
1194
|
+
* @returns The normalized discovery document
|
|
1195
|
+
*/
|
|
1196
|
+
function normalizeDiscoveryUrls(document, issuer, isTrustedOrigin) {
|
|
1197
|
+
const doc = { ...document };
|
|
1198
|
+
doc.token_endpoint = normalizeAndValidateUrl("token_endpoint", doc.token_endpoint, issuer, isTrustedOrigin);
|
|
1199
|
+
doc.authorization_endpoint = normalizeAndValidateUrl("authorization_endpoint", doc.authorization_endpoint, issuer, isTrustedOrigin);
|
|
1200
|
+
doc.jwks_uri = normalizeAndValidateUrl("jwks_uri", doc.jwks_uri, issuer, isTrustedOrigin);
|
|
1201
|
+
if (doc.userinfo_endpoint) doc.userinfo_endpoint = normalizeAndValidateUrl("userinfo_endpoint", doc.userinfo_endpoint, issuer, isTrustedOrigin);
|
|
1202
|
+
if (doc.revocation_endpoint) doc.revocation_endpoint = normalizeAndValidateUrl("revocation_endpoint", doc.revocation_endpoint, issuer, isTrustedOrigin);
|
|
1203
|
+
if (doc.end_session_endpoint) doc.end_session_endpoint = normalizeAndValidateUrl("end_session_endpoint", doc.end_session_endpoint, issuer, isTrustedOrigin);
|
|
1204
|
+
if (doc.introspection_endpoint) doc.introspection_endpoint = normalizeAndValidateUrl("introspection_endpoint", doc.introspection_endpoint, issuer, isTrustedOrigin);
|
|
1205
|
+
return doc;
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* Normalizes and validates a single URL endpoint
|
|
1209
|
+
* @param name The url name
|
|
1210
|
+
* @param endpoint The url to validate
|
|
1211
|
+
* @param issuer The issuer base url
|
|
1212
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
1213
|
+
* @returns
|
|
1214
|
+
*/
|
|
1215
|
+
function normalizeAndValidateUrl(name, endpoint, issuer, isTrustedOrigin) {
|
|
1216
|
+
const url = normalizeUrl(name, endpoint, issuer);
|
|
1217
|
+
if (!isTrustedOrigin(url)) throw new DiscoveryError("discovery_untrusted_origin", `The ${name} "${url}" is not trusted by your trusted origins configuration.`, {
|
|
1218
|
+
endpoint: name,
|
|
1219
|
+
url
|
|
1220
|
+
});
|
|
1221
|
+
return url;
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* Normalize a single URL endpoint.
|
|
1225
|
+
*
|
|
1226
|
+
* @param name - The endpoint name (e.g token_endpoint)
|
|
1227
|
+
* @param endpoint - The endpoint URL to normalize
|
|
1228
|
+
* @param issuer - The base issuer URL
|
|
1229
|
+
* @returns The normalized endpoint URL
|
|
1230
|
+
*/
|
|
1231
|
+
function normalizeUrl(name, endpoint, issuer) {
|
|
1232
|
+
try {
|
|
1233
|
+
return parseURL(name, endpoint).toString();
|
|
1234
|
+
} catch {
|
|
1235
|
+
const issuerURL = parseURL(name, issuer);
|
|
1236
|
+
const basePath = issuerURL.pathname.replace(/\/+$/, "");
|
|
1237
|
+
const endpointPath = endpoint.replace(/^\/+/, "");
|
|
1238
|
+
return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* Parses the given URL or throws in case of invalid or unsupported protocols
|
|
1243
|
+
*
|
|
1244
|
+
* @param name the url name
|
|
1245
|
+
* @param endpoint the endpoint url
|
|
1246
|
+
* @param [base] optional base path
|
|
1247
|
+
* @returns
|
|
1248
|
+
*/
|
|
1249
|
+
function parseURL(name, endpoint, base) {
|
|
1250
|
+
let endpointURL;
|
|
1251
|
+
try {
|
|
1252
|
+
endpointURL = new URL(endpoint, base);
|
|
1253
|
+
if (endpointURL.protocol === "http:" || endpointURL.protocol === "https:") return endpointURL;
|
|
1254
|
+
} catch (error) {
|
|
1255
|
+
throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
|
|
822
1256
|
}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
1257
|
+
throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must use the http or https supported protocols: ${endpoint}`, {
|
|
1258
|
+
url: endpoint,
|
|
1259
|
+
protocol: endpointURL.protocol
|
|
826
1260
|
});
|
|
827
1261
|
}
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
if (
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
|
|
843
|
-
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
844
|
-
});
|
|
845
|
-
} 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");
|
|
846
|
-
}
|
|
1262
|
+
/**
|
|
1263
|
+
* Select the token endpoint authentication method.
|
|
1264
|
+
*
|
|
1265
|
+
* @param doc - The discovery document
|
|
1266
|
+
* @param existing - Existing authentication method from config
|
|
1267
|
+
* @returns The selected authentication method
|
|
1268
|
+
*/
|
|
1269
|
+
function selectTokenEndpointAuthMethod(doc, existing) {
|
|
1270
|
+
if (existing) return existing;
|
|
1271
|
+
const supported = doc.token_endpoint_auth_methods_supported;
|
|
1272
|
+
if (!supported || supported.length === 0) return "client_secret_basic";
|
|
1273
|
+
if (supported.includes("client_secret_basic")) return "client_secret_basic";
|
|
1274
|
+
if (supported.includes("client_secret_post")) return "client_secret_post";
|
|
1275
|
+
return "client_secret_basic";
|
|
847
1276
|
}
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
1277
|
+
/**
|
|
1278
|
+
* Check if a provider configuration needs runtime discovery.
|
|
1279
|
+
*
|
|
1280
|
+
* Returns true if we need discovery at runtime to complete the token exchange
|
|
1281
|
+
* and validation. Specifically checks for:
|
|
1282
|
+
* - `tokenEndpoint` - required for exchanging authorization code for tokens
|
|
1283
|
+
* - `jwksEndpoint` - required for validating ID token signatures
|
|
1284
|
+
*
|
|
1285
|
+
* Note: `authorizationEndpoint` is handled separately in the sign-in flow,
|
|
1286
|
+
* so it's not checked here.
|
|
1287
|
+
*
|
|
1288
|
+
* @param config - Partial OIDC config from the provider
|
|
1289
|
+
* @returns true if runtime discovery should be performed
|
|
1290
|
+
*/
|
|
1291
|
+
function needsRuntimeDiscovery(config) {
|
|
1292
|
+
if (!config) return true;
|
|
1293
|
+
return !config.tokenEndpoint || !config.jwksEndpoint;
|
|
851
1294
|
}
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
1295
|
+
|
|
1296
|
+
//#endregion
|
|
1297
|
+
//#region src/oidc/errors.ts
|
|
1298
|
+
/**
|
|
1299
|
+
* OIDC Discovery Error Mapping
|
|
1300
|
+
*
|
|
1301
|
+
* Maps DiscoveryError codes to appropriate APIError responses.
|
|
1302
|
+
* Used at the boundary between the discovery pipeline and HTTP handlers.
|
|
1303
|
+
*/
|
|
1304
|
+
/**
|
|
1305
|
+
* Maps a DiscoveryError to an appropriate APIError for HTTP responses.
|
|
1306
|
+
*
|
|
1307
|
+
* Error code mapping:
|
|
1308
|
+
* - discovery_invalid_url → 400 BAD_REQUEST
|
|
1309
|
+
* - discovery_not_found → 400 BAD_REQUEST
|
|
1310
|
+
* - discovery_invalid_json → 400 BAD_REQUEST
|
|
1311
|
+
* - discovery_incomplete → 400 BAD_REQUEST
|
|
1312
|
+
* - issuer_mismatch → 400 BAD_REQUEST
|
|
1313
|
+
* - unsupported_token_auth_method → 400 BAD_REQUEST
|
|
1314
|
+
* - discovery_timeout → 502 BAD_GATEWAY
|
|
1315
|
+
* - discovery_unexpected_error → 502 BAD_GATEWAY
|
|
1316
|
+
*
|
|
1317
|
+
* @param error - The DiscoveryError to map
|
|
1318
|
+
* @returns An APIError with appropriate status and message
|
|
1319
|
+
*/
|
|
1320
|
+
function mapDiscoveryErrorToAPIError(error) {
|
|
1321
|
+
switch (error.code) {
|
|
1322
|
+
case "discovery_timeout": return new APIError("BAD_GATEWAY", {
|
|
1323
|
+
message: `OIDC discovery timed out: ${error.message}`,
|
|
1324
|
+
code: error.code
|
|
865
1325
|
});
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
1326
|
+
case "discovery_unexpected_error": return new APIError("BAD_GATEWAY", {
|
|
1327
|
+
message: `OIDC discovery failed: ${error.message}`,
|
|
1328
|
+
code: error.code
|
|
1329
|
+
});
|
|
1330
|
+
case "discovery_not_found": return new APIError("BAD_REQUEST", {
|
|
1331
|
+
message: `OIDC discovery endpoint not found. The issuer may not support OIDC discovery, or the URL is incorrect. ${error.message}`,
|
|
1332
|
+
code: error.code
|
|
1333
|
+
});
|
|
1334
|
+
case "discovery_invalid_url": return new APIError("BAD_REQUEST", {
|
|
1335
|
+
message: `Invalid OIDC discovery URL: ${error.message}`,
|
|
1336
|
+
code: error.code
|
|
1337
|
+
});
|
|
1338
|
+
case "discovery_untrusted_origin": return new APIError("BAD_REQUEST", {
|
|
1339
|
+
message: `Untrusted OIDC discovery URL: ${error.message}`,
|
|
1340
|
+
code: error.code
|
|
1341
|
+
});
|
|
1342
|
+
case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
|
|
1343
|
+
message: `OIDC discovery returned invalid data: ${error.message}`,
|
|
1344
|
+
code: error.code
|
|
1345
|
+
});
|
|
1346
|
+
case "discovery_incomplete": return new APIError("BAD_REQUEST", {
|
|
1347
|
+
message: `OIDC discovery document is missing required fields: ${error.message}`,
|
|
1348
|
+
code: error.code
|
|
1349
|
+
});
|
|
1350
|
+
case "issuer_mismatch": return new APIError("BAD_REQUEST", {
|
|
1351
|
+
message: `OIDC issuer mismatch: ${error.message}`,
|
|
1352
|
+
code: error.code
|
|
1353
|
+
});
|
|
1354
|
+
case "unsupported_token_auth_method": return new APIError("BAD_REQUEST", {
|
|
1355
|
+
message: `Incompatible OIDC provider: ${error.message}`,
|
|
1356
|
+
code: error.code
|
|
878
1357
|
});
|
|
1358
|
+
default:
|
|
1359
|
+
error.code;
|
|
1360
|
+
return new APIError("INTERNAL_SERVER_ERROR", {
|
|
1361
|
+
message: `Unexpected discovery error: ${error.message}`,
|
|
1362
|
+
code: "discovery_unexpected_error"
|
|
1363
|
+
});
|
|
879
1364
|
}
|
|
880
1365
|
}
|
|
881
1366
|
|
|
882
1367
|
//#endregion
|
|
883
|
-
//#region src/saml
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
1368
|
+
//#region src/saml-state.ts
|
|
1369
|
+
async function generateRelayState(c, link, additionalData) {
|
|
1370
|
+
const callbackURL = c.body.callbackURL;
|
|
1371
|
+
if (!callbackURL) throw new APIError("BAD_REQUEST", { message: "callbackURL is required" });
|
|
1372
|
+
const codeVerifier = generateRandomString(128);
|
|
1373
|
+
const stateData = {
|
|
1374
|
+
...additionalData ? additionalData : {},
|
|
1375
|
+
callbackURL,
|
|
1376
|
+
codeVerifier,
|
|
1377
|
+
errorURL: c.body.errorCallbackURL,
|
|
1378
|
+
newUserURL: c.body.newUserCallbackURL,
|
|
1379
|
+
link,
|
|
1380
|
+
expiresAt: Date.now() + 600 * 1e3,
|
|
1381
|
+
requestSignUp: c.body.requestSignUp
|
|
1382
|
+
};
|
|
887
1383
|
try {
|
|
888
|
-
|
|
889
|
-
} catch {
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
1384
|
+
return generateGenericState(c, stateData, { cookieName: "relay_state" });
|
|
1385
|
+
} catch (error) {
|
|
1386
|
+
c.context.logger.error("Failed to create verification for relay state", error);
|
|
1387
|
+
throw new APIError("INTERNAL_SERVER_ERROR", {
|
|
1388
|
+
message: "State error: Unable to create verification for relay state",
|
|
1389
|
+
cause: error
|
|
893
1390
|
});
|
|
894
1391
|
}
|
|
895
|
-
const assertions = countAllNodes(parsed, "Assertion");
|
|
896
|
-
const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
|
|
897
|
-
return {
|
|
898
|
-
assertions,
|
|
899
|
-
encryptedAssertions,
|
|
900
|
-
total: assertions + encryptedAssertions
|
|
901
|
-
};
|
|
902
1392
|
}
|
|
903
|
-
function
|
|
904
|
-
|
|
1393
|
+
async function parseRelayState(c) {
|
|
1394
|
+
const state = c.body.RelayState;
|
|
1395
|
+
const errorURL = c.context.options.onAPIError?.errorURL || `${c.context.baseURL}/error`;
|
|
1396
|
+
let parsedData;
|
|
905
1397
|
try {
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
1398
|
+
parsedData = await parseGenericState(c, state, { cookieName: "relay_state" });
|
|
1399
|
+
} catch (error) {
|
|
1400
|
+
c.context.logger.error("Failed to parse relay state", error);
|
|
909
1401
|
throw new APIError("BAD_REQUEST", {
|
|
910
|
-
message: "
|
|
911
|
-
|
|
1402
|
+
message: "State error: failed to validate relay state",
|
|
1403
|
+
cause: error
|
|
912
1404
|
});
|
|
913
1405
|
}
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
message: "SAML response contains no assertions",
|
|
917
|
-
code: "SAML_NO_ASSERTION"
|
|
918
|
-
});
|
|
919
|
-
if (counts.total > 1) throw new APIError("BAD_REQUEST", {
|
|
920
|
-
message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
|
|
921
|
-
code: "SAML_MULTIPLE_ASSERTIONS"
|
|
922
|
-
});
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
//#endregion
|
|
926
|
-
//#region src/utils.ts
|
|
927
|
-
/**
|
|
928
|
-
* Safely parses a value that might be a JSON string or already a parsed object.
|
|
929
|
-
* This handles cases where ORMs like Drizzle might return already parsed objects
|
|
930
|
-
* instead of JSON strings from TEXT/JSON columns.
|
|
931
|
-
*
|
|
932
|
-
* @param value - The value to parse (string, object, null, or undefined)
|
|
933
|
-
* @returns The parsed object or null
|
|
934
|
-
* @throws Error if string parsing fails
|
|
935
|
-
*/
|
|
936
|
-
function safeJsonParse(value) {
|
|
937
|
-
if (!value) return null;
|
|
938
|
-
if (typeof value === "object") return value;
|
|
939
|
-
if (typeof value === "string") try {
|
|
940
|
-
return JSON.parse(value);
|
|
941
|
-
} catch (error) {
|
|
942
|
-
throw new Error(`Failed to parse JSON: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
943
|
-
}
|
|
944
|
-
return null;
|
|
1406
|
+
if (!parsedData.errorURL) parsedData.errorURL = errorURL;
|
|
1407
|
+
return parsedData;
|
|
945
1408
|
}
|
|
946
|
-
const validateEmailDomain = (email, domain) => {
|
|
947
|
-
const emailDomain = email.split("@")[1]?.toLowerCase();
|
|
948
|
-
const providerDomain = domain.toLowerCase();
|
|
949
|
-
if (!emailDomain || !providerDomain) return false;
|
|
950
|
-
return emailDomain === providerDomain || emailDomain.endsWith(`.${providerDomain}`);
|
|
951
|
-
};
|
|
952
1409
|
|
|
953
1410
|
//#endregion
|
|
954
1411
|
//#region src/routes/sso.ts
|
|
@@ -1043,6 +1500,7 @@ const spMetadata = () => {
|
|
|
1043
1500
|
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1044
1501
|
Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.id}`
|
|
1045
1502
|
}],
|
|
1503
|
+
authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
|
|
1046
1504
|
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1047
1505
|
nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
|
|
1048
1506
|
});
|
|
@@ -1052,7 +1510,7 @@ const spMetadata = () => {
|
|
|
1052
1510
|
const ssoProviderBodySchema = z.object({
|
|
1053
1511
|
providerId: z.string({}).meta({ description: "The ID of the provider. This is used to identify the provider during login and callback" }),
|
|
1054
1512
|
issuer: z.string({}).meta({ description: "The issuer of the provider" }),
|
|
1055
|
-
domain: z.string({}).meta({ description: "The domain of the provider.
|
|
1513
|
+
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')" }),
|
|
1056
1514
|
oidcConfig: z.object({
|
|
1057
1515
|
clientId: z.string({}).meta({ description: "The client ID" }),
|
|
1058
1516
|
clientSecret: z.string({}).meta({ description: "The client secret" }),
|
|
@@ -1427,7 +1885,7 @@ const registerSSOProvider = (options) => {
|
|
|
1427
1885
|
await ctx.context.adapter.create({
|
|
1428
1886
|
model: "verification",
|
|
1429
1887
|
data: {
|
|
1430
|
-
identifier: options
|
|
1888
|
+
identifier: getVerificationIdentifier(options, provider.providerId),
|
|
1431
1889
|
createdAt: /* @__PURE__ */ new Date(),
|
|
1432
1890
|
updatedAt: /* @__PURE__ */ new Date(),
|
|
1433
1891
|
value: domainVerificationToken,
|
|
@@ -1551,20 +2009,33 @@ const signInSSO = (options) => {
|
|
|
1551
2009
|
};
|
|
1552
2010
|
}
|
|
1553
2011
|
if (!providerId && !orgId && !domain) throw new APIError("BAD_REQUEST", { message: "providerId, orgId or domain is required" });
|
|
1554
|
-
if (!provider)
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
return {
|
|
1563
|
-
...res,
|
|
1564
|
-
oidcConfig: res.oidcConfig ? safeJsonParse(res.oidcConfig) || void 0 : void 0,
|
|
1565
|
-
samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
|
|
2012
|
+
if (!provider) {
|
|
2013
|
+
const parseProvider = (res) => {
|
|
2014
|
+
if (!res) return null;
|
|
2015
|
+
return {
|
|
2016
|
+
...res,
|
|
2017
|
+
oidcConfig: res.oidcConfig ? safeJsonParse(res.oidcConfig) || void 0 : void 0,
|
|
2018
|
+
samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
|
|
2019
|
+
};
|
|
1566
2020
|
};
|
|
1567
|
-
|
|
2021
|
+
if (providerId || orgId) provider = parseProvider(await ctx.context.adapter.findOne({
|
|
2022
|
+
model: "ssoProvider",
|
|
2023
|
+
where: [{
|
|
2024
|
+
field: providerId ? "providerId" : "organizationId",
|
|
2025
|
+
value: providerId || orgId
|
|
2026
|
+
}]
|
|
2027
|
+
}));
|
|
2028
|
+
else if (domain) {
|
|
2029
|
+
provider = parseProvider(await ctx.context.adapter.findOne({
|
|
2030
|
+
model: "ssoProvider",
|
|
2031
|
+
where: [{
|
|
2032
|
+
field: "domain",
|
|
2033
|
+
value: domain
|
|
2034
|
+
}]
|
|
2035
|
+
}));
|
|
2036
|
+
if (!provider) provider = parseProvider((await ctx.context.adapter.findMany({ model: "ssoProvider" })).find((p) => domainMatches(domain, p.domain)) ?? null);
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
1568
2039
|
if (!provider) throw new APIError("NOT_FOUND", { message: "No provider found for the issuer" });
|
|
1569
2040
|
if (body.providerType) {
|
|
1570
2041
|
if (body.providerType === "oidc" && !provider.oidcConfig) throw new APIError("BAD_REQUEST", { message: "OIDC provider is not configured" });
|
|
@@ -1613,21 +2084,41 @@ const signInSSO = (options) => {
|
|
|
1613
2084
|
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1614
2085
|
Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.providerId}`
|
|
1615
2086
|
}],
|
|
2087
|
+
authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
|
|
1616
2088
|
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1617
2089
|
nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
|
|
1618
2090
|
}).getMetadata() || "";
|
|
1619
2091
|
const sp = saml.ServiceProvider({
|
|
1620
2092
|
metadata,
|
|
2093
|
+
privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
|
|
2094
|
+
privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
|
|
1621
2095
|
allowCreate: true
|
|
1622
2096
|
});
|
|
1623
|
-
const
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
singleSignOnService:
|
|
2097
|
+
const idpData = parsedSamlConfig.idpMetadata;
|
|
2098
|
+
let idp;
|
|
2099
|
+
if (!idpData?.metadata) idp = saml.IdentityProvider({
|
|
2100
|
+
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
|
2101
|
+
singleSignOnService: idpData?.singleSignOnService || [{
|
|
2102
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
2103
|
+
Location: parsedSamlConfig.entryPoint
|
|
2104
|
+
}],
|
|
2105
|
+
signingCert: idpData?.cert || parsedSamlConfig.cert,
|
|
2106
|
+
wantAuthnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
|
|
2107
|
+
isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
|
|
2108
|
+
encPrivateKey: idpData?.encPrivateKey,
|
|
2109
|
+
encPrivateKeyPass: idpData?.encPrivateKeyPass
|
|
2110
|
+
});
|
|
2111
|
+
else idp = saml.IdentityProvider({
|
|
2112
|
+
metadata: idpData.metadata,
|
|
2113
|
+
privateKey: idpData.privateKey,
|
|
2114
|
+
privateKeyPass: idpData.privateKeyPass,
|
|
2115
|
+
isAssertionEncrypted: idpData.isAssertionEncrypted,
|
|
2116
|
+
encPrivateKey: idpData.encPrivateKey,
|
|
2117
|
+
encPrivateKeyPass: idpData.encPrivateKeyPass
|
|
1628
2118
|
});
|
|
1629
2119
|
const loginRequest = sp.createLoginRequest(idp, "redirect");
|
|
1630
2120
|
if (!loginRequest) throw new APIError("BAD_REQUEST", { message: "Invalid SAML request" });
|
|
2121
|
+
const { state: relayState } = await generateRelayState(ctx, void 0, false);
|
|
1631
2122
|
if (loginRequest.id && options?.saml?.enableInResponseToValidation) {
|
|
1632
2123
|
const ttl = options?.saml?.requestTTL ?? DEFAULT_AUTHN_REQUEST_TTL_MS;
|
|
1633
2124
|
const record = {
|
|
@@ -1643,7 +2134,7 @@ const signInSSO = (options) => {
|
|
|
1643
2134
|
});
|
|
1644
2135
|
}
|
|
1645
2136
|
return ctx.json({
|
|
1646
|
-
url: `${loginRequest.context}&RelayState=${encodeURIComponent(
|
|
2137
|
+
url: `${loginRequest.context}&RelayState=${encodeURIComponent(relayState)}`,
|
|
1647
2138
|
redirect: true
|
|
1648
2139
|
});
|
|
1649
2140
|
}
|
|
@@ -1702,10 +2193,10 @@ const callbackSSO = (options) => {
|
|
|
1702
2193
|
oidcConfig: safeJsonParse(res.oidcConfig) || void 0
|
|
1703
2194
|
};
|
|
1704
2195
|
});
|
|
1705
|
-
if (!provider) throw ctx.redirect(`${errorURL || callbackURL}
|
|
2196
|
+
if (!provider) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
|
|
1706
2197
|
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
1707
2198
|
let config = provider.oidcConfig;
|
|
1708
|
-
if (!config) throw ctx.redirect(`${errorURL || callbackURL}
|
|
2199
|
+
if (!config) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
|
|
1709
2200
|
const discovery = await betterFetch(config.discoveryEndpoint);
|
|
1710
2201
|
if (discovery.data) config = {
|
|
1711
2202
|
tokenEndpoint: discovery.data.token_endpoint,
|
|
@@ -1719,7 +2210,7 @@ const callbackSSO = (options) => {
|
|
|
1719
2210
|
],
|
|
1720
2211
|
...config
|
|
1721
2212
|
};
|
|
1722
|
-
if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}
|
|
2213
|
+
if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_endpoint_not_found`);
|
|
1723
2214
|
const tokenResponse = await validateAuthorizationCode({
|
|
1724
2215
|
code,
|
|
1725
2216
|
codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
|
|
@@ -1734,17 +2225,19 @@ const callbackSSO = (options) => {
|
|
|
1734
2225
|
if (e instanceof BetterFetchError) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${e.message}`);
|
|
1735
2226
|
return null;
|
|
1736
2227
|
});
|
|
1737
|
-
if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}
|
|
2228
|
+
if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_response_not_found`);
|
|
1738
2229
|
let userInfo = null;
|
|
1739
2230
|
if (tokenResponse.idToken) {
|
|
1740
2231
|
const idToken = decodeJwt(tokenResponse.idToken);
|
|
1741
|
-
if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}
|
|
1742
|
-
const verified = await validateToken(tokenResponse.idToken, config.jwksEndpoint
|
|
2232
|
+
if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=jwks_endpoint_not_found`);
|
|
2233
|
+
const verified = await validateToken(tokenResponse.idToken, config.jwksEndpoint, {
|
|
2234
|
+
audience: config.clientId,
|
|
2235
|
+
issuer: provider.issuer
|
|
2236
|
+
}).catch((e) => {
|
|
1743
2237
|
ctx.context.logger.error(e);
|
|
1744
2238
|
return null;
|
|
1745
2239
|
});
|
|
1746
|
-
if (!verified) throw ctx.redirect(`${errorURL || callbackURL}
|
|
1747
|
-
if (verified.payload.iss !== provider.issuer) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=issuer_mismatch`);
|
|
2240
|
+
if (!verified) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_not_verified`);
|
|
1748
2241
|
const mapping = config.mapping || {};
|
|
1749
2242
|
userInfo = {
|
|
1750
2243
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, verified.payload[value]])),
|
|
@@ -1756,12 +2249,12 @@ const callbackSSO = (options) => {
|
|
|
1756
2249
|
};
|
|
1757
2250
|
}
|
|
1758
2251
|
if (!userInfo) {
|
|
1759
|
-
if (!config.userInfoEndpoint) throw ctx.redirect(`${errorURL || callbackURL}
|
|
2252
|
+
if (!config.userInfoEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=user_info_endpoint_not_found`);
|
|
1760
2253
|
const userInfoResponse = await betterFetch(config.userInfoEndpoint, { headers: { Authorization: `Bearer ${tokenResponse.accessToken}` } });
|
|
1761
|
-
if (userInfoResponse.error) throw ctx.redirect(`${errorURL || callbackURL}
|
|
2254
|
+
if (userInfoResponse.error) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${userInfoResponse.error.message}`);
|
|
1762
2255
|
userInfo = userInfoResponse.data;
|
|
1763
2256
|
}
|
|
1764
|
-
if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}
|
|
2257
|
+
if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=missing_user_info`);
|
|
1765
2258
|
const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfo.email, provider.domain);
|
|
1766
2259
|
const linked = await handleOAuthUserInfo(ctx, {
|
|
1767
2260
|
userInfo: {
|
|
@@ -1786,7 +2279,7 @@ const callbackSSO = (options) => {
|
|
|
1786
2279
|
overrideUserInfo: config.overrideUserInfo,
|
|
1787
2280
|
isTrustedProvider
|
|
1788
2281
|
});
|
|
1789
|
-
if (linked.error) throw ctx.redirect(`${errorURL || callbackURL}
|
|
2282
|
+
if (linked.error) throw ctx.redirect(`${errorURL || callbackURL}?error=${linked.error}`);
|
|
1790
2283
|
const { session, user } = linked.data;
|
|
1791
2284
|
if (options?.provisionUser) await options.provisionUser({
|
|
1792
2285
|
user,
|
|
@@ -1825,17 +2318,46 @@ const callbackSSOSAMLBodySchema = z.object({
|
|
|
1825
2318
|
SAMLResponse: z.string(),
|
|
1826
2319
|
RelayState: z.string().optional()
|
|
1827
2320
|
});
|
|
2321
|
+
/**
|
|
2322
|
+
* Validates and returns a safe redirect URL.
|
|
2323
|
+
* - Prevents open redirect attacks by validating against trusted origins
|
|
2324
|
+
* - Prevents redirect loops by checking if URL points to callback route
|
|
2325
|
+
* - Falls back to appOrigin if URL is invalid or unsafe
|
|
2326
|
+
*/
|
|
2327
|
+
const getSafeRedirectUrl = (url, callbackPath, appOrigin, isTrustedOrigin) => {
|
|
2328
|
+
if (!url) return appOrigin;
|
|
2329
|
+
if (url.startsWith("/") && !url.startsWith("//")) {
|
|
2330
|
+
try {
|
|
2331
|
+
const absoluteUrl = new URL(url, appOrigin);
|
|
2332
|
+
if (absoluteUrl.origin !== appOrigin) return appOrigin;
|
|
2333
|
+
const callbackPathname = new URL(callbackPath).pathname;
|
|
2334
|
+
if (absoluteUrl.pathname === callbackPathname) return appOrigin;
|
|
2335
|
+
} catch {
|
|
2336
|
+
return appOrigin;
|
|
2337
|
+
}
|
|
2338
|
+
return url;
|
|
2339
|
+
}
|
|
2340
|
+
if (!isTrustedOrigin(url, { allowRelativePaths: false })) return appOrigin;
|
|
2341
|
+
try {
|
|
2342
|
+
const callbackPathname = new URL(callbackPath).pathname;
|
|
2343
|
+
if (new URL(url).pathname === callbackPathname) return appOrigin;
|
|
2344
|
+
} catch {
|
|
2345
|
+
if (url === callbackPath || url.startsWith(`${callbackPath}?`)) return appOrigin;
|
|
2346
|
+
}
|
|
2347
|
+
return url;
|
|
2348
|
+
};
|
|
1828
2349
|
const callbackSSOSAML = (options) => {
|
|
1829
2350
|
return createAuthEndpoint("/sso/saml2/callback/:providerId", {
|
|
1830
|
-
method: "POST",
|
|
1831
|
-
body: callbackSSOSAMLBodySchema,
|
|
2351
|
+
method: ["GET", "POST"],
|
|
2352
|
+
body: callbackSSOSAMLBodySchema.optional(),
|
|
2353
|
+
query: z.object({ RelayState: z.string().optional() }).optional(),
|
|
1832
2354
|
metadata: {
|
|
1833
2355
|
...HIDE_METADATA,
|
|
1834
2356
|
allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
|
|
1835
2357
|
openapi: {
|
|
1836
2358
|
operationId: "handleSAMLCallback",
|
|
1837
2359
|
summary: "Callback URL for SAML provider",
|
|
1838
|
-
description: "This endpoint is used as the callback URL for SAML providers.",
|
|
2360
|
+
description: "This endpoint is used as the callback URL for SAML providers. Supports both GET and POST methods for IdP-initiated and SP-initiated flows.",
|
|
1839
2361
|
responses: {
|
|
1840
2362
|
"302": { description: "Redirects to the callback URL" },
|
|
1841
2363
|
"400": { description: "Invalid SAML response" },
|
|
@@ -1844,10 +2366,26 @@ const callbackSSOSAML = (options) => {
|
|
|
1844
2366
|
}
|
|
1845
2367
|
}
|
|
1846
2368
|
}, async (ctx) => {
|
|
1847
|
-
const { SAMLResponse, RelayState } = ctx.body;
|
|
1848
2369
|
const { providerId } = ctx.params;
|
|
2370
|
+
const appOrigin = new URL(ctx.context.baseURL).origin;
|
|
2371
|
+
const errorURL = ctx.context.options.onAPIError?.errorURL || `${appOrigin}/error`;
|
|
2372
|
+
const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/callback/${providerId}`;
|
|
2373
|
+
if (ctx.method === "GET" && !ctx.body?.SAMLResponse) {
|
|
2374
|
+
if (!(await getSessionFromCtx(ctx))?.session) throw ctx.redirect(`${errorURL}?error=invalid_request`);
|
|
2375
|
+
const relayState$1 = ctx.query?.RelayState;
|
|
2376
|
+
const safeRedirectUrl = getSafeRedirectUrl(relayState$1, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
2377
|
+
throw ctx.redirect(safeRedirectUrl);
|
|
2378
|
+
}
|
|
2379
|
+
if (!ctx.body?.SAMLResponse) throw new APIError("BAD_REQUEST", { message: "SAMLResponse is required for POST requests" });
|
|
2380
|
+
const { SAMLResponse } = ctx.body;
|
|
1849
2381
|
const maxResponseSize = options?.saml?.maxResponseSize ?? DEFAULT_MAX_SAML_RESPONSE_SIZE;
|
|
1850
2382
|
if (new TextEncoder().encode(SAMLResponse).length > maxResponseSize) throw new APIError("BAD_REQUEST", { message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)` });
|
|
2383
|
+
let relayState = null;
|
|
2384
|
+
if (ctx.body.RelayState) try {
|
|
2385
|
+
relayState = await parseRelayState(ctx);
|
|
2386
|
+
} catch {
|
|
2387
|
+
relayState = null;
|
|
2388
|
+
}
|
|
1851
2389
|
let provider = null;
|
|
1852
2390
|
if (options?.defaultSSO?.length) {
|
|
1853
2391
|
const matchingDefault = options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId);
|
|
@@ -1875,16 +2413,18 @@ const callbackSSOSAML = (options) => {
|
|
|
1875
2413
|
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
1876
2414
|
const parsedSamlConfig = safeJsonParse(provider.samlConfig);
|
|
1877
2415
|
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
2416
|
+
const isTrusted = (url, settings) => ctx.context.isTrustedOrigin(url, settings);
|
|
2417
|
+
const safeErrorUrl = getSafeRedirectUrl(relayState?.errorURL || relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, isTrusted);
|
|
1878
2418
|
const idpData = parsedSamlConfig.idpMetadata;
|
|
1879
2419
|
let idp = null;
|
|
1880
2420
|
if (!idpData?.metadata) idp = saml.IdentityProvider({
|
|
1881
2421
|
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
|
1882
|
-
singleSignOnService: [{
|
|
2422
|
+
singleSignOnService: idpData?.singleSignOnService || [{
|
|
1883
2423
|
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1884
2424
|
Location: parsedSamlConfig.entryPoint
|
|
1885
2425
|
}],
|
|
1886
2426
|
signingCert: idpData?.cert || parsedSamlConfig.cert,
|
|
1887
|
-
wantAuthnRequestsSigned: parsedSamlConfig.
|
|
2427
|
+
wantAuthnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
|
|
1888
2428
|
isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
|
|
1889
2429
|
encPrivateKey: idpData?.encPrivateKey,
|
|
1890
2430
|
encPrivateKeyPass: idpData?.encPrivateKeyPass
|
|
@@ -1918,13 +2458,13 @@ const callbackSSOSAML = (options) => {
|
|
|
1918
2458
|
try {
|
|
1919
2459
|
parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
|
|
1920
2460
|
SAMLResponse,
|
|
1921
|
-
RelayState: RelayState || void 0
|
|
2461
|
+
RelayState: ctx.body.RelayState || void 0
|
|
1922
2462
|
} });
|
|
1923
2463
|
if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
|
|
1924
2464
|
} catch (error) {
|
|
1925
2465
|
ctx.context.logger.error("SAML response validation failed", {
|
|
1926
2466
|
error,
|
|
1927
|
-
decodedResponse:
|
|
2467
|
+
decodedResponse: new TextDecoder().decode(base64.decode(SAMLResponse))
|
|
1928
2468
|
});
|
|
1929
2469
|
throw new APIError("BAD_REQUEST", {
|
|
1930
2470
|
message: "Invalid SAML response",
|
|
@@ -1955,8 +2495,7 @@ const callbackSSOSAML = (options) => {
|
|
|
1955
2495
|
inResponseTo,
|
|
1956
2496
|
providerId: provider.providerId
|
|
1957
2497
|
});
|
|
1958
|
-
|
|
1959
|
-
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
|
|
2498
|
+
throw ctx.redirect(`${safeErrorUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
|
|
1960
2499
|
}
|
|
1961
2500
|
if (storedRequest.providerId !== provider.providerId) {
|
|
1962
2501
|
ctx.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
|
|
@@ -1965,14 +2504,12 @@ const callbackSSOSAML = (options) => {
|
|
|
1965
2504
|
actualProvider: provider.providerId
|
|
1966
2505
|
});
|
|
1967
2506
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
1968
|
-
|
|
1969
|
-
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
|
|
2507
|
+
throw ctx.redirect(`${safeErrorUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
|
|
1970
2508
|
}
|
|
1971
2509
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
1972
2510
|
} else if (!allowIdpInitiated) {
|
|
1973
2511
|
ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: provider.providerId });
|
|
1974
|
-
|
|
1975
|
-
throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
|
|
2512
|
+
throw ctx.redirect(`${safeErrorUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
|
|
1976
2513
|
}
|
|
1977
2514
|
}
|
|
1978
2515
|
const samlContent = parsedResponse.samlContent;
|
|
@@ -1998,8 +2535,7 @@ const callbackSSOSAML = (options) => {
|
|
|
1998
2535
|
issuer,
|
|
1999
2536
|
providerId: provider.providerId
|
|
2000
2537
|
});
|
|
2001
|
-
|
|
2002
|
-
throw ctx.redirect(`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
|
|
2538
|
+
throw ctx.redirect(`${safeErrorUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
|
|
2003
2539
|
}
|
|
2004
2540
|
await ctx.context.internalAdapter.createVerificationValue({
|
|
2005
2541
|
identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
|
|
@@ -2032,7 +2568,7 @@ const callbackSSOSAML = (options) => {
|
|
|
2032
2568
|
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
2033
2569
|
}
|
|
2034
2570
|
const isTrustedProvider = !!ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
2035
|
-
const
|
|
2571
|
+
const safeCallbackUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, isTrusted);
|
|
2036
2572
|
const result = await handleOAuthUserInfo(ctx, {
|
|
2037
2573
|
userInfo: {
|
|
2038
2574
|
email: userInfo.email,
|
|
@@ -2046,11 +2582,11 @@ const callbackSSOSAML = (options) => {
|
|
|
2046
2582
|
accessToken: "",
|
|
2047
2583
|
refreshToken: ""
|
|
2048
2584
|
},
|
|
2049
|
-
callbackURL:
|
|
2585
|
+
callbackURL: safeCallbackUrl,
|
|
2050
2586
|
disableSignUp: options?.disableImplicitSignUp,
|
|
2051
2587
|
isTrustedProvider
|
|
2052
2588
|
});
|
|
2053
|
-
if (result.error) throw ctx.redirect(`${
|
|
2589
|
+
if (result.error) throw ctx.redirect(`${safeCallbackUrl}?error=${result.error.split(" ").join("_")}`);
|
|
2054
2590
|
const { session, user } = result.data;
|
|
2055
2591
|
if (options?.provisionUser) await options.provisionUser({
|
|
2056
2592
|
user,
|
|
@@ -2074,7 +2610,7 @@ const callbackSSOSAML = (options) => {
|
|
|
2074
2610
|
session,
|
|
2075
2611
|
user
|
|
2076
2612
|
});
|
|
2077
|
-
throw ctx.redirect(
|
|
2613
|
+
throw ctx.redirect(safeCallbackUrl);
|
|
2078
2614
|
});
|
|
2079
2615
|
};
|
|
2080
2616
|
const acsEndpointBodySchema = z.object({
|
|
@@ -2096,10 +2632,18 @@ const acsEndpoint = (options) => {
|
|
|
2096
2632
|
}
|
|
2097
2633
|
}
|
|
2098
2634
|
}, async (ctx) => {
|
|
2099
|
-
const { SAMLResponse
|
|
2635
|
+
const { SAMLResponse } = ctx.body;
|
|
2100
2636
|
const { providerId } = ctx.params;
|
|
2637
|
+
const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`;
|
|
2638
|
+
const appOrigin = new URL(ctx.context.baseURL).origin;
|
|
2101
2639
|
const maxResponseSize = options?.saml?.maxResponseSize ?? DEFAULT_MAX_SAML_RESPONSE_SIZE;
|
|
2102
2640
|
if (new TextEncoder().encode(SAMLResponse).length > maxResponseSize) throw new APIError("BAD_REQUEST", { message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)` });
|
|
2641
|
+
let relayState = null;
|
|
2642
|
+
if (ctx.body.RelayState) try {
|
|
2643
|
+
relayState = await parseRelayState(ctx);
|
|
2644
|
+
} catch {
|
|
2645
|
+
relayState = null;
|
|
2646
|
+
}
|
|
2103
2647
|
let provider = null;
|
|
2104
2648
|
if (options?.defaultSSO?.length) {
|
|
2105
2649
|
const matchingDefault = providerId ? options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId) : options.defaultSSO[0];
|
|
@@ -2127,6 +2671,8 @@ const acsEndpoint = (options) => {
|
|
|
2127
2671
|
if (!provider?.samlConfig) throw new APIError("NOT_FOUND", { message: "No SAML provider found" });
|
|
2128
2672
|
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
2129
2673
|
const parsedSamlConfig = provider.samlConfig;
|
|
2674
|
+
const isTrusted = (url, settings) => ctx.context.isTrustedOrigin(url, settings);
|
|
2675
|
+
const safeErrorUrl = getSafeRedirectUrl(relayState?.errorURL || relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, isTrusted);
|
|
2130
2676
|
const sp = saml.ServiceProvider({
|
|
2131
2677
|
entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
|
2132
2678
|
assertionConsumerService: [{
|
|
@@ -2152,9 +2698,8 @@ const acsEndpoint = (options) => {
|
|
|
2152
2698
|
validateSingleAssertion(SAMLResponse);
|
|
2153
2699
|
} catch (error) {
|
|
2154
2700
|
if (error instanceof APIError) {
|
|
2155
|
-
const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2156
2701
|
const errorCode = error.body?.code === "SAML_MULTIPLE_ASSERTIONS" ? "multiple_assertions" : "no_assertion";
|
|
2157
|
-
throw ctx.redirect(`${
|
|
2702
|
+
throw ctx.redirect(`${safeErrorUrl}?error=${errorCode}&error_description=${encodeURIComponent(error.message)}`);
|
|
2158
2703
|
}
|
|
2159
2704
|
throw error;
|
|
2160
2705
|
}
|
|
@@ -2162,13 +2707,13 @@ const acsEndpoint = (options) => {
|
|
|
2162
2707
|
try {
|
|
2163
2708
|
parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
|
|
2164
2709
|
SAMLResponse,
|
|
2165
|
-
RelayState: RelayState || void 0
|
|
2710
|
+
RelayState: ctx.body.RelayState || void 0
|
|
2166
2711
|
} });
|
|
2167
2712
|
if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
|
|
2168
2713
|
} catch (error) {
|
|
2169
2714
|
ctx.context.logger.error("SAML response validation failed", {
|
|
2170
2715
|
error,
|
|
2171
|
-
decodedResponse:
|
|
2716
|
+
decodedResponse: new TextDecoder().decode(base64.decode(SAMLResponse))
|
|
2172
2717
|
});
|
|
2173
2718
|
throw new APIError("BAD_REQUEST", {
|
|
2174
2719
|
message: "Invalid SAML response",
|
|
@@ -2199,8 +2744,7 @@ const acsEndpoint = (options) => {
|
|
|
2199
2744
|
inResponseTo: inResponseToAcs,
|
|
2200
2745
|
providerId
|
|
2201
2746
|
});
|
|
2202
|
-
|
|
2203
|
-
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
|
|
2747
|
+
throw ctx.redirect(`${safeErrorUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
|
|
2204
2748
|
}
|
|
2205
2749
|
if (storedRequest.providerId !== providerId) {
|
|
2206
2750
|
ctx.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
|
|
@@ -2209,17 +2753,15 @@ const acsEndpoint = (options) => {
|
|
|
2209
2753
|
actualProvider: providerId
|
|
2210
2754
|
});
|
|
2211
2755
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
|
|
2212
|
-
|
|
2213
|
-
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
|
|
2756
|
+
throw ctx.redirect(`${safeErrorUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
|
|
2214
2757
|
}
|
|
2215
2758
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
|
|
2216
2759
|
} else if (!allowIdpInitiated) {
|
|
2217
2760
|
ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId });
|
|
2218
|
-
|
|
2219
|
-
throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
|
|
2761
|
+
throw ctx.redirect(`${safeErrorUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
|
|
2220
2762
|
}
|
|
2221
2763
|
}
|
|
2222
|
-
const assertionIdAcs = extractAssertionId(
|
|
2764
|
+
const assertionIdAcs = extractAssertionId(new TextDecoder().decode(base64.decode(SAMLResponse)));
|
|
2223
2765
|
if (assertionIdAcs) {
|
|
2224
2766
|
const issuer = idp.entityMeta.getEntityID();
|
|
2225
2767
|
const conditions = extract.conditions;
|
|
@@ -2241,8 +2783,7 @@ const acsEndpoint = (options) => {
|
|
|
2241
2783
|
issuer,
|
|
2242
2784
|
providerId
|
|
2243
2785
|
});
|
|
2244
|
-
|
|
2245
|
-
throw ctx.redirect(`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
|
|
2786
|
+
throw ctx.redirect(`${safeErrorUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
|
|
2246
2787
|
}
|
|
2247
2788
|
await ctx.context.internalAdapter.createVerificationValue({
|
|
2248
2789
|
identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionIdAcs}`,
|
|
@@ -2275,7 +2816,7 @@ const acsEndpoint = (options) => {
|
|
|
2275
2816
|
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
2276
2817
|
}
|
|
2277
2818
|
const isTrustedProvider = !!ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
2278
|
-
const
|
|
2819
|
+
const safeCallbackUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, isTrusted);
|
|
2279
2820
|
const result = await handleOAuthUserInfo(ctx, {
|
|
2280
2821
|
userInfo: {
|
|
2281
2822
|
email: userInfo.email,
|
|
@@ -2289,11 +2830,11 @@ const acsEndpoint = (options) => {
|
|
|
2289
2830
|
accessToken: "",
|
|
2290
2831
|
refreshToken: ""
|
|
2291
2832
|
},
|
|
2292
|
-
callbackURL:
|
|
2833
|
+
callbackURL: safeCallbackUrl,
|
|
2293
2834
|
disableSignUp: options?.disableImplicitSignUp,
|
|
2294
2835
|
isTrustedProvider
|
|
2295
2836
|
});
|
|
2296
|
-
if (result.error) throw ctx.redirect(`${
|
|
2837
|
+
if (result.error) throw ctx.redirect(`${safeCallbackUrl}?error=${result.error.split(" ").join("_")}`);
|
|
2297
2838
|
const { session, user } = result.data;
|
|
2298
2839
|
if (options?.provisionUser) await options.provisionUser({
|
|
2299
2840
|
user,
|
|
@@ -2317,7 +2858,7 @@ const acsEndpoint = (options) => {
|
|
|
2317
2858
|
session,
|
|
2318
2859
|
user
|
|
2319
2860
|
});
|
|
2320
|
-
throw ctx.redirect(
|
|
2861
|
+
throw ctx.redirect(safeCallbackUrl);
|
|
2321
2862
|
});
|
|
2322
2863
|
};
|
|
2323
2864
|
|
|
@@ -2327,6 +2868,12 @@ saml.setSchemaValidator({ async validate(xml) {
|
|
|
2327
2868
|
if (XMLValidator.validate(xml, { allowBooleanAttributes: true }) === true) return "SUCCESS_VALIDATE_XML";
|
|
2328
2869
|
throw "ERR_INVALID_XML";
|
|
2329
2870
|
} });
|
|
2871
|
+
/**
|
|
2872
|
+
* SAML endpoint paths that should skip origin check validation.
|
|
2873
|
+
* These endpoints receive POST requests from external Identity Providers,
|
|
2874
|
+
* which won't have a matching Origin header.
|
|
2875
|
+
*/
|
|
2876
|
+
const SAML_SKIP_ORIGIN_CHECK_PATHS = ["/sso/saml2/callback", "/sso/saml2/sp/acs"];
|
|
2330
2877
|
function sso(options) {
|
|
2331
2878
|
const optionsWithStore = options;
|
|
2332
2879
|
let endpoints = {
|
|
@@ -2335,7 +2882,11 @@ function sso(options) {
|
|
|
2335
2882
|
signInSSO: signInSSO(optionsWithStore),
|
|
2336
2883
|
callbackSSO: callbackSSO(optionsWithStore),
|
|
2337
2884
|
callbackSSOSAML: callbackSSOSAML(optionsWithStore),
|
|
2338
|
-
acsEndpoint: acsEndpoint(optionsWithStore)
|
|
2885
|
+
acsEndpoint: acsEndpoint(optionsWithStore),
|
|
2886
|
+
listSSOProviders: listSSOProviders(),
|
|
2887
|
+
getSSOProvider: getSSOProvider(),
|
|
2888
|
+
updateSSOProvider: updateSSOProvider(optionsWithStore),
|
|
2889
|
+
deleteSSOProvider: deleteSSOProvider()
|
|
2339
2890
|
};
|
|
2340
2891
|
if (options?.domainVerification?.enabled) {
|
|
2341
2892
|
const domainVerificationEndpoints = {
|
|
@@ -2349,6 +2900,11 @@ function sso(options) {
|
|
|
2349
2900
|
}
|
|
2350
2901
|
return {
|
|
2351
2902
|
id: "sso",
|
|
2903
|
+
init(ctx) {
|
|
2904
|
+
const existing = ctx.skipOriginCheck;
|
|
2905
|
+
if (existing === true) return {};
|
|
2906
|
+
return { context: { skipOriginCheck: [...Array.isArray(existing) ? existing : [], ...SAML_SKIP_ORIGIN_CHECK_PATHS] } };
|
|
2907
|
+
},
|
|
2352
2908
|
endpoints,
|
|
2353
2909
|
hooks: { after: [{
|
|
2354
2910
|
matcher(context) {
|
|
@@ -2357,7 +2913,7 @@ function sso(options) {
|
|
|
2357
2913
|
handler: createAuthMiddleware(async (ctx) => {
|
|
2358
2914
|
const newSession = ctx.context.newSession;
|
|
2359
2915
|
if (!newSession?.user) return;
|
|
2360
|
-
if (!ctx.context.options.plugins?.
|
|
2916
|
+
if (!(ctx.context.options.plugins?.some((plugin) => plugin.id === "organization") ?? false)) return;
|
|
2361
2917
|
await assignOrganizationByDomain(ctx, {
|
|
2362
2918
|
user: newSession.user,
|
|
2363
2919
|
provisioningOptions: options?.organizationProvisioning,
|
|
@@ -2418,4 +2974,5 @@ function sso(options) {
|
|
|
2418
2974
|
}
|
|
2419
2975
|
|
|
2420
2976
|
//#endregion
|
|
2421
|
-
export { DEFAULT_CLOCK_SKEW_MS, DEFAULT_MAX_SAML_METADATA_SIZE, DEFAULT_MAX_SAML_RESPONSE_SIZE, DataEncryptionAlgorithm, DigestAlgorithm, DiscoveryError, KeyEncryptionAlgorithm, REQUIRED_DISCOVERY_FIELDS, SignatureAlgorithm, computeDiscoveryUrl, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, selectTokenEndpointAuthMethod, sso, validateDiscoveryDocument, validateDiscoveryUrl, validateSAMLTimestamp };
|
|
2977
|
+
export { DEFAULT_CLOCK_SKEW_MS, DEFAULT_MAX_SAML_METADATA_SIZE, DEFAULT_MAX_SAML_RESPONSE_SIZE, DataEncryptionAlgorithm, DigestAlgorithm, DiscoveryError, KeyEncryptionAlgorithm, REQUIRED_DISCOVERY_FIELDS, SignatureAlgorithm, computeDiscoveryUrl, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, selectTokenEndpointAuthMethod, sso, validateDiscoveryDocument, validateDiscoveryUrl, validateSAMLTimestamp };
|
|
2978
|
+
//# sourceMappingURL=index.mjs.map
|