@hammadj/better-auth-sso 1.5.0-beta.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +116 -0
- package/LICENSE.md +20 -0
- package/dist/client.d.mts +10 -0
- package/dist/client.mjs +15 -0
- package/dist/client.mjs.map +1 -0
- package/dist/index.d.mts +738 -0
- package/dist/index.mjs +2953 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +87 -0
- package/src/client.ts +29 -0
- package/src/constants.ts +58 -0
- package/src/domain-verification.test.ts +551 -0
- package/src/index.ts +265 -0
- package/src/linking/index.ts +2 -0
- package/src/linking/org-assignment.test.ts +325 -0
- package/src/linking/org-assignment.ts +176 -0
- package/src/linking/types.ts +10 -0
- package/src/oidc/discovery.test.ts +1157 -0
- package/src/oidc/discovery.ts +494 -0
- package/src/oidc/errors.ts +92 -0
- package/src/oidc/index.ts +31 -0
- package/src/oidc/types.ts +219 -0
- package/src/oidc.test.ts +688 -0
- package/src/providers.test.ts +1326 -0
- package/src/routes/domain-verification.ts +275 -0
- package/src/routes/providers.ts +565 -0
- package/src/routes/schemas.ts +96 -0
- package/src/routes/sso.ts +2750 -0
- package/src/saml/algorithms.test.ts +449 -0
- package/src/saml/algorithms.ts +338 -0
- package/src/saml/assertions.test.ts +239 -0
- package/src/saml/assertions.ts +62 -0
- package/src/saml/index.ts +13 -0
- package/src/saml/parser.ts +56 -0
- package/src/saml-state.ts +78 -0
- package/src/saml.test.ts +4319 -0
- package/src/types.ts +365 -0
- package/src/utils.test.ts +103 -0
- package/src/utils.ts +81 -0
- package/tsconfig.json +14 -0
- package/tsdown.config.ts +9 -0
- package/vitest.config.ts +3 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2953 @@
|
|
|
1
|
+
import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
|
|
2
|
+
import { XMLParser, XMLValidator } from "fast-xml-parser";
|
|
3
|
+
import * as saml from "samlify";
|
|
4
|
+
import { X509Certificate } from "node:crypto";
|
|
5
|
+
import { generateRandomString } from "better-auth/crypto";
|
|
6
|
+
import * as z$1 from "zod/v4";
|
|
7
|
+
import z from "zod/v4";
|
|
8
|
+
import { base64 } from "@better-auth/utils/base64";
|
|
9
|
+
import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
|
|
10
|
+
import { HIDE_METADATA, createAuthorizationURL, generateGenericState, generateState, parseGenericState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
|
|
11
|
+
import { setSessionCookie } from "better-auth/cookies";
|
|
12
|
+
import { handleOAuthUserInfo } from "better-auth/oauth2";
|
|
13
|
+
import { decodeJwt } from "jose";
|
|
14
|
+
import { APIError as APIError$1 } from "better-call";
|
|
15
|
+
|
|
16
|
+
//#region src/utils.ts
|
|
17
|
+
/**
|
|
18
|
+
* Safely parses a value that might be a JSON string or already a parsed object.
|
|
19
|
+
* This handles cases where ORMs like Drizzle might return already parsed objects
|
|
20
|
+
* instead of JSON strings from TEXT/JSON columns.
|
|
21
|
+
*
|
|
22
|
+
* @param value - The value to parse (string, object, null, or undefined)
|
|
23
|
+
* @returns The parsed object or null
|
|
24
|
+
* @throws Error if string parsing fails
|
|
25
|
+
*/
|
|
26
|
+
function safeJsonParse(value) {
|
|
27
|
+
if (!value) return null;
|
|
28
|
+
if (typeof value === "object") return value;
|
|
29
|
+
if (typeof value === "string") try {
|
|
30
|
+
return JSON.parse(value);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
throw new Error(`Failed to parse JSON: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Checks if a domain matches any domain in a comma-separated list.
|
|
38
|
+
*/
|
|
39
|
+
const domainMatches = (searchDomain, domainList) => {
|
|
40
|
+
const search = searchDomain.toLowerCase();
|
|
41
|
+
return domainList.split(",").map((d) => d.trim().toLowerCase()).filter(Boolean).some((d) => search === d || search.endsWith(`.${d}`));
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Validates email domain against allowed domain(s).
|
|
45
|
+
* Supports comma-separated domains for multi-domain SSO.
|
|
46
|
+
*/
|
|
47
|
+
const validateEmailDomain = (email, domain) => {
|
|
48
|
+
const emailDomain = email.split("@")[1]?.toLowerCase();
|
|
49
|
+
if (!emailDomain || !domain) return false;
|
|
50
|
+
return domainMatches(emailDomain, domain);
|
|
51
|
+
};
|
|
52
|
+
function parseCertificate(certPem) {
|
|
53
|
+
const cert = new X509Certificate(certPem.includes("-----BEGIN") ? certPem : `-----BEGIN CERTIFICATE-----\n${certPem}\n-----END CERTIFICATE-----`);
|
|
54
|
+
return {
|
|
55
|
+
fingerprintSha256: cert.fingerprint256,
|
|
56
|
+
notBefore: cert.validFrom,
|
|
57
|
+
notAfter: cert.validTo,
|
|
58
|
+
publicKeyAlgorithm: cert.publicKey.asymmetricKeyType?.toUpperCase() || "UNKNOWN"
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function maskClientId(clientId) {
|
|
62
|
+
if (clientId.length <= 4) return "****";
|
|
63
|
+
return `****${clientId.slice(-4)}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
//#endregion
|
|
67
|
+
//#region src/linking/org-assignment.ts
|
|
68
|
+
/**
|
|
69
|
+
* Assigns a user to an organization based on the SSO provider's organizationId.
|
|
70
|
+
* Used in SSO flows (OIDC, SAML) where the provider is already linked to an org.
|
|
71
|
+
*/
|
|
72
|
+
async function assignOrganizationFromProvider(ctx, options) {
|
|
73
|
+
const { user, profile, provider, token, provisioningOptions } = options;
|
|
74
|
+
if (!provider.organizationId) return;
|
|
75
|
+
if (provisioningOptions?.disabled) return;
|
|
76
|
+
if (!ctx.context.hasPlugin("organization")) return;
|
|
77
|
+
if (await ctx.context.adapter.findOne({
|
|
78
|
+
model: "member",
|
|
79
|
+
where: [{
|
|
80
|
+
field: "organizationId",
|
|
81
|
+
value: provider.organizationId
|
|
82
|
+
}, {
|
|
83
|
+
field: "userId",
|
|
84
|
+
value: user.id
|
|
85
|
+
}]
|
|
86
|
+
})) return;
|
|
87
|
+
const role = provisioningOptions?.getRole ? await provisioningOptions.getRole({
|
|
88
|
+
user,
|
|
89
|
+
userInfo: profile.rawAttributes || {},
|
|
90
|
+
token,
|
|
91
|
+
provider
|
|
92
|
+
}) : provisioningOptions?.defaultRole || "member";
|
|
93
|
+
await ctx.context.adapter.create({
|
|
94
|
+
model: "member",
|
|
95
|
+
data: {
|
|
96
|
+
organizationId: provider.organizationId,
|
|
97
|
+
userId: user.id,
|
|
98
|
+
role,
|
|
99
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Assigns a user to an organization based on their email domain.
|
|
105
|
+
* Looks up SSO providers that match the user's email domain and assigns
|
|
106
|
+
* the user to the associated organization.
|
|
107
|
+
*
|
|
108
|
+
* This enables domain-based org assignment for non-SSO sign-in methods
|
|
109
|
+
* (e.g., Google OAuth with @acme.com email gets added to Acme's org).
|
|
110
|
+
*/
|
|
111
|
+
async function assignOrganizationByDomain(ctx, options) {
|
|
112
|
+
const { user, provisioningOptions, domainVerification } = options;
|
|
113
|
+
if (provisioningOptions?.disabled) return;
|
|
114
|
+
if (!ctx.context.hasPlugin("organization")) return;
|
|
115
|
+
const domain = user.email.split("@")[1];
|
|
116
|
+
if (!domain) return;
|
|
117
|
+
const whereClause = [{
|
|
118
|
+
field: "domain",
|
|
119
|
+
value: domain
|
|
120
|
+
}];
|
|
121
|
+
if (domainVerification?.enabled) whereClause.push({
|
|
122
|
+
field: "domainVerified",
|
|
123
|
+
value: true
|
|
124
|
+
});
|
|
125
|
+
let ssoProvider = await ctx.context.adapter.findOne({
|
|
126
|
+
model: "ssoProvider",
|
|
127
|
+
where: whereClause
|
|
128
|
+
});
|
|
129
|
+
if (!ssoProvider) ssoProvider = (await ctx.context.adapter.findMany({
|
|
130
|
+
model: "ssoProvider",
|
|
131
|
+
where: domainVerification?.enabled ? [{
|
|
132
|
+
field: "domainVerified",
|
|
133
|
+
value: true
|
|
134
|
+
}] : []
|
|
135
|
+
})).find((p) => domainMatches(domain, p.domain)) ?? null;
|
|
136
|
+
if (!ssoProvider || !ssoProvider.organizationId) return;
|
|
137
|
+
if (await ctx.context.adapter.findOne({
|
|
138
|
+
model: "member",
|
|
139
|
+
where: [{
|
|
140
|
+
field: "organizationId",
|
|
141
|
+
value: ssoProvider.organizationId
|
|
142
|
+
}, {
|
|
143
|
+
field: "userId",
|
|
144
|
+
value: user.id
|
|
145
|
+
}]
|
|
146
|
+
})) return;
|
|
147
|
+
const role = provisioningOptions?.getRole ? await provisioningOptions.getRole({
|
|
148
|
+
user,
|
|
149
|
+
userInfo: {},
|
|
150
|
+
provider: ssoProvider
|
|
151
|
+
}) : provisioningOptions?.defaultRole || "member";
|
|
152
|
+
await ctx.context.adapter.create({
|
|
153
|
+
model: "member",
|
|
154
|
+
data: {
|
|
155
|
+
organizationId: ssoProvider.organizationId,
|
|
156
|
+
userId: user.id,
|
|
157
|
+
role,
|
|
158
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
//#endregion
|
|
164
|
+
//#region src/routes/domain-verification.ts
|
|
165
|
+
const domainVerificationBodySchema = z$1.object({ providerId: z$1.string() });
|
|
166
|
+
const requestDomainVerification = (options) => {
|
|
167
|
+
return createAuthEndpoint("/sso/request-domain-verification", {
|
|
168
|
+
method: "POST",
|
|
169
|
+
body: domainVerificationBodySchema,
|
|
170
|
+
metadata: { openapi: {
|
|
171
|
+
summary: "Request a domain verification",
|
|
172
|
+
description: "Request a domain verification for the given SSO provider",
|
|
173
|
+
responses: {
|
|
174
|
+
"404": { description: "Provider not found" },
|
|
175
|
+
"409": { description: "Domain has already been verified" },
|
|
176
|
+
"201": { description: "Domain submitted for verification" }
|
|
177
|
+
}
|
|
178
|
+
} },
|
|
179
|
+
use: [sessionMiddleware]
|
|
180
|
+
}, async (ctx) => {
|
|
181
|
+
const body = ctx.body;
|
|
182
|
+
const provider = await ctx.context.adapter.findOne({
|
|
183
|
+
model: "ssoProvider",
|
|
184
|
+
where: [{
|
|
185
|
+
field: "providerId",
|
|
186
|
+
value: body.providerId
|
|
187
|
+
}]
|
|
188
|
+
});
|
|
189
|
+
if (!provider) throw new APIError("NOT_FOUND", {
|
|
190
|
+
message: "Provider not found",
|
|
191
|
+
code: "PROVIDER_NOT_FOUND"
|
|
192
|
+
});
|
|
193
|
+
const userId = ctx.context.session.user.id;
|
|
194
|
+
let isOrgMember = true;
|
|
195
|
+
if (provider.organizationId) isOrgMember = await ctx.context.adapter.count({
|
|
196
|
+
model: "member",
|
|
197
|
+
where: [{
|
|
198
|
+
field: "userId",
|
|
199
|
+
value: userId
|
|
200
|
+
}, {
|
|
201
|
+
field: "organizationId",
|
|
202
|
+
value: provider.organizationId
|
|
203
|
+
}]
|
|
204
|
+
}) > 0;
|
|
205
|
+
if (provider.userId !== userId || !isOrgMember) throw new APIError("FORBIDDEN", {
|
|
206
|
+
message: "User must be owner of or belong to the SSO provider organization",
|
|
207
|
+
code: "INSUFICCIENT_ACCESS"
|
|
208
|
+
});
|
|
209
|
+
if ("domainVerified" in provider && provider.domainVerified) throw new APIError("CONFLICT", {
|
|
210
|
+
message: "Domain has already been verified",
|
|
211
|
+
code: "DOMAIN_VERIFIED"
|
|
212
|
+
});
|
|
213
|
+
const activeVerification = await ctx.context.adapter.findOne({
|
|
214
|
+
model: "verification",
|
|
215
|
+
where: [{
|
|
216
|
+
field: "identifier",
|
|
217
|
+
value: options.domainVerification?.tokenPrefix ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}` : `better-auth-token-${provider.providerId}`
|
|
218
|
+
}, {
|
|
219
|
+
field: "expiresAt",
|
|
220
|
+
value: /* @__PURE__ */ new Date(),
|
|
221
|
+
operator: "gt"
|
|
222
|
+
}]
|
|
223
|
+
});
|
|
224
|
+
if (activeVerification) {
|
|
225
|
+
ctx.setStatus(201);
|
|
226
|
+
return ctx.json({ domainVerificationToken: activeVerification.value });
|
|
227
|
+
}
|
|
228
|
+
const domainVerificationToken = generateRandomString(24);
|
|
229
|
+
await ctx.context.adapter.create({
|
|
230
|
+
model: "verification",
|
|
231
|
+
data: {
|
|
232
|
+
identifier: options.domainVerification?.tokenPrefix ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}` : `better-auth-token-${provider.providerId}`,
|
|
233
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
234
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
235
|
+
value: domainVerificationToken,
|
|
236
|
+
expiresAt: new Date(Date.now() + 3600 * 24 * 7 * 1e3)
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
ctx.setStatus(201);
|
|
240
|
+
return ctx.json({ domainVerificationToken });
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
const verifyDomain = (options) => {
|
|
244
|
+
return createAuthEndpoint("/sso/verify-domain", {
|
|
245
|
+
method: "POST",
|
|
246
|
+
body: domainVerificationBodySchema,
|
|
247
|
+
metadata: { openapi: {
|
|
248
|
+
summary: "Verify the provider domain ownership",
|
|
249
|
+
description: "Verify the provider domain ownership via DNS records",
|
|
250
|
+
responses: {
|
|
251
|
+
"404": { description: "Provider not found" },
|
|
252
|
+
"409": { description: "Domain has already been verified or no pending verification exists" },
|
|
253
|
+
"502": { description: "Unable to verify domain ownership due to upstream validator error" },
|
|
254
|
+
"204": { description: "Domain ownership was verified" }
|
|
255
|
+
}
|
|
256
|
+
} },
|
|
257
|
+
use: [sessionMiddleware]
|
|
258
|
+
}, async (ctx) => {
|
|
259
|
+
const body = ctx.body;
|
|
260
|
+
const provider = await ctx.context.adapter.findOne({
|
|
261
|
+
model: "ssoProvider",
|
|
262
|
+
where: [{
|
|
263
|
+
field: "providerId",
|
|
264
|
+
value: body.providerId
|
|
265
|
+
}]
|
|
266
|
+
});
|
|
267
|
+
if (!provider) throw new APIError("NOT_FOUND", {
|
|
268
|
+
message: "Provider not found",
|
|
269
|
+
code: "PROVIDER_NOT_FOUND"
|
|
270
|
+
});
|
|
271
|
+
const userId = ctx.context.session.user.id;
|
|
272
|
+
let isOrgMember = true;
|
|
273
|
+
if (provider.organizationId) isOrgMember = await ctx.context.adapter.count({
|
|
274
|
+
model: "member",
|
|
275
|
+
where: [{
|
|
276
|
+
field: "userId",
|
|
277
|
+
value: userId
|
|
278
|
+
}, {
|
|
279
|
+
field: "organizationId",
|
|
280
|
+
value: provider.organizationId
|
|
281
|
+
}]
|
|
282
|
+
}) > 0;
|
|
283
|
+
if (provider.userId !== userId || !isOrgMember) throw new APIError("FORBIDDEN", {
|
|
284
|
+
message: "User must be owner of or belong to the SSO provider organization",
|
|
285
|
+
code: "INSUFICCIENT_ACCESS"
|
|
286
|
+
});
|
|
287
|
+
if ("domainVerified" in provider && provider.domainVerified) throw new APIError("CONFLICT", {
|
|
288
|
+
message: "Domain has already been verified",
|
|
289
|
+
code: "DOMAIN_VERIFIED"
|
|
290
|
+
});
|
|
291
|
+
const activeVerification = await ctx.context.adapter.findOne({
|
|
292
|
+
model: "verification",
|
|
293
|
+
where: [{
|
|
294
|
+
field: "identifier",
|
|
295
|
+
value: options.domainVerification?.tokenPrefix ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}` : `better-auth-token-${provider.providerId}`
|
|
296
|
+
}, {
|
|
297
|
+
field: "expiresAt",
|
|
298
|
+
value: /* @__PURE__ */ new Date(),
|
|
299
|
+
operator: "gt"
|
|
300
|
+
}]
|
|
301
|
+
});
|
|
302
|
+
if (!activeVerification) throw new APIError("NOT_FOUND", {
|
|
303
|
+
message: "No pending domain verification exists",
|
|
304
|
+
code: "NO_PENDING_VERIFICATION"
|
|
305
|
+
});
|
|
306
|
+
let records = [];
|
|
307
|
+
let dns;
|
|
308
|
+
try {
|
|
309
|
+
dns = await import("node:dns/promises");
|
|
310
|
+
} catch (error) {
|
|
311
|
+
ctx.context.logger.error("The core node:dns module is required for the domain verification feature", error);
|
|
312
|
+
throw new APIError("INTERNAL_SERVER_ERROR", {
|
|
313
|
+
message: "Unable to verify domain ownership due to server error",
|
|
314
|
+
code: "DOMAIN_VERIFICATION_FAILED"
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
records = (await dns.resolveTxt(new URL(provider.domain).hostname)).flat();
|
|
319
|
+
} catch (error) {
|
|
320
|
+
ctx.context.logger.warn("DNS resolution failure while validating domain ownership", error);
|
|
321
|
+
}
|
|
322
|
+
if (!records.find((record) => record.includes(`${activeVerification.identifier}=${activeVerification.value}`))) throw new APIError("BAD_GATEWAY", {
|
|
323
|
+
message: "Unable to verify domain ownership. Try again later",
|
|
324
|
+
code: "DOMAIN_VERIFICATION_FAILED"
|
|
325
|
+
});
|
|
326
|
+
await ctx.context.adapter.update({
|
|
327
|
+
model: "ssoProvider",
|
|
328
|
+
where: [{
|
|
329
|
+
field: "providerId",
|
|
330
|
+
value: provider.providerId
|
|
331
|
+
}],
|
|
332
|
+
update: { domainVerified: true }
|
|
333
|
+
});
|
|
334
|
+
ctx.setStatus(204);
|
|
335
|
+
});
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
//#endregion
|
|
339
|
+
//#region src/constants.ts
|
|
340
|
+
/**
|
|
341
|
+
* SAML Constants
|
|
342
|
+
*
|
|
343
|
+
* Centralized constants for SAML SSO functionality.
|
|
344
|
+
*/
|
|
345
|
+
/** Prefix for AuthnRequest IDs used in InResponseTo validation */
|
|
346
|
+
const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
|
|
347
|
+
/** Prefix for used Assertion IDs used in replay protection */
|
|
348
|
+
const USED_ASSERTION_KEY_PREFIX = "saml-used-assertion:";
|
|
349
|
+
/**
|
|
350
|
+
* Default TTL for AuthnRequest records (5 minutes).
|
|
351
|
+
* This should be sufficient for most IdPs while protecting against stale requests.
|
|
352
|
+
*/
|
|
353
|
+
const DEFAULT_AUTHN_REQUEST_TTL_MS = 300 * 1e3;
|
|
354
|
+
/**
|
|
355
|
+
* Default TTL for used assertion records (15 minutes).
|
|
356
|
+
* This should match the maximum expected NotOnOrAfter window plus clock skew.
|
|
357
|
+
*/
|
|
358
|
+
const DEFAULT_ASSERTION_TTL_MS = 900 * 1e3;
|
|
359
|
+
/**
|
|
360
|
+
* Default clock skew tolerance (5 minutes).
|
|
361
|
+
* Allows for minor time differences between IdP and SP servers.
|
|
362
|
+
*
|
|
363
|
+
* Accommodates:
|
|
364
|
+
* - Network latency and processing time
|
|
365
|
+
* - Clock synchronization differences (NTP drift)
|
|
366
|
+
* - Distributed systems across timezones
|
|
367
|
+
*/
|
|
368
|
+
const DEFAULT_CLOCK_SKEW_MS = 300 * 1e3;
|
|
369
|
+
/**
|
|
370
|
+
* Default maximum size for SAML responses (256 KB).
|
|
371
|
+
* Protects against memory exhaustion from oversized SAML payloads.
|
|
372
|
+
*/
|
|
373
|
+
const DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024;
|
|
374
|
+
/**
|
|
375
|
+
* Default maximum size for IdP metadata (100 KB).
|
|
376
|
+
* Protects against oversized metadata documents.
|
|
377
|
+
*/
|
|
378
|
+
const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024;
|
|
379
|
+
|
|
380
|
+
//#endregion
|
|
381
|
+
//#region src/saml/parser.ts
|
|
382
|
+
const xmlParser = new XMLParser({
|
|
383
|
+
ignoreAttributes: false,
|
|
384
|
+
attributeNamePrefix: "@_",
|
|
385
|
+
removeNSPrefix: true,
|
|
386
|
+
processEntities: false
|
|
387
|
+
});
|
|
388
|
+
function findNode(obj, nodeName) {
|
|
389
|
+
if (!obj || typeof obj !== "object") return null;
|
|
390
|
+
const record = obj;
|
|
391
|
+
if (nodeName in record) return record[nodeName];
|
|
392
|
+
for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
|
|
393
|
+
const found = findNode(item, nodeName);
|
|
394
|
+
if (found) return found;
|
|
395
|
+
}
|
|
396
|
+
else if (typeof value === "object" && value !== null) {
|
|
397
|
+
const found = findNode(value, nodeName);
|
|
398
|
+
if (found) return found;
|
|
399
|
+
}
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
function countAllNodes(obj, nodeName) {
|
|
403
|
+
if (!obj || typeof obj !== "object") return 0;
|
|
404
|
+
let count = 0;
|
|
405
|
+
const record = obj;
|
|
406
|
+
if (nodeName in record) {
|
|
407
|
+
const node = record[nodeName];
|
|
408
|
+
count += Array.isArray(node) ? node.length : 1;
|
|
409
|
+
}
|
|
410
|
+
for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) count += countAllNodes(item, nodeName);
|
|
411
|
+
else if (typeof value === "object" && value !== null) count += countAllNodes(value, nodeName);
|
|
412
|
+
return count;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
//#endregion
|
|
416
|
+
//#region src/saml/algorithms.ts
|
|
417
|
+
const SignatureAlgorithm = {
|
|
418
|
+
RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
|
|
419
|
+
RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
|
|
420
|
+
RSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
|
|
421
|
+
RSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
|
|
422
|
+
ECDSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
|
|
423
|
+
ECDSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
|
|
424
|
+
ECDSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"
|
|
425
|
+
};
|
|
426
|
+
const DigestAlgorithm = {
|
|
427
|
+
SHA1: "http://www.w3.org/2000/09/xmldsig#sha1",
|
|
428
|
+
SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
|
|
429
|
+
SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
|
|
430
|
+
SHA512: "http://www.w3.org/2001/04/xmlenc#sha512"
|
|
431
|
+
};
|
|
432
|
+
const KeyEncryptionAlgorithm = {
|
|
433
|
+
RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
|
|
434
|
+
RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
|
|
435
|
+
RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep"
|
|
436
|
+
};
|
|
437
|
+
const DataEncryptionAlgorithm = {
|
|
438
|
+
TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
|
|
439
|
+
AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
|
|
440
|
+
AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
|
|
441
|
+
AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
|
|
442
|
+
AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
|
|
443
|
+
AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
|
|
444
|
+
AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
|
|
445
|
+
};
|
|
446
|
+
const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
|
|
447
|
+
const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS = [KeyEncryptionAlgorithm.RSA_1_5];
|
|
448
|
+
const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS = [DataEncryptionAlgorithm.TRIPLEDES_CBC];
|
|
449
|
+
const DEPRECATED_DIGEST_ALGORITHMS = [DigestAlgorithm.SHA1];
|
|
450
|
+
const SECURE_SIGNATURE_ALGORITHMS = [
|
|
451
|
+
SignatureAlgorithm.RSA_SHA256,
|
|
452
|
+
SignatureAlgorithm.RSA_SHA384,
|
|
453
|
+
SignatureAlgorithm.RSA_SHA512,
|
|
454
|
+
SignatureAlgorithm.ECDSA_SHA256,
|
|
455
|
+
SignatureAlgorithm.ECDSA_SHA384,
|
|
456
|
+
SignatureAlgorithm.ECDSA_SHA512
|
|
457
|
+
];
|
|
458
|
+
const SECURE_DIGEST_ALGORITHMS = [
|
|
459
|
+
DigestAlgorithm.SHA256,
|
|
460
|
+
DigestAlgorithm.SHA384,
|
|
461
|
+
DigestAlgorithm.SHA512
|
|
462
|
+
];
|
|
463
|
+
const SHORT_FORM_SIGNATURE_TO_URI = {
|
|
464
|
+
sha1: SignatureAlgorithm.RSA_SHA1,
|
|
465
|
+
sha256: SignatureAlgorithm.RSA_SHA256,
|
|
466
|
+
sha384: SignatureAlgorithm.RSA_SHA384,
|
|
467
|
+
sha512: SignatureAlgorithm.RSA_SHA512,
|
|
468
|
+
"rsa-sha1": SignatureAlgorithm.RSA_SHA1,
|
|
469
|
+
"rsa-sha256": SignatureAlgorithm.RSA_SHA256,
|
|
470
|
+
"rsa-sha384": SignatureAlgorithm.RSA_SHA384,
|
|
471
|
+
"rsa-sha512": SignatureAlgorithm.RSA_SHA512,
|
|
472
|
+
"ecdsa-sha256": SignatureAlgorithm.ECDSA_SHA256,
|
|
473
|
+
"ecdsa-sha384": SignatureAlgorithm.ECDSA_SHA384,
|
|
474
|
+
"ecdsa-sha512": SignatureAlgorithm.ECDSA_SHA512
|
|
475
|
+
};
|
|
476
|
+
const SHORT_FORM_DIGEST_TO_URI = {
|
|
477
|
+
sha1: DigestAlgorithm.SHA1,
|
|
478
|
+
sha256: DigestAlgorithm.SHA256,
|
|
479
|
+
sha384: DigestAlgorithm.SHA384,
|
|
480
|
+
sha512: DigestAlgorithm.SHA512
|
|
481
|
+
};
|
|
482
|
+
function normalizeSignatureAlgorithm(alg) {
|
|
483
|
+
return SHORT_FORM_SIGNATURE_TO_URI[alg.toLowerCase()] ?? alg;
|
|
484
|
+
}
|
|
485
|
+
function normalizeDigestAlgorithm(alg) {
|
|
486
|
+
return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
|
|
487
|
+
}
|
|
488
|
+
function extractEncryptionAlgorithms(xml) {
|
|
489
|
+
try {
|
|
490
|
+
const parsed = xmlParser.parse(xml);
|
|
491
|
+
const keyAlg = (findNode(parsed, "EncryptedKey")?.EncryptionMethod)?.["@_Algorithm"];
|
|
492
|
+
const dataAlg = (findNode(parsed, "EncryptedData")?.EncryptionMethod)?.["@_Algorithm"];
|
|
493
|
+
return {
|
|
494
|
+
keyEncryption: keyAlg || null,
|
|
495
|
+
dataEncryption: dataAlg || null
|
|
496
|
+
};
|
|
497
|
+
} catch {
|
|
498
|
+
return {
|
|
499
|
+
keyEncryption: null,
|
|
500
|
+
dataEncryption: null
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
function hasEncryptedAssertion(xml) {
|
|
505
|
+
try {
|
|
506
|
+
return findNode(xmlParser.parse(xml), "EncryptedAssertion") !== null;
|
|
507
|
+
} catch {
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
function handleDeprecatedAlgorithm(message, behavior, errorCode) {
|
|
512
|
+
switch (behavior) {
|
|
513
|
+
case "reject": throw new APIError("BAD_REQUEST", {
|
|
514
|
+
message,
|
|
515
|
+
code: errorCode
|
|
516
|
+
});
|
|
517
|
+
case "warn":
|
|
518
|
+
console.warn(`[SAML Security Warning] ${message}`);
|
|
519
|
+
break;
|
|
520
|
+
case "allow": break;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
function validateSignatureAlgorithm(algorithm, options = {}) {
|
|
524
|
+
if (!algorithm) return;
|
|
525
|
+
const { onDeprecated = "warn", allowedSignatureAlgorithms } = options;
|
|
526
|
+
if (allowedSignatureAlgorithms) {
|
|
527
|
+
if (!allowedSignatureAlgorithms.includes(algorithm)) throw new APIError("BAD_REQUEST", {
|
|
528
|
+
message: `SAML signature algorithm not in allow-list: ${algorithm}`,
|
|
529
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
530
|
+
});
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(algorithm)) {
|
|
534
|
+
handleDeprecatedAlgorithm(`SAML response uses deprecated signature algorithm: ${algorithm}. Please configure your IdP to use SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (!SECURE_SIGNATURE_ALGORITHMS.includes(algorithm)) throw new APIError("BAD_REQUEST", {
|
|
538
|
+
message: `SAML signature algorithm not recognized: ${algorithm}`,
|
|
539
|
+
code: "SAML_UNKNOWN_ALGORITHM"
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
function validateEncryptionAlgorithms(algorithms, options = {}) {
|
|
543
|
+
const { onDeprecated = "warn", allowedKeyEncryptionAlgorithms, allowedDataEncryptionAlgorithms } = options;
|
|
544
|
+
const { keyEncryption, dataEncryption } = algorithms;
|
|
545
|
+
if (keyEncryption) {
|
|
546
|
+
if (allowedKeyEncryptionAlgorithms) {
|
|
547
|
+
if (!allowedKeyEncryptionAlgorithms.includes(keyEncryption)) throw new APIError("BAD_REQUEST", {
|
|
548
|
+
message: `SAML key encryption algorithm not in allow-list: ${keyEncryption}`,
|
|
549
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
550
|
+
});
|
|
551
|
+
} 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");
|
|
552
|
+
}
|
|
553
|
+
if (dataEncryption) {
|
|
554
|
+
if (allowedDataEncryptionAlgorithms) {
|
|
555
|
+
if (!allowedDataEncryptionAlgorithms.includes(dataEncryption)) throw new APIError("BAD_REQUEST", {
|
|
556
|
+
message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
|
|
557
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
558
|
+
});
|
|
559
|
+
} 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");
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
function validateSAMLAlgorithms(response, options) {
|
|
563
|
+
validateSignatureAlgorithm(response.sigAlg, options);
|
|
564
|
+
if (hasEncryptedAssertion(response.samlContent)) validateEncryptionAlgorithms(extractEncryptionAlgorithms(response.samlContent), options);
|
|
565
|
+
}
|
|
566
|
+
function validateConfigAlgorithms(config, options = {}) {
|
|
567
|
+
const { onDeprecated = "warn", allowedSignatureAlgorithms, allowedDigestAlgorithms } = options;
|
|
568
|
+
if (config.signatureAlgorithm) {
|
|
569
|
+
const normalized = normalizeSignatureAlgorithm(config.signatureAlgorithm);
|
|
570
|
+
if (allowedSignatureAlgorithms) {
|
|
571
|
+
if (!allowedSignatureAlgorithms.map(normalizeSignatureAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
572
|
+
message: `SAML signature algorithm not in allow-list: ${config.signatureAlgorithm}`,
|
|
573
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
574
|
+
});
|
|
575
|
+
} 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");
|
|
576
|
+
else if (!SECURE_SIGNATURE_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
577
|
+
message: `SAML signature algorithm not recognized: ${config.signatureAlgorithm}`,
|
|
578
|
+
code: "SAML_UNKNOWN_ALGORITHM"
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
if (config.digestAlgorithm) {
|
|
582
|
+
const normalized = normalizeDigestAlgorithm(config.digestAlgorithm);
|
|
583
|
+
if (allowedDigestAlgorithms) {
|
|
584
|
+
if (!allowedDigestAlgorithms.map(normalizeDigestAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
585
|
+
message: `SAML digest algorithm not in allow-list: ${config.digestAlgorithm}`,
|
|
586
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED"
|
|
587
|
+
});
|
|
588
|
+
} 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");
|
|
589
|
+
else if (!SECURE_DIGEST_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
|
|
590
|
+
message: `SAML digest algorithm not recognized: ${config.digestAlgorithm}`,
|
|
591
|
+
code: "SAML_UNKNOWN_ALGORITHM"
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
//#endregion
|
|
597
|
+
//#region src/saml/assertions.ts
|
|
598
|
+
/** @lintignore used in tests */
|
|
599
|
+
function countAssertions(xml) {
|
|
600
|
+
let parsed;
|
|
601
|
+
try {
|
|
602
|
+
parsed = xmlParser.parse(xml);
|
|
603
|
+
} catch {
|
|
604
|
+
throw new APIError("BAD_REQUEST", {
|
|
605
|
+
message: "Failed to parse SAML response XML",
|
|
606
|
+
code: "SAML_INVALID_XML"
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
const assertions = countAllNodes(parsed, "Assertion");
|
|
610
|
+
const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
|
|
611
|
+
return {
|
|
612
|
+
assertions,
|
|
613
|
+
encryptedAssertions,
|
|
614
|
+
total: assertions + encryptedAssertions
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
function validateSingleAssertion(samlResponse) {
|
|
618
|
+
let xml;
|
|
619
|
+
try {
|
|
620
|
+
xml = new TextDecoder().decode(base64.decode(samlResponse));
|
|
621
|
+
if (!xml.includes("<")) throw new Error("Not XML");
|
|
622
|
+
} catch {
|
|
623
|
+
throw new APIError("BAD_REQUEST", {
|
|
624
|
+
message: "Invalid base64-encoded SAML response",
|
|
625
|
+
code: "SAML_INVALID_ENCODING"
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
const counts = countAssertions(xml);
|
|
629
|
+
if (counts.total === 0) throw new APIError("BAD_REQUEST", {
|
|
630
|
+
message: "SAML response contains no assertions",
|
|
631
|
+
code: "SAML_NO_ASSERTION"
|
|
632
|
+
});
|
|
633
|
+
if (counts.total > 1) throw new APIError("BAD_REQUEST", {
|
|
634
|
+
message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
|
|
635
|
+
code: "SAML_MULTIPLE_ASSERTIONS"
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
//#endregion
|
|
640
|
+
//#region src/routes/schemas.ts
|
|
641
|
+
const oidcMappingSchema = z.object({
|
|
642
|
+
id: z.string().optional(),
|
|
643
|
+
email: z.string().optional(),
|
|
644
|
+
emailVerified: z.string().optional(),
|
|
645
|
+
name: z.string().optional(),
|
|
646
|
+
image: z.string().optional(),
|
|
647
|
+
extraFields: z.record(z.string(), z.any()).optional()
|
|
648
|
+
}).optional();
|
|
649
|
+
const samlMappingSchema = z.object({
|
|
650
|
+
id: z.string().optional(),
|
|
651
|
+
email: z.string().optional(),
|
|
652
|
+
emailVerified: z.string().optional(),
|
|
653
|
+
name: z.string().optional(),
|
|
654
|
+
firstName: z.string().optional(),
|
|
655
|
+
lastName: z.string().optional(),
|
|
656
|
+
extraFields: z.record(z.string(), z.any()).optional()
|
|
657
|
+
}).optional();
|
|
658
|
+
const oidcConfigSchema = z.object({
|
|
659
|
+
clientId: z.string().optional(),
|
|
660
|
+
clientSecret: z.string().optional(),
|
|
661
|
+
authorizationEndpoint: z.string().url().optional(),
|
|
662
|
+
tokenEndpoint: z.string().url().optional(),
|
|
663
|
+
userInfoEndpoint: z.string().url().optional(),
|
|
664
|
+
tokenEndpointAuthentication: z.enum(["client_secret_post", "client_secret_basic"]).optional(),
|
|
665
|
+
jwksEndpoint: z.string().url().optional(),
|
|
666
|
+
discoveryEndpoint: z.string().url().optional(),
|
|
667
|
+
scopes: z.array(z.string()).optional(),
|
|
668
|
+
pkce: z.boolean().optional(),
|
|
669
|
+
overrideUserInfo: z.boolean().optional(),
|
|
670
|
+
mapping: oidcMappingSchema
|
|
671
|
+
});
|
|
672
|
+
const samlConfigSchema = z.object({
|
|
673
|
+
entryPoint: z.string().url().optional(),
|
|
674
|
+
cert: z.string().optional(),
|
|
675
|
+
callbackUrl: z.string().url().optional(),
|
|
676
|
+
audience: z.string().optional(),
|
|
677
|
+
idpMetadata: z.object({
|
|
678
|
+
metadata: z.string().optional(),
|
|
679
|
+
entityID: z.string().optional(),
|
|
680
|
+
cert: z.string().optional(),
|
|
681
|
+
privateKey: z.string().optional(),
|
|
682
|
+
privateKeyPass: z.string().optional(),
|
|
683
|
+
isAssertionEncrypted: z.boolean().optional(),
|
|
684
|
+
encPrivateKey: z.string().optional(),
|
|
685
|
+
encPrivateKeyPass: z.string().optional(),
|
|
686
|
+
singleSignOnService: z.array(z.object({
|
|
687
|
+
Binding: z.string(),
|
|
688
|
+
Location: z.string().url()
|
|
689
|
+
})).optional()
|
|
690
|
+
}).optional(),
|
|
691
|
+
spMetadata: z.object({
|
|
692
|
+
metadata: z.string().optional(),
|
|
693
|
+
entityID: z.string().optional(),
|
|
694
|
+
binding: z.string().optional(),
|
|
695
|
+
privateKey: z.string().optional(),
|
|
696
|
+
privateKeyPass: z.string().optional(),
|
|
697
|
+
isAssertionEncrypted: z.boolean().optional(),
|
|
698
|
+
encPrivateKey: z.string().optional(),
|
|
699
|
+
encPrivateKeyPass: z.string().optional()
|
|
700
|
+
}).optional(),
|
|
701
|
+
wantAssertionsSigned: z.boolean().optional(),
|
|
702
|
+
authnRequestsSigned: z.boolean().optional(),
|
|
703
|
+
signatureAlgorithm: z.string().optional(),
|
|
704
|
+
digestAlgorithm: z.string().optional(),
|
|
705
|
+
identifierFormat: z.string().optional(),
|
|
706
|
+
privateKey: z.string().optional(),
|
|
707
|
+
decryptionPvk: z.string().optional(),
|
|
708
|
+
additionalParams: z.record(z.string(), z.any()).optional(),
|
|
709
|
+
mapping: samlMappingSchema
|
|
710
|
+
});
|
|
711
|
+
const updateSSOProviderBodySchema = z.object({
|
|
712
|
+
issuer: z.string().url().optional(),
|
|
713
|
+
domain: z.string().optional(),
|
|
714
|
+
oidcConfig: oidcConfigSchema.optional(),
|
|
715
|
+
samlConfig: samlConfigSchema.optional()
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
//#endregion
|
|
719
|
+
//#region src/routes/providers.ts
|
|
720
|
+
const ADMIN_ROLES = ["owner", "admin"];
|
|
721
|
+
async function isOrgAdmin(ctx, userId, organizationId) {
|
|
722
|
+
const member = await ctx.context.adapter.findOne({
|
|
723
|
+
model: "member",
|
|
724
|
+
where: [{
|
|
725
|
+
field: "userId",
|
|
726
|
+
value: userId
|
|
727
|
+
}, {
|
|
728
|
+
field: "organizationId",
|
|
729
|
+
value: organizationId
|
|
730
|
+
}]
|
|
731
|
+
});
|
|
732
|
+
if (!member) return false;
|
|
733
|
+
return member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()));
|
|
734
|
+
}
|
|
735
|
+
async function batchCheckOrgAdmin(ctx, userId, organizationIds) {
|
|
736
|
+
if (organizationIds.length === 0) return /* @__PURE__ */ new Set();
|
|
737
|
+
const members = await ctx.context.adapter.findMany({
|
|
738
|
+
model: "member",
|
|
739
|
+
where: [{
|
|
740
|
+
field: "userId",
|
|
741
|
+
value: userId
|
|
742
|
+
}, {
|
|
743
|
+
field: "organizationId",
|
|
744
|
+
value: organizationIds,
|
|
745
|
+
operator: "in"
|
|
746
|
+
}]
|
|
747
|
+
});
|
|
748
|
+
const adminOrgIds = /* @__PURE__ */ new Set();
|
|
749
|
+
for (const member of members) if (member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()))) adminOrgIds.add(member.organizationId);
|
|
750
|
+
return adminOrgIds;
|
|
751
|
+
}
|
|
752
|
+
function sanitizeProvider(provider, baseURL) {
|
|
753
|
+
let oidcConfig = null;
|
|
754
|
+
let samlConfig = null;
|
|
755
|
+
try {
|
|
756
|
+
oidcConfig = safeJsonParse(provider.oidcConfig);
|
|
757
|
+
} catch {
|
|
758
|
+
oidcConfig = null;
|
|
759
|
+
}
|
|
760
|
+
try {
|
|
761
|
+
samlConfig = safeJsonParse(provider.samlConfig);
|
|
762
|
+
} catch {
|
|
763
|
+
samlConfig = null;
|
|
764
|
+
}
|
|
765
|
+
const type = samlConfig ? "saml" : "oidc";
|
|
766
|
+
return {
|
|
767
|
+
providerId: provider.providerId,
|
|
768
|
+
type,
|
|
769
|
+
issuer: provider.issuer,
|
|
770
|
+
domain: provider.domain,
|
|
771
|
+
organizationId: provider.organizationId || null,
|
|
772
|
+
domainVerified: provider.domainVerified ?? false,
|
|
773
|
+
oidcConfig: oidcConfig ? {
|
|
774
|
+
discoveryEndpoint: oidcConfig.discoveryEndpoint,
|
|
775
|
+
clientIdLastFour: maskClientId(oidcConfig.clientId),
|
|
776
|
+
pkce: oidcConfig.pkce,
|
|
777
|
+
authorizationEndpoint: oidcConfig.authorizationEndpoint,
|
|
778
|
+
tokenEndpoint: oidcConfig.tokenEndpoint,
|
|
779
|
+
userInfoEndpoint: oidcConfig.userInfoEndpoint,
|
|
780
|
+
jwksEndpoint: oidcConfig.jwksEndpoint,
|
|
781
|
+
scopes: oidcConfig.scopes,
|
|
782
|
+
tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication
|
|
783
|
+
} : void 0,
|
|
784
|
+
samlConfig: samlConfig ? {
|
|
785
|
+
entryPoint: samlConfig.entryPoint,
|
|
786
|
+
callbackUrl: samlConfig.callbackUrl,
|
|
787
|
+
audience: samlConfig.audience,
|
|
788
|
+
wantAssertionsSigned: samlConfig.wantAssertionsSigned,
|
|
789
|
+
authnRequestsSigned: samlConfig.authnRequestsSigned,
|
|
790
|
+
identifierFormat: samlConfig.identifierFormat,
|
|
791
|
+
signatureAlgorithm: samlConfig.signatureAlgorithm,
|
|
792
|
+
digestAlgorithm: samlConfig.digestAlgorithm,
|
|
793
|
+
certificate: (() => {
|
|
794
|
+
try {
|
|
795
|
+
return parseCertificate(samlConfig.cert);
|
|
796
|
+
} catch {
|
|
797
|
+
return { error: "Failed to parse certificate" };
|
|
798
|
+
}
|
|
799
|
+
})()
|
|
800
|
+
} : void 0,
|
|
801
|
+
spMetadataUrl: `${baseURL}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(provider.providerId)}`
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
const listSSOProviders = () => {
|
|
805
|
+
return createAuthEndpoint("/sso/providers", {
|
|
806
|
+
method: "GET",
|
|
807
|
+
use: [sessionMiddleware],
|
|
808
|
+
metadata: { openapi: {
|
|
809
|
+
operationId: "listSSOProviders",
|
|
810
|
+
summary: "List SSO providers",
|
|
811
|
+
description: "Returns a list of SSO providers the user has access to",
|
|
812
|
+
responses: { "200": { description: "List of SSO providers" } }
|
|
813
|
+
} }
|
|
814
|
+
}, async (ctx) => {
|
|
815
|
+
const userId = ctx.context.session.user.id;
|
|
816
|
+
const allProviders = await ctx.context.adapter.findMany({ model: "ssoProvider" });
|
|
817
|
+
const userOwnedProviders = allProviders.filter((p) => p.userId === userId && !p.organizationId);
|
|
818
|
+
const orgProviders = allProviders.filter((p) => p.organizationId !== null && p.organizationId !== void 0);
|
|
819
|
+
const orgPluginEnabled = ctx.context.hasPlugin("organization");
|
|
820
|
+
let accessibleProviders = [...userOwnedProviders];
|
|
821
|
+
if (orgPluginEnabled && orgProviders.length > 0) {
|
|
822
|
+
const adminOrgIds = await batchCheckOrgAdmin(ctx, userId, [...new Set(orgProviders.map((p) => p.organizationId).filter((id) => id !== null && id !== void 0))]);
|
|
823
|
+
const orgAccessibleProviders = orgProviders.filter((provider) => provider.organizationId && adminOrgIds.has(provider.organizationId));
|
|
824
|
+
accessibleProviders = [...accessibleProviders, ...orgAccessibleProviders];
|
|
825
|
+
} else if (!orgPluginEnabled) {
|
|
826
|
+
const userOwnedOrgProviders = orgProviders.filter((p) => p.userId === userId);
|
|
827
|
+
accessibleProviders = [...accessibleProviders, ...userOwnedOrgProviders];
|
|
828
|
+
}
|
|
829
|
+
const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL));
|
|
830
|
+
return ctx.json({ providers });
|
|
831
|
+
});
|
|
832
|
+
};
|
|
833
|
+
const getSSOProviderParamsSchema = z.object({ providerId: z.string() });
|
|
834
|
+
async function checkProviderAccess(ctx, providerId) {
|
|
835
|
+
const userId = ctx.context.session.user.id;
|
|
836
|
+
const provider = await ctx.context.adapter.findOne({
|
|
837
|
+
model: "ssoProvider",
|
|
838
|
+
where: [{
|
|
839
|
+
field: "providerId",
|
|
840
|
+
value: providerId
|
|
841
|
+
}]
|
|
842
|
+
});
|
|
843
|
+
if (!provider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
|
|
844
|
+
let hasAccess = false;
|
|
845
|
+
if (provider.organizationId) if (ctx.context.hasPlugin("organization")) hasAccess = await isOrgAdmin(ctx, userId, provider.organizationId);
|
|
846
|
+
else hasAccess = provider.userId === userId;
|
|
847
|
+
else hasAccess = provider.userId === userId;
|
|
848
|
+
if (!hasAccess) throw new APIError("FORBIDDEN", { message: "You don't have access to this provider" });
|
|
849
|
+
return provider;
|
|
850
|
+
}
|
|
851
|
+
const getSSOProvider = () => {
|
|
852
|
+
return createAuthEndpoint("/sso/providers/:providerId", {
|
|
853
|
+
method: "GET",
|
|
854
|
+
use: [sessionMiddleware],
|
|
855
|
+
params: getSSOProviderParamsSchema,
|
|
856
|
+
metadata: { openapi: {
|
|
857
|
+
operationId: "getSSOProvider",
|
|
858
|
+
summary: "Get SSO provider details",
|
|
859
|
+
description: "Returns sanitized details for a specific SSO provider",
|
|
860
|
+
responses: {
|
|
861
|
+
"200": { description: "SSO provider details" },
|
|
862
|
+
"404": { description: "Provider not found" },
|
|
863
|
+
"403": { description: "Access denied" }
|
|
864
|
+
}
|
|
865
|
+
} }
|
|
866
|
+
}, async (ctx) => {
|
|
867
|
+
const { providerId } = ctx.params;
|
|
868
|
+
const provider = await checkProviderAccess(ctx, providerId);
|
|
869
|
+
return ctx.json(sanitizeProvider(provider, ctx.context.baseURL));
|
|
870
|
+
});
|
|
871
|
+
};
|
|
872
|
+
function parseAndValidateConfig(configString, configType) {
|
|
873
|
+
let config = null;
|
|
874
|
+
try {
|
|
875
|
+
config = safeJsonParse(configString);
|
|
876
|
+
} catch {
|
|
877
|
+
config = null;
|
|
878
|
+
}
|
|
879
|
+
if (!config) throw new APIError("BAD_REQUEST", { message: `Cannot update ${configType} config for a provider that doesn't have ${configType} configured` });
|
|
880
|
+
return config;
|
|
881
|
+
}
|
|
882
|
+
function mergeSAMLConfig(current, updates, issuer) {
|
|
883
|
+
return {
|
|
884
|
+
...current,
|
|
885
|
+
...updates,
|
|
886
|
+
issuer,
|
|
887
|
+
entryPoint: updates.entryPoint ?? current.entryPoint,
|
|
888
|
+
cert: updates.cert ?? current.cert,
|
|
889
|
+
callbackUrl: updates.callbackUrl ?? current.callbackUrl,
|
|
890
|
+
spMetadata: updates.spMetadata ?? current.spMetadata,
|
|
891
|
+
idpMetadata: updates.idpMetadata ?? current.idpMetadata,
|
|
892
|
+
mapping: updates.mapping ?? current.mapping,
|
|
893
|
+
audience: updates.audience ?? current.audience,
|
|
894
|
+
wantAssertionsSigned: updates.wantAssertionsSigned ?? current.wantAssertionsSigned,
|
|
895
|
+
authnRequestsSigned: updates.authnRequestsSigned ?? current.authnRequestsSigned,
|
|
896
|
+
identifierFormat: updates.identifierFormat ?? current.identifierFormat,
|
|
897
|
+
signatureAlgorithm: updates.signatureAlgorithm ?? current.signatureAlgorithm,
|
|
898
|
+
digestAlgorithm: updates.digestAlgorithm ?? current.digestAlgorithm
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
function mergeOIDCConfig(current, updates, issuer) {
|
|
902
|
+
return {
|
|
903
|
+
...current,
|
|
904
|
+
...updates,
|
|
905
|
+
issuer,
|
|
906
|
+
pkce: updates.pkce ?? current.pkce ?? true,
|
|
907
|
+
clientId: updates.clientId ?? current.clientId,
|
|
908
|
+
clientSecret: updates.clientSecret ?? current.clientSecret,
|
|
909
|
+
discoveryEndpoint: updates.discoveryEndpoint ?? current.discoveryEndpoint,
|
|
910
|
+
mapping: updates.mapping ?? current.mapping,
|
|
911
|
+
scopes: updates.scopes ?? current.scopes,
|
|
912
|
+
authorizationEndpoint: updates.authorizationEndpoint ?? current.authorizationEndpoint,
|
|
913
|
+
tokenEndpoint: updates.tokenEndpoint ?? current.tokenEndpoint,
|
|
914
|
+
userInfoEndpoint: updates.userInfoEndpoint ?? current.userInfoEndpoint,
|
|
915
|
+
jwksEndpoint: updates.jwksEndpoint ?? current.jwksEndpoint,
|
|
916
|
+
tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
const updateSSOProvider = (options) => {
|
|
920
|
+
return createAuthEndpoint("/sso/providers/:providerId", {
|
|
921
|
+
method: "PATCH",
|
|
922
|
+
use: [sessionMiddleware],
|
|
923
|
+
params: getSSOProviderParamsSchema,
|
|
924
|
+
body: updateSSOProviderBodySchema,
|
|
925
|
+
metadata: { openapi: {
|
|
926
|
+
operationId: "updateSSOProvider",
|
|
927
|
+
summary: "Update SSO provider",
|
|
928
|
+
description: "Partially update an SSO provider. Only provided fields are updated. If domain changes, domainVerified is reset to false.",
|
|
929
|
+
responses: {
|
|
930
|
+
"200": { description: "SSO provider updated successfully" },
|
|
931
|
+
"404": { description: "Provider not found" },
|
|
932
|
+
"403": { description: "Access denied" }
|
|
933
|
+
}
|
|
934
|
+
} }
|
|
935
|
+
}, async (ctx) => {
|
|
936
|
+
const { providerId } = ctx.params;
|
|
937
|
+
const body = ctx.body;
|
|
938
|
+
const { issuer, domain, samlConfig, oidcConfig } = body;
|
|
939
|
+
if (!issuer && !domain && !samlConfig && !oidcConfig) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
|
|
940
|
+
const existingProvider = await checkProviderAccess(ctx, providerId);
|
|
941
|
+
const updateData = {};
|
|
942
|
+
if (body.issuer !== void 0) updateData.issuer = body.issuer;
|
|
943
|
+
if (body.domain !== void 0) {
|
|
944
|
+
updateData.domain = body.domain;
|
|
945
|
+
if (body.domain !== existingProvider.domain) updateData.domainVerified = false;
|
|
946
|
+
}
|
|
947
|
+
if (body.samlConfig) {
|
|
948
|
+
if (body.samlConfig.idpMetadata?.metadata) {
|
|
949
|
+
const maxMetadataSize = options?.saml?.maxMetadataSize ?? DEFAULT_MAX_SAML_METADATA_SIZE;
|
|
950
|
+
if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
|
|
951
|
+
}
|
|
952
|
+
if (body.samlConfig.signatureAlgorithm !== void 0 || body.samlConfig.digestAlgorithm !== void 0) validateConfigAlgorithms({
|
|
953
|
+
signatureAlgorithm: body.samlConfig.signatureAlgorithm,
|
|
954
|
+
digestAlgorithm: body.samlConfig.digestAlgorithm
|
|
955
|
+
}, options?.saml?.algorithms);
|
|
956
|
+
const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
|
|
957
|
+
const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
|
|
958
|
+
updateData.samlConfig = JSON.stringify(updatedSamlConfig);
|
|
959
|
+
}
|
|
960
|
+
if (body.oidcConfig) {
|
|
961
|
+
const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
|
|
962
|
+
const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
|
|
963
|
+
updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
|
|
964
|
+
}
|
|
965
|
+
await ctx.context.adapter.update({
|
|
966
|
+
model: "ssoProvider",
|
|
967
|
+
where: [{
|
|
968
|
+
field: "providerId",
|
|
969
|
+
value: providerId
|
|
970
|
+
}],
|
|
971
|
+
update: updateData
|
|
972
|
+
});
|
|
973
|
+
const fullProvider = await ctx.context.adapter.findOne({
|
|
974
|
+
model: "ssoProvider",
|
|
975
|
+
where: [{
|
|
976
|
+
field: "providerId",
|
|
977
|
+
value: providerId
|
|
978
|
+
}]
|
|
979
|
+
});
|
|
980
|
+
if (!fullProvider) throw new APIError("NOT_FOUND", { message: "Provider not found after update" });
|
|
981
|
+
return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL));
|
|
982
|
+
});
|
|
983
|
+
};
|
|
984
|
+
const deleteSSOProvider = () => {
|
|
985
|
+
return createAuthEndpoint("/sso/providers/:providerId", {
|
|
986
|
+
method: "DELETE",
|
|
987
|
+
use: [sessionMiddleware],
|
|
988
|
+
params: getSSOProviderParamsSchema,
|
|
989
|
+
metadata: { openapi: {
|
|
990
|
+
operationId: "deleteSSOProvider",
|
|
991
|
+
summary: "Delete SSO provider",
|
|
992
|
+
description: "Deletes an SSO provider",
|
|
993
|
+
responses: {
|
|
994
|
+
"200": { description: "SSO provider deleted successfully" },
|
|
995
|
+
"404": { description: "Provider not found" },
|
|
996
|
+
"403": { description: "Access denied" }
|
|
997
|
+
}
|
|
998
|
+
} }
|
|
999
|
+
}, async (ctx) => {
|
|
1000
|
+
const { providerId } = ctx.params;
|
|
1001
|
+
await checkProviderAccess(ctx, providerId);
|
|
1002
|
+
await ctx.context.adapter.delete({
|
|
1003
|
+
model: "ssoProvider",
|
|
1004
|
+
where: [{
|
|
1005
|
+
field: "providerId",
|
|
1006
|
+
value: providerId
|
|
1007
|
+
}]
|
|
1008
|
+
});
|
|
1009
|
+
return ctx.json({ success: true });
|
|
1010
|
+
});
|
|
1011
|
+
};
|
|
1012
|
+
|
|
1013
|
+
//#endregion
|
|
1014
|
+
//#region src/oidc/types.ts
|
|
1015
|
+
/**
|
|
1016
|
+
* Custom error class for OIDC discovery failures.
|
|
1017
|
+
* Can be caught and mapped to APIError at the edge.
|
|
1018
|
+
*/
|
|
1019
|
+
var DiscoveryError = class DiscoveryError extends Error {
|
|
1020
|
+
code;
|
|
1021
|
+
details;
|
|
1022
|
+
constructor(code, message, details, options) {
|
|
1023
|
+
super(message, options);
|
|
1024
|
+
this.name = "DiscoveryError";
|
|
1025
|
+
this.code = code;
|
|
1026
|
+
this.details = details;
|
|
1027
|
+
if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
|
|
1028
|
+
}
|
|
1029
|
+
};
|
|
1030
|
+
/**
|
|
1031
|
+
* Required fields that must be present in a valid discovery document.
|
|
1032
|
+
*/
|
|
1033
|
+
const REQUIRED_DISCOVERY_FIELDS = [
|
|
1034
|
+
"issuer",
|
|
1035
|
+
"authorization_endpoint",
|
|
1036
|
+
"token_endpoint",
|
|
1037
|
+
"jwks_uri"
|
|
1038
|
+
];
|
|
1039
|
+
|
|
1040
|
+
//#endregion
|
|
1041
|
+
//#region src/oidc/discovery.ts
|
|
1042
|
+
/**
|
|
1043
|
+
* OIDC Discovery Pipeline
|
|
1044
|
+
*
|
|
1045
|
+
* Implements OIDC discovery document fetching, validation, and hydration.
|
|
1046
|
+
* This module is used both at provider registration time (to persist validated config)
|
|
1047
|
+
* and at runtime (to hydrate legacy providers that are missing metadata).
|
|
1048
|
+
*
|
|
1049
|
+
* @see https://openid.net/specs/openid-connect-discovery-1_0.html
|
|
1050
|
+
*/
|
|
1051
|
+
/** Default timeout for discovery requests (10 seconds) */
|
|
1052
|
+
const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
|
|
1053
|
+
/**
|
|
1054
|
+
* Main entry point: Discover and hydrate OIDC configuration from an issuer.
|
|
1055
|
+
*
|
|
1056
|
+
* This function:
|
|
1057
|
+
* 1. Computes the discovery URL from the issuer
|
|
1058
|
+
* 2. Validates the discovery URL
|
|
1059
|
+
* 3. Fetches the discovery document
|
|
1060
|
+
* 4. Validates the discovery document (issuer match + required fields)
|
|
1061
|
+
* 5. Normalizes URLs
|
|
1062
|
+
* 6. Selects token endpoint auth method
|
|
1063
|
+
* 7. Merges with existing config (existing values take precedence)
|
|
1064
|
+
*
|
|
1065
|
+
* @param params - Discovery parameters
|
|
1066
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
1067
|
+
* @returns Hydrated OIDC configuration ready for persistence
|
|
1068
|
+
* @throws DiscoveryError on any failure
|
|
1069
|
+
*/
|
|
1070
|
+
async function discoverOIDCConfig(params) {
|
|
1071
|
+
const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
|
|
1072
|
+
const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
|
|
1073
|
+
validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
|
|
1074
|
+
const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
|
|
1075
|
+
validateDiscoveryDocument(discoveryDoc, issuer);
|
|
1076
|
+
const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
|
|
1077
|
+
const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
|
|
1078
|
+
return {
|
|
1079
|
+
issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
|
|
1080
|
+
discoveryEndpoint: existingConfig?.discoveryEndpoint ?? discoveryUrl,
|
|
1081
|
+
authorizationEndpoint: existingConfig?.authorizationEndpoint ?? normalizedDoc.authorization_endpoint,
|
|
1082
|
+
tokenEndpoint: existingConfig?.tokenEndpoint ?? normalizedDoc.token_endpoint,
|
|
1083
|
+
jwksEndpoint: existingConfig?.jwksEndpoint ?? normalizedDoc.jwks_uri,
|
|
1084
|
+
userInfoEndpoint: existingConfig?.userInfoEndpoint ?? normalizedDoc.userinfo_endpoint,
|
|
1085
|
+
tokenEndpointAuthentication: existingConfig?.tokenEndpointAuthentication ?? tokenEndpointAuth,
|
|
1086
|
+
scopesSupported: existingConfig?.scopesSupported ?? normalizedDoc.scopes_supported
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* Compute the discovery URL from an issuer URL.
|
|
1091
|
+
*
|
|
1092
|
+
* Per OIDC Discovery spec, the discovery document is located at:
|
|
1093
|
+
* <issuer>/.well-known/openid-configuration
|
|
1094
|
+
*
|
|
1095
|
+
* Handles trailing slashes correctly.
|
|
1096
|
+
*/
|
|
1097
|
+
function computeDiscoveryUrl(issuer) {
|
|
1098
|
+
return `${issuer.endsWith("/") ? issuer.slice(0, -1) : issuer}/.well-known/openid-configuration`;
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Validate a discovery URL before fetching.
|
|
1102
|
+
*
|
|
1103
|
+
* @param url - The discovery URL to validate
|
|
1104
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
1105
|
+
* @throws DiscoveryError if URL is invalid
|
|
1106
|
+
*/
|
|
1107
|
+
function validateDiscoveryUrl(url, isTrustedOrigin) {
|
|
1108
|
+
const discoveryEndpoint = parseURL("discoveryEndpoint", url).toString();
|
|
1109
|
+
if (!isTrustedOrigin(discoveryEndpoint)) throw new DiscoveryError("discovery_untrusted_origin", `The main discovery endpoint "${discoveryEndpoint}" is not trusted by your trusted origins configuration.`, { url: discoveryEndpoint });
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Fetch the OIDC discovery document from the IdP.
|
|
1113
|
+
*
|
|
1114
|
+
* @param url - The discovery endpoint URL
|
|
1115
|
+
* @param timeout - Request timeout in milliseconds
|
|
1116
|
+
* @returns The parsed discovery document
|
|
1117
|
+
* @throws DiscoveryError on network errors, timeouts, or invalid responses
|
|
1118
|
+
*/
|
|
1119
|
+
async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
|
|
1120
|
+
try {
|
|
1121
|
+
const response = await betterFetch(url, {
|
|
1122
|
+
method: "GET",
|
|
1123
|
+
timeout
|
|
1124
|
+
});
|
|
1125
|
+
if (response.error) {
|
|
1126
|
+
const { status } = response.error;
|
|
1127
|
+
if (status === 404) throw new DiscoveryError("discovery_not_found", "Discovery endpoint not found", {
|
|
1128
|
+
url,
|
|
1129
|
+
status
|
|
1130
|
+
});
|
|
1131
|
+
if (status === 408) throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
|
|
1132
|
+
url,
|
|
1133
|
+
timeout
|
|
1134
|
+
});
|
|
1135
|
+
throw new DiscoveryError("discovery_unexpected_error", `Unexpected discovery error: ${response.error.statusText}`, {
|
|
1136
|
+
url,
|
|
1137
|
+
...response.error
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
if (!response.data) throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned an empty response", { url });
|
|
1141
|
+
const data = response.data;
|
|
1142
|
+
if (typeof data === "string") throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned invalid JSON", {
|
|
1143
|
+
url,
|
|
1144
|
+
bodyPreview: data.slice(0, 200)
|
|
1145
|
+
});
|
|
1146
|
+
return data;
|
|
1147
|
+
} catch (error) {
|
|
1148
|
+
if (error instanceof DiscoveryError) throw error;
|
|
1149
|
+
if (error instanceof Error && error.name === "AbortError") throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
|
|
1150
|
+
url,
|
|
1151
|
+
timeout
|
|
1152
|
+
});
|
|
1153
|
+
throw new DiscoveryError("discovery_unexpected_error", `Unexpected error during discovery: ${error instanceof Error ? error.message : String(error)}`, { url }, { cause: error });
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Validate a discovery document.
|
|
1158
|
+
*
|
|
1159
|
+
* Checks:
|
|
1160
|
+
* 1. All required fields are present
|
|
1161
|
+
* 2. Issuer matches the configured issuer (case-sensitive, exact match)
|
|
1162
|
+
*
|
|
1163
|
+
* Invariant: If this function returns without throwing, the document is safe
|
|
1164
|
+
* to use for hydrating OIDC config (required fields present, issuer matches
|
|
1165
|
+
* configured value, basic structural sanity verified).
|
|
1166
|
+
*
|
|
1167
|
+
* @param doc - The discovery document to validate
|
|
1168
|
+
* @param configuredIssuer - The expected issuer value
|
|
1169
|
+
* @throws DiscoveryError if validation fails
|
|
1170
|
+
*/
|
|
1171
|
+
function validateDiscoveryDocument(doc, configuredIssuer) {
|
|
1172
|
+
const missingFields = [];
|
|
1173
|
+
for (const field of REQUIRED_DISCOVERY_FIELDS) if (!doc[field]) missingFields.push(field);
|
|
1174
|
+
if (missingFields.length > 0) throw new DiscoveryError("discovery_incomplete", `Discovery document is missing required fields: ${missingFields.join(", ")}`, { missingFields });
|
|
1175
|
+
if ((doc.issuer.endsWith("/") ? doc.issuer.slice(0, -1) : doc.issuer) !== (configuredIssuer.endsWith("/") ? configuredIssuer.slice(0, -1) : configuredIssuer)) throw new DiscoveryError("issuer_mismatch", `Discovered issuer "${doc.issuer}" does not match configured issuer "${configuredIssuer}"`, {
|
|
1176
|
+
discovered: doc.issuer,
|
|
1177
|
+
configured: configuredIssuer
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* Normalize URLs in the discovery document.
|
|
1182
|
+
*
|
|
1183
|
+
* @param document - The discovery document
|
|
1184
|
+
* @param issuer - The base issuer URL
|
|
1185
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
1186
|
+
* @returns The normalized discovery document
|
|
1187
|
+
*/
|
|
1188
|
+
function normalizeDiscoveryUrls(document, issuer, isTrustedOrigin) {
|
|
1189
|
+
const doc = { ...document };
|
|
1190
|
+
doc.token_endpoint = normalizeAndValidateUrl("token_endpoint", doc.token_endpoint, issuer, isTrustedOrigin);
|
|
1191
|
+
doc.authorization_endpoint = normalizeAndValidateUrl("authorization_endpoint", doc.authorization_endpoint, issuer, isTrustedOrigin);
|
|
1192
|
+
doc.jwks_uri = normalizeAndValidateUrl("jwks_uri", doc.jwks_uri, issuer, isTrustedOrigin);
|
|
1193
|
+
if (doc.userinfo_endpoint) doc.userinfo_endpoint = normalizeAndValidateUrl("userinfo_endpoint", doc.userinfo_endpoint, issuer, isTrustedOrigin);
|
|
1194
|
+
if (doc.revocation_endpoint) doc.revocation_endpoint = normalizeAndValidateUrl("revocation_endpoint", doc.revocation_endpoint, issuer, isTrustedOrigin);
|
|
1195
|
+
if (doc.end_session_endpoint) doc.end_session_endpoint = normalizeAndValidateUrl("end_session_endpoint", doc.end_session_endpoint, issuer, isTrustedOrigin);
|
|
1196
|
+
if (doc.introspection_endpoint) doc.introspection_endpoint = normalizeAndValidateUrl("introspection_endpoint", doc.introspection_endpoint, issuer, isTrustedOrigin);
|
|
1197
|
+
return doc;
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Normalizes and validates a single URL endpoint
|
|
1201
|
+
* @param name The url name
|
|
1202
|
+
* @param endpoint The url to validate
|
|
1203
|
+
* @param issuer The issuer base url
|
|
1204
|
+
* @param isTrustedOrigin - Origin verification tester function
|
|
1205
|
+
* @returns
|
|
1206
|
+
*/
|
|
1207
|
+
function normalizeAndValidateUrl(name, endpoint, issuer, isTrustedOrigin) {
|
|
1208
|
+
const url = normalizeUrl(name, endpoint, issuer);
|
|
1209
|
+
if (!isTrustedOrigin(url)) throw new DiscoveryError("discovery_untrusted_origin", `The ${name} "${url}" is not trusted by your trusted origins configuration.`, {
|
|
1210
|
+
endpoint: name,
|
|
1211
|
+
url
|
|
1212
|
+
});
|
|
1213
|
+
return url;
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* Normalize a single URL endpoint.
|
|
1217
|
+
*
|
|
1218
|
+
* @param name - The endpoint name (e.g token_endpoint)
|
|
1219
|
+
* @param endpoint - The endpoint URL to normalize
|
|
1220
|
+
* @param issuer - The base issuer URL
|
|
1221
|
+
* @returns The normalized endpoint URL
|
|
1222
|
+
*/
|
|
1223
|
+
function normalizeUrl(name, endpoint, issuer) {
|
|
1224
|
+
try {
|
|
1225
|
+
return parseURL(name, endpoint).toString();
|
|
1226
|
+
} catch {
|
|
1227
|
+
const issuerURL = parseURL(name, issuer);
|
|
1228
|
+
const basePath = issuerURL.pathname.replace(/\/+$/, "");
|
|
1229
|
+
const endpointPath = endpoint.replace(/^\/+/, "");
|
|
1230
|
+
return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
/**
|
|
1234
|
+
* Parses the given URL or throws in case of invalid or unsupported protocols
|
|
1235
|
+
*
|
|
1236
|
+
* @param name the url name
|
|
1237
|
+
* @param endpoint the endpoint url
|
|
1238
|
+
* @param [base] optional base path
|
|
1239
|
+
* @returns
|
|
1240
|
+
*/
|
|
1241
|
+
function parseURL(name, endpoint, base) {
|
|
1242
|
+
let endpointURL;
|
|
1243
|
+
try {
|
|
1244
|
+
endpointURL = new URL(endpoint, base);
|
|
1245
|
+
if (endpointURL.protocol === "http:" || endpointURL.protocol === "https:") return endpointURL;
|
|
1246
|
+
} catch (error) {
|
|
1247
|
+
throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
|
|
1248
|
+
}
|
|
1249
|
+
throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must use the http or https supported protocols: ${endpoint}`, {
|
|
1250
|
+
url: endpoint,
|
|
1251
|
+
protocol: endpointURL.protocol
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* Select the token endpoint authentication method.
|
|
1256
|
+
*
|
|
1257
|
+
* @param doc - The discovery document
|
|
1258
|
+
* @param existing - Existing authentication method from config
|
|
1259
|
+
* @returns The selected authentication method
|
|
1260
|
+
*/
|
|
1261
|
+
function selectTokenEndpointAuthMethod(doc, existing) {
|
|
1262
|
+
if (existing) return existing;
|
|
1263
|
+
const supported = doc.token_endpoint_auth_methods_supported;
|
|
1264
|
+
if (!supported || supported.length === 0) return "client_secret_basic";
|
|
1265
|
+
if (supported.includes("client_secret_basic")) return "client_secret_basic";
|
|
1266
|
+
if (supported.includes("client_secret_post")) return "client_secret_post";
|
|
1267
|
+
return "client_secret_basic";
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Check if a provider configuration needs runtime discovery.
|
|
1271
|
+
*
|
|
1272
|
+
* Returns true if we need discovery at runtime to complete the token exchange
|
|
1273
|
+
* and validation. Specifically checks for:
|
|
1274
|
+
* - `tokenEndpoint` - required for exchanging authorization code for tokens
|
|
1275
|
+
* - `jwksEndpoint` - required for validating ID token signatures
|
|
1276
|
+
*
|
|
1277
|
+
* Note: `authorizationEndpoint` is handled separately in the sign-in flow,
|
|
1278
|
+
* so it's not checked here.
|
|
1279
|
+
*
|
|
1280
|
+
* @param config - Partial OIDC config from the provider
|
|
1281
|
+
* @returns true if runtime discovery should be performed
|
|
1282
|
+
*/
|
|
1283
|
+
function needsRuntimeDiscovery(config) {
|
|
1284
|
+
if (!config) return true;
|
|
1285
|
+
return !config.tokenEndpoint || !config.jwksEndpoint;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
//#endregion
|
|
1289
|
+
//#region src/oidc/errors.ts
|
|
1290
|
+
/**
|
|
1291
|
+
* OIDC Discovery Error Mapping
|
|
1292
|
+
*
|
|
1293
|
+
* Maps DiscoveryError codes to appropriate APIError responses.
|
|
1294
|
+
* Used at the boundary between the discovery pipeline and HTTP handlers.
|
|
1295
|
+
*/
|
|
1296
|
+
/**
|
|
1297
|
+
* Maps a DiscoveryError to an appropriate APIError for HTTP responses.
|
|
1298
|
+
*
|
|
1299
|
+
* Error code mapping:
|
|
1300
|
+
* - discovery_invalid_url → 400 BAD_REQUEST
|
|
1301
|
+
* - discovery_not_found → 400 BAD_REQUEST
|
|
1302
|
+
* - discovery_invalid_json → 400 BAD_REQUEST
|
|
1303
|
+
* - discovery_incomplete → 400 BAD_REQUEST
|
|
1304
|
+
* - issuer_mismatch → 400 BAD_REQUEST
|
|
1305
|
+
* - unsupported_token_auth_method → 400 BAD_REQUEST
|
|
1306
|
+
* - discovery_timeout → 502 BAD_GATEWAY
|
|
1307
|
+
* - discovery_unexpected_error → 502 BAD_GATEWAY
|
|
1308
|
+
*
|
|
1309
|
+
* @param error - The DiscoveryError to map
|
|
1310
|
+
* @returns An APIError with appropriate status and message
|
|
1311
|
+
*/
|
|
1312
|
+
function mapDiscoveryErrorToAPIError(error) {
|
|
1313
|
+
switch (error.code) {
|
|
1314
|
+
case "discovery_timeout": return new APIError("BAD_GATEWAY", {
|
|
1315
|
+
message: `OIDC discovery timed out: ${error.message}`,
|
|
1316
|
+
code: error.code
|
|
1317
|
+
});
|
|
1318
|
+
case "discovery_unexpected_error": return new APIError("BAD_GATEWAY", {
|
|
1319
|
+
message: `OIDC discovery failed: ${error.message}`,
|
|
1320
|
+
code: error.code
|
|
1321
|
+
});
|
|
1322
|
+
case "discovery_not_found": return new APIError("BAD_REQUEST", {
|
|
1323
|
+
message: `OIDC discovery endpoint not found. The issuer may not support OIDC discovery, or the URL is incorrect. ${error.message}`,
|
|
1324
|
+
code: error.code
|
|
1325
|
+
});
|
|
1326
|
+
case "discovery_invalid_url": return new APIError("BAD_REQUEST", {
|
|
1327
|
+
message: `Invalid OIDC discovery URL: ${error.message}`,
|
|
1328
|
+
code: error.code
|
|
1329
|
+
});
|
|
1330
|
+
case "discovery_untrusted_origin": return new APIError("BAD_REQUEST", {
|
|
1331
|
+
message: `Untrusted OIDC discovery URL: ${error.message}`,
|
|
1332
|
+
code: error.code
|
|
1333
|
+
});
|
|
1334
|
+
case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
|
|
1335
|
+
message: `OIDC discovery returned invalid data: ${error.message}`,
|
|
1336
|
+
code: error.code
|
|
1337
|
+
});
|
|
1338
|
+
case "discovery_incomplete": return new APIError("BAD_REQUEST", {
|
|
1339
|
+
message: `OIDC discovery document is missing required fields: ${error.message}`,
|
|
1340
|
+
code: error.code
|
|
1341
|
+
});
|
|
1342
|
+
case "issuer_mismatch": return new APIError("BAD_REQUEST", {
|
|
1343
|
+
message: `OIDC issuer mismatch: ${error.message}`,
|
|
1344
|
+
code: error.code
|
|
1345
|
+
});
|
|
1346
|
+
case "unsupported_token_auth_method": return new APIError("BAD_REQUEST", {
|
|
1347
|
+
message: `Incompatible OIDC provider: ${error.message}`,
|
|
1348
|
+
code: error.code
|
|
1349
|
+
});
|
|
1350
|
+
default:
|
|
1351
|
+
error.code;
|
|
1352
|
+
return new APIError("INTERNAL_SERVER_ERROR", {
|
|
1353
|
+
message: `Unexpected discovery error: ${error.message}`,
|
|
1354
|
+
code: "discovery_unexpected_error"
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
//#endregion
|
|
1360
|
+
//#region src/saml-state.ts
|
|
1361
|
+
async function generateRelayState(c, link, additionalData) {
|
|
1362
|
+
const callbackURL = c.body.callbackURL;
|
|
1363
|
+
if (!callbackURL) throw new APIError$1("BAD_REQUEST", { message: "callbackURL is required" });
|
|
1364
|
+
const codeVerifier = generateRandomString(128);
|
|
1365
|
+
const stateData = {
|
|
1366
|
+
...additionalData ? additionalData : {},
|
|
1367
|
+
callbackURL,
|
|
1368
|
+
codeVerifier,
|
|
1369
|
+
errorURL: c.body.errorCallbackURL,
|
|
1370
|
+
newUserURL: c.body.newUserCallbackURL,
|
|
1371
|
+
link,
|
|
1372
|
+
expiresAt: Date.now() + 600 * 1e3,
|
|
1373
|
+
requestSignUp: c.body.requestSignUp
|
|
1374
|
+
};
|
|
1375
|
+
try {
|
|
1376
|
+
return generateGenericState(c, stateData, { cookieName: "relay_state" });
|
|
1377
|
+
} catch (error) {
|
|
1378
|
+
c.context.logger.error("Failed to create verification for relay state", error);
|
|
1379
|
+
throw new APIError$1("INTERNAL_SERVER_ERROR", {
|
|
1380
|
+
message: "State error: Unable to create verification for relay state",
|
|
1381
|
+
cause: error
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
async function parseRelayState(c) {
|
|
1386
|
+
const state = c.body.RelayState;
|
|
1387
|
+
const errorURL = c.context.options.onAPIError?.errorURL || `${c.context.baseURL}/error`;
|
|
1388
|
+
let parsedData;
|
|
1389
|
+
try {
|
|
1390
|
+
parsedData = await parseGenericState(c, state, { cookieName: "relay_state" });
|
|
1391
|
+
} catch (error) {
|
|
1392
|
+
c.context.logger.error("Failed to parse relay state", error);
|
|
1393
|
+
throw new APIError$1("BAD_REQUEST", {
|
|
1394
|
+
message: "State error: failed to validate relay state",
|
|
1395
|
+
cause: error
|
|
1396
|
+
});
|
|
1397
|
+
}
|
|
1398
|
+
if (!parsedData.errorURL) parsedData.errorURL = errorURL;
|
|
1399
|
+
return parsedData;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
//#endregion
|
|
1403
|
+
//#region src/routes/sso.ts
|
|
1404
|
+
/**
|
|
1405
|
+
* Validates SAML assertion timestamp conditions (NotBefore/NotOnOrAfter).
|
|
1406
|
+
* Prevents acceptance of expired or future-dated assertions.
|
|
1407
|
+
* @throws {APIError} If timestamps are invalid, expired, or not yet valid
|
|
1408
|
+
*/
|
|
1409
|
+
function validateSAMLTimestamp(conditions, options = {}) {
|
|
1410
|
+
const clockSkew = options.clockSkew ?? DEFAULT_CLOCK_SKEW_MS;
|
|
1411
|
+
if (!(conditions?.notBefore || conditions?.notOnOrAfter)) {
|
|
1412
|
+
if (options.requireTimestamps) throw new APIError("BAD_REQUEST", {
|
|
1413
|
+
message: "SAML assertion missing required timestamp conditions",
|
|
1414
|
+
details: "Assertions must include NotBefore and/or NotOnOrAfter conditions"
|
|
1415
|
+
});
|
|
1416
|
+
options.logger?.warn("SAML assertion accepted without timestamp conditions", { hasConditions: !!conditions });
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
const now = Date.now();
|
|
1420
|
+
if (conditions?.notBefore) {
|
|
1421
|
+
const notBeforeTime = new Date(conditions.notBefore).getTime();
|
|
1422
|
+
if (Number.isNaN(notBeforeTime)) throw new APIError("BAD_REQUEST", {
|
|
1423
|
+
message: "SAML assertion has invalid NotBefore timestamp",
|
|
1424
|
+
details: `Unable to parse NotBefore value: ${conditions.notBefore}`
|
|
1425
|
+
});
|
|
1426
|
+
if (now < notBeforeTime - clockSkew) throw new APIError("BAD_REQUEST", {
|
|
1427
|
+
message: "SAML assertion is not yet valid",
|
|
1428
|
+
details: `Current time is before NotBefore (with ${clockSkew}ms clock skew tolerance)`
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
if (conditions?.notOnOrAfter) {
|
|
1432
|
+
const notOnOrAfterTime = new Date(conditions.notOnOrAfter).getTime();
|
|
1433
|
+
if (Number.isNaN(notOnOrAfterTime)) throw new APIError("BAD_REQUEST", {
|
|
1434
|
+
message: "SAML assertion has invalid NotOnOrAfter timestamp",
|
|
1435
|
+
details: `Unable to parse NotOnOrAfter value: ${conditions.notOnOrAfter}`
|
|
1436
|
+
});
|
|
1437
|
+
if (now > notOnOrAfterTime + clockSkew) throw new APIError("BAD_REQUEST", {
|
|
1438
|
+
message: "SAML assertion has expired",
|
|
1439
|
+
details: `Current time is after NotOnOrAfter (with ${clockSkew}ms clock skew tolerance)`
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* Extracts the Assertion ID from a SAML response XML.
|
|
1445
|
+
* Returns null if the assertion ID cannot be found.
|
|
1446
|
+
*/
|
|
1447
|
+
function extractAssertionId(samlContent) {
|
|
1448
|
+
try {
|
|
1449
|
+
const parsed = new XMLParser({
|
|
1450
|
+
ignoreAttributes: false,
|
|
1451
|
+
attributeNamePrefix: "@_",
|
|
1452
|
+
removeNSPrefix: true
|
|
1453
|
+
}).parse(samlContent);
|
|
1454
|
+
const response = parsed.Response || parsed["samlp:Response"];
|
|
1455
|
+
if (!response) return null;
|
|
1456
|
+
const rawAssertion = response.Assertion || response["saml:Assertion"];
|
|
1457
|
+
const assertion = Array.isArray(rawAssertion) ? rawAssertion[0] : rawAssertion;
|
|
1458
|
+
if (!assertion) return null;
|
|
1459
|
+
return assertion["@_ID"] || null;
|
|
1460
|
+
} catch {
|
|
1461
|
+
return null;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
const spMetadataQuerySchema = z.object({
|
|
1465
|
+
providerId: z.string(),
|
|
1466
|
+
format: z.enum(["xml", "json"]).default("xml")
|
|
1467
|
+
});
|
|
1468
|
+
const spMetadata = () => {
|
|
1469
|
+
return createAuthEndpoint("/sso/saml2/sp/metadata", {
|
|
1470
|
+
method: "GET",
|
|
1471
|
+
query: spMetadataQuerySchema,
|
|
1472
|
+
metadata: { openapi: {
|
|
1473
|
+
operationId: "getSSOServiceProviderMetadata",
|
|
1474
|
+
summary: "Get Service Provider metadata",
|
|
1475
|
+
description: "Returns the SAML metadata for the Service Provider",
|
|
1476
|
+
responses: { "200": { description: "SAML metadata in XML format" } }
|
|
1477
|
+
} }
|
|
1478
|
+
}, async (ctx) => {
|
|
1479
|
+
const provider = await ctx.context.adapter.findOne({
|
|
1480
|
+
model: "ssoProvider",
|
|
1481
|
+
where: [{
|
|
1482
|
+
field: "providerId",
|
|
1483
|
+
value: ctx.query.providerId
|
|
1484
|
+
}]
|
|
1485
|
+
});
|
|
1486
|
+
if (!provider) throw new APIError("NOT_FOUND", { message: "No provider found for the given providerId" });
|
|
1487
|
+
const parsedSamlConfig = safeJsonParse(provider.samlConfig);
|
|
1488
|
+
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
1489
|
+
const sp = parsedSamlConfig.spMetadata.metadata ? saml.ServiceProvider({ metadata: parsedSamlConfig.spMetadata.metadata }) : saml.SPMetadata({
|
|
1490
|
+
entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
|
1491
|
+
assertionConsumerService: [{
|
|
1492
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1493
|
+
Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.id}`
|
|
1494
|
+
}],
|
|
1495
|
+
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1496
|
+
authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
|
|
1497
|
+
nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
|
|
1498
|
+
});
|
|
1499
|
+
return new Response(sp.getMetadata(), { headers: { "Content-Type": "application/xml" } });
|
|
1500
|
+
});
|
|
1501
|
+
};
|
|
1502
|
+
const ssoProviderBodySchema = z.object({
|
|
1503
|
+
providerId: z.string({}).meta({ description: "The ID of the provider. This is used to identify the provider during login and callback" }),
|
|
1504
|
+
issuer: z.string({}).meta({ description: "The issuer of the provider" }),
|
|
1505
|
+
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')" }),
|
|
1506
|
+
oidcConfig: z.object({
|
|
1507
|
+
clientId: z.string({}).meta({ description: "The client ID" }),
|
|
1508
|
+
clientSecret: z.string({}).meta({ description: "The client secret" }),
|
|
1509
|
+
authorizationEndpoint: z.string({}).meta({ description: "The authorization endpoint" }).optional(),
|
|
1510
|
+
tokenEndpoint: z.string({}).meta({ description: "The token endpoint" }).optional(),
|
|
1511
|
+
userInfoEndpoint: z.string({}).meta({ description: "The user info endpoint" }).optional(),
|
|
1512
|
+
tokenEndpointAuthentication: z.enum(["client_secret_post", "client_secret_basic"]).optional(),
|
|
1513
|
+
jwksEndpoint: z.string({}).meta({ description: "The JWKS endpoint" }).optional(),
|
|
1514
|
+
discoveryEndpoint: z.string().optional(),
|
|
1515
|
+
skipDiscovery: z.boolean().meta({ description: "Skip OIDC discovery during registration. When true, you must provide authorizationEndpoint, tokenEndpoint, and jwksEndpoint manually." }).optional(),
|
|
1516
|
+
scopes: z.array(z.string(), {}).meta({ description: "The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']" }).optional(),
|
|
1517
|
+
pkce: z.boolean({}).meta({ description: "Whether to use PKCE for the authorization flow" }).default(true).optional(),
|
|
1518
|
+
mapping: z.object({
|
|
1519
|
+
id: z.string({}).meta({ description: "Field mapping for user ID (defaults to 'sub')" }),
|
|
1520
|
+
email: z.string({}).meta({ description: "Field mapping for email (defaults to 'email')" }),
|
|
1521
|
+
emailVerified: z.string({}).meta({ description: "Field mapping for email verification (defaults to 'email_verified')" }).optional(),
|
|
1522
|
+
name: z.string({}).meta({ description: "Field mapping for name (defaults to 'name')" }),
|
|
1523
|
+
image: z.string({}).meta({ description: "Field mapping for image (defaults to 'picture')" }).optional(),
|
|
1524
|
+
extraFields: z.record(z.string(), z.any()).optional()
|
|
1525
|
+
}).optional()
|
|
1526
|
+
}).optional(),
|
|
1527
|
+
samlConfig: z.object({
|
|
1528
|
+
entryPoint: z.string({}).meta({ description: "The entry point of the provider" }),
|
|
1529
|
+
cert: z.string({}).meta({ description: "The certificate of the provider" }),
|
|
1530
|
+
callbackUrl: z.string({}).meta({ description: "The callback URL of the provider" }),
|
|
1531
|
+
audience: z.string().optional(),
|
|
1532
|
+
idpMetadata: z.object({
|
|
1533
|
+
metadata: z.string().optional(),
|
|
1534
|
+
entityID: z.string().optional(),
|
|
1535
|
+
cert: z.string().optional(),
|
|
1536
|
+
privateKey: z.string().optional(),
|
|
1537
|
+
privateKeyPass: z.string().optional(),
|
|
1538
|
+
isAssertionEncrypted: z.boolean().optional(),
|
|
1539
|
+
encPrivateKey: z.string().optional(),
|
|
1540
|
+
encPrivateKeyPass: z.string().optional(),
|
|
1541
|
+
singleSignOnService: z.array(z.object({
|
|
1542
|
+
Binding: z.string().meta({ description: "The binding type for the SSO service" }),
|
|
1543
|
+
Location: z.string().meta({ description: "The URL for the SSO service" })
|
|
1544
|
+
})).optional().meta({ description: "Single Sign-On service configuration" })
|
|
1545
|
+
}).optional(),
|
|
1546
|
+
spMetadata: z.object({
|
|
1547
|
+
metadata: z.string().optional(),
|
|
1548
|
+
entityID: z.string().optional(),
|
|
1549
|
+
binding: z.string().optional(),
|
|
1550
|
+
privateKey: z.string().optional(),
|
|
1551
|
+
privateKeyPass: z.string().optional(),
|
|
1552
|
+
isAssertionEncrypted: z.boolean().optional(),
|
|
1553
|
+
encPrivateKey: z.string().optional(),
|
|
1554
|
+
encPrivateKeyPass: z.string().optional()
|
|
1555
|
+
}),
|
|
1556
|
+
wantAssertionsSigned: z.boolean().optional(),
|
|
1557
|
+
authnRequestsSigned: z.boolean().optional(),
|
|
1558
|
+
signatureAlgorithm: z.string().optional(),
|
|
1559
|
+
digestAlgorithm: z.string().optional(),
|
|
1560
|
+
identifierFormat: z.string().optional(),
|
|
1561
|
+
privateKey: z.string().optional(),
|
|
1562
|
+
decryptionPvk: z.string().optional(),
|
|
1563
|
+
additionalParams: z.record(z.string(), z.any()).optional(),
|
|
1564
|
+
mapping: z.object({
|
|
1565
|
+
id: z.string({}).meta({ description: "Field mapping for user ID (defaults to 'nameID')" }),
|
|
1566
|
+
email: z.string({}).meta({ description: "Field mapping for email (defaults to 'email')" }),
|
|
1567
|
+
emailVerified: z.string({}).meta({ description: "Field mapping for email verification" }).optional(),
|
|
1568
|
+
name: z.string({}).meta({ description: "Field mapping for name (defaults to 'displayName')" }),
|
|
1569
|
+
firstName: z.string({}).meta({ description: "Field mapping for first name (defaults to 'givenName')" }).optional(),
|
|
1570
|
+
lastName: z.string({}).meta({ description: "Field mapping for last name (defaults to 'surname')" }).optional(),
|
|
1571
|
+
extraFields: z.record(z.string(), z.any()).optional()
|
|
1572
|
+
}).optional()
|
|
1573
|
+
}).optional(),
|
|
1574
|
+
organizationId: z.string({}).meta({ description: "If organization plugin is enabled, the organization id to link the provider to" }).optional(),
|
|
1575
|
+
overrideUserInfo: z.boolean({}).meta({ description: "Override user info with the provider info. Defaults to false" }).default(false).optional()
|
|
1576
|
+
});
|
|
1577
|
+
const registerSSOProvider = (options) => {
|
|
1578
|
+
return createAuthEndpoint("/sso/register", {
|
|
1579
|
+
method: "POST",
|
|
1580
|
+
body: ssoProviderBodySchema,
|
|
1581
|
+
use: [sessionMiddleware],
|
|
1582
|
+
metadata: { openapi: {
|
|
1583
|
+
operationId: "registerSSOProvider",
|
|
1584
|
+
summary: "Register an OIDC provider",
|
|
1585
|
+
description: "This endpoint is used to register an OIDC provider. This is used to configure the provider and link it to an organization",
|
|
1586
|
+
responses: { "200": {
|
|
1587
|
+
description: "OIDC provider created successfully",
|
|
1588
|
+
content: { "application/json": { schema: {
|
|
1589
|
+
type: "object",
|
|
1590
|
+
properties: {
|
|
1591
|
+
issuer: {
|
|
1592
|
+
type: "string",
|
|
1593
|
+
format: "uri",
|
|
1594
|
+
description: "The issuer URL of the provider"
|
|
1595
|
+
},
|
|
1596
|
+
domain: {
|
|
1597
|
+
type: "string",
|
|
1598
|
+
description: "The domain of the provider, used for email matching"
|
|
1599
|
+
},
|
|
1600
|
+
domainVerified: {
|
|
1601
|
+
type: "boolean",
|
|
1602
|
+
description: "A boolean indicating whether the domain has been verified or not"
|
|
1603
|
+
},
|
|
1604
|
+
domainVerificationToken: {
|
|
1605
|
+
type: "string",
|
|
1606
|
+
description: "Domain verification token. It can be used to prove ownership over the SSO domain"
|
|
1607
|
+
},
|
|
1608
|
+
oidcConfig: {
|
|
1609
|
+
type: "object",
|
|
1610
|
+
properties: {
|
|
1611
|
+
issuer: {
|
|
1612
|
+
type: "string",
|
|
1613
|
+
format: "uri",
|
|
1614
|
+
description: "The issuer URL of the provider"
|
|
1615
|
+
},
|
|
1616
|
+
pkce: {
|
|
1617
|
+
type: "boolean",
|
|
1618
|
+
description: "Whether PKCE is enabled for the authorization flow"
|
|
1619
|
+
},
|
|
1620
|
+
clientId: {
|
|
1621
|
+
type: "string",
|
|
1622
|
+
description: "The client ID for the provider"
|
|
1623
|
+
},
|
|
1624
|
+
clientSecret: {
|
|
1625
|
+
type: "string",
|
|
1626
|
+
description: "The client secret for the provider"
|
|
1627
|
+
},
|
|
1628
|
+
authorizationEndpoint: {
|
|
1629
|
+
type: "string",
|
|
1630
|
+
format: "uri",
|
|
1631
|
+
nullable: true,
|
|
1632
|
+
description: "The authorization endpoint URL"
|
|
1633
|
+
},
|
|
1634
|
+
discoveryEndpoint: {
|
|
1635
|
+
type: "string",
|
|
1636
|
+
format: "uri",
|
|
1637
|
+
description: "The discovery endpoint URL"
|
|
1638
|
+
},
|
|
1639
|
+
userInfoEndpoint: {
|
|
1640
|
+
type: "string",
|
|
1641
|
+
format: "uri",
|
|
1642
|
+
nullable: true,
|
|
1643
|
+
description: "The user info endpoint URL"
|
|
1644
|
+
},
|
|
1645
|
+
scopes: {
|
|
1646
|
+
type: "array",
|
|
1647
|
+
items: { type: "string" },
|
|
1648
|
+
nullable: true,
|
|
1649
|
+
description: "The scopes requested from the provider"
|
|
1650
|
+
},
|
|
1651
|
+
tokenEndpoint: {
|
|
1652
|
+
type: "string",
|
|
1653
|
+
format: "uri",
|
|
1654
|
+
nullable: true,
|
|
1655
|
+
description: "The token endpoint URL"
|
|
1656
|
+
},
|
|
1657
|
+
tokenEndpointAuthentication: {
|
|
1658
|
+
type: "string",
|
|
1659
|
+
enum: ["client_secret_post", "client_secret_basic"],
|
|
1660
|
+
nullable: true,
|
|
1661
|
+
description: "Authentication method for the token endpoint"
|
|
1662
|
+
},
|
|
1663
|
+
jwksEndpoint: {
|
|
1664
|
+
type: "string",
|
|
1665
|
+
format: "uri",
|
|
1666
|
+
nullable: true,
|
|
1667
|
+
description: "The JWKS endpoint URL"
|
|
1668
|
+
},
|
|
1669
|
+
mapping: {
|
|
1670
|
+
type: "object",
|
|
1671
|
+
nullable: true,
|
|
1672
|
+
properties: {
|
|
1673
|
+
id: {
|
|
1674
|
+
type: "string",
|
|
1675
|
+
description: "Field mapping for user ID (defaults to 'sub')"
|
|
1676
|
+
},
|
|
1677
|
+
email: {
|
|
1678
|
+
type: "string",
|
|
1679
|
+
description: "Field mapping for email (defaults to 'email')"
|
|
1680
|
+
},
|
|
1681
|
+
emailVerified: {
|
|
1682
|
+
type: "string",
|
|
1683
|
+
nullable: true,
|
|
1684
|
+
description: "Field mapping for email verification (defaults to 'email_verified')"
|
|
1685
|
+
},
|
|
1686
|
+
name: {
|
|
1687
|
+
type: "string",
|
|
1688
|
+
description: "Field mapping for name (defaults to 'name')"
|
|
1689
|
+
},
|
|
1690
|
+
image: {
|
|
1691
|
+
type: "string",
|
|
1692
|
+
nullable: true,
|
|
1693
|
+
description: "Field mapping for image (defaults to 'picture')"
|
|
1694
|
+
},
|
|
1695
|
+
extraFields: {
|
|
1696
|
+
type: "object",
|
|
1697
|
+
additionalProperties: { type: "string" },
|
|
1698
|
+
nullable: true,
|
|
1699
|
+
description: "Additional field mappings"
|
|
1700
|
+
}
|
|
1701
|
+
},
|
|
1702
|
+
required: [
|
|
1703
|
+
"id",
|
|
1704
|
+
"email",
|
|
1705
|
+
"name"
|
|
1706
|
+
]
|
|
1707
|
+
}
|
|
1708
|
+
},
|
|
1709
|
+
required: [
|
|
1710
|
+
"issuer",
|
|
1711
|
+
"pkce",
|
|
1712
|
+
"clientId",
|
|
1713
|
+
"clientSecret",
|
|
1714
|
+
"discoveryEndpoint"
|
|
1715
|
+
],
|
|
1716
|
+
description: "OIDC configuration for the provider"
|
|
1717
|
+
},
|
|
1718
|
+
organizationId: {
|
|
1719
|
+
type: "string",
|
|
1720
|
+
nullable: true,
|
|
1721
|
+
description: "ID of the linked organization, if any"
|
|
1722
|
+
},
|
|
1723
|
+
userId: {
|
|
1724
|
+
type: "string",
|
|
1725
|
+
description: "ID of the user who registered the provider"
|
|
1726
|
+
},
|
|
1727
|
+
providerId: {
|
|
1728
|
+
type: "string",
|
|
1729
|
+
description: "Unique identifier for the provider"
|
|
1730
|
+
},
|
|
1731
|
+
redirectURI: {
|
|
1732
|
+
type: "string",
|
|
1733
|
+
format: "uri",
|
|
1734
|
+
description: "The redirect URI for the provider callback"
|
|
1735
|
+
}
|
|
1736
|
+
},
|
|
1737
|
+
required: [
|
|
1738
|
+
"issuer",
|
|
1739
|
+
"domain",
|
|
1740
|
+
"oidcConfig",
|
|
1741
|
+
"userId",
|
|
1742
|
+
"providerId",
|
|
1743
|
+
"redirectURI"
|
|
1744
|
+
]
|
|
1745
|
+
} } }
|
|
1746
|
+
} }
|
|
1747
|
+
} }
|
|
1748
|
+
}, async (ctx) => {
|
|
1749
|
+
const user = ctx.context.session?.user;
|
|
1750
|
+
if (!user) throw new APIError("UNAUTHORIZED");
|
|
1751
|
+
const limit = typeof options?.providersLimit === "function" ? await options.providersLimit(user) : options?.providersLimit ?? 10;
|
|
1752
|
+
if (!limit) throw new APIError("FORBIDDEN", { message: "SSO provider registration is disabled" });
|
|
1753
|
+
if ((await ctx.context.adapter.findMany({
|
|
1754
|
+
model: "ssoProvider",
|
|
1755
|
+
where: [{
|
|
1756
|
+
field: "userId",
|
|
1757
|
+
value: user.id
|
|
1758
|
+
}]
|
|
1759
|
+
})).length >= limit) throw new APIError("FORBIDDEN", { message: "You have reached the maximum number of SSO providers" });
|
|
1760
|
+
const body = ctx.body;
|
|
1761
|
+
if (z.string().url().safeParse(body.issuer).error) throw new APIError("BAD_REQUEST", { message: "Invalid issuer. Must be a valid URL" });
|
|
1762
|
+
if (body.samlConfig?.idpMetadata?.metadata) {
|
|
1763
|
+
const maxMetadataSize = options?.saml?.maxMetadataSize ?? DEFAULT_MAX_SAML_METADATA_SIZE;
|
|
1764
|
+
if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
|
|
1765
|
+
}
|
|
1766
|
+
if (ctx.body.organizationId) {
|
|
1767
|
+
if (!await ctx.context.adapter.findOne({
|
|
1768
|
+
model: "member",
|
|
1769
|
+
where: [{
|
|
1770
|
+
field: "userId",
|
|
1771
|
+
value: user.id
|
|
1772
|
+
}, {
|
|
1773
|
+
field: "organizationId",
|
|
1774
|
+
value: ctx.body.organizationId
|
|
1775
|
+
}]
|
|
1776
|
+
})) throw new APIError("BAD_REQUEST", { message: "You are not a member of the organization" });
|
|
1777
|
+
}
|
|
1778
|
+
if (await ctx.context.adapter.findOne({
|
|
1779
|
+
model: "ssoProvider",
|
|
1780
|
+
where: [{
|
|
1781
|
+
field: "providerId",
|
|
1782
|
+
value: body.providerId
|
|
1783
|
+
}]
|
|
1784
|
+
})) {
|
|
1785
|
+
ctx.context.logger.info(`SSO provider creation attempt with existing providerId: ${body.providerId}`);
|
|
1786
|
+
throw new APIError("UNPROCESSABLE_ENTITY", { message: "SSO provider with this providerId already exists" });
|
|
1787
|
+
}
|
|
1788
|
+
let hydratedOIDCConfig = null;
|
|
1789
|
+
if (body.oidcConfig && !body.oidcConfig.skipDiscovery) try {
|
|
1790
|
+
hydratedOIDCConfig = await discoverOIDCConfig({
|
|
1791
|
+
issuer: body.issuer,
|
|
1792
|
+
existingConfig: {
|
|
1793
|
+
discoveryEndpoint: body.oidcConfig.discoveryEndpoint,
|
|
1794
|
+
authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
|
|
1795
|
+
tokenEndpoint: body.oidcConfig.tokenEndpoint,
|
|
1796
|
+
jwksEndpoint: body.oidcConfig.jwksEndpoint,
|
|
1797
|
+
userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
|
|
1798
|
+
tokenEndpointAuthentication: body.oidcConfig.tokenEndpointAuthentication
|
|
1799
|
+
},
|
|
1800
|
+
isTrustedOrigin: (url) => ctx.context.isTrustedOrigin(url)
|
|
1801
|
+
});
|
|
1802
|
+
} catch (error) {
|
|
1803
|
+
if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
|
|
1804
|
+
throw error;
|
|
1805
|
+
}
|
|
1806
|
+
const buildOIDCConfig = () => {
|
|
1807
|
+
if (!body.oidcConfig) return null;
|
|
1808
|
+
if (body.oidcConfig.skipDiscovery) return JSON.stringify({
|
|
1809
|
+
issuer: body.issuer,
|
|
1810
|
+
clientId: body.oidcConfig.clientId,
|
|
1811
|
+
clientSecret: body.oidcConfig.clientSecret,
|
|
1812
|
+
authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
|
|
1813
|
+
tokenEndpoint: body.oidcConfig.tokenEndpoint,
|
|
1814
|
+
tokenEndpointAuthentication: body.oidcConfig.tokenEndpointAuthentication || "client_secret_basic",
|
|
1815
|
+
jwksEndpoint: body.oidcConfig.jwksEndpoint,
|
|
1816
|
+
pkce: body.oidcConfig.pkce,
|
|
1817
|
+
discoveryEndpoint: body.oidcConfig.discoveryEndpoint || `${body.issuer}/.well-known/openid-configuration`,
|
|
1818
|
+
mapping: body.oidcConfig.mapping,
|
|
1819
|
+
scopes: body.oidcConfig.scopes,
|
|
1820
|
+
userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
|
|
1821
|
+
overrideUserInfo: ctx.body.overrideUserInfo || options?.defaultOverrideUserInfo || false
|
|
1822
|
+
});
|
|
1823
|
+
if (!hydratedOIDCConfig) return null;
|
|
1824
|
+
return JSON.stringify({
|
|
1825
|
+
issuer: hydratedOIDCConfig.issuer,
|
|
1826
|
+
clientId: body.oidcConfig.clientId,
|
|
1827
|
+
clientSecret: body.oidcConfig.clientSecret,
|
|
1828
|
+
authorizationEndpoint: hydratedOIDCConfig.authorizationEndpoint,
|
|
1829
|
+
tokenEndpoint: hydratedOIDCConfig.tokenEndpoint,
|
|
1830
|
+
tokenEndpointAuthentication: hydratedOIDCConfig.tokenEndpointAuthentication,
|
|
1831
|
+
jwksEndpoint: hydratedOIDCConfig.jwksEndpoint,
|
|
1832
|
+
pkce: body.oidcConfig.pkce,
|
|
1833
|
+
discoveryEndpoint: hydratedOIDCConfig.discoveryEndpoint,
|
|
1834
|
+
mapping: body.oidcConfig.mapping,
|
|
1835
|
+
scopes: body.oidcConfig.scopes,
|
|
1836
|
+
userInfoEndpoint: hydratedOIDCConfig.userInfoEndpoint,
|
|
1837
|
+
overrideUserInfo: ctx.body.overrideUserInfo || options?.defaultOverrideUserInfo || false
|
|
1838
|
+
});
|
|
1839
|
+
};
|
|
1840
|
+
if (body.samlConfig) validateConfigAlgorithms({
|
|
1841
|
+
signatureAlgorithm: body.samlConfig.signatureAlgorithm,
|
|
1842
|
+
digestAlgorithm: body.samlConfig.digestAlgorithm
|
|
1843
|
+
}, options?.saml?.algorithms);
|
|
1844
|
+
const provider = await ctx.context.adapter.create({
|
|
1845
|
+
model: "ssoProvider",
|
|
1846
|
+
data: {
|
|
1847
|
+
issuer: body.issuer,
|
|
1848
|
+
domain: body.domain,
|
|
1849
|
+
domainVerified: false,
|
|
1850
|
+
oidcConfig: buildOIDCConfig(),
|
|
1851
|
+
samlConfig: body.samlConfig ? JSON.stringify({
|
|
1852
|
+
issuer: body.issuer,
|
|
1853
|
+
entryPoint: body.samlConfig.entryPoint,
|
|
1854
|
+
cert: body.samlConfig.cert,
|
|
1855
|
+
callbackUrl: body.samlConfig.callbackUrl,
|
|
1856
|
+
audience: body.samlConfig.audience,
|
|
1857
|
+
idpMetadata: body.samlConfig.idpMetadata,
|
|
1858
|
+
spMetadata: body.samlConfig.spMetadata,
|
|
1859
|
+
wantAssertionsSigned: body.samlConfig.wantAssertionsSigned,
|
|
1860
|
+
authnRequestsSigned: body.samlConfig.authnRequestsSigned,
|
|
1861
|
+
signatureAlgorithm: body.samlConfig.signatureAlgorithm,
|
|
1862
|
+
digestAlgorithm: body.samlConfig.digestAlgorithm,
|
|
1863
|
+
identifierFormat: body.samlConfig.identifierFormat,
|
|
1864
|
+
privateKey: body.samlConfig.privateKey,
|
|
1865
|
+
decryptionPvk: body.samlConfig.decryptionPvk,
|
|
1866
|
+
additionalParams: body.samlConfig.additionalParams,
|
|
1867
|
+
mapping: body.samlConfig.mapping
|
|
1868
|
+
}) : null,
|
|
1869
|
+
organizationId: body.organizationId,
|
|
1870
|
+
userId: ctx.context.session.user.id,
|
|
1871
|
+
providerId: body.providerId
|
|
1872
|
+
}
|
|
1873
|
+
});
|
|
1874
|
+
let domainVerificationToken;
|
|
1875
|
+
let domainVerified;
|
|
1876
|
+
if (options?.domainVerification?.enabled) {
|
|
1877
|
+
domainVerified = false;
|
|
1878
|
+
domainVerificationToken = generateRandomString(24);
|
|
1879
|
+
await ctx.context.adapter.create({
|
|
1880
|
+
model: "verification",
|
|
1881
|
+
data: {
|
|
1882
|
+
identifier: options.domainVerification?.tokenPrefix ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}` : `better-auth-token-${provider.providerId}`,
|
|
1883
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1884
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
1885
|
+
value: domainVerificationToken,
|
|
1886
|
+
expiresAt: new Date(Date.now() + 3600 * 24 * 7 * 1e3)
|
|
1887
|
+
}
|
|
1888
|
+
});
|
|
1889
|
+
}
|
|
1890
|
+
const result = {
|
|
1891
|
+
...provider,
|
|
1892
|
+
oidcConfig: safeJsonParse(provider.oidcConfig),
|
|
1893
|
+
samlConfig: safeJsonParse(provider.samlConfig),
|
|
1894
|
+
redirectURI: `${ctx.context.baseURL}/sso/callback/${provider.providerId}`,
|
|
1895
|
+
...options?.domainVerification?.enabled ? { domainVerified } : {},
|
|
1896
|
+
...options?.domainVerification?.enabled ? { domainVerificationToken } : {}
|
|
1897
|
+
};
|
|
1898
|
+
return ctx.json(result);
|
|
1899
|
+
});
|
|
1900
|
+
};
|
|
1901
|
+
const signInSSOBodySchema = z.object({
|
|
1902
|
+
email: z.string({}).meta({ description: "The email address to sign in with. This is used to identify the issuer to sign in with. It's optional if the issuer is provided" }).optional(),
|
|
1903
|
+
organizationSlug: z.string({}).meta({ description: "The slug of the organization to sign in with" }).optional(),
|
|
1904
|
+
providerId: z.string({}).meta({ description: "The ID of the provider to sign in with. This can be provided instead of email or issuer" }).optional(),
|
|
1905
|
+
domain: z.string({}).meta({ description: "The domain of the provider." }).optional(),
|
|
1906
|
+
callbackURL: z.string({}).meta({ description: "The URL to redirect to after login" }),
|
|
1907
|
+
errorCallbackURL: z.string({}).meta({ description: "The URL to redirect to after login" }).optional(),
|
|
1908
|
+
newUserCallbackURL: z.string({}).meta({ description: "The URL to redirect to after login if the user is new" }).optional(),
|
|
1909
|
+
scopes: z.array(z.string(), {}).meta({ description: "Scopes to request from the provider." }).optional(),
|
|
1910
|
+
loginHint: z.string({}).meta({ description: "Login hint to send to the identity provider (e.g., email or identifier). If supported, will be sent as 'login_hint'." }).optional(),
|
|
1911
|
+
requestSignUp: z.boolean({}).meta({ description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider" }).optional(),
|
|
1912
|
+
providerType: z.enum(["oidc", "saml"]).optional()
|
|
1913
|
+
});
|
|
1914
|
+
const signInSSO = (options) => {
|
|
1915
|
+
return createAuthEndpoint("/sign-in/sso", {
|
|
1916
|
+
method: "POST",
|
|
1917
|
+
body: signInSSOBodySchema,
|
|
1918
|
+
metadata: { openapi: {
|
|
1919
|
+
operationId: "signInWithSSO",
|
|
1920
|
+
summary: "Sign in with SSO provider",
|
|
1921
|
+
description: "This endpoint is used to sign in with an SSO provider. It redirects to the provider's authorization URL",
|
|
1922
|
+
requestBody: { content: { "application/json": { schema: {
|
|
1923
|
+
type: "object",
|
|
1924
|
+
properties: {
|
|
1925
|
+
email: {
|
|
1926
|
+
type: "string",
|
|
1927
|
+
description: "The email address to sign in with. This is used to identify the issuer to sign in with. It's optional if the issuer is provided"
|
|
1928
|
+
},
|
|
1929
|
+
issuer: {
|
|
1930
|
+
type: "string",
|
|
1931
|
+
description: "The issuer identifier, this is the URL of the provider and can be used to verify the provider and identify the provider during login. It's optional if the email is provided"
|
|
1932
|
+
},
|
|
1933
|
+
providerId: {
|
|
1934
|
+
type: "string",
|
|
1935
|
+
description: "The ID of the provider to sign in with. This can be provided instead of email or issuer"
|
|
1936
|
+
},
|
|
1937
|
+
callbackURL: {
|
|
1938
|
+
type: "string",
|
|
1939
|
+
description: "The URL to redirect to after login"
|
|
1940
|
+
},
|
|
1941
|
+
errorCallbackURL: {
|
|
1942
|
+
type: "string",
|
|
1943
|
+
description: "The URL to redirect to after login"
|
|
1944
|
+
},
|
|
1945
|
+
newUserCallbackURL: {
|
|
1946
|
+
type: "string",
|
|
1947
|
+
description: "The URL to redirect to after login if the user is new"
|
|
1948
|
+
},
|
|
1949
|
+
loginHint: {
|
|
1950
|
+
type: "string",
|
|
1951
|
+
description: "Login hint to send to the identity provider (e.g., email or identifier). If supported, sent as 'login_hint'."
|
|
1952
|
+
}
|
|
1953
|
+
},
|
|
1954
|
+
required: ["callbackURL"]
|
|
1955
|
+
} } } },
|
|
1956
|
+
responses: { "200": {
|
|
1957
|
+
description: "Authorization URL generated successfully for SSO sign-in",
|
|
1958
|
+
content: { "application/json": { schema: {
|
|
1959
|
+
type: "object",
|
|
1960
|
+
properties: {
|
|
1961
|
+
url: {
|
|
1962
|
+
type: "string",
|
|
1963
|
+
format: "uri",
|
|
1964
|
+
description: "The authorization URL to redirect the user to for SSO sign-in"
|
|
1965
|
+
},
|
|
1966
|
+
redirect: {
|
|
1967
|
+
type: "boolean",
|
|
1968
|
+
description: "Indicates that the client should redirect to the provided URL",
|
|
1969
|
+
enum: [true]
|
|
1970
|
+
}
|
|
1971
|
+
},
|
|
1972
|
+
required: ["url", "redirect"]
|
|
1973
|
+
} } }
|
|
1974
|
+
} }
|
|
1975
|
+
} }
|
|
1976
|
+
}, async (ctx) => {
|
|
1977
|
+
const body = ctx.body;
|
|
1978
|
+
let { email, organizationSlug, providerId, domain } = body;
|
|
1979
|
+
if (!options?.defaultSSO?.length && !email && !organizationSlug && !domain && !providerId) throw new APIError("BAD_REQUEST", { message: "email, organizationSlug, domain or providerId is required" });
|
|
1980
|
+
domain = body.domain || email?.split("@")[1];
|
|
1981
|
+
let orgId = "";
|
|
1982
|
+
if (organizationSlug) orgId = await ctx.context.adapter.findOne({
|
|
1983
|
+
model: "organization",
|
|
1984
|
+
where: [{
|
|
1985
|
+
field: "slug",
|
|
1986
|
+
value: organizationSlug
|
|
1987
|
+
}]
|
|
1988
|
+
}).then((res) => {
|
|
1989
|
+
if (!res) return "";
|
|
1990
|
+
return res.id;
|
|
1991
|
+
});
|
|
1992
|
+
let provider = null;
|
|
1993
|
+
if (options?.defaultSSO?.length) {
|
|
1994
|
+
const matchingDefault = providerId ? options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId) : options.defaultSSO.find((defaultProvider) => defaultProvider.domain === domain);
|
|
1995
|
+
if (matchingDefault) provider = {
|
|
1996
|
+
issuer: matchingDefault.samlConfig?.issuer || matchingDefault.oidcConfig?.issuer || "",
|
|
1997
|
+
providerId: matchingDefault.providerId,
|
|
1998
|
+
userId: "default",
|
|
1999
|
+
oidcConfig: matchingDefault.oidcConfig,
|
|
2000
|
+
samlConfig: matchingDefault.samlConfig,
|
|
2001
|
+
domain: matchingDefault.domain,
|
|
2002
|
+
...options.domainVerification?.enabled ? { domainVerified: true } : {}
|
|
2003
|
+
};
|
|
2004
|
+
}
|
|
2005
|
+
if (!providerId && !orgId && !domain) throw new APIError("BAD_REQUEST", { message: "providerId, orgId or domain is required" });
|
|
2006
|
+
if (!provider) {
|
|
2007
|
+
const parseProvider = (res) => {
|
|
2008
|
+
if (!res) return null;
|
|
2009
|
+
return {
|
|
2010
|
+
...res,
|
|
2011
|
+
oidcConfig: res.oidcConfig ? safeJsonParse(res.oidcConfig) || void 0 : void 0,
|
|
2012
|
+
samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
|
|
2013
|
+
};
|
|
2014
|
+
};
|
|
2015
|
+
if (providerId || orgId) provider = parseProvider(await ctx.context.adapter.findOne({
|
|
2016
|
+
model: "ssoProvider",
|
|
2017
|
+
where: [{
|
|
2018
|
+
field: providerId ? "providerId" : "organizationId",
|
|
2019
|
+
value: providerId || orgId
|
|
2020
|
+
}]
|
|
2021
|
+
}));
|
|
2022
|
+
else if (domain) {
|
|
2023
|
+
provider = parseProvider(await ctx.context.adapter.findOne({
|
|
2024
|
+
model: "ssoProvider",
|
|
2025
|
+
where: [{
|
|
2026
|
+
field: "domain",
|
|
2027
|
+
value: domain
|
|
2028
|
+
}]
|
|
2029
|
+
}));
|
|
2030
|
+
if (!provider) provider = parseProvider((await ctx.context.adapter.findMany({ model: "ssoProvider" })).find((p) => domainMatches(domain, p.domain)) ?? null);
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
if (!provider) throw new APIError("NOT_FOUND", { message: "No provider found for the issuer" });
|
|
2034
|
+
if (body.providerType) {
|
|
2035
|
+
if (body.providerType === "oidc" && !provider.oidcConfig) throw new APIError("BAD_REQUEST", { message: "OIDC provider is not configured" });
|
|
2036
|
+
if (body.providerType === "saml" && !provider.samlConfig) throw new APIError("BAD_REQUEST", { message: "SAML provider is not configured" });
|
|
2037
|
+
}
|
|
2038
|
+
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
2039
|
+
if (provider.oidcConfig && body.providerType !== "saml") {
|
|
2040
|
+
let finalAuthUrl = provider.oidcConfig.authorizationEndpoint;
|
|
2041
|
+
if (!finalAuthUrl && provider.oidcConfig.discoveryEndpoint) {
|
|
2042
|
+
const discovery = await betterFetch(provider.oidcConfig.discoveryEndpoint, { method: "GET" });
|
|
2043
|
+
if (discovery.data) finalAuthUrl = discovery.data.authorization_endpoint;
|
|
2044
|
+
}
|
|
2045
|
+
if (!finalAuthUrl) throw new APIError("BAD_REQUEST", { message: "Invalid OIDC configuration. Authorization URL not found." });
|
|
2046
|
+
const state = await generateState(ctx, void 0, false);
|
|
2047
|
+
const redirectURI = `${ctx.context.baseURL}/sso/callback/${provider.providerId}`;
|
|
2048
|
+
const authorizationURL = await createAuthorizationURL({
|
|
2049
|
+
id: provider.issuer,
|
|
2050
|
+
options: {
|
|
2051
|
+
clientId: provider.oidcConfig.clientId,
|
|
2052
|
+
clientSecret: provider.oidcConfig.clientSecret
|
|
2053
|
+
},
|
|
2054
|
+
redirectURI,
|
|
2055
|
+
state: state.state,
|
|
2056
|
+
codeVerifier: provider.oidcConfig.pkce ? state.codeVerifier : void 0,
|
|
2057
|
+
scopes: ctx.body.scopes || provider.oidcConfig.scopes || [
|
|
2058
|
+
"openid",
|
|
2059
|
+
"email",
|
|
2060
|
+
"profile",
|
|
2061
|
+
"offline_access"
|
|
2062
|
+
],
|
|
2063
|
+
loginHint: ctx.body.loginHint || email,
|
|
2064
|
+
authorizationEndpoint: finalAuthUrl
|
|
2065
|
+
});
|
|
2066
|
+
return ctx.json({
|
|
2067
|
+
url: authorizationURL.toString(),
|
|
2068
|
+
redirect: true
|
|
2069
|
+
});
|
|
2070
|
+
}
|
|
2071
|
+
if (provider.samlConfig) {
|
|
2072
|
+
const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
|
|
2073
|
+
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
2074
|
+
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 });
|
|
2075
|
+
let metadata = parsedSamlConfig.spMetadata.metadata;
|
|
2076
|
+
if (!metadata) metadata = saml.SPMetadata({
|
|
2077
|
+
entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
|
2078
|
+
assertionConsumerService: [{
|
|
2079
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
2080
|
+
Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.providerId}`
|
|
2081
|
+
}],
|
|
2082
|
+
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
2083
|
+
authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
|
|
2084
|
+
nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
|
|
2085
|
+
}).getMetadata() || "";
|
|
2086
|
+
const sp = saml.ServiceProvider({
|
|
2087
|
+
metadata,
|
|
2088
|
+
allowCreate: true,
|
|
2089
|
+
privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
|
|
2090
|
+
privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass
|
|
2091
|
+
});
|
|
2092
|
+
const idp = saml.IdentityProvider({
|
|
2093
|
+
metadata: parsedSamlConfig.idpMetadata?.metadata,
|
|
2094
|
+
entityID: parsedSamlConfig.idpMetadata?.entityID,
|
|
2095
|
+
encryptCert: parsedSamlConfig.idpMetadata?.cert,
|
|
2096
|
+
singleSignOnService: parsedSamlConfig.idpMetadata?.singleSignOnService
|
|
2097
|
+
});
|
|
2098
|
+
const loginRequest = sp.createLoginRequest(idp, "redirect");
|
|
2099
|
+
if (!loginRequest) throw new APIError("BAD_REQUEST", { message: "Invalid SAML request" });
|
|
2100
|
+
const { state: relayState } = await generateRelayState(ctx, void 0, false);
|
|
2101
|
+
if (loginRequest.id && options?.saml?.enableInResponseToValidation) {
|
|
2102
|
+
const ttl = options?.saml?.requestTTL ?? DEFAULT_AUTHN_REQUEST_TTL_MS;
|
|
2103
|
+
const record = {
|
|
2104
|
+
id: loginRequest.id,
|
|
2105
|
+
providerId: provider.providerId,
|
|
2106
|
+
createdAt: Date.now(),
|
|
2107
|
+
expiresAt: Date.now() + ttl
|
|
2108
|
+
};
|
|
2109
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
2110
|
+
identifier: `${AUTHN_REQUEST_KEY_PREFIX}${record.id}`,
|
|
2111
|
+
value: JSON.stringify(record),
|
|
2112
|
+
expiresAt: new Date(record.expiresAt)
|
|
2113
|
+
});
|
|
2114
|
+
}
|
|
2115
|
+
return ctx.json({
|
|
2116
|
+
url: `${loginRequest.context}&RelayState=${encodeURIComponent(relayState)}`,
|
|
2117
|
+
redirect: true
|
|
2118
|
+
});
|
|
2119
|
+
}
|
|
2120
|
+
throw new APIError("BAD_REQUEST", { message: "Invalid SSO provider" });
|
|
2121
|
+
});
|
|
2122
|
+
};
|
|
2123
|
+
const callbackSSOQuerySchema = z.object({
|
|
2124
|
+
code: z.string().optional(),
|
|
2125
|
+
state: z.string(),
|
|
2126
|
+
error: z.string().optional(),
|
|
2127
|
+
error_description: z.string().optional()
|
|
2128
|
+
});
|
|
2129
|
+
const callbackSSO = (options) => {
|
|
2130
|
+
return createAuthEndpoint("/sso/callback/:providerId", {
|
|
2131
|
+
method: "GET",
|
|
2132
|
+
query: callbackSSOQuerySchema,
|
|
2133
|
+
allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
|
|
2134
|
+
metadata: {
|
|
2135
|
+
...HIDE_METADATA,
|
|
2136
|
+
openapi: {
|
|
2137
|
+
operationId: "handleSSOCallback",
|
|
2138
|
+
summary: "Callback URL for SSO provider",
|
|
2139
|
+
description: "This endpoint is used as the callback URL for SSO providers. It handles the authorization code and exchanges it for an access token",
|
|
2140
|
+
responses: { "302": { description: "Redirects to the callback URL" } }
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
}, async (ctx) => {
|
|
2144
|
+
const { code, error, error_description } = ctx.query;
|
|
2145
|
+
const stateData = await parseState(ctx);
|
|
2146
|
+
if (!stateData) {
|
|
2147
|
+
const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
|
|
2148
|
+
throw ctx.redirect(`${errorURL}?error=invalid_state`);
|
|
2149
|
+
}
|
|
2150
|
+
const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
|
|
2151
|
+
if (!code || error) throw ctx.redirect(`${errorURL || callbackURL}?error=${error}&error_description=${error_description}`);
|
|
2152
|
+
let provider = null;
|
|
2153
|
+
if (options?.defaultSSO?.length) {
|
|
2154
|
+
const matchingDefault = options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === ctx.params.providerId);
|
|
2155
|
+
if (matchingDefault) provider = {
|
|
2156
|
+
...matchingDefault,
|
|
2157
|
+
issuer: matchingDefault.oidcConfig?.issuer || "",
|
|
2158
|
+
userId: "default",
|
|
2159
|
+
...options.domainVerification?.enabled ? { domainVerified: true } : {}
|
|
2160
|
+
};
|
|
2161
|
+
}
|
|
2162
|
+
if (!provider) provider = await ctx.context.adapter.findOne({
|
|
2163
|
+
model: "ssoProvider",
|
|
2164
|
+
where: [{
|
|
2165
|
+
field: "providerId",
|
|
2166
|
+
value: ctx.params.providerId
|
|
2167
|
+
}]
|
|
2168
|
+
}).then((res) => {
|
|
2169
|
+
if (!res) return null;
|
|
2170
|
+
return {
|
|
2171
|
+
...res,
|
|
2172
|
+
oidcConfig: safeJsonParse(res.oidcConfig) || void 0
|
|
2173
|
+
};
|
|
2174
|
+
});
|
|
2175
|
+
if (!provider) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=provider not found`);
|
|
2176
|
+
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
2177
|
+
let config = provider.oidcConfig;
|
|
2178
|
+
if (!config) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=provider not found`);
|
|
2179
|
+
const discovery = await betterFetch(config.discoveryEndpoint);
|
|
2180
|
+
if (discovery.data) config = {
|
|
2181
|
+
tokenEndpoint: discovery.data.token_endpoint,
|
|
2182
|
+
tokenEndpointAuthentication: discovery.data.token_endpoint_auth_method,
|
|
2183
|
+
userInfoEndpoint: discovery.data.userinfo_endpoint,
|
|
2184
|
+
scopes: [
|
|
2185
|
+
"openid",
|
|
2186
|
+
"email",
|
|
2187
|
+
"profile",
|
|
2188
|
+
"offline_access"
|
|
2189
|
+
],
|
|
2190
|
+
...config
|
|
2191
|
+
};
|
|
2192
|
+
if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=token_endpoint_not_found`);
|
|
2193
|
+
const tokenResponse = await validateAuthorizationCode({
|
|
2194
|
+
code,
|
|
2195
|
+
codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
|
|
2196
|
+
redirectURI: `${ctx.context.baseURL}/sso/callback/${provider.providerId}`,
|
|
2197
|
+
options: {
|
|
2198
|
+
clientId: config.clientId,
|
|
2199
|
+
clientSecret: config.clientSecret
|
|
2200
|
+
},
|
|
2201
|
+
tokenEndpoint: config.tokenEndpoint,
|
|
2202
|
+
authentication: config.tokenEndpointAuthentication === "client_secret_post" ? "post" : "basic"
|
|
2203
|
+
}).catch((e) => {
|
|
2204
|
+
if (e instanceof BetterFetchError) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${e.message}`);
|
|
2205
|
+
return null;
|
|
2206
|
+
});
|
|
2207
|
+
if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=token_response_not_found`);
|
|
2208
|
+
let userInfo = null;
|
|
2209
|
+
if (tokenResponse.idToken) {
|
|
2210
|
+
const idToken = decodeJwt(tokenResponse.idToken);
|
|
2211
|
+
if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=jwks_endpoint_not_found`);
|
|
2212
|
+
const verified = await validateToken(tokenResponse.idToken, config.jwksEndpoint).catch((e) => {
|
|
2213
|
+
ctx.context.logger.error(e);
|
|
2214
|
+
return null;
|
|
2215
|
+
});
|
|
2216
|
+
if (!verified) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=token_not_verified`);
|
|
2217
|
+
if (verified.payload.iss !== provider.issuer) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=issuer_mismatch`);
|
|
2218
|
+
const mapping = config.mapping || {};
|
|
2219
|
+
userInfo = {
|
|
2220
|
+
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, verified.payload[value]])),
|
|
2221
|
+
id: idToken[mapping.id || "sub"],
|
|
2222
|
+
email: idToken[mapping.email || "email"],
|
|
2223
|
+
emailVerified: options?.trustEmailVerified ? idToken[mapping.emailVerified || "email_verified"] : false,
|
|
2224
|
+
name: idToken[mapping.name || "name"],
|
|
2225
|
+
image: idToken[mapping.image || "picture"]
|
|
2226
|
+
};
|
|
2227
|
+
}
|
|
2228
|
+
if (!userInfo) {
|
|
2229
|
+
if (!config.userInfoEndpoint) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=user_info_endpoint_not_found`);
|
|
2230
|
+
const userInfoResponse = await betterFetch(config.userInfoEndpoint, { headers: { Authorization: `Bearer ${tokenResponse.accessToken}` } });
|
|
2231
|
+
if (userInfoResponse.error) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=${userInfoResponse.error.message}`);
|
|
2232
|
+
userInfo = userInfoResponse.data;
|
|
2233
|
+
}
|
|
2234
|
+
if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=missing_user_info`);
|
|
2235
|
+
const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfo.email, provider.domain);
|
|
2236
|
+
const linked = await handleOAuthUserInfo(ctx, {
|
|
2237
|
+
userInfo: {
|
|
2238
|
+
email: userInfo.email,
|
|
2239
|
+
name: userInfo.name || userInfo.email,
|
|
2240
|
+
id: userInfo.id,
|
|
2241
|
+
image: userInfo.image,
|
|
2242
|
+
emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
|
|
2243
|
+
},
|
|
2244
|
+
account: {
|
|
2245
|
+
idToken: tokenResponse.idToken,
|
|
2246
|
+
accessToken: tokenResponse.accessToken,
|
|
2247
|
+
refreshToken: tokenResponse.refreshToken,
|
|
2248
|
+
accountId: userInfo.id,
|
|
2249
|
+
providerId: provider.providerId,
|
|
2250
|
+
accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
|
|
2251
|
+
refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
|
|
2252
|
+
scope: tokenResponse.scopes?.join(",")
|
|
2253
|
+
},
|
|
2254
|
+
callbackURL,
|
|
2255
|
+
disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
|
|
2256
|
+
overrideUserInfo: config.overrideUserInfo,
|
|
2257
|
+
isTrustedProvider
|
|
2258
|
+
});
|
|
2259
|
+
if (linked.error) throw ctx.redirect(`${errorURL || callbackURL}/error?error=${linked.error}`);
|
|
2260
|
+
const { session, user } = linked.data;
|
|
2261
|
+
if (options?.provisionUser) await options.provisionUser({
|
|
2262
|
+
user,
|
|
2263
|
+
userInfo,
|
|
2264
|
+
token: tokenResponse,
|
|
2265
|
+
provider
|
|
2266
|
+
});
|
|
2267
|
+
await assignOrganizationFromProvider(ctx, {
|
|
2268
|
+
user,
|
|
2269
|
+
profile: {
|
|
2270
|
+
providerType: "oidc",
|
|
2271
|
+
providerId: provider.providerId,
|
|
2272
|
+
accountId: userInfo.id,
|
|
2273
|
+
email: userInfo.email,
|
|
2274
|
+
emailVerified: Boolean(userInfo.emailVerified),
|
|
2275
|
+
rawAttributes: userInfo
|
|
2276
|
+
},
|
|
2277
|
+
provider,
|
|
2278
|
+
token: tokenResponse,
|
|
2279
|
+
provisioningOptions: options?.organizationProvisioning
|
|
2280
|
+
});
|
|
2281
|
+
await setSessionCookie(ctx, {
|
|
2282
|
+
session,
|
|
2283
|
+
user
|
|
2284
|
+
});
|
|
2285
|
+
let toRedirectTo;
|
|
2286
|
+
try {
|
|
2287
|
+
toRedirectTo = (linked.isRegister ? newUserURL || callbackURL : callbackURL).toString();
|
|
2288
|
+
} catch {
|
|
2289
|
+
toRedirectTo = linked.isRegister ? newUserURL || callbackURL : callbackURL;
|
|
2290
|
+
}
|
|
2291
|
+
throw ctx.redirect(toRedirectTo);
|
|
2292
|
+
});
|
|
2293
|
+
};
|
|
2294
|
+
const callbackSSOSAMLBodySchema = z.object({
|
|
2295
|
+
SAMLResponse: z.string(),
|
|
2296
|
+
RelayState: z.string().optional()
|
|
2297
|
+
});
|
|
2298
|
+
/**
|
|
2299
|
+
* Validates and returns a safe redirect URL.
|
|
2300
|
+
* - Prevents open redirect attacks by validating against trusted origins
|
|
2301
|
+
* - Prevents redirect loops by checking if URL points to callback route
|
|
2302
|
+
* - Falls back to appOrigin if URL is invalid or unsafe
|
|
2303
|
+
*/
|
|
2304
|
+
const getSafeRedirectUrl = (url, callbackPath, appOrigin, isTrustedOrigin) => {
|
|
2305
|
+
if (!url) return appOrigin;
|
|
2306
|
+
if (url.startsWith("/") && !url.startsWith("//")) {
|
|
2307
|
+
try {
|
|
2308
|
+
const absoluteUrl = new URL(url, appOrigin);
|
|
2309
|
+
if (absoluteUrl.origin !== appOrigin) return appOrigin;
|
|
2310
|
+
const callbackPathname = new URL(callbackPath).pathname;
|
|
2311
|
+
if (absoluteUrl.pathname === callbackPathname) return appOrigin;
|
|
2312
|
+
} catch {
|
|
2313
|
+
return appOrigin;
|
|
2314
|
+
}
|
|
2315
|
+
return url;
|
|
2316
|
+
}
|
|
2317
|
+
if (!isTrustedOrigin(url, { allowRelativePaths: false })) return appOrigin;
|
|
2318
|
+
try {
|
|
2319
|
+
const callbackPathname = new URL(callbackPath).pathname;
|
|
2320
|
+
if (new URL(url).pathname === callbackPathname) return appOrigin;
|
|
2321
|
+
} catch {
|
|
2322
|
+
if (url === callbackPath || url.startsWith(`${callbackPath}?`)) return appOrigin;
|
|
2323
|
+
}
|
|
2324
|
+
return url;
|
|
2325
|
+
};
|
|
2326
|
+
const callbackSSOSAML = (options) => {
|
|
2327
|
+
return createAuthEndpoint("/sso/saml2/callback/:providerId", {
|
|
2328
|
+
method: ["GET", "POST"],
|
|
2329
|
+
body: callbackSSOSAMLBodySchema.optional(),
|
|
2330
|
+
query: z.object({ RelayState: z.string().optional() }).optional(),
|
|
2331
|
+
metadata: {
|
|
2332
|
+
...HIDE_METADATA,
|
|
2333
|
+
allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
|
|
2334
|
+
openapi: {
|
|
2335
|
+
operationId: "handleSAMLCallback",
|
|
2336
|
+
summary: "Callback URL for SAML provider",
|
|
2337
|
+
description: "This endpoint is used as the callback URL for SAML providers. Supports both GET and POST methods for IdP-initiated and SP-initiated flows.",
|
|
2338
|
+
responses: {
|
|
2339
|
+
"302": { description: "Redirects to the callback URL" },
|
|
2340
|
+
"400": { description: "Invalid SAML response" },
|
|
2341
|
+
"401": { description: "Unauthorized - SAML authentication failed" }
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
}, async (ctx) => {
|
|
2346
|
+
const { providerId } = ctx.params;
|
|
2347
|
+
const appOrigin = new URL(ctx.context.baseURL).origin;
|
|
2348
|
+
const errorURL = ctx.context.options.onAPIError?.errorURL || `${appOrigin}/error`;
|
|
2349
|
+
const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/callback/${providerId}`;
|
|
2350
|
+
if (ctx.method === "GET" && !ctx.body?.SAMLResponse) {
|
|
2351
|
+
if (!(await getSessionFromCtx(ctx))?.session) throw ctx.redirect(`${errorURL}?error=invalid_request`);
|
|
2352
|
+
const relayState = ctx.query?.RelayState;
|
|
2353
|
+
const safeRedirectUrl = getSafeRedirectUrl(relayState, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
2354
|
+
throw ctx.redirect(safeRedirectUrl);
|
|
2355
|
+
}
|
|
2356
|
+
if (!ctx.body?.SAMLResponse) throw new APIError("BAD_REQUEST", { message: "SAMLResponse is required for POST requests" });
|
|
2357
|
+
const { SAMLResponse } = ctx.body;
|
|
2358
|
+
const maxResponseSize = options?.saml?.maxResponseSize ?? DEFAULT_MAX_SAML_RESPONSE_SIZE;
|
|
2359
|
+
if (new TextEncoder().encode(SAMLResponse).length > maxResponseSize) throw new APIError("BAD_REQUEST", { message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)` });
|
|
2360
|
+
let relayState = null;
|
|
2361
|
+
if (ctx.body.RelayState) try {
|
|
2362
|
+
relayState = await parseRelayState(ctx);
|
|
2363
|
+
} catch {
|
|
2364
|
+
relayState = null;
|
|
2365
|
+
}
|
|
2366
|
+
let provider = null;
|
|
2367
|
+
if (options?.defaultSSO?.length) {
|
|
2368
|
+
const matchingDefault = options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId);
|
|
2369
|
+
if (matchingDefault) provider = {
|
|
2370
|
+
...matchingDefault,
|
|
2371
|
+
userId: "default",
|
|
2372
|
+
issuer: matchingDefault.samlConfig?.issuer || "",
|
|
2373
|
+
...options.domainVerification?.enabled ? { domainVerified: true } : {}
|
|
2374
|
+
};
|
|
2375
|
+
}
|
|
2376
|
+
if (!provider) provider = await ctx.context.adapter.findOne({
|
|
2377
|
+
model: "ssoProvider",
|
|
2378
|
+
where: [{
|
|
2379
|
+
field: "providerId",
|
|
2380
|
+
value: providerId
|
|
2381
|
+
}]
|
|
2382
|
+
}).then((res) => {
|
|
2383
|
+
if (!res) return null;
|
|
2384
|
+
return {
|
|
2385
|
+
...res,
|
|
2386
|
+
samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
|
|
2387
|
+
};
|
|
2388
|
+
});
|
|
2389
|
+
if (!provider) throw new APIError("NOT_FOUND", { message: "No provider found for the given providerId" });
|
|
2390
|
+
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
2391
|
+
const parsedSamlConfig = safeJsonParse(provider.samlConfig);
|
|
2392
|
+
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
2393
|
+
const idpData = parsedSamlConfig.idpMetadata;
|
|
2394
|
+
let idp = null;
|
|
2395
|
+
if (!idpData?.metadata) idp = saml.IdentityProvider({
|
|
2396
|
+
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
|
2397
|
+
singleSignOnService: [{
|
|
2398
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
2399
|
+
Location: parsedSamlConfig.entryPoint
|
|
2400
|
+
}],
|
|
2401
|
+
signingCert: idpData?.cert || parsedSamlConfig.cert,
|
|
2402
|
+
wantAuthnRequestsSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
2403
|
+
isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
|
|
2404
|
+
encPrivateKey: idpData?.encPrivateKey,
|
|
2405
|
+
encPrivateKeyPass: idpData?.encPrivateKeyPass
|
|
2406
|
+
});
|
|
2407
|
+
else idp = saml.IdentityProvider({
|
|
2408
|
+
metadata: idpData.metadata,
|
|
2409
|
+
privateKey: idpData.privateKey,
|
|
2410
|
+
privateKeyPass: idpData.privateKeyPass,
|
|
2411
|
+
isAssertionEncrypted: idpData.isAssertionEncrypted,
|
|
2412
|
+
encPrivateKey: idpData.encPrivateKey,
|
|
2413
|
+
encPrivateKeyPass: idpData.encPrivateKeyPass
|
|
2414
|
+
});
|
|
2415
|
+
const spData = parsedSamlConfig.spMetadata;
|
|
2416
|
+
const sp = saml.ServiceProvider({
|
|
2417
|
+
metadata: spData?.metadata,
|
|
2418
|
+
entityID: spData?.entityID || parsedSamlConfig.issuer,
|
|
2419
|
+
assertionConsumerService: spData?.metadata ? void 0 : [{
|
|
2420
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
2421
|
+
Location: parsedSamlConfig.callbackUrl
|
|
2422
|
+
}],
|
|
2423
|
+
privateKey: spData?.privateKey || parsedSamlConfig.privateKey,
|
|
2424
|
+
privateKeyPass: spData?.privateKeyPass,
|
|
2425
|
+
isAssertionEncrypted: spData?.isAssertionEncrypted || false,
|
|
2426
|
+
encPrivateKey: spData?.encPrivateKey,
|
|
2427
|
+
encPrivateKeyPass: spData?.encPrivateKeyPass,
|
|
2428
|
+
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
2429
|
+
nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
|
|
2430
|
+
});
|
|
2431
|
+
validateSingleAssertion(SAMLResponse);
|
|
2432
|
+
let parsedResponse;
|
|
2433
|
+
try {
|
|
2434
|
+
parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
|
|
2435
|
+
SAMLResponse,
|
|
2436
|
+
RelayState: ctx.body.RelayState || void 0
|
|
2437
|
+
} });
|
|
2438
|
+
if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
|
|
2439
|
+
} catch (error) {
|
|
2440
|
+
ctx.context.logger.error("SAML response validation failed", {
|
|
2441
|
+
error,
|
|
2442
|
+
decodedResponse: Buffer.from(SAMLResponse, "base64").toString("utf-8")
|
|
2443
|
+
});
|
|
2444
|
+
throw new APIError("BAD_REQUEST", {
|
|
2445
|
+
message: "Invalid SAML response",
|
|
2446
|
+
details: error instanceof Error ? error.message : String(error)
|
|
2447
|
+
});
|
|
2448
|
+
}
|
|
2449
|
+
const { extract } = parsedResponse;
|
|
2450
|
+
validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
|
|
2451
|
+
validateSAMLTimestamp(extract.conditions, {
|
|
2452
|
+
clockSkew: options?.saml?.clockSkew,
|
|
2453
|
+
requireTimestamps: options?.saml?.requireTimestamps,
|
|
2454
|
+
logger: ctx.context.logger
|
|
2455
|
+
});
|
|
2456
|
+
const inResponseTo = extract.inResponseTo;
|
|
2457
|
+
if (options?.saml?.enableInResponseToValidation) {
|
|
2458
|
+
const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
|
|
2459
|
+
if (inResponseTo) {
|
|
2460
|
+
let storedRequest = null;
|
|
2461
|
+
const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
2462
|
+
if (verification) try {
|
|
2463
|
+
storedRequest = JSON.parse(verification.value);
|
|
2464
|
+
if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
|
|
2465
|
+
} catch {
|
|
2466
|
+
storedRequest = null;
|
|
2467
|
+
}
|
|
2468
|
+
if (!storedRequest) {
|
|
2469
|
+
ctx.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
|
|
2470
|
+
inResponseTo,
|
|
2471
|
+
providerId: provider.providerId
|
|
2472
|
+
});
|
|
2473
|
+
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2474
|
+
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
|
|
2475
|
+
}
|
|
2476
|
+
if (storedRequest.providerId !== provider.providerId) {
|
|
2477
|
+
ctx.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
|
|
2478
|
+
inResponseTo,
|
|
2479
|
+
expectedProvider: storedRequest.providerId,
|
|
2480
|
+
actualProvider: provider.providerId
|
|
2481
|
+
});
|
|
2482
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
2483
|
+
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2484
|
+
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
|
|
2485
|
+
}
|
|
2486
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
2487
|
+
} else if (!allowIdpInitiated) {
|
|
2488
|
+
ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: provider.providerId });
|
|
2489
|
+
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2490
|
+
throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
const samlContent = parsedResponse.samlContent;
|
|
2494
|
+
const assertionId = samlContent ? extractAssertionId(samlContent) : null;
|
|
2495
|
+
if (assertionId) {
|
|
2496
|
+
const issuer = idp.entityMeta.getEntityID();
|
|
2497
|
+
const conditions = extract.conditions;
|
|
2498
|
+
const clockSkew = options?.saml?.clockSkew ?? DEFAULT_CLOCK_SKEW_MS;
|
|
2499
|
+
const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
|
|
2500
|
+
const existingAssertion = await ctx.context.internalAdapter.findVerificationValue(`${USED_ASSERTION_KEY_PREFIX}${assertionId}`);
|
|
2501
|
+
let isReplay = false;
|
|
2502
|
+
if (existingAssertion) try {
|
|
2503
|
+
if (JSON.parse(existingAssertion.value).expiresAt >= Date.now()) isReplay = true;
|
|
2504
|
+
} catch (error) {
|
|
2505
|
+
ctx.context.logger.warn("Failed to parse stored assertion record", {
|
|
2506
|
+
assertionId,
|
|
2507
|
+
error
|
|
2508
|
+
});
|
|
2509
|
+
}
|
|
2510
|
+
if (isReplay) {
|
|
2511
|
+
ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
|
|
2512
|
+
assertionId,
|
|
2513
|
+
issuer,
|
|
2514
|
+
providerId: provider.providerId
|
|
2515
|
+
});
|
|
2516
|
+
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2517
|
+
throw ctx.redirect(`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
|
|
2518
|
+
}
|
|
2519
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
2520
|
+
identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
|
|
2521
|
+
value: JSON.stringify({
|
|
2522
|
+
assertionId,
|
|
2523
|
+
issuer,
|
|
2524
|
+
providerId: provider.providerId,
|
|
2525
|
+
usedAt: Date.now(),
|
|
2526
|
+
expiresAt
|
|
2527
|
+
}),
|
|
2528
|
+
expiresAt: new Date(expiresAt)
|
|
2529
|
+
});
|
|
2530
|
+
} else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId: provider.providerId });
|
|
2531
|
+
const attributes = extract.attributes || {};
|
|
2532
|
+
const mapping = parsedSamlConfig.mapping ?? {};
|
|
2533
|
+
const userInfo = {
|
|
2534
|
+
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
|
|
2535
|
+
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
2536
|
+
email: (attributes[mapping.email || "email"] || extract.nameID).toLowerCase(),
|
|
2537
|
+
name: [attributes[mapping.firstName || "givenName"], attributes[mapping.lastName || "surname"]].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
|
|
2538
|
+
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
|
|
2539
|
+
};
|
|
2540
|
+
if (!userInfo.id || !userInfo.email) {
|
|
2541
|
+
ctx.context.logger.error("Missing essential user info from SAML response", {
|
|
2542
|
+
attributes: Object.keys(attributes),
|
|
2543
|
+
mapping,
|
|
2544
|
+
extractedId: userInfo.id,
|
|
2545
|
+
extractedEmail: userInfo.email
|
|
2546
|
+
});
|
|
2547
|
+
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
2548
|
+
}
|
|
2549
|
+
const isTrustedProvider = !!ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
2550
|
+
const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2551
|
+
const result = await handleOAuthUserInfo(ctx, {
|
|
2552
|
+
userInfo: {
|
|
2553
|
+
email: userInfo.email,
|
|
2554
|
+
name: userInfo.name || userInfo.email,
|
|
2555
|
+
id: userInfo.id,
|
|
2556
|
+
emailVerified: Boolean(userInfo.emailVerified)
|
|
2557
|
+
},
|
|
2558
|
+
account: {
|
|
2559
|
+
providerId: provider.providerId,
|
|
2560
|
+
accountId: userInfo.id,
|
|
2561
|
+
accessToken: "",
|
|
2562
|
+
refreshToken: ""
|
|
2563
|
+
},
|
|
2564
|
+
callbackURL: callbackUrl,
|
|
2565
|
+
disableSignUp: options?.disableImplicitSignUp,
|
|
2566
|
+
isTrustedProvider
|
|
2567
|
+
});
|
|
2568
|
+
if (result.error) throw ctx.redirect(`${callbackUrl}?error=${result.error.split(" ").join("_")}`);
|
|
2569
|
+
const { session, user } = result.data;
|
|
2570
|
+
if (options?.provisionUser) await options.provisionUser({
|
|
2571
|
+
user,
|
|
2572
|
+
userInfo,
|
|
2573
|
+
provider
|
|
2574
|
+
});
|
|
2575
|
+
await assignOrganizationFromProvider(ctx, {
|
|
2576
|
+
user,
|
|
2577
|
+
profile: {
|
|
2578
|
+
providerType: "saml",
|
|
2579
|
+
providerId: provider.providerId,
|
|
2580
|
+
accountId: userInfo.id,
|
|
2581
|
+
email: userInfo.email,
|
|
2582
|
+
emailVerified: Boolean(userInfo.emailVerified),
|
|
2583
|
+
rawAttributes: attributes
|
|
2584
|
+
},
|
|
2585
|
+
provider,
|
|
2586
|
+
provisioningOptions: options?.organizationProvisioning
|
|
2587
|
+
});
|
|
2588
|
+
await setSessionCookie(ctx, {
|
|
2589
|
+
session,
|
|
2590
|
+
user
|
|
2591
|
+
});
|
|
2592
|
+
const safeRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
2593
|
+
throw ctx.redirect(safeRedirectUrl);
|
|
2594
|
+
});
|
|
2595
|
+
};
|
|
2596
|
+
const acsEndpointBodySchema = z.object({
|
|
2597
|
+
SAMLResponse: z.string(),
|
|
2598
|
+
RelayState: z.string().optional()
|
|
2599
|
+
});
|
|
2600
|
+
const acsEndpoint = (options) => {
|
|
2601
|
+
return createAuthEndpoint("/sso/saml2/sp/acs/:providerId", {
|
|
2602
|
+
method: "POST",
|
|
2603
|
+
body: acsEndpointBodySchema,
|
|
2604
|
+
metadata: {
|
|
2605
|
+
...HIDE_METADATA,
|
|
2606
|
+
allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
|
|
2607
|
+
openapi: {
|
|
2608
|
+
operationId: "handleSAMLAssertionConsumerService",
|
|
2609
|
+
summary: "SAML Assertion Consumer Service",
|
|
2610
|
+
description: "Handles SAML responses from IdP after successful authentication",
|
|
2611
|
+
responses: { "302": { description: "Redirects to the callback URL after successful authentication" } }
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
}, async (ctx) => {
|
|
2615
|
+
const { SAMLResponse, RelayState = "" } = ctx.body;
|
|
2616
|
+
const { providerId } = ctx.params;
|
|
2617
|
+
const maxResponseSize = options?.saml?.maxResponseSize ?? DEFAULT_MAX_SAML_RESPONSE_SIZE;
|
|
2618
|
+
if (new TextEncoder().encode(SAMLResponse).length > maxResponseSize) throw new APIError("BAD_REQUEST", { message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)` });
|
|
2619
|
+
let provider = null;
|
|
2620
|
+
if (options?.defaultSSO?.length) {
|
|
2621
|
+
const matchingDefault = providerId ? options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId) : options.defaultSSO[0];
|
|
2622
|
+
if (matchingDefault) provider = {
|
|
2623
|
+
issuer: matchingDefault.samlConfig?.issuer || "",
|
|
2624
|
+
providerId: matchingDefault.providerId,
|
|
2625
|
+
userId: "default",
|
|
2626
|
+
samlConfig: matchingDefault.samlConfig,
|
|
2627
|
+
domain: matchingDefault.domain,
|
|
2628
|
+
...options.domainVerification?.enabled ? { domainVerified: true } : {}
|
|
2629
|
+
};
|
|
2630
|
+
} else provider = await ctx.context.adapter.findOne({
|
|
2631
|
+
model: "ssoProvider",
|
|
2632
|
+
where: [{
|
|
2633
|
+
field: "providerId",
|
|
2634
|
+
value: providerId
|
|
2635
|
+
}]
|
|
2636
|
+
}).then((res) => {
|
|
2637
|
+
if (!res) return null;
|
|
2638
|
+
return {
|
|
2639
|
+
...res,
|
|
2640
|
+
samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
|
|
2641
|
+
};
|
|
2642
|
+
});
|
|
2643
|
+
if (!provider?.samlConfig) throw new APIError("NOT_FOUND", { message: "No SAML provider found" });
|
|
2644
|
+
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
2645
|
+
const parsedSamlConfig = provider.samlConfig;
|
|
2646
|
+
const sp = saml.ServiceProvider({
|
|
2647
|
+
entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
|
2648
|
+
assertionConsumerService: [{
|
|
2649
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
2650
|
+
Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`
|
|
2651
|
+
}],
|
|
2652
|
+
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
2653
|
+
metadata: parsedSamlConfig.spMetadata?.metadata,
|
|
2654
|
+
privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
|
|
2655
|
+
privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
|
|
2656
|
+
nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
|
|
2657
|
+
});
|
|
2658
|
+
const idpData = parsedSamlConfig.idpMetadata;
|
|
2659
|
+
const idp = !idpData?.metadata ? saml.IdentityProvider({
|
|
2660
|
+
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
|
2661
|
+
singleSignOnService: idpData?.singleSignOnService || [{
|
|
2662
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
2663
|
+
Location: parsedSamlConfig.entryPoint
|
|
2664
|
+
}],
|
|
2665
|
+
signingCert: idpData?.cert || parsedSamlConfig.cert
|
|
2666
|
+
}) : saml.IdentityProvider({ metadata: idpData.metadata });
|
|
2667
|
+
try {
|
|
2668
|
+
validateSingleAssertion(SAMLResponse);
|
|
2669
|
+
} catch (error) {
|
|
2670
|
+
if (error instanceof APIError) {
|
|
2671
|
+
const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2672
|
+
const errorCode = error.body?.code === "SAML_MULTIPLE_ASSERTIONS" ? "multiple_assertions" : "no_assertion";
|
|
2673
|
+
throw ctx.redirect(`${redirectUrl}?error=${errorCode}&error_description=${encodeURIComponent(error.message)}`);
|
|
2674
|
+
}
|
|
2675
|
+
throw error;
|
|
2676
|
+
}
|
|
2677
|
+
let parsedResponse;
|
|
2678
|
+
try {
|
|
2679
|
+
parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
|
|
2680
|
+
SAMLResponse,
|
|
2681
|
+
RelayState: RelayState || void 0
|
|
2682
|
+
} });
|
|
2683
|
+
if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
|
|
2684
|
+
} catch (error) {
|
|
2685
|
+
ctx.context.logger.error("SAML response validation failed", {
|
|
2686
|
+
error,
|
|
2687
|
+
decodedResponse: Buffer.from(SAMLResponse, "base64").toString("utf-8")
|
|
2688
|
+
});
|
|
2689
|
+
throw new APIError("BAD_REQUEST", {
|
|
2690
|
+
message: "Invalid SAML response",
|
|
2691
|
+
details: error instanceof Error ? error.message : String(error)
|
|
2692
|
+
});
|
|
2693
|
+
}
|
|
2694
|
+
const { extract } = parsedResponse;
|
|
2695
|
+
validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
|
|
2696
|
+
validateSAMLTimestamp(extract.conditions, {
|
|
2697
|
+
clockSkew: options?.saml?.clockSkew,
|
|
2698
|
+
requireTimestamps: options?.saml?.requireTimestamps,
|
|
2699
|
+
logger: ctx.context.logger
|
|
2700
|
+
});
|
|
2701
|
+
const inResponseToAcs = extract.inResponseTo;
|
|
2702
|
+
if (options?.saml?.enableInResponseToValidation) {
|
|
2703
|
+
const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
|
|
2704
|
+
if (inResponseToAcs) {
|
|
2705
|
+
let storedRequest = null;
|
|
2706
|
+
const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
|
|
2707
|
+
if (verification) try {
|
|
2708
|
+
storedRequest = JSON.parse(verification.value);
|
|
2709
|
+
if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
|
|
2710
|
+
} catch {
|
|
2711
|
+
storedRequest = null;
|
|
2712
|
+
}
|
|
2713
|
+
if (!storedRequest) {
|
|
2714
|
+
ctx.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
|
|
2715
|
+
inResponseTo: inResponseToAcs,
|
|
2716
|
+
providerId
|
|
2717
|
+
});
|
|
2718
|
+
const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2719
|
+
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
|
|
2720
|
+
}
|
|
2721
|
+
if (storedRequest.providerId !== providerId) {
|
|
2722
|
+
ctx.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
|
|
2723
|
+
inResponseTo: inResponseToAcs,
|
|
2724
|
+
expectedProvider: storedRequest.providerId,
|
|
2725
|
+
actualProvider: providerId
|
|
2726
|
+
});
|
|
2727
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
|
|
2728
|
+
const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2729
|
+
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
|
|
2730
|
+
}
|
|
2731
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
|
|
2732
|
+
} else if (!allowIdpInitiated) {
|
|
2733
|
+
ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId });
|
|
2734
|
+
const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2735
|
+
throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
const assertionIdAcs = extractAssertionId(Buffer.from(SAMLResponse, "base64").toString("utf-8"));
|
|
2739
|
+
if (assertionIdAcs) {
|
|
2740
|
+
const issuer = idp.entityMeta.getEntityID();
|
|
2741
|
+
const conditions = extract.conditions;
|
|
2742
|
+
const clockSkew = options?.saml?.clockSkew ?? DEFAULT_CLOCK_SKEW_MS;
|
|
2743
|
+
const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
|
|
2744
|
+
const existingAssertion = await ctx.context.internalAdapter.findVerificationValue(`${USED_ASSERTION_KEY_PREFIX}${assertionIdAcs}`);
|
|
2745
|
+
let isReplay = false;
|
|
2746
|
+
if (existingAssertion) try {
|
|
2747
|
+
if (JSON.parse(existingAssertion.value).expiresAt >= Date.now()) isReplay = true;
|
|
2748
|
+
} catch (error) {
|
|
2749
|
+
ctx.context.logger.warn("Failed to parse stored assertion record", {
|
|
2750
|
+
assertionId: assertionIdAcs,
|
|
2751
|
+
error
|
|
2752
|
+
});
|
|
2753
|
+
}
|
|
2754
|
+
if (isReplay) {
|
|
2755
|
+
ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
|
|
2756
|
+
assertionId: assertionIdAcs,
|
|
2757
|
+
issuer,
|
|
2758
|
+
providerId
|
|
2759
|
+
});
|
|
2760
|
+
const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2761
|
+
throw ctx.redirect(`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
|
|
2762
|
+
}
|
|
2763
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
2764
|
+
identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionIdAcs}`,
|
|
2765
|
+
value: JSON.stringify({
|
|
2766
|
+
assertionId: assertionIdAcs,
|
|
2767
|
+
issuer,
|
|
2768
|
+
providerId,
|
|
2769
|
+
usedAt: Date.now(),
|
|
2770
|
+
expiresAt
|
|
2771
|
+
}),
|
|
2772
|
+
expiresAt: new Date(expiresAt)
|
|
2773
|
+
});
|
|
2774
|
+
} else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
|
|
2775
|
+
const attributes = extract.attributes || {};
|
|
2776
|
+
const mapping = parsedSamlConfig.mapping ?? {};
|
|
2777
|
+
const userInfo = {
|
|
2778
|
+
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
|
|
2779
|
+
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
2780
|
+
email: (attributes[mapping.email || "email"] || extract.nameID).toLowerCase(),
|
|
2781
|
+
name: [attributes[mapping.firstName || "givenName"], attributes[mapping.lastName || "surname"]].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
|
|
2782
|
+
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
|
|
2783
|
+
};
|
|
2784
|
+
if (!userInfo.id || !userInfo.email) {
|
|
2785
|
+
ctx.context.logger.error("Missing essential user info from SAML response", {
|
|
2786
|
+
attributes: Object.keys(attributes),
|
|
2787
|
+
mapping,
|
|
2788
|
+
extractedId: userInfo.id,
|
|
2789
|
+
extractedEmail: userInfo.email
|
|
2790
|
+
});
|
|
2791
|
+
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
2792
|
+
}
|
|
2793
|
+
const isTrustedProvider = !!ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
2794
|
+
const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2795
|
+
const result = await handleOAuthUserInfo(ctx, {
|
|
2796
|
+
userInfo: {
|
|
2797
|
+
email: userInfo.email,
|
|
2798
|
+
name: userInfo.name || userInfo.email,
|
|
2799
|
+
id: userInfo.id,
|
|
2800
|
+
emailVerified: Boolean(userInfo.emailVerified)
|
|
2801
|
+
},
|
|
2802
|
+
account: {
|
|
2803
|
+
providerId: provider.providerId,
|
|
2804
|
+
accountId: userInfo.id,
|
|
2805
|
+
accessToken: "",
|
|
2806
|
+
refreshToken: ""
|
|
2807
|
+
},
|
|
2808
|
+
callbackURL: callbackUrl,
|
|
2809
|
+
disableSignUp: options?.disableImplicitSignUp,
|
|
2810
|
+
isTrustedProvider
|
|
2811
|
+
});
|
|
2812
|
+
if (result.error) throw ctx.redirect(`${callbackUrl}?error=${result.error.split(" ").join("_")}`);
|
|
2813
|
+
const { session, user } = result.data;
|
|
2814
|
+
if (options?.provisionUser) await options.provisionUser({
|
|
2815
|
+
user,
|
|
2816
|
+
userInfo,
|
|
2817
|
+
provider
|
|
2818
|
+
});
|
|
2819
|
+
await assignOrganizationFromProvider(ctx, {
|
|
2820
|
+
user,
|
|
2821
|
+
profile: {
|
|
2822
|
+
providerType: "saml",
|
|
2823
|
+
providerId: provider.providerId,
|
|
2824
|
+
accountId: userInfo.id,
|
|
2825
|
+
email: userInfo.email,
|
|
2826
|
+
emailVerified: Boolean(userInfo.emailVerified),
|
|
2827
|
+
rawAttributes: attributes
|
|
2828
|
+
},
|
|
2829
|
+
provider,
|
|
2830
|
+
provisioningOptions: options?.organizationProvisioning
|
|
2831
|
+
});
|
|
2832
|
+
await setSessionCookie(ctx, {
|
|
2833
|
+
session,
|
|
2834
|
+
user
|
|
2835
|
+
});
|
|
2836
|
+
throw ctx.redirect(callbackUrl);
|
|
2837
|
+
});
|
|
2838
|
+
};
|
|
2839
|
+
|
|
2840
|
+
//#endregion
|
|
2841
|
+
//#region src/index.ts
|
|
2842
|
+
saml.setSchemaValidator({ async validate(xml) {
|
|
2843
|
+
if (XMLValidator.validate(xml, { allowBooleanAttributes: true }) === true) return "SUCCESS_VALIDATE_XML";
|
|
2844
|
+
throw "ERR_INVALID_XML";
|
|
2845
|
+
} });
|
|
2846
|
+
/**
|
|
2847
|
+
* SAML endpoint paths that should skip origin check validation.
|
|
2848
|
+
* These endpoints receive POST requests from external Identity Providers,
|
|
2849
|
+
* which won't have a matching Origin header.
|
|
2850
|
+
*/
|
|
2851
|
+
const SAML_SKIP_ORIGIN_CHECK_PATHS = ["/sso/saml2/callback", "/sso/saml2/sp/acs"];
|
|
2852
|
+
function sso(options) {
|
|
2853
|
+
const optionsWithStore = options;
|
|
2854
|
+
let endpoints = {
|
|
2855
|
+
spMetadata: spMetadata(),
|
|
2856
|
+
registerSSOProvider: registerSSOProvider(optionsWithStore),
|
|
2857
|
+
signInSSO: signInSSO(optionsWithStore),
|
|
2858
|
+
callbackSSO: callbackSSO(optionsWithStore),
|
|
2859
|
+
callbackSSOSAML: callbackSSOSAML(optionsWithStore),
|
|
2860
|
+
acsEndpoint: acsEndpoint(optionsWithStore),
|
|
2861
|
+
listSSOProviders: listSSOProviders(),
|
|
2862
|
+
getSSOProvider: getSSOProvider(),
|
|
2863
|
+
updateSSOProvider: updateSSOProvider(optionsWithStore),
|
|
2864
|
+
deleteSSOProvider: deleteSSOProvider()
|
|
2865
|
+
};
|
|
2866
|
+
if (options?.domainVerification?.enabled) {
|
|
2867
|
+
const domainVerificationEndpoints = {
|
|
2868
|
+
requestDomainVerification: requestDomainVerification(optionsWithStore),
|
|
2869
|
+
verifyDomain: verifyDomain(optionsWithStore)
|
|
2870
|
+
};
|
|
2871
|
+
endpoints = {
|
|
2872
|
+
...endpoints,
|
|
2873
|
+
...domainVerificationEndpoints
|
|
2874
|
+
};
|
|
2875
|
+
}
|
|
2876
|
+
return {
|
|
2877
|
+
id: "sso",
|
|
2878
|
+
init(ctx) {
|
|
2879
|
+
const existing = ctx.skipOriginCheck;
|
|
2880
|
+
if (existing === true) return {};
|
|
2881
|
+
return { context: { skipOriginCheck: [...Array.isArray(existing) ? existing : [], ...SAML_SKIP_ORIGIN_CHECK_PATHS] } };
|
|
2882
|
+
},
|
|
2883
|
+
endpoints,
|
|
2884
|
+
hooks: { after: [{
|
|
2885
|
+
matcher(context) {
|
|
2886
|
+
return context.path?.startsWith("/callback/") ?? false;
|
|
2887
|
+
},
|
|
2888
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
2889
|
+
const newSession = ctx.context.newSession;
|
|
2890
|
+
if (!newSession?.user) return;
|
|
2891
|
+
if (!ctx.context.hasPlugin("organization")) return;
|
|
2892
|
+
await assignOrganizationByDomain(ctx, {
|
|
2893
|
+
user: newSession.user,
|
|
2894
|
+
provisioningOptions: options?.organizationProvisioning,
|
|
2895
|
+
domainVerification: options?.domainVerification
|
|
2896
|
+
});
|
|
2897
|
+
})
|
|
2898
|
+
}] },
|
|
2899
|
+
schema: { ssoProvider: {
|
|
2900
|
+
modelName: options?.modelName ?? "ssoProvider",
|
|
2901
|
+
fields: {
|
|
2902
|
+
issuer: {
|
|
2903
|
+
type: "string",
|
|
2904
|
+
required: true,
|
|
2905
|
+
fieldName: options?.fields?.issuer ?? "issuer"
|
|
2906
|
+
},
|
|
2907
|
+
oidcConfig: {
|
|
2908
|
+
type: "string",
|
|
2909
|
+
required: false,
|
|
2910
|
+
fieldName: options?.fields?.oidcConfig ?? "oidcConfig"
|
|
2911
|
+
},
|
|
2912
|
+
samlConfig: {
|
|
2913
|
+
type: "string",
|
|
2914
|
+
required: false,
|
|
2915
|
+
fieldName: options?.fields?.samlConfig ?? "samlConfig"
|
|
2916
|
+
},
|
|
2917
|
+
userId: {
|
|
2918
|
+
type: "string",
|
|
2919
|
+
references: {
|
|
2920
|
+
model: "user",
|
|
2921
|
+
field: "id"
|
|
2922
|
+
},
|
|
2923
|
+
fieldName: options?.fields?.userId ?? "userId"
|
|
2924
|
+
},
|
|
2925
|
+
providerId: {
|
|
2926
|
+
type: "string",
|
|
2927
|
+
required: true,
|
|
2928
|
+
unique: true,
|
|
2929
|
+
fieldName: options?.fields?.providerId ?? "providerId"
|
|
2930
|
+
},
|
|
2931
|
+
organizationId: {
|
|
2932
|
+
type: "string",
|
|
2933
|
+
required: false,
|
|
2934
|
+
fieldName: options?.fields?.organizationId ?? "organizationId"
|
|
2935
|
+
},
|
|
2936
|
+
domain: {
|
|
2937
|
+
type: "string",
|
|
2938
|
+
required: true,
|
|
2939
|
+
fieldName: options?.fields?.domain ?? "domain"
|
|
2940
|
+
},
|
|
2941
|
+
...options?.domainVerification?.enabled ? { domainVerified: {
|
|
2942
|
+
type: "boolean",
|
|
2943
|
+
required: false
|
|
2944
|
+
} } : {}
|
|
2945
|
+
}
|
|
2946
|
+
} },
|
|
2947
|
+
options
|
|
2948
|
+
};
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
//#endregion
|
|
2952
|
+
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 };
|
|
2953
|
+
//# sourceMappingURL=index.mjs.map
|