@better-auth/sso 1.5.0-beta.8 → 1.5.0
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/README.md +17 -0
- package/dist/client.d.mts +8 -2
- package/dist/client.mjs +7 -2
- package/dist/client.mjs.map +1 -0
- package/dist/{index-BT0wtuq1.d.mts → index-BQp9TZiG.d.mts} +494 -66
- package/dist/index.d.mts +56 -2
- package/dist/index.mjs +1505 -629
- package/dist/index.mjs.map +1 -0
- package/package.json +35 -31
- package/.turbo/turbo-build.log +0 -16
- package/src/client.ts +0 -25
- package/src/constants.ts +0 -58
- package/src/domain-verification.test.ts +0 -551
- package/src/index.ts +0 -253
- package/src/linking/index.ts +0 -2
- package/src/linking/org-assignment.test.ts +0 -325
- package/src/linking/org-assignment.ts +0 -169
- package/src/linking/types.ts +0 -10
- package/src/oidc/discovery.test.ts +0 -1157
- package/src/oidc/discovery.ts +0 -494
- package/src/oidc/errors.ts +0 -92
- package/src/oidc/index.ts +0 -31
- package/src/oidc/types.ts +0 -219
- package/src/oidc.test.ts +0 -576
- package/src/routes/domain-verification.ts +0 -275
- package/src/routes/sso.ts +0 -2710
- package/src/saml/algorithms.test.ts +0 -449
- package/src/saml/algorithms.ts +0 -338
- package/src/saml/assertions.test.ts +0 -239
- package/src/saml/assertions.ts +0 -62
- package/src/saml/index.ts +0 -13
- package/src/saml/parser.ts +0 -56
- package/src/saml-state.ts +0 -78
- package/src/saml.test.ts +0 -4003
- package/src/types.ts +0 -357
- package/src/utils.ts +0 -41
- package/tsconfig.json +0 -14
- package/tsdown.config.ts +0 -8
- package/vitest.config.ts +0 -3
package/dist/index.mjs
CHANGED
|
@@ -1,17 +1,123 @@
|
|
|
1
1
|
import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
|
|
2
2
|
import { XMLParser, XMLValidator } from "fast-xml-parser";
|
|
3
|
-
import
|
|
3
|
+
import 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
10
|
import { HIDE_METADATA, createAuthorizationURL, generateGenericState, generateState, parseGenericState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
|
|
9
|
-
import { setSessionCookie } from "better-auth/cookies";
|
|
11
|
+
import { deleteSessionCookie, setSessionCookie } from "better-auth/cookies";
|
|
10
12
|
import { handleOAuthUserInfo } from "better-auth/oauth2";
|
|
11
13
|
import { decodeJwt } from "jose";
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
+
import { defineErrorCodes } from "@better-auth/core/utils/error-codes";
|
|
15
|
+
|
|
16
|
+
//#region src/constants.ts
|
|
17
|
+
/**
|
|
18
|
+
* SAML Constants
|
|
19
|
+
*
|
|
20
|
+
* Centralized constants for SAML SSO functionality.
|
|
21
|
+
*/
|
|
22
|
+
/** Prefix for AuthnRequest IDs used in InResponseTo validation */
|
|
23
|
+
const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
|
|
24
|
+
/** Prefix for used Assertion IDs used in replay protection */
|
|
25
|
+
const USED_ASSERTION_KEY_PREFIX = "saml-used-assertion:";
|
|
26
|
+
/** Prefix for SAML session data (NameID + SessionIndex) for SLO */
|
|
27
|
+
const SAML_SESSION_KEY_PREFIX = "saml-session:";
|
|
28
|
+
/** Prefix for reverse lookup of SAML session by Better Auth session ID */
|
|
29
|
+
const SAML_SESSION_BY_ID_PREFIX = "saml-session-by-id:";
|
|
30
|
+
/** Prefix for LogoutRequest IDs used in SP-initiated SLO validation */
|
|
31
|
+
const LOGOUT_REQUEST_KEY_PREFIX = "saml-logout-request:";
|
|
32
|
+
/**
|
|
33
|
+
* Default TTL for AuthnRequest records (5 minutes).
|
|
34
|
+
* This should be sufficient for most IdPs while protecting against stale requests.
|
|
35
|
+
*/
|
|
36
|
+
const DEFAULT_AUTHN_REQUEST_TTL_MS = 300 * 1e3;
|
|
37
|
+
/**
|
|
38
|
+
* Default TTL for used assertion records (15 minutes).
|
|
39
|
+
* This should match the maximum expected NotOnOrAfter window plus clock skew.
|
|
40
|
+
*/
|
|
41
|
+
const DEFAULT_ASSERTION_TTL_MS = 900 * 1e3;
|
|
42
|
+
/**
|
|
43
|
+
* Default TTL for LogoutRequest records (5 minutes).
|
|
44
|
+
* Should be sufficient for IdP to process and respond.
|
|
45
|
+
*/
|
|
46
|
+
const DEFAULT_LOGOUT_REQUEST_TTL_MS = 300 * 1e3;
|
|
47
|
+
/**
|
|
48
|
+
* Default clock skew tolerance (5 minutes).
|
|
49
|
+
* Allows for minor time differences between IdP and SP servers.
|
|
50
|
+
*
|
|
51
|
+
* Accommodates:
|
|
52
|
+
* - Network latency and processing time
|
|
53
|
+
* - Clock synchronization differences (NTP drift)
|
|
54
|
+
* - Distributed systems across timezones
|
|
55
|
+
*/
|
|
56
|
+
const DEFAULT_CLOCK_SKEW_MS = 300 * 1e3;
|
|
57
|
+
/**
|
|
58
|
+
* Default maximum size for SAML responses (256 KB).
|
|
59
|
+
* Protects against memory exhaustion from oversized SAML payloads.
|
|
60
|
+
*/
|
|
61
|
+
const DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024;
|
|
62
|
+
/**
|
|
63
|
+
* Default maximum size for IdP metadata (100 KB).
|
|
64
|
+
* Protects against oversized metadata documents.
|
|
65
|
+
*/
|
|
66
|
+
const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024;
|
|
67
|
+
const SAML_STATUS_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success";
|
|
68
|
+
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region src/utils.ts
|
|
71
|
+
/**
|
|
72
|
+
* Safely parses a value that might be a JSON string or already a parsed object.
|
|
73
|
+
* This handles cases where ORMs like Drizzle might return already parsed objects
|
|
74
|
+
* instead of JSON strings from TEXT/JSON columns.
|
|
75
|
+
*
|
|
76
|
+
* @param value - The value to parse (string, object, null, or undefined)
|
|
77
|
+
* @returns The parsed object or null
|
|
78
|
+
* @throws Error if string parsing fails
|
|
79
|
+
*/
|
|
80
|
+
function safeJsonParse(value) {
|
|
81
|
+
if (!value) return null;
|
|
82
|
+
if (typeof value === "object") return value;
|
|
83
|
+
if (typeof value === "string") try {
|
|
84
|
+
return JSON.parse(value);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
throw new Error(`Failed to parse JSON: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Checks if a domain matches any domain in a comma-separated list.
|
|
92
|
+
*/
|
|
93
|
+
const domainMatches = (searchDomain, domainList) => {
|
|
94
|
+
const search = searchDomain.toLowerCase();
|
|
95
|
+
return domainList.split(",").map((d) => d.trim().toLowerCase()).filter(Boolean).some((d) => search === d || search.endsWith(`.${d}`));
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Validates email domain against allowed domain(s).
|
|
99
|
+
* Supports comma-separated domains for multi-domain SSO.
|
|
100
|
+
*/
|
|
101
|
+
const validateEmailDomain = (email, domain) => {
|
|
102
|
+
const emailDomain = email.split("@")[1]?.toLowerCase();
|
|
103
|
+
if (!emailDomain || !domain) return false;
|
|
104
|
+
return domainMatches(emailDomain, domain);
|
|
105
|
+
};
|
|
106
|
+
function parseCertificate(certPem) {
|
|
107
|
+
const cert = new X509Certificate(certPem.includes("-----BEGIN") ? certPem : `-----BEGIN CERTIFICATE-----\n${certPem}\n-----END CERTIFICATE-----`);
|
|
108
|
+
return {
|
|
109
|
+
fingerprintSha256: cert.fingerprint256,
|
|
110
|
+
notBefore: cert.validFrom,
|
|
111
|
+
notAfter: cert.validTo,
|
|
112
|
+
publicKeyAlgorithm: cert.publicKey.asymmetricKeyType?.toUpperCase() || "UNKNOWN"
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function maskClientId(clientId) {
|
|
116
|
+
if (clientId.length <= 4) return "****";
|
|
117
|
+
return `****${clientId.slice(-4)}`;
|
|
118
|
+
}
|
|
14
119
|
|
|
120
|
+
//#endregion
|
|
15
121
|
//#region src/linking/org-assignment.ts
|
|
16
122
|
/**
|
|
17
123
|
* Assigns a user to an organization based on the SSO provider's organizationId.
|
|
@@ -21,7 +127,7 @@ async function assignOrganizationFromProvider(ctx, options) {
|
|
|
21
127
|
const { user, profile, provider, token, provisioningOptions } = options;
|
|
22
128
|
if (!provider.organizationId) return;
|
|
23
129
|
if (provisioningOptions?.disabled) return;
|
|
24
|
-
if (!ctx.context.
|
|
130
|
+
if (!ctx.context.hasPlugin("organization")) return;
|
|
25
131
|
if (await ctx.context.adapter.findOne({
|
|
26
132
|
model: "member",
|
|
27
133
|
where: [{
|
|
@@ -59,7 +165,7 @@ async function assignOrganizationFromProvider(ctx, options) {
|
|
|
59
165
|
async function assignOrganizationByDomain(ctx, options) {
|
|
60
166
|
const { user, provisioningOptions, domainVerification } = options;
|
|
61
167
|
if (provisioningOptions?.disabled) return;
|
|
62
|
-
if (!ctx.context.
|
|
168
|
+
if (!ctx.context.hasPlugin("organization")) return;
|
|
63
169
|
const domain = user.email.split("@")[1];
|
|
64
170
|
if (!domain) return;
|
|
65
171
|
const whereClause = [{
|
|
@@ -70,10 +176,17 @@ async function assignOrganizationByDomain(ctx, options) {
|
|
|
70
176
|
field: "domainVerified",
|
|
71
177
|
value: true
|
|
72
178
|
});
|
|
73
|
-
|
|
179
|
+
let ssoProvider = await ctx.context.adapter.findOne({
|
|
74
180
|
model: "ssoProvider",
|
|
75
181
|
where: whereClause
|
|
76
182
|
});
|
|
183
|
+
if (!ssoProvider) ssoProvider = (await ctx.context.adapter.findMany({
|
|
184
|
+
model: "ssoProvider",
|
|
185
|
+
where: domainVerification?.enabled ? [{
|
|
186
|
+
field: "domainVerified",
|
|
187
|
+
value: true
|
|
188
|
+
}] : []
|
|
189
|
+
})).find((p) => domainMatches(domain, p.domain)) ?? null;
|
|
77
190
|
if (!ssoProvider || !ssoProvider.organizationId) return;
|
|
78
191
|
if (await ctx.context.adapter.findOne({
|
|
79
192
|
model: "member",
|
|
@@ -103,7 +216,12 @@ async function assignOrganizationByDomain(ctx, options) {
|
|
|
103
216
|
|
|
104
217
|
//#endregion
|
|
105
218
|
//#region src/routes/domain-verification.ts
|
|
219
|
+
const DNS_LABEL_MAX_LENGTH = 63;
|
|
220
|
+
const DEFAULT_TOKEN_PREFIX = "better-auth-token";
|
|
106
221
|
const domainVerificationBodySchema = z$1.object({ providerId: z$1.string() });
|
|
222
|
+
function getVerificationIdentifier(options, providerId) {
|
|
223
|
+
return `_${options.domainVerification?.tokenPrefix || DEFAULT_TOKEN_PREFIX}-${providerId}`;
|
|
224
|
+
}
|
|
107
225
|
const requestDomainVerification = (options) => {
|
|
108
226
|
return createAuthEndpoint("/sso/request-domain-verification", {
|
|
109
227
|
method: "POST",
|
|
@@ -151,11 +269,12 @@ const requestDomainVerification = (options) => {
|
|
|
151
269
|
message: "Domain has already been verified",
|
|
152
270
|
code: "DOMAIN_VERIFIED"
|
|
153
271
|
});
|
|
272
|
+
const identifier = getVerificationIdentifier(options, provider.providerId);
|
|
154
273
|
const activeVerification = await ctx.context.adapter.findOne({
|
|
155
274
|
model: "verification",
|
|
156
275
|
where: [{
|
|
157
276
|
field: "identifier",
|
|
158
|
-
value:
|
|
277
|
+
value: identifier
|
|
159
278
|
}, {
|
|
160
279
|
field: "expiresAt",
|
|
161
280
|
value: /* @__PURE__ */ new Date(),
|
|
@@ -170,7 +289,7 @@ const requestDomainVerification = (options) => {
|
|
|
170
289
|
await ctx.context.adapter.create({
|
|
171
290
|
model: "verification",
|
|
172
291
|
data: {
|
|
173
|
-
identifier
|
|
292
|
+
identifier,
|
|
174
293
|
createdAt: /* @__PURE__ */ new Date(),
|
|
175
294
|
updatedAt: /* @__PURE__ */ new Date(),
|
|
176
295
|
value: domainVerificationToken,
|
|
@@ -229,11 +348,16 @@ const verifyDomain = (options) => {
|
|
|
229
348
|
message: "Domain has already been verified",
|
|
230
349
|
code: "DOMAIN_VERIFIED"
|
|
231
350
|
});
|
|
351
|
+
const identifier = getVerificationIdentifier(options, provider.providerId);
|
|
352
|
+
if (identifier.length > DNS_LABEL_MAX_LENGTH) throw new APIError("BAD_REQUEST", {
|
|
353
|
+
message: `Verification identifier exceeds the DNS label limit of ${DNS_LABEL_MAX_LENGTH} characters`,
|
|
354
|
+
code: "IDENTIFIER_TOO_LONG"
|
|
355
|
+
});
|
|
232
356
|
const activeVerification = await ctx.context.adapter.findOne({
|
|
233
357
|
model: "verification",
|
|
234
358
|
where: [{
|
|
235
359
|
field: "identifier",
|
|
236
|
-
value:
|
|
360
|
+
value: identifier
|
|
237
361
|
}, {
|
|
238
362
|
field: "expiresAt",
|
|
239
363
|
value: /* @__PURE__ */ new Date(),
|
|
@@ -256,7 +380,8 @@ const verifyDomain = (options) => {
|
|
|
256
380
|
});
|
|
257
381
|
}
|
|
258
382
|
try {
|
|
259
|
-
|
|
383
|
+
const hostname = new URL(provider.domain).hostname;
|
|
384
|
+
records = (await dns.resolveTxt(`${identifier}.${hostname}`)).flat();
|
|
260
385
|
} catch (error) {
|
|
261
386
|
ctx.context.logger.warn("DNS resolution failure while validating domain ownership", error);
|
|
262
387
|
}
|
|
@@ -277,110 +402,699 @@ const verifyDomain = (options) => {
|
|
|
277
402
|
};
|
|
278
403
|
|
|
279
404
|
//#endregion
|
|
280
|
-
//#region src/
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
*/
|
|
314
|
-
const DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024;
|
|
315
|
-
/**
|
|
316
|
-
* Default maximum size for IdP metadata (100 KB).
|
|
317
|
-
* Protects against oversized metadata documents.
|
|
318
|
-
*/
|
|
319
|
-
const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024;
|
|
405
|
+
//#region src/saml/parser.ts
|
|
406
|
+
const xmlParser = new XMLParser({
|
|
407
|
+
ignoreAttributes: false,
|
|
408
|
+
attributeNamePrefix: "@_",
|
|
409
|
+
removeNSPrefix: true,
|
|
410
|
+
processEntities: false
|
|
411
|
+
});
|
|
412
|
+
function findNode(obj, nodeName) {
|
|
413
|
+
if (!obj || typeof obj !== "object") return null;
|
|
414
|
+
const record = obj;
|
|
415
|
+
if (nodeName in record) return record[nodeName];
|
|
416
|
+
for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
|
|
417
|
+
const found = findNode(item, nodeName);
|
|
418
|
+
if (found) return found;
|
|
419
|
+
}
|
|
420
|
+
else if (typeof value === "object" && value !== null) {
|
|
421
|
+
const found = findNode(value, nodeName);
|
|
422
|
+
if (found) return found;
|
|
423
|
+
}
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
function countAllNodes(obj, nodeName) {
|
|
427
|
+
if (!obj || typeof obj !== "object") return 0;
|
|
428
|
+
let count = 0;
|
|
429
|
+
const record = obj;
|
|
430
|
+
if (nodeName in record) {
|
|
431
|
+
const node = record[nodeName];
|
|
432
|
+
count += Array.isArray(node) ? node.length : 1;
|
|
433
|
+
}
|
|
434
|
+
for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) count += countAllNodes(item, nodeName);
|
|
435
|
+
else if (typeof value === "object" && value !== null) count += countAllNodes(value, nodeName);
|
|
436
|
+
return count;
|
|
437
|
+
}
|
|
320
438
|
|
|
321
439
|
//#endregion
|
|
322
|
-
//#region src/
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
super(message, options);
|
|
332
|
-
this.name = "DiscoveryError";
|
|
333
|
-
this.code = code;
|
|
334
|
-
this.details = details;
|
|
335
|
-
if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
|
|
336
|
-
}
|
|
440
|
+
//#region src/saml/algorithms.ts
|
|
441
|
+
const SignatureAlgorithm = {
|
|
442
|
+
RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
|
|
443
|
+
RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
|
|
444
|
+
RSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
|
|
445
|
+
RSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
|
|
446
|
+
ECDSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
|
|
447
|
+
ECDSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
|
|
448
|
+
ECDSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"
|
|
337
449
|
};
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
"
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
"
|
|
450
|
+
const DigestAlgorithm = {
|
|
451
|
+
SHA1: "http://www.w3.org/2000/09/xmldsig#sha1",
|
|
452
|
+
SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
|
|
453
|
+
SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
|
|
454
|
+
SHA512: "http://www.w3.org/2001/04/xmlenc#sha512"
|
|
455
|
+
};
|
|
456
|
+
const KeyEncryptionAlgorithm = {
|
|
457
|
+
RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
|
|
458
|
+
RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
|
|
459
|
+
RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep"
|
|
460
|
+
};
|
|
461
|
+
const DataEncryptionAlgorithm = {
|
|
462
|
+
TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
|
|
463
|
+
AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
|
|
464
|
+
AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
|
|
465
|
+
AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
|
|
466
|
+
AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
|
|
467
|
+
AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
|
|
468
|
+
AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
|
|
469
|
+
};
|
|
470
|
+
const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
|
|
471
|
+
const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS = [KeyEncryptionAlgorithm.RSA_1_5];
|
|
472
|
+
const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS = [DataEncryptionAlgorithm.TRIPLEDES_CBC];
|
|
473
|
+
const DEPRECATED_DIGEST_ALGORITHMS = [DigestAlgorithm.SHA1];
|
|
474
|
+
const SECURE_SIGNATURE_ALGORITHMS = [
|
|
475
|
+
SignatureAlgorithm.RSA_SHA256,
|
|
476
|
+
SignatureAlgorithm.RSA_SHA384,
|
|
477
|
+
SignatureAlgorithm.RSA_SHA512,
|
|
478
|
+
SignatureAlgorithm.ECDSA_SHA256,
|
|
479
|
+
SignatureAlgorithm.ECDSA_SHA384,
|
|
480
|
+
SignatureAlgorithm.ECDSA_SHA512
|
|
346
481
|
];
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
482
|
+
const SECURE_DIGEST_ALGORITHMS = [
|
|
483
|
+
DigestAlgorithm.SHA256,
|
|
484
|
+
DigestAlgorithm.SHA384,
|
|
485
|
+
DigestAlgorithm.SHA512
|
|
486
|
+
];
|
|
487
|
+
const SHORT_FORM_SIGNATURE_TO_URI = {
|
|
488
|
+
sha1: SignatureAlgorithm.RSA_SHA1,
|
|
489
|
+
sha256: SignatureAlgorithm.RSA_SHA256,
|
|
490
|
+
sha384: SignatureAlgorithm.RSA_SHA384,
|
|
491
|
+
sha512: SignatureAlgorithm.RSA_SHA512,
|
|
492
|
+
"rsa-sha1": SignatureAlgorithm.RSA_SHA1,
|
|
493
|
+
"rsa-sha256": SignatureAlgorithm.RSA_SHA256,
|
|
494
|
+
"rsa-sha384": SignatureAlgorithm.RSA_SHA384,
|
|
495
|
+
"rsa-sha512": SignatureAlgorithm.RSA_SHA512,
|
|
496
|
+
"ecdsa-sha256": SignatureAlgorithm.ECDSA_SHA256,
|
|
497
|
+
"ecdsa-sha384": SignatureAlgorithm.ECDSA_SHA384,
|
|
498
|
+
"ecdsa-sha512": SignatureAlgorithm.ECDSA_SHA512
|
|
499
|
+
};
|
|
500
|
+
const SHORT_FORM_DIGEST_TO_URI = {
|
|
501
|
+
sha1: DigestAlgorithm.SHA1,
|
|
502
|
+
sha256: DigestAlgorithm.SHA256,
|
|
503
|
+
sha384: DigestAlgorithm.SHA384,
|
|
504
|
+
sha512: DigestAlgorithm.SHA512
|
|
505
|
+
};
|
|
506
|
+
function normalizeSignatureAlgorithm(alg) {
|
|
507
|
+
return SHORT_FORM_SIGNATURE_TO_URI[alg.toLowerCase()] ?? alg;
|
|
508
|
+
}
|
|
509
|
+
function normalizeDigestAlgorithm(alg) {
|
|
510
|
+
return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
|
|
511
|
+
}
|
|
512
|
+
function extractEncryptionAlgorithms(xml) {
|
|
513
|
+
try {
|
|
514
|
+
const parsed = xmlParser.parse(xml);
|
|
515
|
+
const keyAlg = (findNode(parsed, "EncryptedKey")?.EncryptionMethod)?.["@_Algorithm"];
|
|
516
|
+
const dataAlg = (findNode(parsed, "EncryptedData")?.EncryptionMethod)?.["@_Algorithm"];
|
|
517
|
+
return {
|
|
518
|
+
keyEncryption: keyAlg || null,
|
|
519
|
+
dataEncryption: dataAlg || null
|
|
520
|
+
};
|
|
521
|
+
} catch {
|
|
522
|
+
return {
|
|
523
|
+
keyEncryption: null,
|
|
524
|
+
dataEncryption: null
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
function hasEncryptedAssertion(xml) {
|
|
529
|
+
try {
|
|
530
|
+
return findNode(xmlParser.parse(xml), "EncryptedAssertion") !== null;
|
|
531
|
+
} catch {
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
function handleDeprecatedAlgorithm(message, behavior, errorCode) {
|
|
536
|
+
switch (behavior) {
|
|
537
|
+
case "reject": throw new APIError("BAD_REQUEST", {
|
|
538
|
+
message,
|
|
539
|
+
code: errorCode
|
|
540
|
+
});
|
|
541
|
+
case "warn":
|
|
542
|
+
console.warn(`[SAML Security Warning] ${message}`);
|
|
543
|
+
break;
|
|
544
|
+
case "allow": break;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
function validateSignatureAlgorithm(algorithm, options = {}) {
|
|
548
|
+
if (!algorithm) return;
|
|
549
|
+
const { onDeprecated = "warn", allowedSignatureAlgorithms } = options;
|
|
550
|
+
if (allowedSignatureAlgorithms) {
|
|
551
|
+
if (!allowedSignatureAlgorithms.includes(algorithm)) throw new APIError("BAD_REQUEST", {
|
|
552
|
+
message: `SAML signature algorithm not in allow-list: ${algorithm}`,
|
|
553
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
554
|
+
});
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(algorithm)) {
|
|
558
|
+
handleDeprecatedAlgorithm(`SAML response uses deprecated signature algorithm: ${algorithm}. Please configure your IdP to use SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (!SECURE_SIGNATURE_ALGORITHMS.includes(algorithm)) throw new APIError("BAD_REQUEST", {
|
|
562
|
+
message: `SAML signature algorithm not recognized: ${algorithm}`,
|
|
563
|
+
code: "SAML_UNKNOWN_ALGORITHM"
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
function validateEncryptionAlgorithms(algorithms, options = {}) {
|
|
567
|
+
const { onDeprecated = "warn", allowedKeyEncryptionAlgorithms, allowedDataEncryptionAlgorithms } = options;
|
|
568
|
+
const { keyEncryption, dataEncryption } = algorithms;
|
|
569
|
+
if (keyEncryption) {
|
|
570
|
+
if (allowedKeyEncryptionAlgorithms) {
|
|
571
|
+
if (!allowedKeyEncryptionAlgorithms.includes(keyEncryption)) throw new APIError("BAD_REQUEST", {
|
|
572
|
+
message: `SAML key encryption algorithm not in allow-list: ${keyEncryption}`,
|
|
573
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
574
|
+
});
|
|
575
|
+
} 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");
|
|
576
|
+
}
|
|
577
|
+
if (dataEncryption) {
|
|
578
|
+
if (allowedDataEncryptionAlgorithms) {
|
|
579
|
+
if (!allowedDataEncryptionAlgorithms.includes(dataEncryption)) throw new APIError("BAD_REQUEST", {
|
|
580
|
+
message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
|
|
581
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
582
|
+
});
|
|
583
|
+
} 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");
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
function validateSAMLAlgorithms(response, options) {
|
|
587
|
+
validateSignatureAlgorithm(response.sigAlg, options);
|
|
588
|
+
if (hasEncryptedAssertion(response.samlContent)) validateEncryptionAlgorithms(extractEncryptionAlgorithms(response.samlContent), options);
|
|
589
|
+
}
|
|
590
|
+
function validateConfigAlgorithms(config, options = {}) {
|
|
591
|
+
const { onDeprecated = "warn", allowedSignatureAlgorithms, allowedDigestAlgorithms } = options;
|
|
592
|
+
if (config.signatureAlgorithm) {
|
|
593
|
+
const normalized = normalizeSignatureAlgorithm(config.signatureAlgorithm);
|
|
594
|
+
if (allowedSignatureAlgorithms) {
|
|
595
|
+
if (!allowedSignatureAlgorithms.map(normalizeSignatureAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
596
|
+
message: `SAML signature algorithm not in allow-list: ${config.signatureAlgorithm}`,
|
|
597
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
598
|
+
});
|
|
599
|
+
} 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");
|
|
600
|
+
else if (!SECURE_SIGNATURE_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
601
|
+
message: `SAML signature algorithm not recognized: ${config.signatureAlgorithm}`,
|
|
602
|
+
code: "SAML_UNKNOWN_ALGORITHM"
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
if (config.digestAlgorithm) {
|
|
606
|
+
const normalized = normalizeDigestAlgorithm(config.digestAlgorithm);
|
|
607
|
+
if (allowedDigestAlgorithms) {
|
|
608
|
+
if (!allowedDigestAlgorithms.map(normalizeDigestAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
609
|
+
message: `SAML digest algorithm not in allow-list: ${config.digestAlgorithm}`,
|
|
610
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
611
|
+
});
|
|
612
|
+
} 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");
|
|
613
|
+
else if (!SECURE_DIGEST_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
614
|
+
message: `SAML digest algorithm not recognized: ${config.digestAlgorithm}`,
|
|
615
|
+
code: "SAML_UNKNOWN_ALGORITHM"
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
//#endregion
|
|
621
|
+
//#region src/saml/assertions.ts
|
|
622
|
+
/** @lintignore used in tests */
|
|
623
|
+
function countAssertions(xml) {
|
|
624
|
+
let parsed;
|
|
625
|
+
try {
|
|
626
|
+
parsed = xmlParser.parse(xml);
|
|
627
|
+
} catch {
|
|
628
|
+
throw new APIError("BAD_REQUEST", {
|
|
629
|
+
message: "Failed to parse SAML response XML",
|
|
630
|
+
code: "SAML_INVALID_XML"
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
const assertions = countAllNodes(parsed, "Assertion");
|
|
634
|
+
const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
|
|
635
|
+
return {
|
|
636
|
+
assertions,
|
|
637
|
+
encryptedAssertions,
|
|
638
|
+
total: assertions + encryptedAssertions
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
function validateSingleAssertion(samlResponse) {
|
|
642
|
+
let xml;
|
|
643
|
+
try {
|
|
644
|
+
xml = new TextDecoder().decode(base64.decode(samlResponse));
|
|
645
|
+
if (!xml.includes("<")) throw new Error("Not XML");
|
|
646
|
+
} catch {
|
|
647
|
+
throw new APIError("BAD_REQUEST", {
|
|
648
|
+
message: "Invalid base64-encoded SAML response",
|
|
649
|
+
code: "SAML_INVALID_ENCODING"
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
const counts = countAssertions(xml);
|
|
653
|
+
if (counts.total === 0) throw new APIError("BAD_REQUEST", {
|
|
654
|
+
message: "SAML response contains no assertions",
|
|
655
|
+
code: "SAML_NO_ASSERTION"
|
|
656
|
+
});
|
|
657
|
+
if (counts.total > 1) throw new APIError("BAD_REQUEST", {
|
|
658
|
+
message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
|
|
659
|
+
code: "SAML_MULTIPLE_ASSERTIONS"
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
//#endregion
|
|
664
|
+
//#region src/routes/schemas.ts
|
|
665
|
+
const oidcMappingSchema = z.object({
|
|
666
|
+
id: z.string().optional(),
|
|
667
|
+
email: z.string().optional(),
|
|
668
|
+
emailVerified: z.string().optional(),
|
|
669
|
+
name: z.string().optional(),
|
|
670
|
+
image: z.string().optional(),
|
|
671
|
+
extraFields: z.record(z.string(), z.any()).optional()
|
|
672
|
+
}).optional();
|
|
673
|
+
const samlMappingSchema = z.object({
|
|
674
|
+
id: z.string().optional(),
|
|
675
|
+
email: z.string().optional(),
|
|
676
|
+
emailVerified: z.string().optional(),
|
|
677
|
+
name: z.string().optional(),
|
|
678
|
+
firstName: z.string().optional(),
|
|
679
|
+
lastName: z.string().optional(),
|
|
680
|
+
extraFields: z.record(z.string(), z.any()).optional()
|
|
681
|
+
}).optional();
|
|
682
|
+
const oidcConfigSchema = z.object({
|
|
683
|
+
clientId: z.string().optional(),
|
|
684
|
+
clientSecret: z.string().optional(),
|
|
685
|
+
authorizationEndpoint: z.string().url().optional(),
|
|
686
|
+
tokenEndpoint: z.string().url().optional(),
|
|
687
|
+
userInfoEndpoint: z.string().url().optional(),
|
|
688
|
+
tokenEndpointAuthentication: z.enum(["client_secret_post", "client_secret_basic"]).optional(),
|
|
689
|
+
jwksEndpoint: z.string().url().optional(),
|
|
690
|
+
discoveryEndpoint: z.string().url().optional(),
|
|
691
|
+
scopes: z.array(z.string()).optional(),
|
|
692
|
+
pkce: z.boolean().optional(),
|
|
693
|
+
overrideUserInfo: z.boolean().optional(),
|
|
694
|
+
mapping: oidcMappingSchema
|
|
695
|
+
});
|
|
696
|
+
const samlConfigSchema = z.object({
|
|
697
|
+
entryPoint: z.string().url().optional(),
|
|
698
|
+
cert: z.string().optional(),
|
|
699
|
+
callbackUrl: z.string().url().optional(),
|
|
700
|
+
audience: z.string().optional(),
|
|
701
|
+
idpMetadata: z.object({
|
|
702
|
+
metadata: z.string().optional(),
|
|
703
|
+
entityID: z.string().optional(),
|
|
704
|
+
cert: z.string().optional(),
|
|
705
|
+
privateKey: z.string().optional(),
|
|
706
|
+
privateKeyPass: z.string().optional(),
|
|
707
|
+
isAssertionEncrypted: z.boolean().optional(),
|
|
708
|
+
encPrivateKey: z.string().optional(),
|
|
709
|
+
encPrivateKeyPass: z.string().optional(),
|
|
710
|
+
singleSignOnService: z.array(z.object({
|
|
711
|
+
Binding: z.string(),
|
|
712
|
+
Location: z.string().url()
|
|
713
|
+
})).optional()
|
|
714
|
+
}).optional(),
|
|
715
|
+
spMetadata: z.object({
|
|
716
|
+
metadata: z.string().optional(),
|
|
717
|
+
entityID: z.string().optional(),
|
|
718
|
+
binding: z.string().optional(),
|
|
719
|
+
privateKey: z.string().optional(),
|
|
720
|
+
privateKeyPass: z.string().optional(),
|
|
721
|
+
isAssertionEncrypted: z.boolean().optional(),
|
|
722
|
+
encPrivateKey: z.string().optional(),
|
|
723
|
+
encPrivateKeyPass: z.string().optional()
|
|
724
|
+
}).optional(),
|
|
725
|
+
wantAssertionsSigned: z.boolean().optional(),
|
|
726
|
+
authnRequestsSigned: z.boolean().optional(),
|
|
727
|
+
signatureAlgorithm: z.string().optional(),
|
|
728
|
+
digestAlgorithm: z.string().optional(),
|
|
729
|
+
identifierFormat: z.string().optional(),
|
|
730
|
+
privateKey: z.string().optional(),
|
|
731
|
+
decryptionPvk: z.string().optional(),
|
|
732
|
+
additionalParams: z.record(z.string(), z.any()).optional(),
|
|
733
|
+
mapping: samlMappingSchema
|
|
734
|
+
});
|
|
735
|
+
const updateSSOProviderBodySchema = z.object({
|
|
736
|
+
issuer: z.string().url().optional(),
|
|
737
|
+
domain: z.string().optional(),
|
|
738
|
+
oidcConfig: oidcConfigSchema.optional(),
|
|
739
|
+
samlConfig: samlConfigSchema.optional()
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
//#endregion
|
|
743
|
+
//#region src/routes/providers.ts
|
|
744
|
+
const ADMIN_ROLES = ["owner", "admin"];
|
|
745
|
+
async function isOrgAdmin(ctx, userId, organizationId) {
|
|
746
|
+
const member = await ctx.context.adapter.findOne({
|
|
747
|
+
model: "member",
|
|
748
|
+
where: [{
|
|
749
|
+
field: "userId",
|
|
750
|
+
value: userId
|
|
751
|
+
}, {
|
|
752
|
+
field: "organizationId",
|
|
753
|
+
value: organizationId
|
|
754
|
+
}]
|
|
755
|
+
});
|
|
756
|
+
if (!member) return false;
|
|
757
|
+
return member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()));
|
|
758
|
+
}
|
|
759
|
+
async function batchCheckOrgAdmin(ctx, userId, organizationIds) {
|
|
760
|
+
if (organizationIds.length === 0) return /* @__PURE__ */ new Set();
|
|
761
|
+
const members = await ctx.context.adapter.findMany({
|
|
762
|
+
model: "member",
|
|
763
|
+
where: [{
|
|
764
|
+
field: "userId",
|
|
765
|
+
value: userId
|
|
766
|
+
}, {
|
|
767
|
+
field: "organizationId",
|
|
768
|
+
value: organizationIds,
|
|
769
|
+
operator: "in"
|
|
770
|
+
}]
|
|
771
|
+
});
|
|
772
|
+
const adminOrgIds = /* @__PURE__ */ new Set();
|
|
773
|
+
for (const member of members) if (member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()))) adminOrgIds.add(member.organizationId);
|
|
774
|
+
return adminOrgIds;
|
|
775
|
+
}
|
|
776
|
+
function sanitizeProvider(provider, baseURL) {
|
|
777
|
+
let oidcConfig = null;
|
|
778
|
+
let samlConfig = null;
|
|
779
|
+
try {
|
|
780
|
+
oidcConfig = safeJsonParse(provider.oidcConfig);
|
|
781
|
+
} catch {
|
|
782
|
+
oidcConfig = null;
|
|
783
|
+
}
|
|
784
|
+
try {
|
|
785
|
+
samlConfig = safeJsonParse(provider.samlConfig);
|
|
786
|
+
} catch {
|
|
787
|
+
samlConfig = null;
|
|
788
|
+
}
|
|
789
|
+
const type = samlConfig ? "saml" : "oidc";
|
|
790
|
+
return {
|
|
791
|
+
providerId: provider.providerId,
|
|
792
|
+
type,
|
|
793
|
+
issuer: provider.issuer,
|
|
794
|
+
domain: provider.domain,
|
|
795
|
+
organizationId: provider.organizationId || null,
|
|
796
|
+
domainVerified: provider.domainVerified ?? false,
|
|
797
|
+
oidcConfig: oidcConfig ? {
|
|
798
|
+
discoveryEndpoint: oidcConfig.discoveryEndpoint,
|
|
799
|
+
clientIdLastFour: maskClientId(oidcConfig.clientId),
|
|
800
|
+
pkce: oidcConfig.pkce,
|
|
801
|
+
authorizationEndpoint: oidcConfig.authorizationEndpoint,
|
|
802
|
+
tokenEndpoint: oidcConfig.tokenEndpoint,
|
|
803
|
+
userInfoEndpoint: oidcConfig.userInfoEndpoint,
|
|
804
|
+
jwksEndpoint: oidcConfig.jwksEndpoint,
|
|
805
|
+
scopes: oidcConfig.scopes,
|
|
806
|
+
tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication
|
|
807
|
+
} : void 0,
|
|
808
|
+
samlConfig: samlConfig ? {
|
|
809
|
+
entryPoint: samlConfig.entryPoint,
|
|
810
|
+
callbackUrl: samlConfig.callbackUrl,
|
|
811
|
+
audience: samlConfig.audience,
|
|
812
|
+
wantAssertionsSigned: samlConfig.wantAssertionsSigned,
|
|
813
|
+
authnRequestsSigned: samlConfig.authnRequestsSigned,
|
|
814
|
+
identifierFormat: samlConfig.identifierFormat,
|
|
815
|
+
signatureAlgorithm: samlConfig.signatureAlgorithm,
|
|
816
|
+
digestAlgorithm: samlConfig.digestAlgorithm,
|
|
817
|
+
certificate: (() => {
|
|
818
|
+
try {
|
|
819
|
+
return parseCertificate(samlConfig.cert);
|
|
820
|
+
} catch {
|
|
821
|
+
return { error: "Failed to parse certificate" };
|
|
822
|
+
}
|
|
823
|
+
})()
|
|
824
|
+
} : void 0,
|
|
825
|
+
spMetadataUrl: `${baseURL}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(provider.providerId)}`
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
const listSSOProviders = () => {
|
|
829
|
+
return createAuthEndpoint("/sso/providers", {
|
|
830
|
+
method: "GET",
|
|
831
|
+
use: [sessionMiddleware],
|
|
832
|
+
metadata: { openapi: {
|
|
833
|
+
operationId: "listSSOProviders",
|
|
834
|
+
summary: "List SSO providers",
|
|
835
|
+
description: "Returns a list of SSO providers the user has access to",
|
|
836
|
+
responses: { "200": { description: "List of SSO providers" } }
|
|
837
|
+
} }
|
|
838
|
+
}, async (ctx) => {
|
|
839
|
+
const userId = ctx.context.session.user.id;
|
|
840
|
+
const allProviders = await ctx.context.adapter.findMany({ model: "ssoProvider" });
|
|
841
|
+
const userOwnedProviders = allProviders.filter((p) => p.userId === userId && !p.organizationId);
|
|
842
|
+
const orgProviders = allProviders.filter((p) => p.organizationId !== null && p.organizationId !== void 0);
|
|
843
|
+
const orgPluginEnabled = ctx.context.hasPlugin("organization");
|
|
844
|
+
let accessibleProviders = [...userOwnedProviders];
|
|
845
|
+
if (orgPluginEnabled && orgProviders.length > 0) {
|
|
846
|
+
const adminOrgIds = await batchCheckOrgAdmin(ctx, userId, [...new Set(orgProviders.map((p) => p.organizationId).filter((id) => id !== null && id !== void 0))]);
|
|
847
|
+
const orgAccessibleProviders = orgProviders.filter((provider) => provider.organizationId && adminOrgIds.has(provider.organizationId));
|
|
848
|
+
accessibleProviders = [...accessibleProviders, ...orgAccessibleProviders];
|
|
849
|
+
} else if (!orgPluginEnabled) {
|
|
850
|
+
const userOwnedOrgProviders = orgProviders.filter((p) => p.userId === userId);
|
|
851
|
+
accessibleProviders = [...accessibleProviders, ...userOwnedOrgProviders];
|
|
852
|
+
}
|
|
853
|
+
const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL));
|
|
854
|
+
return ctx.json({ providers });
|
|
855
|
+
});
|
|
856
|
+
};
|
|
857
|
+
const getSSOProviderQuerySchema = z.object({ providerId: z.string() });
|
|
858
|
+
async function checkProviderAccess(ctx, providerId) {
|
|
859
|
+
const userId = ctx.context.session.user.id;
|
|
860
|
+
const provider = await ctx.context.adapter.findOne({
|
|
861
|
+
model: "ssoProvider",
|
|
862
|
+
where: [{
|
|
863
|
+
field: "providerId",
|
|
864
|
+
value: providerId
|
|
865
|
+
}]
|
|
866
|
+
});
|
|
867
|
+
if (!provider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
|
|
868
|
+
let hasAccess = false;
|
|
869
|
+
if (provider.organizationId) if (ctx.context.hasPlugin("organization")) hasAccess = await isOrgAdmin(ctx, userId, provider.organizationId);
|
|
870
|
+
else hasAccess = provider.userId === userId;
|
|
871
|
+
else hasAccess = provider.userId === userId;
|
|
872
|
+
if (!hasAccess) throw new APIError("FORBIDDEN", { message: "You don't have access to this provider" });
|
|
873
|
+
return provider;
|
|
874
|
+
}
|
|
875
|
+
const getSSOProvider = () => {
|
|
876
|
+
return createAuthEndpoint("/sso/get-provider", {
|
|
877
|
+
method: "GET",
|
|
878
|
+
use: [sessionMiddleware],
|
|
879
|
+
query: getSSOProviderQuerySchema,
|
|
880
|
+
metadata: { openapi: {
|
|
881
|
+
operationId: "getSSOProvider",
|
|
882
|
+
summary: "Get SSO provider details",
|
|
883
|
+
description: "Returns sanitized details for a specific SSO provider",
|
|
884
|
+
responses: {
|
|
885
|
+
"200": { description: "SSO provider details" },
|
|
886
|
+
"404": { description: "Provider not found" },
|
|
887
|
+
"403": { description: "Access denied" }
|
|
888
|
+
}
|
|
889
|
+
} }
|
|
890
|
+
}, async (ctx) => {
|
|
891
|
+
const { providerId } = ctx.query;
|
|
892
|
+
const provider = await checkProviderAccess(ctx, providerId);
|
|
893
|
+
return ctx.json(sanitizeProvider(provider, ctx.context.baseURL));
|
|
894
|
+
});
|
|
895
|
+
};
|
|
896
|
+
function parseAndValidateConfig(configString, configType) {
|
|
897
|
+
let config = null;
|
|
898
|
+
try {
|
|
899
|
+
config = safeJsonParse(configString);
|
|
900
|
+
} catch {
|
|
901
|
+
config = null;
|
|
902
|
+
}
|
|
903
|
+
if (!config) throw new APIError("BAD_REQUEST", { message: `Cannot update ${configType} config for a provider that doesn't have ${configType} configured` });
|
|
904
|
+
return config;
|
|
905
|
+
}
|
|
906
|
+
function mergeSAMLConfig(current, updates, issuer) {
|
|
907
|
+
return {
|
|
908
|
+
...current,
|
|
909
|
+
...updates,
|
|
910
|
+
issuer,
|
|
911
|
+
entryPoint: updates.entryPoint ?? current.entryPoint,
|
|
912
|
+
cert: updates.cert ?? current.cert,
|
|
913
|
+
callbackUrl: updates.callbackUrl ?? current.callbackUrl,
|
|
914
|
+
spMetadata: updates.spMetadata ?? current.spMetadata,
|
|
915
|
+
idpMetadata: updates.idpMetadata ?? current.idpMetadata,
|
|
916
|
+
mapping: updates.mapping ?? current.mapping,
|
|
917
|
+
audience: updates.audience ?? current.audience,
|
|
918
|
+
wantAssertionsSigned: updates.wantAssertionsSigned ?? current.wantAssertionsSigned,
|
|
919
|
+
authnRequestsSigned: updates.authnRequestsSigned ?? current.authnRequestsSigned,
|
|
920
|
+
identifierFormat: updates.identifierFormat ?? current.identifierFormat,
|
|
921
|
+
signatureAlgorithm: updates.signatureAlgorithm ?? current.signatureAlgorithm,
|
|
922
|
+
digestAlgorithm: updates.digestAlgorithm ?? current.digestAlgorithm
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
function mergeOIDCConfig(current, updates, issuer) {
|
|
926
|
+
return {
|
|
927
|
+
...current,
|
|
928
|
+
...updates,
|
|
929
|
+
issuer,
|
|
930
|
+
pkce: updates.pkce ?? current.pkce ?? true,
|
|
931
|
+
clientId: updates.clientId ?? current.clientId,
|
|
932
|
+
clientSecret: updates.clientSecret ?? current.clientSecret,
|
|
933
|
+
discoveryEndpoint: updates.discoveryEndpoint ?? current.discoveryEndpoint,
|
|
934
|
+
mapping: updates.mapping ?? current.mapping,
|
|
935
|
+
scopes: updates.scopes ?? current.scopes,
|
|
936
|
+
authorizationEndpoint: updates.authorizationEndpoint ?? current.authorizationEndpoint,
|
|
937
|
+
tokenEndpoint: updates.tokenEndpoint ?? current.tokenEndpoint,
|
|
938
|
+
userInfoEndpoint: updates.userInfoEndpoint ?? current.userInfoEndpoint,
|
|
939
|
+
jwksEndpoint: updates.jwksEndpoint ?? current.jwksEndpoint,
|
|
940
|
+
tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
const updateSSOProvider = (options) => {
|
|
944
|
+
return createAuthEndpoint("/sso/update-provider", {
|
|
945
|
+
method: "POST",
|
|
946
|
+
use: [sessionMiddleware],
|
|
947
|
+
body: updateSSOProviderBodySchema.extend({ providerId: z.string() }),
|
|
948
|
+
metadata: { openapi: {
|
|
949
|
+
operationId: "updateSSOProvider",
|
|
950
|
+
summary: "Update SSO provider",
|
|
951
|
+
description: "Partially update an SSO provider. Only provided fields are updated. If domain changes, domainVerified is reset to false.",
|
|
952
|
+
responses: {
|
|
953
|
+
"200": { description: "SSO provider updated successfully" },
|
|
954
|
+
"404": { description: "Provider not found" },
|
|
955
|
+
"403": { description: "Access denied" }
|
|
956
|
+
}
|
|
957
|
+
} }
|
|
958
|
+
}, async (ctx) => {
|
|
959
|
+
const { providerId, ...body } = ctx.body;
|
|
960
|
+
const { issuer, domain, samlConfig, oidcConfig } = body;
|
|
961
|
+
if (!issuer && !domain && !samlConfig && !oidcConfig) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
|
|
962
|
+
const existingProvider = await checkProviderAccess(ctx, providerId);
|
|
963
|
+
const updateData = {};
|
|
964
|
+
if (body.issuer !== void 0) updateData.issuer = body.issuer;
|
|
965
|
+
if (body.domain !== void 0) {
|
|
966
|
+
updateData.domain = body.domain;
|
|
967
|
+
if (body.domain !== existingProvider.domain) updateData.domainVerified = false;
|
|
968
|
+
}
|
|
969
|
+
if (body.samlConfig) {
|
|
970
|
+
if (body.samlConfig.idpMetadata?.metadata) {
|
|
971
|
+
const maxMetadataSize = options?.saml?.maxMetadataSize ?? DEFAULT_MAX_SAML_METADATA_SIZE;
|
|
972
|
+
if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
|
|
973
|
+
}
|
|
974
|
+
if (body.samlConfig.signatureAlgorithm !== void 0 || body.samlConfig.digestAlgorithm !== void 0) validateConfigAlgorithms({
|
|
975
|
+
signatureAlgorithm: body.samlConfig.signatureAlgorithm,
|
|
976
|
+
digestAlgorithm: body.samlConfig.digestAlgorithm
|
|
977
|
+
}, options?.saml?.algorithms);
|
|
978
|
+
const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
|
|
979
|
+
const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
|
|
980
|
+
updateData.samlConfig = JSON.stringify(updatedSamlConfig);
|
|
981
|
+
}
|
|
982
|
+
if (body.oidcConfig) {
|
|
983
|
+
const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
|
|
984
|
+
const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
|
|
985
|
+
updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
|
|
986
|
+
}
|
|
987
|
+
await ctx.context.adapter.update({
|
|
988
|
+
model: "ssoProvider",
|
|
989
|
+
where: [{
|
|
990
|
+
field: "providerId",
|
|
991
|
+
value: providerId
|
|
992
|
+
}],
|
|
993
|
+
update: updateData
|
|
994
|
+
});
|
|
995
|
+
const fullProvider = await ctx.context.adapter.findOne({
|
|
996
|
+
model: "ssoProvider",
|
|
997
|
+
where: [{
|
|
998
|
+
field: "providerId",
|
|
999
|
+
value: providerId
|
|
1000
|
+
}]
|
|
1001
|
+
});
|
|
1002
|
+
if (!fullProvider) throw new APIError("NOT_FOUND", { message: "Provider not found after update" });
|
|
1003
|
+
return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL));
|
|
1004
|
+
});
|
|
1005
|
+
};
|
|
1006
|
+
const deleteSSOProvider = () => {
|
|
1007
|
+
return createAuthEndpoint("/sso/delete-provider", {
|
|
1008
|
+
method: "POST",
|
|
1009
|
+
use: [sessionMiddleware],
|
|
1010
|
+
body: z.object({ providerId: z.string() }),
|
|
1011
|
+
metadata: { openapi: {
|
|
1012
|
+
operationId: "deleteSSOProvider",
|
|
1013
|
+
summary: "Delete SSO provider",
|
|
1014
|
+
description: "Deletes an SSO provider",
|
|
1015
|
+
responses: {
|
|
1016
|
+
"200": { description: "SSO provider deleted successfully" },
|
|
1017
|
+
"404": { description: "Provider not found" },
|
|
1018
|
+
"403": { description: "Access denied" }
|
|
1019
|
+
}
|
|
1020
|
+
} }
|
|
1021
|
+
}, async (ctx) => {
|
|
1022
|
+
const { providerId } = ctx.body;
|
|
1023
|
+
await checkProviderAccess(ctx, providerId);
|
|
1024
|
+
await ctx.context.adapter.delete({
|
|
1025
|
+
model: "ssoProvider",
|
|
1026
|
+
where: [{
|
|
1027
|
+
field: "providerId",
|
|
1028
|
+
value: providerId
|
|
1029
|
+
}]
|
|
1030
|
+
});
|
|
1031
|
+
return ctx.json({ success: true });
|
|
1032
|
+
});
|
|
1033
|
+
};
|
|
1034
|
+
|
|
1035
|
+
//#endregion
|
|
1036
|
+
//#region src/oidc/types.ts
|
|
1037
|
+
/**
|
|
1038
|
+
* Custom error class for OIDC discovery failures.
|
|
1039
|
+
* Can be caught and mapped to APIError at the edge.
|
|
1040
|
+
*/
|
|
1041
|
+
var DiscoveryError = class DiscoveryError extends Error {
|
|
1042
|
+
code;
|
|
1043
|
+
details;
|
|
1044
|
+
constructor(code, message, details, options) {
|
|
1045
|
+
super(message, options);
|
|
1046
|
+
this.name = "DiscoveryError";
|
|
1047
|
+
this.code = code;
|
|
1048
|
+
this.details = details;
|
|
1049
|
+
if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
|
|
1050
|
+
}
|
|
1051
|
+
};
|
|
1052
|
+
/**
|
|
1053
|
+
* Required fields that must be present in a valid discovery document.
|
|
1054
|
+
*/
|
|
1055
|
+
const REQUIRED_DISCOVERY_FIELDS = [
|
|
1056
|
+
"issuer",
|
|
1057
|
+
"authorization_endpoint",
|
|
1058
|
+
"token_endpoint",
|
|
1059
|
+
"jwks_uri"
|
|
1060
|
+
];
|
|
1061
|
+
|
|
1062
|
+
//#endregion
|
|
1063
|
+
//#region src/oidc/discovery.ts
|
|
1064
|
+
/**
|
|
1065
|
+
* OIDC Discovery Pipeline
|
|
1066
|
+
*
|
|
1067
|
+
* Implements OIDC discovery document fetching, validation, and hydration.
|
|
1068
|
+
* This module is used both at provider registration time (to persist validated config)
|
|
1069
|
+
* and at runtime (to hydrate legacy providers that are missing metadata).
|
|
1070
|
+
*
|
|
1071
|
+
* @see https://openid.net/specs/openid-connect-discovery-1_0.html
|
|
1072
|
+
*/
|
|
1073
|
+
/** Default timeout for discovery requests (10 seconds) */
|
|
1074
|
+
const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
|
|
1075
|
+
/**
|
|
1076
|
+
* Main entry point: Discover and hydrate OIDC configuration from an issuer.
|
|
1077
|
+
*
|
|
1078
|
+
* This function:
|
|
1079
|
+
* 1. Computes the discovery URL from the issuer
|
|
1080
|
+
* 2. Validates the discovery URL
|
|
1081
|
+
* 3. Fetches the discovery document
|
|
1082
|
+
* 4. Validates the discovery document (issuer match + required fields)
|
|
1083
|
+
* 5. Normalizes URLs
|
|
1084
|
+
* 6. Selects token endpoint auth method
|
|
1085
|
+
* 7. Merges with existing config (existing values take precedence)
|
|
1086
|
+
*
|
|
1087
|
+
* @param params - Discovery parameters
|
|
1088
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
1089
|
+
* @returns Hydrated OIDC configuration ready for persistence
|
|
1090
|
+
* @throws DiscoveryError on any failure
|
|
1091
|
+
*/
|
|
1092
|
+
async function discoverOIDCConfig(params) {
|
|
1093
|
+
const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
|
|
1094
|
+
const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
|
|
1095
|
+
validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
|
|
1096
|
+
const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
|
|
1097
|
+
validateDiscoveryDocument(discoveryDoc, issuer);
|
|
384
1098
|
const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
|
|
385
1099
|
const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
|
|
386
1100
|
return {
|
|
@@ -581,16 +1295,35 @@ function selectTokenEndpointAuthMethod(doc, existing) {
|
|
|
581
1295
|
* and validation. Specifically checks for:
|
|
582
1296
|
* - `tokenEndpoint` - required for exchanging authorization code for tokens
|
|
583
1297
|
* - `jwksEndpoint` - required for validating ID token signatures
|
|
584
|
-
*
|
|
585
|
-
* Note: `authorizationEndpoint` is handled separately in the sign-in flow,
|
|
586
|
-
* so it's not checked here.
|
|
1298
|
+
* - `authorizationEndpoint` - required for redirecting users to the IdP for login
|
|
587
1299
|
*
|
|
588
1300
|
* @param config - Partial OIDC config from the provider
|
|
589
1301
|
* @returns true if runtime discovery should be performed
|
|
590
1302
|
*/
|
|
591
1303
|
function needsRuntimeDiscovery(config) {
|
|
592
1304
|
if (!config) return true;
|
|
593
|
-
return !config.tokenEndpoint || !config.jwksEndpoint;
|
|
1305
|
+
return !config.tokenEndpoint || !config.jwksEndpoint || !config.authorizationEndpoint;
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Runs runtime OIDC discovery when the stored config is missing required
|
|
1309
|
+
* endpoints, and merges the hydrated fields back into the config.
|
|
1310
|
+
* Throws if discovery fails.
|
|
1311
|
+
*/
|
|
1312
|
+
async function ensureRuntimeDiscovery(config, issuer, isTrustedOrigin) {
|
|
1313
|
+
if (!needsRuntimeDiscovery(config)) return config;
|
|
1314
|
+
const hydrated = await discoverOIDCConfig({
|
|
1315
|
+
issuer,
|
|
1316
|
+
existingConfig: config,
|
|
1317
|
+
isTrustedOrigin
|
|
1318
|
+
});
|
|
1319
|
+
return {
|
|
1320
|
+
...config,
|
|
1321
|
+
authorizationEndpoint: hydrated.authorizationEndpoint,
|
|
1322
|
+
tokenEndpoint: hydrated.tokenEndpoint,
|
|
1323
|
+
tokenEndpointAuthentication: hydrated.tokenEndpointAuthentication,
|
|
1324
|
+
userInfoEndpoint: hydrated.userInfoEndpoint,
|
|
1325
|
+
jwksEndpoint: hydrated.jwksEndpoint
|
|
1326
|
+
};
|
|
594
1327
|
}
|
|
595
1328
|
|
|
596
1329
|
//#endregion
|
|
@@ -663,271 +1396,23 @@ function mapDiscoveryErrorToAPIError(error) {
|
|
|
663
1396
|
});
|
|
664
1397
|
}
|
|
665
1398
|
}
|
|
666
|
-
|
|
667
|
-
//#endregion
|
|
668
|
-
//#region src/saml/
|
|
669
|
-
const
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
const record = obj;
|
|
678
|
-
if (nodeName in record) return record[nodeName];
|
|
679
|
-
for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
|
|
680
|
-
const found = findNode(item, nodeName);
|
|
681
|
-
if (found) return found;
|
|
682
|
-
}
|
|
683
|
-
else if (typeof value === "object" && value !== null) {
|
|
684
|
-
const found = findNode(value, nodeName);
|
|
685
|
-
if (found) return found;
|
|
686
|
-
}
|
|
687
|
-
return null;
|
|
688
|
-
}
|
|
689
|
-
function countAllNodes(obj, nodeName) {
|
|
690
|
-
if (!obj || typeof obj !== "object") return 0;
|
|
691
|
-
let count = 0;
|
|
692
|
-
const record = obj;
|
|
693
|
-
if (nodeName in record) {
|
|
694
|
-
const node = record[nodeName];
|
|
695
|
-
count += Array.isArray(node) ? node.length : 1;
|
|
696
|
-
}
|
|
697
|
-
for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) count += countAllNodes(item, nodeName);
|
|
698
|
-
else if (typeof value === "object" && value !== null) count += countAllNodes(value, nodeName);
|
|
699
|
-
return count;
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
//#endregion
|
|
703
|
-
//#region src/saml/algorithms.ts
|
|
704
|
-
const SignatureAlgorithm = {
|
|
705
|
-
RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
|
|
706
|
-
RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
|
|
707
|
-
RSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
|
|
708
|
-
RSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
|
|
709
|
-
ECDSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
|
|
710
|
-
ECDSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
|
|
711
|
-
ECDSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"
|
|
712
|
-
};
|
|
713
|
-
const DigestAlgorithm = {
|
|
714
|
-
SHA1: "http://www.w3.org/2000/09/xmldsig#sha1",
|
|
715
|
-
SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
|
|
716
|
-
SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
|
|
717
|
-
SHA512: "http://www.w3.org/2001/04/xmlenc#sha512"
|
|
718
|
-
};
|
|
719
|
-
const KeyEncryptionAlgorithm = {
|
|
720
|
-
RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
|
|
721
|
-
RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
|
|
722
|
-
RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep"
|
|
723
|
-
};
|
|
724
|
-
const DataEncryptionAlgorithm = {
|
|
725
|
-
TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
|
|
726
|
-
AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
|
|
727
|
-
AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
|
|
728
|
-
AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
|
|
729
|
-
AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
|
|
730
|
-
AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
|
|
731
|
-
AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
|
|
732
|
-
};
|
|
733
|
-
const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
|
|
734
|
-
const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS = [KeyEncryptionAlgorithm.RSA_1_5];
|
|
735
|
-
const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS = [DataEncryptionAlgorithm.TRIPLEDES_CBC];
|
|
736
|
-
const DEPRECATED_DIGEST_ALGORITHMS = [DigestAlgorithm.SHA1];
|
|
737
|
-
const SECURE_SIGNATURE_ALGORITHMS = [
|
|
738
|
-
SignatureAlgorithm.RSA_SHA256,
|
|
739
|
-
SignatureAlgorithm.RSA_SHA384,
|
|
740
|
-
SignatureAlgorithm.RSA_SHA512,
|
|
741
|
-
SignatureAlgorithm.ECDSA_SHA256,
|
|
742
|
-
SignatureAlgorithm.ECDSA_SHA384,
|
|
743
|
-
SignatureAlgorithm.ECDSA_SHA512
|
|
744
|
-
];
|
|
745
|
-
const SECURE_DIGEST_ALGORITHMS = [
|
|
746
|
-
DigestAlgorithm.SHA256,
|
|
747
|
-
DigestAlgorithm.SHA384,
|
|
748
|
-
DigestAlgorithm.SHA512
|
|
749
|
-
];
|
|
750
|
-
const SHORT_FORM_SIGNATURE_TO_URI = {
|
|
751
|
-
sha1: SignatureAlgorithm.RSA_SHA1,
|
|
752
|
-
sha256: SignatureAlgorithm.RSA_SHA256,
|
|
753
|
-
sha384: SignatureAlgorithm.RSA_SHA384,
|
|
754
|
-
sha512: SignatureAlgorithm.RSA_SHA512,
|
|
755
|
-
"rsa-sha1": SignatureAlgorithm.RSA_SHA1,
|
|
756
|
-
"rsa-sha256": SignatureAlgorithm.RSA_SHA256,
|
|
757
|
-
"rsa-sha384": SignatureAlgorithm.RSA_SHA384,
|
|
758
|
-
"rsa-sha512": SignatureAlgorithm.RSA_SHA512,
|
|
759
|
-
"ecdsa-sha256": SignatureAlgorithm.ECDSA_SHA256,
|
|
760
|
-
"ecdsa-sha384": SignatureAlgorithm.ECDSA_SHA384,
|
|
761
|
-
"ecdsa-sha512": SignatureAlgorithm.ECDSA_SHA512
|
|
762
|
-
};
|
|
763
|
-
const SHORT_FORM_DIGEST_TO_URI = {
|
|
764
|
-
sha1: DigestAlgorithm.SHA1,
|
|
765
|
-
sha256: DigestAlgorithm.SHA256,
|
|
766
|
-
sha384: DigestAlgorithm.SHA384,
|
|
767
|
-
sha512: DigestAlgorithm.SHA512
|
|
768
|
-
};
|
|
769
|
-
function normalizeSignatureAlgorithm(alg) {
|
|
770
|
-
return SHORT_FORM_SIGNATURE_TO_URI[alg.toLowerCase()] ?? alg;
|
|
771
|
-
}
|
|
772
|
-
function normalizeDigestAlgorithm(alg) {
|
|
773
|
-
return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
|
|
774
|
-
}
|
|
775
|
-
function extractEncryptionAlgorithms(xml) {
|
|
776
|
-
try {
|
|
777
|
-
const parsed = xmlParser.parse(xml);
|
|
778
|
-
const keyAlg = (findNode(parsed, "EncryptedKey")?.EncryptionMethod)?.["@_Algorithm"];
|
|
779
|
-
const dataAlg = (findNode(parsed, "EncryptedData")?.EncryptionMethod)?.["@_Algorithm"];
|
|
780
|
-
return {
|
|
781
|
-
keyEncryption: keyAlg || null,
|
|
782
|
-
dataEncryption: dataAlg || null
|
|
783
|
-
};
|
|
784
|
-
} catch {
|
|
785
|
-
return {
|
|
786
|
-
keyEncryption: null,
|
|
787
|
-
dataEncryption: null
|
|
788
|
-
};
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
function hasEncryptedAssertion(xml) {
|
|
792
|
-
try {
|
|
793
|
-
return findNode(xmlParser.parse(xml), "EncryptedAssertion") !== null;
|
|
794
|
-
} catch {
|
|
795
|
-
return false;
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
function handleDeprecatedAlgorithm(message, behavior, errorCode) {
|
|
799
|
-
switch (behavior) {
|
|
800
|
-
case "reject": throw new APIError("BAD_REQUEST", {
|
|
801
|
-
message,
|
|
802
|
-
code: errorCode
|
|
803
|
-
});
|
|
804
|
-
case "warn":
|
|
805
|
-
console.warn(`[SAML Security Warning] ${message}`);
|
|
806
|
-
break;
|
|
807
|
-
case "allow": break;
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
function validateSignatureAlgorithm(algorithm, options = {}) {
|
|
811
|
-
if (!algorithm) return;
|
|
812
|
-
const { onDeprecated = "warn", allowedSignatureAlgorithms } = options;
|
|
813
|
-
if (allowedSignatureAlgorithms) {
|
|
814
|
-
if (!allowedSignatureAlgorithms.includes(algorithm)) throw new APIError("BAD_REQUEST", {
|
|
815
|
-
message: `SAML signature algorithm not in allow-list: ${algorithm}`,
|
|
816
|
-
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
817
|
-
});
|
|
818
|
-
return;
|
|
819
|
-
}
|
|
820
|
-
if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(algorithm)) {
|
|
821
|
-
handleDeprecatedAlgorithm(`SAML response uses deprecated signature algorithm: ${algorithm}. Please configure your IdP to use SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
|
|
822
|
-
return;
|
|
823
|
-
}
|
|
824
|
-
if (!SECURE_SIGNATURE_ALGORITHMS.includes(algorithm)) throw new APIError("BAD_REQUEST", {
|
|
825
|
-
message: `SAML signature algorithm not recognized: ${algorithm}`,
|
|
826
|
-
code: "SAML_UNKNOWN_ALGORITHM"
|
|
827
|
-
});
|
|
828
|
-
}
|
|
829
|
-
function validateEncryptionAlgorithms(algorithms, options = {}) {
|
|
830
|
-
const { onDeprecated = "warn", allowedKeyEncryptionAlgorithms, allowedDataEncryptionAlgorithms } = options;
|
|
831
|
-
const { keyEncryption, dataEncryption } = algorithms;
|
|
832
|
-
if (keyEncryption) {
|
|
833
|
-
if (allowedKeyEncryptionAlgorithms) {
|
|
834
|
-
if (!allowedKeyEncryptionAlgorithms.includes(keyEncryption)) throw new APIError("BAD_REQUEST", {
|
|
835
|
-
message: `SAML key encryption algorithm not in allow-list: ${keyEncryption}`,
|
|
836
|
-
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
837
|
-
});
|
|
838
|
-
} 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");
|
|
839
|
-
}
|
|
840
|
-
if (dataEncryption) {
|
|
841
|
-
if (allowedDataEncryptionAlgorithms) {
|
|
842
|
-
if (!allowedDataEncryptionAlgorithms.includes(dataEncryption)) throw new APIError("BAD_REQUEST", {
|
|
843
|
-
message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
|
|
844
|
-
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
845
|
-
});
|
|
846
|
-
} 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");
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
function validateSAMLAlgorithms(response, options) {
|
|
850
|
-
validateSignatureAlgorithm(response.sigAlg, options);
|
|
851
|
-
if (hasEncryptedAssertion(response.samlContent)) validateEncryptionAlgorithms(extractEncryptionAlgorithms(response.samlContent), options);
|
|
852
|
-
}
|
|
853
|
-
function validateConfigAlgorithms(config, options = {}) {
|
|
854
|
-
const { onDeprecated = "warn", allowedSignatureAlgorithms, allowedDigestAlgorithms } = options;
|
|
855
|
-
if (config.signatureAlgorithm) {
|
|
856
|
-
const normalized = normalizeSignatureAlgorithm(config.signatureAlgorithm);
|
|
857
|
-
if (allowedSignatureAlgorithms) {
|
|
858
|
-
if (!allowedSignatureAlgorithms.map(normalizeSignatureAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
859
|
-
message: `SAML signature algorithm not in allow-list: ${config.signatureAlgorithm}`,
|
|
860
|
-
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
861
|
-
});
|
|
862
|
-
} 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");
|
|
863
|
-
else if (!SECURE_SIGNATURE_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
864
|
-
message: `SAML signature algorithm not recognized: ${config.signatureAlgorithm}`,
|
|
865
|
-
code: "SAML_UNKNOWN_ALGORITHM"
|
|
866
|
-
});
|
|
867
|
-
}
|
|
868
|
-
if (config.digestAlgorithm) {
|
|
869
|
-
const normalized = normalizeDigestAlgorithm(config.digestAlgorithm);
|
|
870
|
-
if (allowedDigestAlgorithms) {
|
|
871
|
-
if (!allowedDigestAlgorithms.map(normalizeDigestAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
872
|
-
message: `SAML digest algorithm not in allow-list: ${config.digestAlgorithm}`,
|
|
873
|
-
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
874
|
-
});
|
|
875
|
-
} 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");
|
|
876
|
-
else if (!SECURE_DIGEST_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
877
|
-
message: `SAML digest algorithm not recognized: ${config.digestAlgorithm}`,
|
|
878
|
-
code: "SAML_UNKNOWN_ALGORITHM"
|
|
879
|
-
});
|
|
880
|
-
}
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
//#endregion
|
|
884
|
-
//#region src/saml/assertions.ts
|
|
885
|
-
/** @lintignore used in tests */
|
|
886
|
-
function countAssertions(xml) {
|
|
887
|
-
let parsed;
|
|
888
|
-
try {
|
|
889
|
-
parsed = xmlParser.parse(xml);
|
|
890
|
-
} catch {
|
|
891
|
-
throw new APIError("BAD_REQUEST", {
|
|
892
|
-
message: "Failed to parse SAML response XML",
|
|
893
|
-
code: "SAML_INVALID_XML"
|
|
894
|
-
});
|
|
895
|
-
}
|
|
896
|
-
const assertions = countAllNodes(parsed, "Assertion");
|
|
897
|
-
const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
|
|
898
|
-
return {
|
|
899
|
-
assertions,
|
|
900
|
-
encryptedAssertions,
|
|
901
|
-
total: assertions + encryptedAssertions
|
|
902
|
-
};
|
|
903
|
-
}
|
|
904
|
-
function validateSingleAssertion(samlResponse) {
|
|
905
|
-
let xml;
|
|
906
|
-
try {
|
|
907
|
-
xml = new TextDecoder().decode(base64.decode(samlResponse));
|
|
908
|
-
if (!xml.includes("<")) throw new Error("Not XML");
|
|
909
|
-
} catch {
|
|
910
|
-
throw new APIError("BAD_REQUEST", {
|
|
911
|
-
message: "Invalid base64-encoded SAML response",
|
|
912
|
-
code: "SAML_INVALID_ENCODING"
|
|
913
|
-
});
|
|
914
|
-
}
|
|
915
|
-
const counts = countAssertions(xml);
|
|
916
|
-
if (counts.total === 0) throw new APIError("BAD_REQUEST", {
|
|
917
|
-
message: "SAML response contains no assertions",
|
|
918
|
-
code: "SAML_NO_ASSERTION"
|
|
919
|
-
});
|
|
920
|
-
if (counts.total > 1) throw new APIError("BAD_REQUEST", {
|
|
921
|
-
message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
|
|
922
|
-
code: "SAML_MULTIPLE_ASSERTIONS"
|
|
923
|
-
});
|
|
924
|
-
}
|
|
1399
|
+
|
|
1400
|
+
//#endregion
|
|
1401
|
+
//#region src/saml/error-codes.ts
|
|
1402
|
+
const SAML_ERROR_CODES = defineErrorCodes({
|
|
1403
|
+
SINGLE_LOGOUT_NOT_ENABLED: "Single Logout is not enabled",
|
|
1404
|
+
INVALID_LOGOUT_RESPONSE: "Invalid LogoutResponse",
|
|
1405
|
+
INVALID_LOGOUT_REQUEST: "Invalid LogoutRequest",
|
|
1406
|
+
LOGOUT_FAILED_AT_IDP: "Logout failed at IdP",
|
|
1407
|
+
IDP_SLO_NOT_SUPPORTED: "IdP does not support Single Logout Service",
|
|
1408
|
+
SAML_PROVIDER_NOT_FOUND: "SAML provider not found"
|
|
1409
|
+
});
|
|
925
1410
|
|
|
926
1411
|
//#endregion
|
|
927
1412
|
//#region src/saml-state.ts
|
|
928
1413
|
async function generateRelayState(c, link, additionalData) {
|
|
929
1414
|
const callbackURL = c.body.callbackURL;
|
|
930
|
-
if (!callbackURL) throw new APIError
|
|
1415
|
+
if (!callbackURL) throw new APIError("BAD_REQUEST", { message: "callbackURL is required" });
|
|
931
1416
|
const codeVerifier = generateRandomString(128);
|
|
932
1417
|
const stateData = {
|
|
933
1418
|
...additionalData ? additionalData : {},
|
|
@@ -943,7 +1428,7 @@ async function generateRelayState(c, link, additionalData) {
|
|
|
943
1428
|
return generateGenericState(c, stateData, { cookieName: "relay_state" });
|
|
944
1429
|
} catch (error) {
|
|
945
1430
|
c.context.logger.error("Failed to create verification for relay state", error);
|
|
946
|
-
throw new APIError
|
|
1431
|
+
throw new APIError("INTERNAL_SERVER_ERROR", {
|
|
947
1432
|
message: "State error: Unable to create verification for relay state",
|
|
948
1433
|
cause: error
|
|
949
1434
|
});
|
|
@@ -957,7 +1442,7 @@ async function parseRelayState(c) {
|
|
|
957
1442
|
parsedData = await parseGenericState(c, state, { cookieName: "relay_state" });
|
|
958
1443
|
} catch (error) {
|
|
959
1444
|
c.context.logger.error("Failed to parse relay state", error);
|
|
960
|
-
throw new APIError
|
|
1445
|
+
throw new APIError("BAD_REQUEST", {
|
|
961
1446
|
message: "State error: failed to validate relay state",
|
|
962
1447
|
cause: error
|
|
963
1448
|
});
|
|
@@ -967,36 +1452,101 @@ async function parseRelayState(c) {
|
|
|
967
1452
|
}
|
|
968
1453
|
|
|
969
1454
|
//#endregion
|
|
970
|
-
//#region src/
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
function safeJsonParse(value) {
|
|
981
|
-
if (!value) return null;
|
|
982
|
-
if (typeof value === "object") return value;
|
|
983
|
-
if (typeof value === "string") try {
|
|
984
|
-
return JSON.parse(value);
|
|
985
|
-
} catch (error) {
|
|
986
|
-
throw new Error(`Failed to parse JSON: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
1455
|
+
//#region src/routes/helpers.ts
|
|
1456
|
+
async function findSAMLProvider(providerId, options, adapter) {
|
|
1457
|
+
if (options?.defaultSSO?.length) {
|
|
1458
|
+
const match = options.defaultSSO.find((p) => p.providerId === providerId);
|
|
1459
|
+
if (match) return {
|
|
1460
|
+
...match,
|
|
1461
|
+
userId: "default",
|
|
1462
|
+
issuer: match.samlConfig?.issuer || "",
|
|
1463
|
+
...options.domainVerification?.enabled ? { domainVerified: true } : {}
|
|
1464
|
+
};
|
|
987
1465
|
}
|
|
988
|
-
|
|
1466
|
+
const res = await adapter.findOne({
|
|
1467
|
+
model: "ssoProvider",
|
|
1468
|
+
where: [{
|
|
1469
|
+
field: "providerId",
|
|
1470
|
+
value: providerId
|
|
1471
|
+
}]
|
|
1472
|
+
});
|
|
1473
|
+
if (!res) return null;
|
|
1474
|
+
return {
|
|
1475
|
+
...res,
|
|
1476
|
+
samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
function createSP(config, baseURL, providerId, sloOptions) {
|
|
1480
|
+
const sloLocation = `${baseURL}/sso/saml2/sp/slo/${providerId}`;
|
|
1481
|
+
return saml.ServiceProvider({
|
|
1482
|
+
entityID: config.spMetadata?.entityID || config.issuer,
|
|
1483
|
+
assertionConsumerService: [{
|
|
1484
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1485
|
+
Location: config.callbackUrl || `${baseURL}/sso/saml2/sp/acs/${providerId}`
|
|
1486
|
+
}],
|
|
1487
|
+
singleLogoutService: [{
|
|
1488
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1489
|
+
Location: sloLocation
|
|
1490
|
+
}, {
|
|
1491
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1492
|
+
Location: sloLocation
|
|
1493
|
+
}],
|
|
1494
|
+
wantMessageSigned: config.wantAssertionsSigned || false,
|
|
1495
|
+
wantLogoutRequestSigned: sloOptions?.wantLogoutRequestSigned ?? false,
|
|
1496
|
+
wantLogoutResponseSigned: sloOptions?.wantLogoutResponseSigned ?? false,
|
|
1497
|
+
metadata: config.spMetadata?.metadata,
|
|
1498
|
+
privateKey: config.spMetadata?.privateKey || config.privateKey,
|
|
1499
|
+
privateKeyPass: config.spMetadata?.privateKeyPass
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
function createIdP(config) {
|
|
1503
|
+
const idpData = config.idpMetadata;
|
|
1504
|
+
if (idpData?.metadata) return saml.IdentityProvider({
|
|
1505
|
+
metadata: idpData.metadata,
|
|
1506
|
+
privateKey: idpData.privateKey,
|
|
1507
|
+
privateKeyPass: idpData.privateKeyPass,
|
|
1508
|
+
encPrivateKey: idpData.encPrivateKey,
|
|
1509
|
+
encPrivateKeyPass: idpData.encPrivateKeyPass
|
|
1510
|
+
});
|
|
1511
|
+
return saml.IdentityProvider({
|
|
1512
|
+
entityID: idpData?.entityID || config.issuer,
|
|
1513
|
+
singleSignOnService: idpData?.singleSignOnService || [{
|
|
1514
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1515
|
+
Location: config.entryPoint
|
|
1516
|
+
}],
|
|
1517
|
+
singleLogoutService: idpData?.singleLogoutService,
|
|
1518
|
+
signingCert: idpData?.cert || config.cert
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
function escapeHtml(str) {
|
|
1522
|
+
if (!str) return "";
|
|
1523
|
+
return String(str).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1524
|
+
}
|
|
1525
|
+
function createSAMLPostForm(action, samlParam, samlValue, relayState) {
|
|
1526
|
+
const safeAction = escapeHtml(action);
|
|
1527
|
+
const safeSamlParam = escapeHtml(samlParam);
|
|
1528
|
+
const safeSamlValue = escapeHtml(samlValue);
|
|
1529
|
+
const safeRelayState = relayState ? escapeHtml(relayState) : void 0;
|
|
1530
|
+
const html = `<!DOCTYPE html><html><body onload="document.forms[0].submit();"><form method="POST" action="${safeAction}"><input type="hidden" name="${safeSamlParam}" value="${safeSamlValue}" />${safeRelayState ? `<input type="hidden" name="RelayState" value="${safeRelayState}" />` : ""}<noscript><input type="submit" value="Continue" /></noscript></form></body></html>`;
|
|
1531
|
+
return new Response(html, { headers: { "Content-Type": "text/html" } });
|
|
989
1532
|
}
|
|
990
|
-
const validateEmailDomain = (email, domain) => {
|
|
991
|
-
const emailDomain = email.split("@")[1]?.toLowerCase();
|
|
992
|
-
const providerDomain = domain.toLowerCase();
|
|
993
|
-
if (!emailDomain || !providerDomain) return false;
|
|
994
|
-
return emailDomain === providerDomain || emailDomain.endsWith(`.${providerDomain}`);
|
|
995
|
-
};
|
|
996
1533
|
|
|
997
1534
|
//#endregion
|
|
998
1535
|
//#region src/routes/sso.ts
|
|
999
1536
|
/**
|
|
1537
|
+
* Builds the OIDC redirect URI. Uses the shared `redirectURI` option
|
|
1538
|
+
* when set, otherwise falls back to `/sso/callback/:providerId`.
|
|
1539
|
+
*/
|
|
1540
|
+
function getOIDCRedirectURI(baseURL, providerId, options) {
|
|
1541
|
+
if (options?.redirectURI?.trim()) try {
|
|
1542
|
+
new URL(options.redirectURI);
|
|
1543
|
+
return options.redirectURI;
|
|
1544
|
+
} catch {
|
|
1545
|
+
return `${baseURL}${options.redirectURI.startsWith("/") ? options.redirectURI : `/${options.redirectURI}`}`;
|
|
1546
|
+
}
|
|
1547
|
+
return `${baseURL}/sso/callback/${providerId}`;
|
|
1548
|
+
}
|
|
1549
|
+
/**
|
|
1000
1550
|
* Validates SAML assertion timestamp conditions (NotBefore/NotOnOrAfter).
|
|
1001
1551
|
* Prevents acceptance of expired or future-dated assertions.
|
|
1002
1552
|
* @throws {APIError} If timestamps are invalid, expired, or not yet valid
|
|
@@ -1060,7 +1610,7 @@ const spMetadataQuerySchema = z.object({
|
|
|
1060
1610
|
providerId: z.string(),
|
|
1061
1611
|
format: z.enum(["xml", "json"]).default("xml")
|
|
1062
1612
|
});
|
|
1063
|
-
const spMetadata = () => {
|
|
1613
|
+
const spMetadata = (options) => {
|
|
1064
1614
|
return createAuthEndpoint("/sso/saml2/sp/metadata", {
|
|
1065
1615
|
method: "GET",
|
|
1066
1616
|
query: spMetadataQuerySchema,
|
|
@@ -1081,13 +1631,23 @@ const spMetadata = () => {
|
|
|
1081
1631
|
if (!provider) throw new APIError("NOT_FOUND", { message: "No provider found for the given providerId" });
|
|
1082
1632
|
const parsedSamlConfig = safeJsonParse(provider.samlConfig);
|
|
1083
1633
|
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
1634
|
+
const sloLocation = `${ctx.context.baseURL}/sso/saml2/sp/slo/${ctx.query.providerId}`;
|
|
1635
|
+
const singleLogoutService = options?.saml?.enableSingleLogout ? [{
|
|
1636
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1637
|
+
Location: sloLocation
|
|
1638
|
+
}, {
|
|
1639
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1640
|
+
Location: sloLocation
|
|
1641
|
+
}] : void 0;
|
|
1084
1642
|
const sp = parsedSamlConfig.spMetadata.metadata ? saml.ServiceProvider({ metadata: parsedSamlConfig.spMetadata.metadata }) : saml.SPMetadata({
|
|
1085
1643
|
entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
|
1086
1644
|
assertionConsumerService: [{
|
|
1087
1645
|
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1088
1646
|
Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.id}`
|
|
1089
1647
|
}],
|
|
1648
|
+
singleLogoutService,
|
|
1090
1649
|
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1650
|
+
authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
|
|
1091
1651
|
nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
|
|
1092
1652
|
});
|
|
1093
1653
|
return new Response(sp.getMetadata(), { headers: { "Content-Type": "application/xml" } });
|
|
@@ -1096,7 +1656,7 @@ const spMetadata = () => {
|
|
|
1096
1656
|
const ssoProviderBodySchema = z.object({
|
|
1097
1657
|
providerId: z.string({}).meta({ description: "The ID of the provider. This is used to identify the provider during login and callback" }),
|
|
1098
1658
|
issuer: z.string({}).meta({ description: "The issuer of the provider" }),
|
|
1099
|
-
domain: z.string({}).meta({ description: "The domain of the provider.
|
|
1659
|
+
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')" }),
|
|
1100
1660
|
oidcConfig: z.object({
|
|
1101
1661
|
clientId: z.string({}).meta({ description: "The client ID" }),
|
|
1102
1662
|
clientSecret: z.string({}).meta({ description: "The client secret" }),
|
|
@@ -1148,6 +1708,7 @@ const ssoProviderBodySchema = z.object({
|
|
|
1148
1708
|
encPrivateKeyPass: z.string().optional()
|
|
1149
1709
|
}),
|
|
1150
1710
|
wantAssertionsSigned: z.boolean().optional(),
|
|
1711
|
+
authnRequestsSigned: z.boolean().optional(),
|
|
1151
1712
|
signatureAlgorithm: z.string().optional(),
|
|
1152
1713
|
digestAlgorithm: z.string().optional(),
|
|
1153
1714
|
identifierFormat: z.string().optional(),
|
|
@@ -1450,6 +2011,7 @@ const registerSSOProvider = (options) => {
|
|
|
1450
2011
|
idpMetadata: body.samlConfig.idpMetadata,
|
|
1451
2012
|
spMetadata: body.samlConfig.spMetadata,
|
|
1452
2013
|
wantAssertionsSigned: body.samlConfig.wantAssertionsSigned,
|
|
2014
|
+
authnRequestsSigned: body.samlConfig.authnRequestsSigned,
|
|
1453
2015
|
signatureAlgorithm: body.samlConfig.signatureAlgorithm,
|
|
1454
2016
|
digestAlgorithm: body.samlConfig.digestAlgorithm,
|
|
1455
2017
|
identifierFormat: body.samlConfig.identifierFormat,
|
|
@@ -1471,7 +2033,7 @@ const registerSSOProvider = (options) => {
|
|
|
1471
2033
|
await ctx.context.adapter.create({
|
|
1472
2034
|
model: "verification",
|
|
1473
2035
|
data: {
|
|
1474
|
-
identifier: options
|
|
2036
|
+
identifier: getVerificationIdentifier(options, provider.providerId),
|
|
1475
2037
|
createdAt: /* @__PURE__ */ new Date(),
|
|
1476
2038
|
updatedAt: /* @__PURE__ */ new Date(),
|
|
1477
2039
|
value: domainVerificationToken,
|
|
@@ -1483,7 +2045,7 @@ const registerSSOProvider = (options) => {
|
|
|
1483
2045
|
...provider,
|
|
1484
2046
|
oidcConfig: safeJsonParse(provider.oidcConfig),
|
|
1485
2047
|
samlConfig: safeJsonParse(provider.samlConfig),
|
|
1486
|
-
redirectURI:
|
|
2048
|
+
redirectURI: getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options),
|
|
1487
2049
|
...options?.domainVerification?.enabled ? { domainVerified } : {},
|
|
1488
2050
|
...options?.domainVerification?.enabled ? { domainVerificationToken } : {}
|
|
1489
2051
|
};
|
|
@@ -1595,20 +2157,33 @@ const signInSSO = (options) => {
|
|
|
1595
2157
|
};
|
|
1596
2158
|
}
|
|
1597
2159
|
if (!providerId && !orgId && !domain) throw new APIError("BAD_REQUEST", { message: "providerId, orgId or domain is required" });
|
|
1598
|
-
if (!provider)
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
return {
|
|
1607
|
-
...res,
|
|
1608
|
-
oidcConfig: res.oidcConfig ? safeJsonParse(res.oidcConfig) || void 0 : void 0,
|
|
1609
|
-
samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
|
|
2160
|
+
if (!provider) {
|
|
2161
|
+
const parseProvider = (res) => {
|
|
2162
|
+
if (!res) return null;
|
|
2163
|
+
return {
|
|
2164
|
+
...res,
|
|
2165
|
+
oidcConfig: res.oidcConfig ? safeJsonParse(res.oidcConfig) || void 0 : void 0,
|
|
2166
|
+
samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
|
|
2167
|
+
};
|
|
1610
2168
|
};
|
|
1611
|
-
|
|
2169
|
+
if (providerId || orgId) provider = parseProvider(await ctx.context.adapter.findOne({
|
|
2170
|
+
model: "ssoProvider",
|
|
2171
|
+
where: [{
|
|
2172
|
+
field: providerId ? "providerId" : "organizationId",
|
|
2173
|
+
value: providerId || orgId
|
|
2174
|
+
}]
|
|
2175
|
+
}));
|
|
2176
|
+
else if (domain) {
|
|
2177
|
+
provider = parseProvider(await ctx.context.adapter.findOne({
|
|
2178
|
+
model: "ssoProvider",
|
|
2179
|
+
where: [{
|
|
2180
|
+
field: "domain",
|
|
2181
|
+
value: domain
|
|
2182
|
+
}]
|
|
2183
|
+
}));
|
|
2184
|
+
if (!provider) provider = parseProvider((await ctx.context.adapter.findMany({ model: "ssoProvider" })).find((p) => domainMatches(domain, p.domain)) ?? null);
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
1612
2187
|
if (!provider) throw new APIError("NOT_FOUND", { message: "No provider found for the issuer" });
|
|
1613
2188
|
if (body.providerType) {
|
|
1614
2189
|
if (body.providerType === "oidc" && !provider.oidcConfig) throw new APIError("BAD_REQUEST", { message: "OIDC provider is not configured" });
|
|
@@ -1616,31 +2191,33 @@ const signInSSO = (options) => {
|
|
|
1616
2191
|
}
|
|
1617
2192
|
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
1618
2193
|
if (provider.oidcConfig && body.providerType !== "saml") {
|
|
1619
|
-
let
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
2194
|
+
let config = provider.oidcConfig;
|
|
2195
|
+
try {
|
|
2196
|
+
config = await ensureRuntimeDiscovery(provider.oidcConfig, provider.issuer, (url) => ctx.context.isTrustedOrigin(url));
|
|
2197
|
+
} catch (error) {
|
|
2198
|
+
if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
|
|
2199
|
+
throw error;
|
|
1623
2200
|
}
|
|
1624
|
-
if (!
|
|
1625
|
-
const state = await generateState(ctx, void 0, false);
|
|
1626
|
-
const redirectURI =
|
|
2201
|
+
if (!config.authorizationEndpoint) throw new APIError("BAD_REQUEST", { message: "Invalid OIDC configuration. Authorization URL not found." });
|
|
2202
|
+
const state = await generateState(ctx, void 0, options?.redirectURI?.trim() ? { ssoProviderId: provider.providerId } : false);
|
|
2203
|
+
const redirectURI = getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options);
|
|
1627
2204
|
const authorizationURL = await createAuthorizationURL({
|
|
1628
2205
|
id: provider.issuer,
|
|
1629
2206
|
options: {
|
|
1630
|
-
clientId:
|
|
1631
|
-
clientSecret:
|
|
2207
|
+
clientId: config.clientId,
|
|
2208
|
+
clientSecret: config.clientSecret
|
|
1632
2209
|
},
|
|
1633
2210
|
redirectURI,
|
|
1634
2211
|
state: state.state,
|
|
1635
|
-
codeVerifier:
|
|
1636
|
-
scopes: ctx.body.scopes ||
|
|
2212
|
+
codeVerifier: config.pkce ? state.codeVerifier : void 0,
|
|
2213
|
+
scopes: ctx.body.scopes || config.scopes || [
|
|
1637
2214
|
"openid",
|
|
1638
2215
|
"email",
|
|
1639
2216
|
"profile",
|
|
1640
2217
|
"offline_access"
|
|
1641
2218
|
],
|
|
1642
2219
|
loginHint: ctx.body.loginHint || email,
|
|
1643
|
-
authorizationEndpoint:
|
|
2220
|
+
authorizationEndpoint: config.authorizationEndpoint
|
|
1644
2221
|
});
|
|
1645
2222
|
return ctx.json({
|
|
1646
2223
|
url: authorizationURL.toString(),
|
|
@@ -1650,6 +2227,7 @@ const signInSSO = (options) => {
|
|
|
1650
2227
|
if (provider.samlConfig) {
|
|
1651
2228
|
const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
|
|
1652
2229
|
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
2230
|
+
if (parsedSamlConfig.authnRequestsSigned && !parsedSamlConfig.spMetadata?.privateKey && !parsedSamlConfig.privateKey) ctx.context.logger.warn("authnRequestsSigned is enabled but no privateKey provided - AuthnRequests will not be signed", { providerId: provider.providerId });
|
|
1653
2231
|
let metadata = parsedSamlConfig.spMetadata.metadata;
|
|
1654
2232
|
if (!metadata) metadata = saml.SPMetadata({
|
|
1655
2233
|
entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
|
@@ -1658,17 +2236,36 @@ const signInSSO = (options) => {
|
|
|
1658
2236
|
Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.providerId}`
|
|
1659
2237
|
}],
|
|
1660
2238
|
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
2239
|
+
authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
|
|
1661
2240
|
nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
|
|
1662
2241
|
}).getMetadata() || "";
|
|
1663
2242
|
const sp = saml.ServiceProvider({
|
|
1664
2243
|
metadata,
|
|
1665
|
-
allowCreate: true
|
|
2244
|
+
allowCreate: true,
|
|
2245
|
+
privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
|
|
2246
|
+
privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass
|
|
2247
|
+
});
|
|
2248
|
+
const idpData = parsedSamlConfig.idpMetadata;
|
|
2249
|
+
let idp;
|
|
2250
|
+
if (!idpData?.metadata) idp = saml.IdentityProvider({
|
|
2251
|
+
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
|
2252
|
+
singleSignOnService: idpData?.singleSignOnService || [{
|
|
2253
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
2254
|
+
Location: parsedSamlConfig.entryPoint
|
|
2255
|
+
}],
|
|
2256
|
+
signingCert: idpData?.cert || parsedSamlConfig.cert,
|
|
2257
|
+
wantAuthnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
|
|
2258
|
+
isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
|
|
2259
|
+
encPrivateKey: idpData?.encPrivateKey,
|
|
2260
|
+
encPrivateKeyPass: idpData?.encPrivateKeyPass
|
|
1666
2261
|
});
|
|
1667
|
-
|
|
1668
|
-
metadata:
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
2262
|
+
else idp = saml.IdentityProvider({
|
|
2263
|
+
metadata: idpData.metadata,
|
|
2264
|
+
privateKey: idpData.privateKey,
|
|
2265
|
+
privateKeyPass: idpData.privateKeyPass,
|
|
2266
|
+
isAssertionEncrypted: idpData.isAssertionEncrypted,
|
|
2267
|
+
encPrivateKey: idpData.encPrivateKey,
|
|
2268
|
+
encPrivateKeyPass: idpData.encPrivateKeyPass
|
|
1672
2269
|
});
|
|
1673
2270
|
const loginRequest = sp.createLoginRequest(idp, "redirect");
|
|
1674
2271
|
if (!loginRequest) throw new APIError("BAD_REQUEST", { message: "Invalid SAML request" });
|
|
@@ -1701,169 +2298,216 @@ const callbackSSOQuerySchema = z.object({
|
|
|
1701
2298
|
error: z.string().optional(),
|
|
1702
2299
|
error_description: z.string().optional()
|
|
1703
2300
|
});
|
|
2301
|
+
/**
|
|
2302
|
+
* Core OIDC callback handler logic, shared between the per-provider and
|
|
2303
|
+
* shared callback endpoints. Resolves the provider, exchanges the
|
|
2304
|
+
* authorization code for tokens, and creates a session.
|
|
2305
|
+
*
|
|
2306
|
+
* @param stateData - Pre-parsed state data. If not provided, it will be
|
|
2307
|
+
* parsed from the request context.
|
|
2308
|
+
*/
|
|
2309
|
+
async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
2310
|
+
const { code, error, error_description } = ctx.query;
|
|
2311
|
+
if (!stateData) stateData = await parseState(ctx);
|
|
2312
|
+
if (!stateData) {
|
|
2313
|
+
const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
|
|
2314
|
+
throw ctx.redirect(`${errorURL}?error=invalid_state`);
|
|
2315
|
+
}
|
|
2316
|
+
const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
|
|
2317
|
+
if (!code || error) throw ctx.redirect(`${errorURL || callbackURL}?error=${error}&error_description=${error_description}`);
|
|
2318
|
+
let provider = null;
|
|
2319
|
+
if (options?.defaultSSO?.length) {
|
|
2320
|
+
const matchingDefault = options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId);
|
|
2321
|
+
if (matchingDefault) provider = {
|
|
2322
|
+
...matchingDefault,
|
|
2323
|
+
issuer: matchingDefault.oidcConfig?.issuer || "",
|
|
2324
|
+
userId: "default",
|
|
2325
|
+
...options.domainVerification?.enabled ? { domainVerified: true } : {}
|
|
2326
|
+
};
|
|
2327
|
+
}
|
|
2328
|
+
if (!provider) provider = await ctx.context.adapter.findOne({
|
|
2329
|
+
model: "ssoProvider",
|
|
2330
|
+
where: [{
|
|
2331
|
+
field: "providerId",
|
|
2332
|
+
value: providerId
|
|
2333
|
+
}]
|
|
2334
|
+
}).then((res) => {
|
|
2335
|
+
if (!res) return null;
|
|
2336
|
+
return {
|
|
2337
|
+
...res,
|
|
2338
|
+
oidcConfig: safeJsonParse(res.oidcConfig) || void 0
|
|
2339
|
+
};
|
|
2340
|
+
});
|
|
2341
|
+
if (!provider) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
|
|
2342
|
+
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
2343
|
+
let config = provider.oidcConfig;
|
|
2344
|
+
if (!config) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
|
|
2345
|
+
try {
|
|
2346
|
+
config = await ensureRuntimeDiscovery(config, provider.issuer, (url) => ctx.context.isTrustedOrigin(url));
|
|
2347
|
+
} catch (error) {
|
|
2348
|
+
if (error instanceof DiscoveryError) throw ctx.redirect(`${errorURL || callbackURL}?error=discovery_failed&error_description=${encodeURIComponent(error.message)}`);
|
|
2349
|
+
throw ctx.redirect(`${errorURL || callbackURL}?error=discovery_failed&error_description=unexpected_discovery_error`);
|
|
2350
|
+
}
|
|
2351
|
+
if (!config.scopes) config = {
|
|
2352
|
+
...config,
|
|
2353
|
+
scopes: [
|
|
2354
|
+
"openid",
|
|
2355
|
+
"email",
|
|
2356
|
+
"profile",
|
|
2357
|
+
"offline_access"
|
|
2358
|
+
]
|
|
2359
|
+
};
|
|
2360
|
+
if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_endpoint_not_found`);
|
|
2361
|
+
const tokenResponse = await validateAuthorizationCode({
|
|
2362
|
+
code,
|
|
2363
|
+
codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
|
|
2364
|
+
redirectURI: getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options),
|
|
2365
|
+
options: {
|
|
2366
|
+
clientId: config.clientId,
|
|
2367
|
+
clientSecret: config.clientSecret
|
|
2368
|
+
},
|
|
2369
|
+
tokenEndpoint: config.tokenEndpoint,
|
|
2370
|
+
authentication: config.tokenEndpointAuthentication === "client_secret_post" ? "post" : "basic"
|
|
2371
|
+
}).catch((e) => {
|
|
2372
|
+
if (e instanceof BetterFetchError) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${e.message}`);
|
|
2373
|
+
return null;
|
|
2374
|
+
});
|
|
2375
|
+
if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_response_not_found`);
|
|
2376
|
+
let userInfo = null;
|
|
2377
|
+
if (tokenResponse.idToken) {
|
|
2378
|
+
const idToken = decodeJwt(tokenResponse.idToken);
|
|
2379
|
+
if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=jwks_endpoint_not_found`);
|
|
2380
|
+
const verified = await validateToken(tokenResponse.idToken, config.jwksEndpoint, {
|
|
2381
|
+
audience: config.clientId,
|
|
2382
|
+
issuer: provider.issuer
|
|
2383
|
+
}).catch((e) => {
|
|
2384
|
+
ctx.context.logger.error(e);
|
|
2385
|
+
return null;
|
|
2386
|
+
});
|
|
2387
|
+
if (!verified) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_not_verified`);
|
|
2388
|
+
const mapping = config.mapping || {};
|
|
2389
|
+
userInfo = {
|
|
2390
|
+
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, verified.payload[value]])),
|
|
2391
|
+
id: idToken[mapping.id || "sub"],
|
|
2392
|
+
email: idToken[mapping.email || "email"],
|
|
2393
|
+
emailVerified: options?.trustEmailVerified ? idToken[mapping.emailVerified || "email_verified"] : false,
|
|
2394
|
+
name: idToken[mapping.name || "name"],
|
|
2395
|
+
image: idToken[mapping.image || "picture"]
|
|
2396
|
+
};
|
|
2397
|
+
}
|
|
2398
|
+
if (!userInfo) {
|
|
2399
|
+
if (!config.userInfoEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=user_info_endpoint_not_found`);
|
|
2400
|
+
const userInfoResponse = await betterFetch(config.userInfoEndpoint, { headers: { Authorization: `Bearer ${tokenResponse.accessToken}` } });
|
|
2401
|
+
if (userInfoResponse.error) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${userInfoResponse.error.message}`);
|
|
2402
|
+
userInfo = userInfoResponse.data;
|
|
2403
|
+
}
|
|
2404
|
+
if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=missing_user_info`);
|
|
2405
|
+
const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfo.email, provider.domain);
|
|
2406
|
+
const linked = await handleOAuthUserInfo(ctx, {
|
|
2407
|
+
userInfo: {
|
|
2408
|
+
email: userInfo.email,
|
|
2409
|
+
name: userInfo.name || "",
|
|
2410
|
+
id: userInfo.id,
|
|
2411
|
+
image: userInfo.image,
|
|
2412
|
+
emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
|
|
2413
|
+
},
|
|
2414
|
+
account: {
|
|
2415
|
+
idToken: tokenResponse.idToken,
|
|
2416
|
+
accessToken: tokenResponse.accessToken,
|
|
2417
|
+
refreshToken: tokenResponse.refreshToken,
|
|
2418
|
+
accountId: userInfo.id,
|
|
2419
|
+
providerId: provider.providerId,
|
|
2420
|
+
accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
|
|
2421
|
+
refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
|
|
2422
|
+
scope: tokenResponse.scopes?.join(",")
|
|
2423
|
+
},
|
|
2424
|
+
callbackURL,
|
|
2425
|
+
disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
|
|
2426
|
+
overrideUserInfo: config.overrideUserInfo,
|
|
2427
|
+
isTrustedProvider
|
|
2428
|
+
});
|
|
2429
|
+
if (linked.error) throw ctx.redirect(`${errorURL || callbackURL}?error=${linked.error}`);
|
|
2430
|
+
const { session, user } = linked.data;
|
|
2431
|
+
if (options?.provisionUser && linked.isRegister) await options.provisionUser({
|
|
2432
|
+
user,
|
|
2433
|
+
userInfo,
|
|
2434
|
+
token: tokenResponse,
|
|
2435
|
+
provider
|
|
2436
|
+
});
|
|
2437
|
+
await assignOrganizationFromProvider(ctx, {
|
|
2438
|
+
user,
|
|
2439
|
+
profile: {
|
|
2440
|
+
providerType: "oidc",
|
|
2441
|
+
providerId: provider.providerId,
|
|
2442
|
+
accountId: userInfo.id,
|
|
2443
|
+
email: userInfo.email,
|
|
2444
|
+
emailVerified: Boolean(userInfo.emailVerified),
|
|
2445
|
+
rawAttributes: userInfo
|
|
2446
|
+
},
|
|
2447
|
+
provider,
|
|
2448
|
+
token: tokenResponse,
|
|
2449
|
+
provisioningOptions: options?.organizationProvisioning
|
|
2450
|
+
});
|
|
2451
|
+
await setSessionCookie(ctx, {
|
|
2452
|
+
session,
|
|
2453
|
+
user
|
|
2454
|
+
});
|
|
2455
|
+
let toRedirectTo;
|
|
2456
|
+
try {
|
|
2457
|
+
toRedirectTo = (linked.isRegister ? newUserURL || callbackURL : callbackURL).toString();
|
|
2458
|
+
} catch {
|
|
2459
|
+
toRedirectTo = linked.isRegister ? newUserURL || callbackURL : callbackURL;
|
|
2460
|
+
}
|
|
2461
|
+
throw ctx.redirect(toRedirectTo);
|
|
2462
|
+
}
|
|
2463
|
+
const callbackSSOEndpointConfig = {
|
|
2464
|
+
method: "GET",
|
|
2465
|
+
query: callbackSSOQuerySchema,
|
|
2466
|
+
allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
|
|
2467
|
+
metadata: {
|
|
2468
|
+
...HIDE_METADATA,
|
|
2469
|
+
openapi: {
|
|
2470
|
+
operationId: "handleSSOCallback",
|
|
2471
|
+
summary: "Callback URL for SSO provider",
|
|
2472
|
+
description: "This endpoint is used as the callback URL for SSO providers. It handles the authorization code and exchanges it for an access token",
|
|
2473
|
+
responses: { "302": { description: "Redirects to the callback URL" } }
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
};
|
|
1704
2477
|
const callbackSSO = (options) => {
|
|
1705
|
-
return createAuthEndpoint("/sso/callback/:providerId", {
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
2478
|
+
return createAuthEndpoint("/sso/callback/:providerId", callbackSSOEndpointConfig, async (ctx) => {
|
|
2479
|
+
return handleOIDCCallback(ctx, options, ctx.params.providerId);
|
|
2480
|
+
});
|
|
2481
|
+
};
|
|
2482
|
+
/**
|
|
2483
|
+
* Shared OIDC callback endpoint (no `:providerId` in path).
|
|
2484
|
+
* Used when `options.redirectURI` is set — the `providerId` is read from
|
|
2485
|
+
* the OAuth state instead of the URL path.
|
|
2486
|
+
*/
|
|
2487
|
+
const callbackSSOShared = (options) => {
|
|
2488
|
+
return createAuthEndpoint("/sso/callback", {
|
|
2489
|
+
...callbackSSOEndpointConfig,
|
|
1709
2490
|
metadata: {
|
|
1710
|
-
...
|
|
2491
|
+
...callbackSSOEndpointConfig.metadata,
|
|
1711
2492
|
openapi: {
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
2493
|
+
...callbackSSOEndpointConfig.metadata.openapi,
|
|
2494
|
+
operationId: "handleSSOCallbackShared",
|
|
2495
|
+
summary: "Shared callback URL for all SSO providers",
|
|
2496
|
+
description: "This endpoint is used as a shared callback URL for all SSO providers when `redirectURI` is configured. The provider is identified via the OAuth state parameter."
|
|
1716
2497
|
}
|
|
1717
2498
|
}
|
|
1718
2499
|
}, async (ctx) => {
|
|
1719
|
-
const { code, error, error_description } = ctx.query;
|
|
1720
2500
|
const stateData = await parseState(ctx);
|
|
1721
2501
|
if (!stateData) {
|
|
1722
|
-
const errorURL
|
|
1723
|
-
throw ctx.redirect(`${errorURL
|
|
1724
|
-
}
|
|
1725
|
-
const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
|
|
1726
|
-
if (!code || error) throw ctx.redirect(`${errorURL || callbackURL}?error=${error}&error_description=${error_description}`);
|
|
1727
|
-
let provider = null;
|
|
1728
|
-
if (options?.defaultSSO?.length) {
|
|
1729
|
-
const matchingDefault = options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === ctx.params.providerId);
|
|
1730
|
-
if (matchingDefault) provider = {
|
|
1731
|
-
...matchingDefault,
|
|
1732
|
-
issuer: matchingDefault.oidcConfig?.issuer || "",
|
|
1733
|
-
userId: "default",
|
|
1734
|
-
...options.domainVerification?.enabled ? { domainVerified: true } : {}
|
|
1735
|
-
};
|
|
1736
|
-
}
|
|
1737
|
-
if (!provider) provider = await ctx.context.adapter.findOne({
|
|
1738
|
-
model: "ssoProvider",
|
|
1739
|
-
where: [{
|
|
1740
|
-
field: "providerId",
|
|
1741
|
-
value: ctx.params.providerId
|
|
1742
|
-
}]
|
|
1743
|
-
}).then((res) => {
|
|
1744
|
-
if (!res) return null;
|
|
1745
|
-
return {
|
|
1746
|
-
...res,
|
|
1747
|
-
oidcConfig: safeJsonParse(res.oidcConfig) || void 0
|
|
1748
|
-
};
|
|
1749
|
-
});
|
|
1750
|
-
if (!provider) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=provider not found`);
|
|
1751
|
-
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
1752
|
-
let config = provider.oidcConfig;
|
|
1753
|
-
if (!config) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=provider not found`);
|
|
1754
|
-
const discovery = await betterFetch(config.discoveryEndpoint);
|
|
1755
|
-
if (discovery.data) config = {
|
|
1756
|
-
tokenEndpoint: discovery.data.token_endpoint,
|
|
1757
|
-
tokenEndpointAuthentication: discovery.data.token_endpoint_auth_method,
|
|
1758
|
-
userInfoEndpoint: discovery.data.userinfo_endpoint,
|
|
1759
|
-
scopes: [
|
|
1760
|
-
"openid",
|
|
1761
|
-
"email",
|
|
1762
|
-
"profile",
|
|
1763
|
-
"offline_access"
|
|
1764
|
-
],
|
|
1765
|
-
...config
|
|
1766
|
-
};
|
|
1767
|
-
if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=token_endpoint_not_found`);
|
|
1768
|
-
const tokenResponse = await validateAuthorizationCode({
|
|
1769
|
-
code,
|
|
1770
|
-
codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
|
|
1771
|
-
redirectURI: `${ctx.context.baseURL}/sso/callback/${provider.providerId}`,
|
|
1772
|
-
options: {
|
|
1773
|
-
clientId: config.clientId,
|
|
1774
|
-
clientSecret: config.clientSecret
|
|
1775
|
-
},
|
|
1776
|
-
tokenEndpoint: config.tokenEndpoint,
|
|
1777
|
-
authentication: config.tokenEndpointAuthentication === "client_secret_post" ? "post" : "basic"
|
|
1778
|
-
}).catch((e) => {
|
|
1779
|
-
if (e instanceof BetterFetchError) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${e.message}`);
|
|
1780
|
-
return null;
|
|
1781
|
-
});
|
|
1782
|
-
if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=token_response_not_found`);
|
|
1783
|
-
let userInfo = null;
|
|
1784
|
-
if (tokenResponse.idToken) {
|
|
1785
|
-
const idToken = decodeJwt(tokenResponse.idToken);
|
|
1786
|
-
if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=jwks_endpoint_not_found`);
|
|
1787
|
-
const verified = await validateToken(tokenResponse.idToken, config.jwksEndpoint).catch((e) => {
|
|
1788
|
-
ctx.context.logger.error(e);
|
|
1789
|
-
return null;
|
|
1790
|
-
});
|
|
1791
|
-
if (!verified) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=token_not_verified`);
|
|
1792
|
-
if (verified.payload.iss !== provider.issuer) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=issuer_mismatch`);
|
|
1793
|
-
const mapping = config.mapping || {};
|
|
1794
|
-
userInfo = {
|
|
1795
|
-
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, verified.payload[value]])),
|
|
1796
|
-
id: idToken[mapping.id || "sub"],
|
|
1797
|
-
email: idToken[mapping.email || "email"],
|
|
1798
|
-
emailVerified: options?.trustEmailVerified ? idToken[mapping.emailVerified || "email_verified"] : false,
|
|
1799
|
-
name: idToken[mapping.name || "name"],
|
|
1800
|
-
image: idToken[mapping.image || "picture"]
|
|
1801
|
-
};
|
|
2502
|
+
const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
|
|
2503
|
+
throw ctx.redirect(`${errorURL}?error=invalid_state`);
|
|
1802
2504
|
}
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
const
|
|
1806
|
-
|
|
1807
|
-
userInfo = userInfoResponse.data;
|
|
1808
|
-
}
|
|
1809
|
-
if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=missing_user_info`);
|
|
1810
|
-
const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfo.email, provider.domain);
|
|
1811
|
-
const linked = await handleOAuthUserInfo(ctx, {
|
|
1812
|
-
userInfo: {
|
|
1813
|
-
email: userInfo.email,
|
|
1814
|
-
name: userInfo.name || userInfo.email,
|
|
1815
|
-
id: userInfo.id,
|
|
1816
|
-
image: userInfo.image,
|
|
1817
|
-
emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
|
|
1818
|
-
},
|
|
1819
|
-
account: {
|
|
1820
|
-
idToken: tokenResponse.idToken,
|
|
1821
|
-
accessToken: tokenResponse.accessToken,
|
|
1822
|
-
refreshToken: tokenResponse.refreshToken,
|
|
1823
|
-
accountId: userInfo.id,
|
|
1824
|
-
providerId: provider.providerId,
|
|
1825
|
-
accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
|
|
1826
|
-
refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
|
|
1827
|
-
scope: tokenResponse.scopes?.join(",")
|
|
1828
|
-
},
|
|
1829
|
-
callbackURL,
|
|
1830
|
-
disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
|
|
1831
|
-
overrideUserInfo: config.overrideUserInfo,
|
|
1832
|
-
isTrustedProvider
|
|
1833
|
-
});
|
|
1834
|
-
if (linked.error) throw ctx.redirect(`${errorURL || callbackURL}/error?error=${linked.error}`);
|
|
1835
|
-
const { session, user } = linked.data;
|
|
1836
|
-
if (options?.provisionUser) await options.provisionUser({
|
|
1837
|
-
user,
|
|
1838
|
-
userInfo,
|
|
1839
|
-
token: tokenResponse,
|
|
1840
|
-
provider
|
|
1841
|
-
});
|
|
1842
|
-
await assignOrganizationFromProvider(ctx, {
|
|
1843
|
-
user,
|
|
1844
|
-
profile: {
|
|
1845
|
-
providerType: "oidc",
|
|
1846
|
-
providerId: provider.providerId,
|
|
1847
|
-
accountId: userInfo.id,
|
|
1848
|
-
email: userInfo.email,
|
|
1849
|
-
emailVerified: Boolean(userInfo.emailVerified),
|
|
1850
|
-
rawAttributes: userInfo
|
|
1851
|
-
},
|
|
1852
|
-
provider,
|
|
1853
|
-
token: tokenResponse,
|
|
1854
|
-
provisioningOptions: options?.organizationProvisioning
|
|
1855
|
-
});
|
|
1856
|
-
await setSessionCookie(ctx, {
|
|
1857
|
-
session,
|
|
1858
|
-
user
|
|
1859
|
-
});
|
|
1860
|
-
let toRedirectTo;
|
|
1861
|
-
try {
|
|
1862
|
-
toRedirectTo = (linked.isRegister ? newUserURL || callbackURL : callbackURL).toString();
|
|
1863
|
-
} catch {
|
|
1864
|
-
toRedirectTo = linked.isRegister ? newUserURL || callbackURL : callbackURL;
|
|
2505
|
+
const providerId = stateData.ssoProviderId;
|
|
2506
|
+
if (!providerId) {
|
|
2507
|
+
const errorURL = stateData.errorURL || stateData.callbackURL;
|
|
2508
|
+
throw ctx.redirect(`${errorURL}?error=invalid_state&error_description=missing_provider_id`);
|
|
1865
2509
|
}
|
|
1866
|
-
|
|
2510
|
+
return handleOIDCCallback(ctx, options, providerId, stateData);
|
|
1867
2511
|
});
|
|
1868
2512
|
};
|
|
1869
2513
|
const callbackSSOSAMLBodySchema = z.object({
|
|
@@ -1924,9 +2568,9 @@ const callbackSSOSAML = (options) => {
|
|
|
1924
2568
|
const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/callback/${providerId}`;
|
|
1925
2569
|
if (ctx.method === "GET" && !ctx.body?.SAMLResponse) {
|
|
1926
2570
|
if (!(await getSessionFromCtx(ctx))?.session) throw ctx.redirect(`${errorURL}?error=invalid_request`);
|
|
1927
|
-
const relayState
|
|
1928
|
-
const safeRedirectUrl
|
|
1929
|
-
throw ctx.redirect(safeRedirectUrl
|
|
2571
|
+
const relayState = ctx.query?.RelayState;
|
|
2572
|
+
const safeRedirectUrl = getSafeRedirectUrl(relayState, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
2573
|
+
throw ctx.redirect(safeRedirectUrl);
|
|
1930
2574
|
}
|
|
1931
2575
|
if (!ctx.body?.SAMLResponse) throw new APIError("BAD_REQUEST", { message: "SAMLResponse is required for POST requests" });
|
|
1932
2576
|
const { SAMLResponse } = ctx.body;
|
|
@@ -1969,12 +2613,12 @@ const callbackSSOSAML = (options) => {
|
|
|
1969
2613
|
let idp = null;
|
|
1970
2614
|
if (!idpData?.metadata) idp = saml.IdentityProvider({
|
|
1971
2615
|
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
|
1972
|
-
singleSignOnService: [{
|
|
2616
|
+
singleSignOnService: idpData?.singleSignOnService || [{
|
|
1973
2617
|
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1974
2618
|
Location: parsedSamlConfig.entryPoint
|
|
1975
2619
|
}],
|
|
1976
2620
|
signingCert: idpData?.cert || parsedSamlConfig.cert,
|
|
1977
|
-
wantAuthnRequestsSigned: parsedSamlConfig.
|
|
2621
|
+
wantAuthnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
|
|
1978
2622
|
isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
|
|
1979
2623
|
encPrivateKey: idpData?.encPrivateKey,
|
|
1980
2624
|
encPrivateKeyPass: idpData?.encPrivateKeyPass
|
|
@@ -2108,7 +2752,7 @@ const callbackSSOSAML = (options) => {
|
|
|
2108
2752
|
const userInfo = {
|
|
2109
2753
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
|
|
2110
2754
|
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
2111
|
-
email: attributes[mapping.email || "email"] || extract.nameID,
|
|
2755
|
+
email: (attributes[mapping.email || "email"] || extract.nameID).toLowerCase(),
|
|
2112
2756
|
name: [attributes[mapping.firstName || "givenName"], attributes[mapping.lastName || "surname"]].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
|
|
2113
2757
|
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
|
|
2114
2758
|
};
|
|
@@ -2121,7 +2765,7 @@ const callbackSSOSAML = (options) => {
|
|
|
2121
2765
|
});
|
|
2122
2766
|
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
2123
2767
|
}
|
|
2124
|
-
const isTrustedProvider =
|
|
2768
|
+
const isTrustedProvider = ctx.context.trustedProviders.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
2125
2769
|
const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2126
2770
|
const result = await handleOAuthUserInfo(ctx, {
|
|
2127
2771
|
userInfo: {
|
|
@@ -2164,11 +2808,29 @@ const callbackSSOSAML = (options) => {
|
|
|
2164
2808
|
session,
|
|
2165
2809
|
user
|
|
2166
2810
|
});
|
|
2811
|
+
if (options?.saml?.enableSingleLogout && extract.nameID) {
|
|
2812
|
+
const samlSessionKey = `${SAML_SESSION_KEY_PREFIX}${provider.providerId}:${extract.nameID}`;
|
|
2813
|
+
const samlSessionData = {
|
|
2814
|
+
sessionId: session.id,
|
|
2815
|
+
providerId: provider.providerId,
|
|
2816
|
+
nameID: extract.nameID,
|
|
2817
|
+
sessionIndex: extract.sessionIndex
|
|
2818
|
+
};
|
|
2819
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
2820
|
+
identifier: samlSessionKey,
|
|
2821
|
+
value: JSON.stringify(samlSessionData),
|
|
2822
|
+
expiresAt: session.expiresAt
|
|
2823
|
+
}).catch((e) => ctx.context.logger.warn("Failed to create SAML session record", { error: e }));
|
|
2824
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
2825
|
+
identifier: `${SAML_SESSION_BY_ID_PREFIX}${session.id}`,
|
|
2826
|
+
value: samlSessionKey,
|
|
2827
|
+
expiresAt: session.expiresAt
|
|
2828
|
+
}).catch((e) => ctx.context.logger.warn("Failed to create SAML session lookup record", e));
|
|
2829
|
+
}
|
|
2167
2830
|
const safeRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
2168
2831
|
throw ctx.redirect(safeRedirectUrl);
|
|
2169
2832
|
});
|
|
2170
2833
|
};
|
|
2171
|
-
const acsEndpointParamsSchema = z.object({ providerId: z.string().optional() });
|
|
2172
2834
|
const acsEndpointBodySchema = z.object({
|
|
2173
2835
|
SAMLResponse: z.string(),
|
|
2174
2836
|
RelayState: z.string().optional()
|
|
@@ -2176,7 +2838,6 @@ const acsEndpointBodySchema = z.object({
|
|
|
2176
2838
|
const acsEndpoint = (options) => {
|
|
2177
2839
|
return createAuthEndpoint("/sso/saml2/sp/acs/:providerId", {
|
|
2178
2840
|
method: "POST",
|
|
2179
|
-
params: acsEndpointParamsSchema,
|
|
2180
2841
|
body: acsEndpointBodySchema,
|
|
2181
2842
|
metadata: {
|
|
2182
2843
|
...HIDE_METADATA,
|
|
@@ -2189,10 +2850,18 @@ const acsEndpoint = (options) => {
|
|
|
2189
2850
|
}
|
|
2190
2851
|
}
|
|
2191
2852
|
}, async (ctx) => {
|
|
2192
|
-
const { SAMLResponse
|
|
2853
|
+
const { SAMLResponse } = ctx.body;
|
|
2193
2854
|
const { providerId } = ctx.params;
|
|
2855
|
+
const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`;
|
|
2856
|
+
const appOrigin = new URL(ctx.context.baseURL).origin;
|
|
2194
2857
|
const maxResponseSize = options?.saml?.maxResponseSize ?? DEFAULT_MAX_SAML_RESPONSE_SIZE;
|
|
2195
2858
|
if (new TextEncoder().encode(SAMLResponse).length > maxResponseSize) throw new APIError("BAD_REQUEST", { message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)` });
|
|
2859
|
+
let relayState = null;
|
|
2860
|
+
if (ctx.body.RelayState) try {
|
|
2861
|
+
relayState = await parseRelayState(ctx);
|
|
2862
|
+
} catch {
|
|
2863
|
+
relayState = null;
|
|
2864
|
+
}
|
|
2196
2865
|
let provider = null;
|
|
2197
2866
|
if (options?.defaultSSO?.length) {
|
|
2198
2867
|
const matchingDefault = providerId ? options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId) : options.defaultSSO[0];
|
|
@@ -2208,7 +2877,7 @@ const acsEndpoint = (options) => {
|
|
|
2208
2877
|
model: "ssoProvider",
|
|
2209
2878
|
where: [{
|
|
2210
2879
|
field: "providerId",
|
|
2211
|
-
value: providerId
|
|
2880
|
+
value: providerId
|
|
2212
2881
|
}]
|
|
2213
2882
|
}).then((res) => {
|
|
2214
2883
|
if (!res) return null;
|
|
@@ -2245,7 +2914,7 @@ const acsEndpoint = (options) => {
|
|
|
2245
2914
|
validateSingleAssertion(SAMLResponse);
|
|
2246
2915
|
} catch (error) {
|
|
2247
2916
|
if (error instanceof APIError) {
|
|
2248
|
-
const redirectUrl =
|
|
2917
|
+
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2249
2918
|
const errorCode = error.body?.code === "SAML_MULTIPLE_ASSERTIONS" ? "multiple_assertions" : "no_assertion";
|
|
2250
2919
|
throw ctx.redirect(`${redirectUrl}?error=${errorCode}&error_description=${encodeURIComponent(error.message)}`);
|
|
2251
2920
|
}
|
|
@@ -2255,7 +2924,7 @@ const acsEndpoint = (options) => {
|
|
|
2255
2924
|
try {
|
|
2256
2925
|
parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
|
|
2257
2926
|
SAMLResponse,
|
|
2258
|
-
RelayState: RelayState || void 0
|
|
2927
|
+
RelayState: ctx.body.RelayState || void 0
|
|
2259
2928
|
} });
|
|
2260
2929
|
if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
|
|
2261
2930
|
} catch (error) {
|
|
@@ -2292,7 +2961,7 @@ const acsEndpoint = (options) => {
|
|
|
2292
2961
|
inResponseTo: inResponseToAcs,
|
|
2293
2962
|
providerId
|
|
2294
2963
|
});
|
|
2295
|
-
const redirectUrl =
|
|
2964
|
+
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2296
2965
|
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
|
|
2297
2966
|
}
|
|
2298
2967
|
if (storedRequest.providerId !== providerId) {
|
|
@@ -2302,13 +2971,13 @@ const acsEndpoint = (options) => {
|
|
|
2302
2971
|
actualProvider: providerId
|
|
2303
2972
|
});
|
|
2304
2973
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
|
|
2305
|
-
const redirectUrl =
|
|
2974
|
+
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2306
2975
|
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
|
|
2307
2976
|
}
|
|
2308
2977
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
|
|
2309
2978
|
} else if (!allowIdpInitiated) {
|
|
2310
2979
|
ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId });
|
|
2311
|
-
const redirectUrl =
|
|
2980
|
+
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2312
2981
|
throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
|
|
2313
2982
|
}
|
|
2314
2983
|
}
|
|
@@ -2334,7 +3003,7 @@ const acsEndpoint = (options) => {
|
|
|
2334
3003
|
issuer,
|
|
2335
3004
|
providerId
|
|
2336
3005
|
});
|
|
2337
|
-
const redirectUrl =
|
|
3006
|
+
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2338
3007
|
throw ctx.redirect(`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
|
|
2339
3008
|
}
|
|
2340
3009
|
await ctx.context.internalAdapter.createVerificationValue({
|
|
@@ -2354,7 +3023,7 @@ const acsEndpoint = (options) => {
|
|
|
2354
3023
|
const userInfo = {
|
|
2355
3024
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
|
|
2356
3025
|
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
2357
|
-
email: attributes[mapping.email || "email"] || extract.nameID,
|
|
3026
|
+
email: (attributes[mapping.email || "email"] || extract.nameID).toLowerCase(),
|
|
2358
3027
|
name: [attributes[mapping.firstName || "givenName"], attributes[mapping.lastName || "surname"]].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
|
|
2359
3028
|
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
|
|
2360
3029
|
};
|
|
@@ -2367,8 +3036,8 @@ const acsEndpoint = (options) => {
|
|
|
2367
3036
|
});
|
|
2368
3037
|
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
2369
3038
|
}
|
|
2370
|
-
const isTrustedProvider =
|
|
2371
|
-
const callbackUrl =
|
|
3039
|
+
const isTrustedProvider = ctx.context.trustedProviders.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
3040
|
+
const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2372
3041
|
const result = await handleOAuthUserInfo(ctx, {
|
|
2373
3042
|
userInfo: {
|
|
2374
3043
|
email: userInfo.email,
|
|
@@ -2410,7 +3079,184 @@ const acsEndpoint = (options) => {
|
|
|
2410
3079
|
session,
|
|
2411
3080
|
user
|
|
2412
3081
|
});
|
|
2413
|
-
|
|
3082
|
+
if (options?.saml?.enableSingleLogout && extract.nameID) {
|
|
3083
|
+
const samlSessionKey = `${SAML_SESSION_KEY_PREFIX}${providerId}:${extract.nameID}`;
|
|
3084
|
+
const samlSessionData = {
|
|
3085
|
+
sessionId: session.id,
|
|
3086
|
+
providerId,
|
|
3087
|
+
nameID: extract.nameID,
|
|
3088
|
+
sessionIndex: extract.sessionIndex
|
|
3089
|
+
};
|
|
3090
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
3091
|
+
identifier: samlSessionKey,
|
|
3092
|
+
value: JSON.stringify(samlSessionData),
|
|
3093
|
+
expiresAt: session.expiresAt
|
|
3094
|
+
}).catch((e) => ctx.context.logger.warn("Failed to create SAML session record", { error: e }));
|
|
3095
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
3096
|
+
identifier: `${SAML_SESSION_BY_ID_PREFIX}${session.id}`,
|
|
3097
|
+
value: samlSessionKey,
|
|
3098
|
+
expiresAt: session.expiresAt
|
|
3099
|
+
}).catch((e) => ctx.context.logger.warn("Failed to create SAML session lookup record", e));
|
|
3100
|
+
}
|
|
3101
|
+
const safeRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
3102
|
+
throw ctx.redirect(safeRedirectUrl);
|
|
3103
|
+
});
|
|
3104
|
+
};
|
|
3105
|
+
const sloSchema = z.object({
|
|
3106
|
+
SAMLRequest: z.string().optional(),
|
|
3107
|
+
SAMLResponse: z.string().optional(),
|
|
3108
|
+
RelayState: z.string().optional(),
|
|
3109
|
+
SigAlg: z.string().optional(),
|
|
3110
|
+
Signature: z.string().optional()
|
|
3111
|
+
});
|
|
3112
|
+
const sloEndpoint = (options) => {
|
|
3113
|
+
return createAuthEndpoint("/sso/saml2/sp/slo/:providerId", {
|
|
3114
|
+
method: ["GET", "POST"],
|
|
3115
|
+
body: sloSchema.optional(),
|
|
3116
|
+
query: sloSchema.optional(),
|
|
3117
|
+
metadata: {
|
|
3118
|
+
...HIDE_METADATA,
|
|
3119
|
+
allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"]
|
|
3120
|
+
}
|
|
3121
|
+
}, async (ctx) => {
|
|
3122
|
+
if (!options?.saml?.enableSingleLogout) throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.SINGLE_LOGOUT_NOT_ENABLED);
|
|
3123
|
+
const { providerId } = ctx.params;
|
|
3124
|
+
const samlRequest = ctx.body?.SAMLRequest || ctx.query?.SAMLRequest;
|
|
3125
|
+
const samlResponse = ctx.body?.SAMLResponse || ctx.query?.SAMLResponse;
|
|
3126
|
+
const relayState = ctx.body?.RelayState || ctx.query?.RelayState;
|
|
3127
|
+
const appOrigin = new URL(ctx.context.baseURL).origin;
|
|
3128
|
+
const safeErrorURL = getSafeRedirectUrl(relayState, `${appOrigin}/sso/saml2/sp/slo/${providerId}`, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
3129
|
+
if (!samlRequest && !samlResponse) throw ctx.redirect(`${safeErrorURL}?error=invalid_request&error_description=missing_logout_data`);
|
|
3130
|
+
const provider = await findSAMLProvider(providerId, options, ctx.context.adapter);
|
|
3131
|
+
if (!provider?.samlConfig) throw APIError.from("NOT_FOUND", SAML_ERROR_CODES.SAML_PROVIDER_NOT_FOUND);
|
|
3132
|
+
const config = provider.samlConfig;
|
|
3133
|
+
const sp = createSP(config, ctx.context.baseURL, providerId, {
|
|
3134
|
+
wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned,
|
|
3135
|
+
wantLogoutResponseSigned: options?.saml?.wantLogoutResponseSigned
|
|
3136
|
+
});
|
|
3137
|
+
const idp = createIdP(config);
|
|
3138
|
+
if (samlResponse) return handleLogoutResponse(ctx, sp, idp, relayState, providerId);
|
|
3139
|
+
return handleLogoutRequest(ctx, sp, idp, relayState, providerId);
|
|
3140
|
+
});
|
|
3141
|
+
};
|
|
3142
|
+
async function handleLogoutResponse(ctx, sp, idp, relayState, providerId) {
|
|
3143
|
+
const binding = ctx.method === "POST" && ctx.body?.SAMLResponse ? "post" : "redirect";
|
|
3144
|
+
let parsed;
|
|
3145
|
+
try {
|
|
3146
|
+
parsed = await sp.parseLogoutResponse(idp, binding, {
|
|
3147
|
+
body: ctx.body,
|
|
3148
|
+
query: ctx.query
|
|
3149
|
+
});
|
|
3150
|
+
} catch (error) {
|
|
3151
|
+
ctx.context.logger.error("LogoutResponse validation failed", { error });
|
|
3152
|
+
throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.INVALID_LOGOUT_RESPONSE);
|
|
3153
|
+
}
|
|
3154
|
+
const extract = parsed?.extract;
|
|
3155
|
+
const statusCode = extract?.statusCode || extract?.status || parsed?.samlContent?.status?.statusCode;
|
|
3156
|
+
if (statusCode && statusCode !== SAML_STATUS_SUCCESS) {
|
|
3157
|
+
ctx.context.logger.warn("LogoutResponse indicates failure", { statusCode });
|
|
3158
|
+
throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.LOGOUT_FAILED_AT_IDP);
|
|
3159
|
+
}
|
|
3160
|
+
const inResponseTo = extract?.response?.inResponseTo;
|
|
3161
|
+
if (inResponseTo) {
|
|
3162
|
+
const key = `${LOGOUT_REQUEST_KEY_PREFIX}${inResponseTo}`;
|
|
3163
|
+
if (!await ctx.context.internalAdapter.findVerificationValue(key)) ctx.context.logger.warn("LogoutResponse references unknown or expired request", { inResponseTo });
|
|
3164
|
+
await ctx.context.internalAdapter.deleteVerificationValue(key).catch((e) => ctx.context.logger.warn("Failed to delete logout request verification value", e));
|
|
3165
|
+
}
|
|
3166
|
+
deleteSessionCookie(ctx);
|
|
3167
|
+
const appOrigin = new URL(ctx.context.baseURL).origin;
|
|
3168
|
+
const safeRedirectUrl = getSafeRedirectUrl(relayState, `${appOrigin}/sso/saml2/sp/slo/${providerId}`, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
3169
|
+
throw ctx.redirect(safeRedirectUrl);
|
|
3170
|
+
}
|
|
3171
|
+
async function handleLogoutRequest(ctx, sp, idp, relayState, providerId) {
|
|
3172
|
+
const binding = ctx.method === "POST" && ctx.body?.SAMLRequest ? "post" : "redirect";
|
|
3173
|
+
let parsed;
|
|
3174
|
+
try {
|
|
3175
|
+
parsed = await sp.parseLogoutRequest(idp, binding, {
|
|
3176
|
+
body: ctx.body,
|
|
3177
|
+
query: ctx.query
|
|
3178
|
+
});
|
|
3179
|
+
} catch (error) {
|
|
3180
|
+
ctx.context.logger.error("LogoutRequest validation failed", { error });
|
|
3181
|
+
throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.INVALID_LOGOUT_REQUEST);
|
|
3182
|
+
}
|
|
3183
|
+
if (!parsed?.extract) throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.INVALID_LOGOUT_REQUEST);
|
|
3184
|
+
const { nameID } = parsed.extract;
|
|
3185
|
+
const sessionIndex = parsed.extract.sessionIndex;
|
|
3186
|
+
const key = `${SAML_SESSION_KEY_PREFIX}${providerId}:${nameID}`;
|
|
3187
|
+
const stored = await ctx.context.internalAdapter.findVerificationValue(key);
|
|
3188
|
+
if (stored) {
|
|
3189
|
+
const data = safeJsonParse(stored.value);
|
|
3190
|
+
if (data) if (!sessionIndex || !data.sessionIndex || sessionIndex === data.sessionIndex) {
|
|
3191
|
+
await ctx.context.internalAdapter.deleteSession(data.sessionId).catch((e) => ctx.context.logger.warn("Failed to delete session during SLO", { error: e }));
|
|
3192
|
+
await ctx.context.internalAdapter.deleteVerificationValue(`${SAML_SESSION_BY_ID_PREFIX}${data.sessionId}`).catch((e) => ctx.context.logger.warn("Failed to delete SAML session lookup during SLO", e));
|
|
3193
|
+
} else ctx.context.logger.warn("SessionIndex mismatch in LogoutRequest - skipping session deletion", {
|
|
3194
|
+
providerId,
|
|
3195
|
+
requestedSessionIndex: sessionIndex,
|
|
3196
|
+
storedSessionIndex: data.sessionIndex
|
|
3197
|
+
});
|
|
3198
|
+
await ctx.context.internalAdapter.deleteVerificationValue(key).catch((e) => ctx.context.logger.warn("Failed to delete SAML session key during SLO", e));
|
|
3199
|
+
}
|
|
3200
|
+
const currentSession = await getSessionFromCtx(ctx);
|
|
3201
|
+
if (currentSession?.session) await ctx.context.internalAdapter.deleteSession(currentSession.session.id);
|
|
3202
|
+
deleteSessionCookie(ctx);
|
|
3203
|
+
const requestId = parsed.extract.request?.id || "";
|
|
3204
|
+
const res = sp.createLogoutResponse(idp, null, binding, relayState || "", (template) => template.replace("{InResponseTo}", requestId).replace("{StatusCode}", SAML_STATUS_SUCCESS));
|
|
3205
|
+
if (binding === "post" && res.entityEndpoint) return createSAMLPostForm(res.entityEndpoint, "SAMLResponse", res.context, relayState);
|
|
3206
|
+
throw ctx.redirect(res.context);
|
|
3207
|
+
}
|
|
3208
|
+
const initiateSLO = (options) => {
|
|
3209
|
+
return createAuthEndpoint("/sso/saml2/logout/:providerId", {
|
|
3210
|
+
method: "POST",
|
|
3211
|
+
body: z.object({ callbackURL: z.string().optional() }),
|
|
3212
|
+
use: [sessionMiddleware],
|
|
3213
|
+
metadata: HIDE_METADATA
|
|
3214
|
+
}, async (ctx) => {
|
|
3215
|
+
if (!options?.saml?.enableSingleLogout) throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.SINGLE_LOGOUT_NOT_ENABLED);
|
|
3216
|
+
const { providerId } = ctx.params;
|
|
3217
|
+
const callbackURL = ctx.body.callbackURL || ctx.context.baseURL;
|
|
3218
|
+
const provider = await findSAMLProvider(providerId, options, ctx.context.adapter);
|
|
3219
|
+
if (!provider?.samlConfig) throw APIError.from("NOT_FOUND", SAML_ERROR_CODES.SAML_PROVIDER_NOT_FOUND);
|
|
3220
|
+
const config = provider.samlConfig;
|
|
3221
|
+
if (!(config.idpMetadata?.singleLogoutService?.length || config.idpMetadata?.metadata && config.idpMetadata.metadata.includes("SingleLogoutService"))) throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.IDP_SLO_NOT_SUPPORTED);
|
|
3222
|
+
const sp = createSP(config, ctx.context.baseURL, providerId, {
|
|
3223
|
+
wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned,
|
|
3224
|
+
wantLogoutResponseSigned: options?.saml?.wantLogoutResponseSigned
|
|
3225
|
+
});
|
|
3226
|
+
const idp = createIdP(config);
|
|
3227
|
+
const session = ctx.context.session;
|
|
3228
|
+
const sessionLookupKey = `${SAML_SESSION_BY_ID_PREFIX}${session.session.id}`;
|
|
3229
|
+
const sessionLookup = await ctx.context.internalAdapter.findVerificationValue(sessionLookupKey);
|
|
3230
|
+
let nameID = session.user.email;
|
|
3231
|
+
let sessionIndex;
|
|
3232
|
+
let samlSessionKey;
|
|
3233
|
+
if (sessionLookup) {
|
|
3234
|
+
samlSessionKey = sessionLookup.value;
|
|
3235
|
+
const stored = await ctx.context.internalAdapter.findVerificationValue(samlSessionKey);
|
|
3236
|
+
if (stored) {
|
|
3237
|
+
const data = safeJsonParse(stored.value);
|
|
3238
|
+
if (data) {
|
|
3239
|
+
nameID = data.nameID || nameID;
|
|
3240
|
+
sessionIndex = data.sessionIndex;
|
|
3241
|
+
}
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3244
|
+
const logoutRequest = sp.createLogoutRequest(idp, "redirect", {
|
|
3245
|
+
logoutNameID: nameID,
|
|
3246
|
+
sessionIndex,
|
|
3247
|
+
relayState: callbackURL
|
|
3248
|
+
});
|
|
3249
|
+
const ttl = options?.saml?.logoutRequestTTL ?? DEFAULT_LOGOUT_REQUEST_TTL_MS;
|
|
3250
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
3251
|
+
identifier: `${LOGOUT_REQUEST_KEY_PREFIX}${logoutRequest.id}`,
|
|
3252
|
+
value: providerId,
|
|
3253
|
+
expiresAt: new Date(Date.now() + ttl)
|
|
3254
|
+
});
|
|
3255
|
+
if (samlSessionKey) await ctx.context.internalAdapter.deleteVerificationValue(samlSessionKey).catch((e) => ctx.context.logger.warn("Failed to delete SAML session key during logout", e));
|
|
3256
|
+
await ctx.context.internalAdapter.deleteVerificationValue(sessionLookupKey).catch((e) => ctx.context.logger.warn("Failed to delete session lookup key during logout", e));
|
|
3257
|
+
await ctx.context.internalAdapter.deleteSession(session.session.id);
|
|
3258
|
+
deleteSessionCookie(ctx);
|
|
3259
|
+
throw ctx.redirect(logoutRequest.context);
|
|
2414
3260
|
});
|
|
2415
3261
|
};
|
|
2416
3262
|
|
|
@@ -2425,16 +3271,27 @@ saml.setSchemaValidator({ async validate(xml) {
|
|
|
2425
3271
|
* These endpoints receive POST requests from external Identity Providers,
|
|
2426
3272
|
* which won't have a matching Origin header.
|
|
2427
3273
|
*/
|
|
2428
|
-
const SAML_SKIP_ORIGIN_CHECK_PATHS = [
|
|
3274
|
+
const SAML_SKIP_ORIGIN_CHECK_PATHS = [
|
|
3275
|
+
"/sso/saml2/callback",
|
|
3276
|
+
"/sso/saml2/sp/acs",
|
|
3277
|
+
"/sso/saml2/sp/slo"
|
|
3278
|
+
];
|
|
2429
3279
|
function sso(options) {
|
|
2430
3280
|
const optionsWithStore = options;
|
|
2431
3281
|
let endpoints = {
|
|
2432
|
-
spMetadata: spMetadata(),
|
|
3282
|
+
spMetadata: spMetadata(optionsWithStore),
|
|
2433
3283
|
registerSSOProvider: registerSSOProvider(optionsWithStore),
|
|
2434
3284
|
signInSSO: signInSSO(optionsWithStore),
|
|
2435
3285
|
callbackSSO: callbackSSO(optionsWithStore),
|
|
3286
|
+
callbackSSOShared: callbackSSOShared(optionsWithStore),
|
|
2436
3287
|
callbackSSOSAML: callbackSSOSAML(optionsWithStore),
|
|
2437
|
-
acsEndpoint: acsEndpoint(optionsWithStore)
|
|
3288
|
+
acsEndpoint: acsEndpoint(optionsWithStore),
|
|
3289
|
+
sloEndpoint: sloEndpoint(optionsWithStore),
|
|
3290
|
+
initiateSLO: initiateSLO(optionsWithStore),
|
|
3291
|
+
listSSOProviders: listSSOProviders(),
|
|
3292
|
+
getSSOProvider: getSSOProvider(),
|
|
3293
|
+
updateSSOProvider: updateSSOProvider(optionsWithStore),
|
|
3294
|
+
deleteSSOProvider: deleteSSOProvider()
|
|
2438
3295
|
};
|
|
2439
3296
|
if (options?.domainVerification?.enabled) {
|
|
2440
3297
|
const domainVerificationEndpoints = {
|
|
@@ -2454,21 +3311,39 @@ function sso(options) {
|
|
|
2454
3311
|
return { context: { skipOriginCheck: [...Array.isArray(existing) ? existing : [], ...SAML_SKIP_ORIGIN_CHECK_PATHS] } };
|
|
2455
3312
|
},
|
|
2456
3313
|
endpoints,
|
|
2457
|
-
hooks: {
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
3314
|
+
hooks: {
|
|
3315
|
+
before: [{
|
|
3316
|
+
matcher(context) {
|
|
3317
|
+
return context.path === "/sign-out";
|
|
3318
|
+
},
|
|
3319
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
3320
|
+
if (!options?.saml?.enableSingleLogout) return;
|
|
3321
|
+
const session = await getSessionFromCtx(ctx);
|
|
3322
|
+
if (!session?.session?.id) return;
|
|
3323
|
+
const sessionLookupKey = `${SAML_SESSION_BY_ID_PREFIX}${session.session.id}`;
|
|
3324
|
+
const sessionLookup = await ctx.context.internalAdapter.findVerificationValue(sessionLookupKey);
|
|
3325
|
+
if (sessionLookup?.value) {
|
|
3326
|
+
await ctx.context.internalAdapter.deleteVerificationValue(sessionLookup.value).catch(() => {});
|
|
3327
|
+
await ctx.context.internalAdapter.deleteVerificationValue(sessionLookupKey).catch(() => {});
|
|
3328
|
+
}
|
|
3329
|
+
})
|
|
3330
|
+
}],
|
|
3331
|
+
after: [{
|
|
3332
|
+
matcher(context) {
|
|
3333
|
+
return context.path?.startsWith("/callback/") ?? false;
|
|
3334
|
+
},
|
|
3335
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
3336
|
+
const newSession = ctx.context.newSession;
|
|
3337
|
+
if (!newSession?.user) return;
|
|
3338
|
+
if (!ctx.context.hasPlugin("organization")) return;
|
|
3339
|
+
await assignOrganizationByDomain(ctx, {
|
|
3340
|
+
user: newSession.user,
|
|
3341
|
+
provisioningOptions: options?.organizationProvisioning,
|
|
3342
|
+
domainVerification: options?.domainVerification
|
|
3343
|
+
});
|
|
3344
|
+
})
|
|
3345
|
+
}]
|
|
3346
|
+
},
|
|
2472
3347
|
schema: { ssoProvider: {
|
|
2473
3348
|
modelName: options?.modelName ?? "ssoProvider",
|
|
2474
3349
|
fields: {
|
|
@@ -2522,4 +3397,5 @@ function sso(options) {
|
|
|
2522
3397
|
}
|
|
2523
3398
|
|
|
2524
3399
|
//#endregion
|
|
2525
|
-
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 };
|
|
3400
|
+
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 };
|
|
3401
|
+
//# sourceMappingURL=index.mjs.map
|