@startino/better-auth-oidc 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1492 @@
1
+ import { APIError, createAuthEndpoint, createAuthMiddleware, sessionMiddleware } from "better-auth/api";
2
+ import { generateRandomString } from "better-auth/crypto";
3
+ import * as z$1 from "zod/v4";
4
+ import z from "zod/v4";
5
+ import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
6
+ import { HIDE_METADATA, createAuthorizationURL, generateState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
7
+ import { setSessionCookie } from "better-auth/cookies";
8
+ import { handleOAuthUserInfo } from "better-auth/oauth2";
9
+ import { decodeJwt } from "jose";
10
+
11
+ //#region src/utils.ts
12
+ /**
13
+ * Safely parses a value that might be a JSON string or already a parsed object.
14
+ * This handles cases where ORMs like Drizzle might return already parsed objects
15
+ * instead of JSON strings from TEXT/JSON columns.
16
+ *
17
+ * @param value - The value to parse (string, object, null, or undefined)
18
+ * @returns The parsed object or null
19
+ * @throws Error if string parsing fails
20
+ */
21
+ function safeJsonParse(value) {
22
+ if (!value) return null;
23
+ if (typeof value === "object") return value;
24
+ if (typeof value === "string") try {
25
+ return JSON.parse(value);
26
+ } catch (error) {
27
+ throw new Error(`Failed to parse JSON: ${error instanceof Error ? error.message : "Unknown error"}`);
28
+ }
29
+ return null;
30
+ }
31
+ /**
32
+ * Checks if a domain matches any domain in a comma-separated list.
33
+ */
34
+ const domainMatches = (searchDomain, domainList) => {
35
+ const search = searchDomain.toLowerCase();
36
+ return domainList.split(",").map((d) => d.trim().toLowerCase()).filter(Boolean).some((d) => search === d || search.endsWith(`.${d}`));
37
+ };
38
+ /**
39
+ * Validates email domain against allowed domain(s).
40
+ * Supports comma-separated domains for multi-domain SSO.
41
+ */
42
+ const validateEmailDomain = (email, domain) => {
43
+ const emailDomain = email.split("@")[1]?.toLowerCase();
44
+ if (!emailDomain || !domain) return false;
45
+ return domainMatches(emailDomain, domain);
46
+ };
47
+ function maskClientId(clientId) {
48
+ if (clientId.length <= 4) return "****";
49
+ return `****${clientId.slice(-4)}`;
50
+ }
51
+
52
+ //#endregion
53
+ //#region src/linking/org-assignment.ts
54
+ /**
55
+ * Assigns a user to an organization based on the SSO provider's organizationId.
56
+ * Used in SSO flows (OIDC, SAML) where the provider is already linked to an org.
57
+ */
58
+ async function assignOrganizationFromProvider(ctx, options) {
59
+ const { user, profile, provider, token, provisioningOptions } = options;
60
+ if (!provider.organizationId) return;
61
+ if (provisioningOptions?.disabled) return;
62
+ if (!ctx.context.hasPlugin("organization")) return;
63
+ if (await ctx.context.adapter.findOne({
64
+ model: "member",
65
+ where: [{
66
+ field: "organizationId",
67
+ value: provider.organizationId
68
+ }, {
69
+ field: "userId",
70
+ value: user.id
71
+ }]
72
+ })) return;
73
+ const role = provisioningOptions?.getRole ? await provisioningOptions.getRole({
74
+ user,
75
+ userInfo: profile.rawAttributes || {},
76
+ token,
77
+ provider
78
+ }) : provisioningOptions?.defaultRole || "member";
79
+ await ctx.context.adapter.create({
80
+ model: "member",
81
+ data: {
82
+ organizationId: provider.organizationId,
83
+ userId: user.id,
84
+ role,
85
+ createdAt: /* @__PURE__ */ new Date()
86
+ }
87
+ });
88
+ }
89
+ /**
90
+ * Assigns a user to an organization based on their email domain.
91
+ * Looks up SSO providers that match the user's email domain and assigns
92
+ * the user to the associated organization.
93
+ *
94
+ * This enables domain-based org assignment for non-SSO sign-in methods
95
+ * (e.g., Google OAuth with @acme.com email gets added to Acme's org).
96
+ */
97
+ async function assignOrganizationByDomain(ctx, options) {
98
+ const { user, provisioningOptions, domainVerification } = options;
99
+ if (provisioningOptions?.disabled) return;
100
+ if (!ctx.context.hasPlugin("organization")) return;
101
+ const domain = user.email.split("@")[1];
102
+ if (!domain) return;
103
+ const whereClause = [{
104
+ field: "domain",
105
+ value: domain
106
+ }];
107
+ if (domainVerification?.enabled) whereClause.push({
108
+ field: "domainVerified",
109
+ value: true
110
+ });
111
+ let ssoProvider = await ctx.context.adapter.findOne({
112
+ model: "ssoProvider",
113
+ where: whereClause
114
+ });
115
+ if (!ssoProvider) ssoProvider = (await ctx.context.adapter.findMany({
116
+ model: "ssoProvider",
117
+ where: domainVerification?.enabled ? [{
118
+ field: "domainVerified",
119
+ value: true
120
+ }] : []
121
+ })).find((p) => domainMatches(domain, p.domain)) ?? null;
122
+ if (!ssoProvider || !ssoProvider.organizationId) return;
123
+ if (await ctx.context.adapter.findOne({
124
+ model: "member",
125
+ where: [{
126
+ field: "organizationId",
127
+ value: ssoProvider.organizationId
128
+ }, {
129
+ field: "userId",
130
+ value: user.id
131
+ }]
132
+ })) return;
133
+ const role = provisioningOptions?.getRole ? await provisioningOptions.getRole({
134
+ user,
135
+ userInfo: {},
136
+ provider: ssoProvider
137
+ }) : provisioningOptions?.defaultRole || "member";
138
+ await ctx.context.adapter.create({
139
+ model: "member",
140
+ data: {
141
+ organizationId: ssoProvider.organizationId,
142
+ userId: user.id,
143
+ role,
144
+ createdAt: /* @__PURE__ */ new Date()
145
+ }
146
+ });
147
+ }
148
+
149
+ //#endregion
150
+ //#region src/routes/domain-verification.ts
151
+ const DNS_LABEL_MAX_LENGTH = 63;
152
+ const DEFAULT_TOKEN_PREFIX = "better-auth-token";
153
+ const domainVerificationBodySchema = z$1.object({ providerId: z$1.string() });
154
+ function getVerificationIdentifier(options, providerId) {
155
+ return `_${options.domainVerification?.tokenPrefix || DEFAULT_TOKEN_PREFIX}-${providerId}`;
156
+ }
157
+ /**
158
+ * DNS-over-HTTPS TXT record lookup using Cloudflare's resolver.
159
+ * Replaces node:dns/promises for edge/serverless compatibility.
160
+ */
161
+ async function resolveTxtDoH(hostname) {
162
+ const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(hostname)}&type=TXT`;
163
+ const response = await fetch(url, { headers: { Accept: "application/dns-json" } });
164
+ if (!response.ok) throw new Error(`DoH request failed with status ${response.status}`);
165
+ const data = await response.json();
166
+ if (!data.Answer) return [];
167
+ return data.Answer.filter((record) => record.type === 16).map((record) => record.data.replace(/^"|"$/g, ""));
168
+ }
169
+ const requestDomainVerification = (options) => {
170
+ return createAuthEndpoint("/sso/request-domain-verification", {
171
+ method: "POST",
172
+ body: domainVerificationBodySchema,
173
+ metadata: { openapi: {
174
+ summary: "Request a domain verification",
175
+ description: "Request a domain verification for the given SSO provider",
176
+ responses: {
177
+ "404": { description: "Provider not found" },
178
+ "409": { description: "Domain has already been verified" },
179
+ "201": { description: "Domain submitted for verification" }
180
+ }
181
+ } },
182
+ use: [sessionMiddleware]
183
+ }, async (ctx) => {
184
+ const body = ctx.body;
185
+ const provider = await ctx.context.adapter.findOne({
186
+ model: "ssoProvider",
187
+ where: [{
188
+ field: "providerId",
189
+ value: body.providerId
190
+ }]
191
+ });
192
+ if (!provider) throw new APIError("NOT_FOUND", {
193
+ message: "Provider not found",
194
+ code: "PROVIDER_NOT_FOUND"
195
+ });
196
+ const userId = ctx.context.session.user.id;
197
+ let isOrgMember = true;
198
+ if (provider.organizationId) isOrgMember = await ctx.context.adapter.count({
199
+ model: "member",
200
+ where: [{
201
+ field: "userId",
202
+ value: userId
203
+ }, {
204
+ field: "organizationId",
205
+ value: provider.organizationId
206
+ }]
207
+ }) > 0;
208
+ if (provider.userId !== userId || !isOrgMember) throw new APIError("FORBIDDEN", {
209
+ message: "User must be owner of or belong to the SSO provider organization",
210
+ code: "INSUFICCIENT_ACCESS"
211
+ });
212
+ if ("domainVerified" in provider && provider.domainVerified) throw new APIError("CONFLICT", {
213
+ message: "Domain has already been verified",
214
+ code: "DOMAIN_VERIFIED"
215
+ });
216
+ const identifier = getVerificationIdentifier(options, provider.providerId);
217
+ const activeVerification = await ctx.context.adapter.findOne({
218
+ model: "verification",
219
+ where: [{
220
+ field: "identifier",
221
+ value: identifier
222
+ }, {
223
+ field: "expiresAt",
224
+ value: /* @__PURE__ */ new Date(),
225
+ operator: "gt"
226
+ }]
227
+ });
228
+ if (activeVerification) {
229
+ ctx.setStatus(201);
230
+ return ctx.json({ domainVerificationToken: activeVerification.value });
231
+ }
232
+ const domainVerificationToken = generateRandomString(24);
233
+ await ctx.context.adapter.create({
234
+ model: "verification",
235
+ data: {
236
+ identifier,
237
+ createdAt: /* @__PURE__ */ new Date(),
238
+ updatedAt: /* @__PURE__ */ new Date(),
239
+ value: domainVerificationToken,
240
+ expiresAt: new Date(Date.now() + 3600 * 24 * 7 * 1e3)
241
+ }
242
+ });
243
+ ctx.setStatus(201);
244
+ return ctx.json({ domainVerificationToken });
245
+ });
246
+ };
247
+ const verifyDomain = (options) => {
248
+ return createAuthEndpoint("/sso/verify-domain", {
249
+ method: "POST",
250
+ body: domainVerificationBodySchema,
251
+ metadata: { openapi: {
252
+ summary: "Verify the provider domain ownership",
253
+ description: "Verify the provider domain ownership via DNS records",
254
+ responses: {
255
+ "404": { description: "Provider not found" },
256
+ "409": { description: "Domain has already been verified or no pending verification exists" },
257
+ "502": { description: "Unable to verify domain ownership due to upstream validator error" },
258
+ "204": { description: "Domain ownership was verified" }
259
+ }
260
+ } },
261
+ use: [sessionMiddleware]
262
+ }, async (ctx) => {
263
+ const body = ctx.body;
264
+ const provider = await ctx.context.adapter.findOne({
265
+ model: "ssoProvider",
266
+ where: [{
267
+ field: "providerId",
268
+ value: body.providerId
269
+ }]
270
+ });
271
+ if (!provider) throw new APIError("NOT_FOUND", {
272
+ message: "Provider not found",
273
+ code: "PROVIDER_NOT_FOUND"
274
+ });
275
+ const userId = ctx.context.session.user.id;
276
+ let isOrgMember = true;
277
+ if (provider.organizationId) isOrgMember = await ctx.context.adapter.count({
278
+ model: "member",
279
+ where: [{
280
+ field: "userId",
281
+ value: userId
282
+ }, {
283
+ field: "organizationId",
284
+ value: provider.organizationId
285
+ }]
286
+ }) > 0;
287
+ if (provider.userId !== userId || !isOrgMember) throw new APIError("FORBIDDEN", {
288
+ message: "User must be owner of or belong to the SSO provider organization",
289
+ code: "INSUFICCIENT_ACCESS"
290
+ });
291
+ if ("domainVerified" in provider && provider.domainVerified) throw new APIError("CONFLICT", {
292
+ message: "Domain has already been verified",
293
+ code: "DOMAIN_VERIFIED"
294
+ });
295
+ const identifier = getVerificationIdentifier(options, provider.providerId);
296
+ if (identifier.length > DNS_LABEL_MAX_LENGTH) throw new APIError("BAD_REQUEST", {
297
+ message: `Verification identifier exceeds the DNS label limit of ${DNS_LABEL_MAX_LENGTH} characters`,
298
+ code: "IDENTIFIER_TOO_LONG"
299
+ });
300
+ const activeVerification = await ctx.context.adapter.findOne({
301
+ model: "verification",
302
+ where: [{
303
+ field: "identifier",
304
+ value: identifier
305
+ }, {
306
+ field: "expiresAt",
307
+ value: /* @__PURE__ */ new Date(),
308
+ operator: "gt"
309
+ }]
310
+ });
311
+ if (!activeVerification) throw new APIError("NOT_FOUND", {
312
+ message: "No pending domain verification exists",
313
+ code: "NO_PENDING_VERIFICATION"
314
+ });
315
+ let records = [];
316
+ try {
317
+ const hostname = new URL(provider.domain).hostname;
318
+ records = await resolveTxtDoH(`${identifier}.${hostname}`);
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/routes/schemas.ts
340
+ const oidcMappingSchema = z.object({
341
+ id: z.string().optional(),
342
+ email: z.string().optional(),
343
+ emailVerified: z.string().optional(),
344
+ name: z.string().optional(),
345
+ image: z.string().optional(),
346
+ extraFields: z.record(z.string(), z.any()).optional()
347
+ }).optional();
348
+ const oidcConfigSchema = z.object({
349
+ clientId: z.string().optional(),
350
+ clientSecret: z.string().optional(),
351
+ authorizationEndpoint: z.string().url().optional(),
352
+ tokenEndpoint: z.string().url().optional(),
353
+ userInfoEndpoint: z.string().url().optional(),
354
+ tokenEndpointAuthentication: z.enum(["client_secret_post", "client_secret_basic"]).optional(),
355
+ jwksEndpoint: z.string().url().optional(),
356
+ discoveryEndpoint: z.string().url().optional(),
357
+ scopes: z.array(z.string()).optional(),
358
+ pkce: z.boolean().optional(),
359
+ overrideUserInfo: z.boolean().optional(),
360
+ mapping: oidcMappingSchema
361
+ });
362
+ const updateSSOProviderBodySchema = z.object({
363
+ issuer: z.string().url().optional(),
364
+ domain: z.string().optional(),
365
+ oidcConfig: oidcConfigSchema.optional()
366
+ });
367
+
368
+ //#endregion
369
+ //#region src/routes/providers.ts
370
+ const ADMIN_ROLES = ["owner", "admin"];
371
+ async function isOrgAdmin(ctx, userId, organizationId) {
372
+ const member = await ctx.context.adapter.findOne({
373
+ model: "member",
374
+ where: [{
375
+ field: "userId",
376
+ value: userId
377
+ }, {
378
+ field: "organizationId",
379
+ value: organizationId
380
+ }]
381
+ });
382
+ if (!member) return false;
383
+ return member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()));
384
+ }
385
+ async function batchCheckOrgAdmin(ctx, userId, organizationIds) {
386
+ if (organizationIds.length === 0) return /* @__PURE__ */ new Set();
387
+ const members = await ctx.context.adapter.findMany({
388
+ model: "member",
389
+ where: [{
390
+ field: "userId",
391
+ value: userId
392
+ }, {
393
+ field: "organizationId",
394
+ value: organizationIds,
395
+ operator: "in"
396
+ }]
397
+ });
398
+ const adminOrgIds = /* @__PURE__ */ new Set();
399
+ for (const member of members) if (member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()))) adminOrgIds.add(member.organizationId);
400
+ return adminOrgIds;
401
+ }
402
+ function sanitizeProvider(provider) {
403
+ let oidcConfig = null;
404
+ try {
405
+ oidcConfig = safeJsonParse(provider.oidcConfig);
406
+ } catch {
407
+ oidcConfig = null;
408
+ }
409
+ return {
410
+ providerId: provider.providerId,
411
+ type: "oidc",
412
+ issuer: provider.issuer,
413
+ domain: provider.domain,
414
+ organizationId: provider.organizationId || null,
415
+ domainVerified: provider.domainVerified ?? false,
416
+ oidcConfig: oidcConfig ? {
417
+ discoveryEndpoint: oidcConfig.discoveryEndpoint,
418
+ clientIdLastFour: maskClientId(oidcConfig.clientId),
419
+ pkce: oidcConfig.pkce,
420
+ authorizationEndpoint: oidcConfig.authorizationEndpoint,
421
+ tokenEndpoint: oidcConfig.tokenEndpoint,
422
+ userInfoEndpoint: oidcConfig.userInfoEndpoint,
423
+ jwksEndpoint: oidcConfig.jwksEndpoint,
424
+ scopes: oidcConfig.scopes,
425
+ tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication
426
+ } : void 0
427
+ };
428
+ }
429
+ const listSSOProviders = () => {
430
+ return createAuthEndpoint("/sso/providers", {
431
+ method: "GET",
432
+ use: [sessionMiddleware],
433
+ metadata: { openapi: {
434
+ operationId: "listSSOProviders",
435
+ summary: "List SSO providers",
436
+ description: "Returns a list of SSO providers the user has access to",
437
+ responses: { "200": { description: "List of SSO providers" } }
438
+ } }
439
+ }, async (ctx) => {
440
+ const userId = ctx.context.session.user.id;
441
+ const allProviders = await ctx.context.adapter.findMany({ model: "ssoProvider" });
442
+ const userOwnedProviders = allProviders.filter((p) => p.userId === userId && !p.organizationId);
443
+ const orgProviders = allProviders.filter((p) => p.organizationId !== null && p.organizationId !== void 0);
444
+ const orgPluginEnabled = !!ctx.context.hasPlugin?.("organization");
445
+ let accessibleProviders = [...userOwnedProviders];
446
+ if (orgPluginEnabled && orgProviders.length > 0) {
447
+ const adminOrgIds = await batchCheckOrgAdmin(ctx, userId, [...new Set(orgProviders.map((p) => p.organizationId).filter((id) => id !== null && id !== void 0))]);
448
+ const orgAccessibleProviders = orgProviders.filter((provider) => provider.organizationId && adminOrgIds.has(provider.organizationId));
449
+ accessibleProviders = [...accessibleProviders, ...orgAccessibleProviders];
450
+ } else if (!orgPluginEnabled) {
451
+ const userOwnedOrgProviders = orgProviders.filter((p) => p.userId === userId);
452
+ accessibleProviders = [...accessibleProviders, ...userOwnedOrgProviders];
453
+ }
454
+ const providers = accessibleProviders.map((p) => sanitizeProvider(p));
455
+ return ctx.json({ providers });
456
+ });
457
+ };
458
+ const getSSOProviderQuerySchema = z.object({ providerId: z.string() });
459
+ async function checkProviderAccess(ctx, providerId) {
460
+ const userId = ctx.context.session.user.id;
461
+ const provider = await ctx.context.adapter.findOne({
462
+ model: "ssoProvider",
463
+ where: [{
464
+ field: "providerId",
465
+ value: providerId
466
+ }]
467
+ });
468
+ if (!provider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
469
+ let hasAccess = false;
470
+ if (provider.organizationId) if (ctx.context.hasPlugin?.("organization")) hasAccess = await isOrgAdmin(ctx, userId, provider.organizationId);
471
+ else hasAccess = provider.userId === userId;
472
+ else hasAccess = provider.userId === userId;
473
+ if (!hasAccess) throw new APIError("FORBIDDEN", { message: "You don't have access to this provider" });
474
+ return provider;
475
+ }
476
+ const getSSOProvider = () => {
477
+ return createAuthEndpoint("/sso/get-provider", {
478
+ method: "GET",
479
+ use: [sessionMiddleware],
480
+ query: getSSOProviderQuerySchema,
481
+ metadata: { openapi: {
482
+ operationId: "getSSOProvider",
483
+ summary: "Get SSO provider details",
484
+ description: "Returns sanitized details for a specific SSO provider",
485
+ responses: {
486
+ "200": { description: "SSO provider details" },
487
+ "404": { description: "Provider not found" },
488
+ "403": { description: "Access denied" }
489
+ }
490
+ } }
491
+ }, async (ctx) => {
492
+ const { providerId } = ctx.query;
493
+ const provider = await checkProviderAccess(ctx, providerId);
494
+ return ctx.json(sanitizeProvider(provider));
495
+ });
496
+ };
497
+ function parseAndValidateConfig(configString, configType) {
498
+ let config = null;
499
+ try {
500
+ config = safeJsonParse(configString);
501
+ } catch {
502
+ config = null;
503
+ }
504
+ if (!config) throw new APIError("BAD_REQUEST", { message: `Cannot update ${configType} config for a provider that doesn't have ${configType} configured` });
505
+ return config;
506
+ }
507
+ function mergeOIDCConfig(current, updates, issuer) {
508
+ return {
509
+ ...current,
510
+ ...updates,
511
+ issuer,
512
+ pkce: updates.pkce ?? current.pkce ?? true,
513
+ clientId: updates.clientId ?? current.clientId,
514
+ clientSecret: updates.clientSecret ?? current.clientSecret,
515
+ discoveryEndpoint: updates.discoveryEndpoint ?? current.discoveryEndpoint,
516
+ mapping: updates.mapping ?? current.mapping,
517
+ scopes: updates.scopes ?? current.scopes,
518
+ authorizationEndpoint: updates.authorizationEndpoint ?? current.authorizationEndpoint,
519
+ tokenEndpoint: updates.tokenEndpoint ?? current.tokenEndpoint,
520
+ userInfoEndpoint: updates.userInfoEndpoint ?? current.userInfoEndpoint,
521
+ jwksEndpoint: updates.jwksEndpoint ?? current.jwksEndpoint,
522
+ tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication
523
+ };
524
+ }
525
+ const updateSSOProvider = (options) => {
526
+ return createAuthEndpoint("/sso/update-provider", {
527
+ method: "POST",
528
+ use: [sessionMiddleware],
529
+ body: updateSSOProviderBodySchema.extend({ providerId: z.string() }),
530
+ metadata: { openapi: {
531
+ operationId: "updateSSOProvider",
532
+ summary: "Update SSO provider",
533
+ description: "Partially update an SSO provider. Only provided fields are updated. If domain changes, domainVerified is reset to false.",
534
+ responses: {
535
+ "200": { description: "SSO provider updated successfully" },
536
+ "404": { description: "Provider not found" },
537
+ "403": { description: "Access denied" }
538
+ }
539
+ } }
540
+ }, async (ctx) => {
541
+ const { providerId, ...body } = ctx.body;
542
+ const { issuer, domain, oidcConfig } = body;
543
+ if (!issuer && !domain && !oidcConfig) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
544
+ const existingProvider = await checkProviderAccess(ctx, providerId);
545
+ const updateData = {};
546
+ if (body.issuer !== void 0) updateData.issuer = body.issuer;
547
+ if (body.domain !== void 0) {
548
+ updateData.domain = body.domain;
549
+ if (body.domain !== existingProvider.domain) updateData.domainVerified = false;
550
+ }
551
+ if (body.oidcConfig) {
552
+ const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
553
+ const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
554
+ updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
555
+ }
556
+ await ctx.context.adapter.update({
557
+ model: "ssoProvider",
558
+ where: [{
559
+ field: "providerId",
560
+ value: providerId
561
+ }],
562
+ update: updateData
563
+ });
564
+ const fullProvider = await ctx.context.adapter.findOne({
565
+ model: "ssoProvider",
566
+ where: [{
567
+ field: "providerId",
568
+ value: providerId
569
+ }]
570
+ });
571
+ if (!fullProvider) throw new APIError("NOT_FOUND", { message: "Provider not found after update" });
572
+ return ctx.json(sanitizeProvider(fullProvider));
573
+ });
574
+ };
575
+ const deleteSSOProvider = () => {
576
+ return createAuthEndpoint("/sso/delete-provider", {
577
+ method: "POST",
578
+ use: [sessionMiddleware],
579
+ body: z.object({ providerId: z.string() }),
580
+ metadata: { openapi: {
581
+ operationId: "deleteSSOProvider",
582
+ summary: "Delete SSO provider",
583
+ description: "Deletes an SSO provider",
584
+ responses: {
585
+ "200": { description: "SSO provider deleted successfully" },
586
+ "404": { description: "Provider not found" },
587
+ "403": { description: "Access denied" }
588
+ }
589
+ } }
590
+ }, async (ctx) => {
591
+ const { providerId } = ctx.body;
592
+ await checkProviderAccess(ctx, providerId);
593
+ await ctx.context.adapter.delete({
594
+ model: "ssoProvider",
595
+ where: [{
596
+ field: "providerId",
597
+ value: providerId
598
+ }]
599
+ });
600
+ return ctx.json({ success: true });
601
+ });
602
+ };
603
+
604
+ //#endregion
605
+ //#region src/oidc/types.ts
606
+ /**
607
+ * Custom error class for OIDC discovery failures.
608
+ * Can be caught and mapped to APIError at the edge.
609
+ */
610
+ var DiscoveryError = class DiscoveryError extends Error {
611
+ code;
612
+ details;
613
+ constructor(code, message, details, options) {
614
+ super(message, options);
615
+ this.name = "DiscoveryError";
616
+ this.code = code;
617
+ this.details = details;
618
+ if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
619
+ }
620
+ };
621
+ /**
622
+ * Required fields that must be present in a valid discovery document.
623
+ */
624
+ const REQUIRED_DISCOVERY_FIELDS = [
625
+ "issuer",
626
+ "authorization_endpoint",
627
+ "token_endpoint",
628
+ "jwks_uri"
629
+ ];
630
+
631
+ //#endregion
632
+ //#region src/oidc/discovery.ts
633
+ /**
634
+ * OIDC Discovery Pipeline
635
+ *
636
+ * Implements OIDC discovery document fetching, validation, and hydration.
637
+ * This module is used both at provider registration time (to persist validated config)
638
+ * and at runtime (to hydrate legacy providers that are missing metadata).
639
+ *
640
+ * @see https://openid.net/specs/openid-connect-discovery-1_0.html
641
+ */
642
+ /** Default timeout for discovery requests (10 seconds) */
643
+ const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
644
+ /**
645
+ * Main entry point: Discover and hydrate OIDC configuration from an issuer.
646
+ *
647
+ * This function:
648
+ * 1. Computes the discovery URL from the issuer
649
+ * 2. Validates the discovery URL
650
+ * 3. Fetches the discovery document
651
+ * 4. Validates the discovery document (issuer match + required fields)
652
+ * 5. Normalizes URLs
653
+ * 6. Selects token endpoint auth method
654
+ * 7. Merges with existing config (existing values take precedence)
655
+ *
656
+ * @param params - Discovery parameters
657
+ * @param isTrustedOrigin - Origin verification tester function
658
+ * @returns Hydrated OIDC configuration ready for persistence
659
+ * @throws DiscoveryError on any failure
660
+ */
661
+ async function discoverOIDCConfig(params) {
662
+ const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
663
+ const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
664
+ validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
665
+ const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
666
+ validateDiscoveryDocument(discoveryDoc, issuer);
667
+ const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
668
+ const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
669
+ return {
670
+ issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
671
+ discoveryEndpoint: existingConfig?.discoveryEndpoint ?? discoveryUrl,
672
+ authorizationEndpoint: existingConfig?.authorizationEndpoint ?? normalizedDoc.authorization_endpoint,
673
+ tokenEndpoint: existingConfig?.tokenEndpoint ?? normalizedDoc.token_endpoint,
674
+ jwksEndpoint: existingConfig?.jwksEndpoint ?? normalizedDoc.jwks_uri,
675
+ userInfoEndpoint: existingConfig?.userInfoEndpoint ?? normalizedDoc.userinfo_endpoint,
676
+ tokenEndpointAuthentication: existingConfig?.tokenEndpointAuthentication ?? tokenEndpointAuth,
677
+ scopesSupported: existingConfig?.scopesSupported ?? normalizedDoc.scopes_supported
678
+ };
679
+ }
680
+ /**
681
+ * Compute the discovery URL from an issuer URL.
682
+ *
683
+ * Per OIDC Discovery spec, the discovery document is located at:
684
+ * <issuer>/.well-known/openid-configuration
685
+ *
686
+ * Handles trailing slashes correctly.
687
+ */
688
+ function computeDiscoveryUrl(issuer) {
689
+ return `${issuer.endsWith("/") ? issuer.slice(0, -1) : issuer}/.well-known/openid-configuration`;
690
+ }
691
+ /**
692
+ * Validate a discovery URL before fetching.
693
+ *
694
+ * @param url - The discovery URL to validate
695
+ * @param isTrustedOrigin - Origin verification tester function
696
+ * @throws DiscoveryError if URL is invalid
697
+ */
698
+ function validateDiscoveryUrl(url, isTrustedOrigin) {
699
+ const discoveryEndpoint = parseURL("discoveryEndpoint", url).toString();
700
+ if (!isTrustedOrigin(discoveryEndpoint)) throw new DiscoveryError("discovery_untrusted_origin", `The main discovery endpoint "${discoveryEndpoint}" is not trusted by your trusted origins configuration.`, { url: discoveryEndpoint });
701
+ }
702
+ /**
703
+ * Fetch the OIDC discovery document from the IdP.
704
+ *
705
+ * @param url - The discovery endpoint URL
706
+ * @param timeout - Request timeout in milliseconds
707
+ * @returns The parsed discovery document
708
+ * @throws DiscoveryError on network errors, timeouts, or invalid responses
709
+ */
710
+ async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
711
+ try {
712
+ const response = await betterFetch(url, {
713
+ method: "GET",
714
+ timeout
715
+ });
716
+ if (response.error) {
717
+ const { status } = response.error;
718
+ if (status === 404) throw new DiscoveryError("discovery_not_found", "Discovery endpoint not found", {
719
+ url,
720
+ status
721
+ });
722
+ if (status === 408) throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
723
+ url,
724
+ timeout
725
+ });
726
+ throw new DiscoveryError("discovery_unexpected_error", `Unexpected discovery error: ${response.error.statusText}`, {
727
+ url,
728
+ ...response.error
729
+ });
730
+ }
731
+ if (!response.data) throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned an empty response", { url });
732
+ const data = response.data;
733
+ if (typeof data === "string") throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned invalid JSON", {
734
+ url,
735
+ bodyPreview: data.slice(0, 200)
736
+ });
737
+ return data;
738
+ } catch (error) {
739
+ if (error instanceof DiscoveryError) throw error;
740
+ if (error instanceof Error && error.name === "AbortError") throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
741
+ url,
742
+ timeout
743
+ });
744
+ throw new DiscoveryError("discovery_unexpected_error", `Unexpected error during discovery: ${error instanceof Error ? error.message : String(error)}`, { url }, { cause: error });
745
+ }
746
+ }
747
+ /**
748
+ * Validate a discovery document.
749
+ *
750
+ * Checks:
751
+ * 1. All required fields are present
752
+ * 2. Issuer matches the configured issuer (case-sensitive, exact match)
753
+ *
754
+ * Invariant: If this function returns without throwing, the document is safe
755
+ * to use for hydrating OIDC config (required fields present, issuer matches
756
+ * configured value, basic structural sanity verified).
757
+ *
758
+ * @param doc - The discovery document to validate
759
+ * @param configuredIssuer - The expected issuer value
760
+ * @throws DiscoveryError if validation fails
761
+ */
762
+ function validateDiscoveryDocument(doc, configuredIssuer) {
763
+ const missingFields = [];
764
+ for (const field of REQUIRED_DISCOVERY_FIELDS) if (!doc[field]) missingFields.push(field);
765
+ if (missingFields.length > 0) throw new DiscoveryError("discovery_incomplete", `Discovery document is missing required fields: ${missingFields.join(", ")}`, { missingFields });
766
+ 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}"`, {
767
+ discovered: doc.issuer,
768
+ configured: configuredIssuer
769
+ });
770
+ }
771
+ /**
772
+ * Normalize URLs in the discovery document.
773
+ *
774
+ * @param document - The discovery document
775
+ * @param issuer - The base issuer URL
776
+ * @param isTrustedOrigin - Origin verification tester function
777
+ * @returns The normalized discovery document
778
+ */
779
+ function normalizeDiscoveryUrls(document, issuer, isTrustedOrigin) {
780
+ const doc = { ...document };
781
+ doc.token_endpoint = normalizeAndValidateUrl("token_endpoint", doc.token_endpoint, issuer, isTrustedOrigin);
782
+ doc.authorization_endpoint = normalizeAndValidateUrl("authorization_endpoint", doc.authorization_endpoint, issuer, isTrustedOrigin);
783
+ doc.jwks_uri = normalizeAndValidateUrl("jwks_uri", doc.jwks_uri, issuer, isTrustedOrigin);
784
+ if (doc.userinfo_endpoint) doc.userinfo_endpoint = normalizeAndValidateUrl("userinfo_endpoint", doc.userinfo_endpoint, issuer, isTrustedOrigin);
785
+ if (doc.revocation_endpoint) doc.revocation_endpoint = normalizeAndValidateUrl("revocation_endpoint", doc.revocation_endpoint, issuer, isTrustedOrigin);
786
+ if (doc.end_session_endpoint) doc.end_session_endpoint = normalizeAndValidateUrl("end_session_endpoint", doc.end_session_endpoint, issuer, isTrustedOrigin);
787
+ if (doc.introspection_endpoint) doc.introspection_endpoint = normalizeAndValidateUrl("introspection_endpoint", doc.introspection_endpoint, issuer, isTrustedOrigin);
788
+ return doc;
789
+ }
790
+ /**
791
+ * Normalizes and validates a single URL endpoint
792
+ * @param name The url name
793
+ * @param endpoint The url to validate
794
+ * @param issuer The issuer base url
795
+ * @param isTrustedOrigin - Origin verification tester function
796
+ * @returns
797
+ */
798
+ function normalizeAndValidateUrl(name, endpoint, issuer, isTrustedOrigin) {
799
+ const url = normalizeUrl(name, endpoint, issuer);
800
+ if (!isTrustedOrigin(url)) throw new DiscoveryError("discovery_untrusted_origin", `The ${name} "${url}" is not trusted by your trusted origins configuration.`, {
801
+ endpoint: name,
802
+ url
803
+ });
804
+ return url;
805
+ }
806
+ /**
807
+ * Normalize a single URL endpoint.
808
+ *
809
+ * @param name - The endpoint name (e.g token_endpoint)
810
+ * @param endpoint - The endpoint URL to normalize
811
+ * @param issuer - The base issuer URL
812
+ * @returns The normalized endpoint URL
813
+ */
814
+ function normalizeUrl(name, endpoint, issuer) {
815
+ try {
816
+ return parseURL(name, endpoint).toString();
817
+ } catch {
818
+ const issuerURL = parseURL(name, issuer);
819
+ const basePath = issuerURL.pathname.replace(/\/+$/, "");
820
+ const endpointPath = endpoint.replace(/^\/+/, "");
821
+ return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
822
+ }
823
+ }
824
+ /**
825
+ * Parses the given URL or throws in case of invalid or unsupported protocols
826
+ *
827
+ * @param name the url name
828
+ * @param endpoint the endpoint url
829
+ * @param [base] optional base path
830
+ * @returns
831
+ */
832
+ function parseURL(name, endpoint, base) {
833
+ let endpointURL;
834
+ try {
835
+ endpointURL = new URL(endpoint, base);
836
+ if (endpointURL.protocol === "http:" || endpointURL.protocol === "https:") return endpointURL;
837
+ } catch (error) {
838
+ throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
839
+ }
840
+ throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must use the http or https supported protocols: ${endpoint}`, {
841
+ url: endpoint,
842
+ protocol: endpointURL.protocol
843
+ });
844
+ }
845
+ /**
846
+ * Select the token endpoint authentication method.
847
+ *
848
+ * @param doc - The discovery document
849
+ * @param existing - Existing authentication method from config
850
+ * @returns The selected authentication method
851
+ */
852
+ function selectTokenEndpointAuthMethod(doc, existing) {
853
+ if (existing) return existing;
854
+ const supported = doc.token_endpoint_auth_methods_supported;
855
+ if (!supported || supported.length === 0) return "client_secret_basic";
856
+ if (supported.includes("client_secret_basic")) return "client_secret_basic";
857
+ if (supported.includes("client_secret_post")) return "client_secret_post";
858
+ return "client_secret_basic";
859
+ }
860
+ /**
861
+ * Check if a provider configuration needs runtime discovery.
862
+ *
863
+ * Returns true if we need discovery at runtime to complete the token exchange
864
+ * and validation. Specifically checks for:
865
+ * - `tokenEndpoint` - required for exchanging authorization code for tokens
866
+ * - `jwksEndpoint` - required for validating ID token signatures
867
+ *
868
+ * Note: `authorizationEndpoint` is handled separately in the sign-in flow,
869
+ * so it's not checked here.
870
+ *
871
+ * @param config - Partial OIDC config from the provider
872
+ * @returns true if runtime discovery should be performed
873
+ */
874
+ function needsRuntimeDiscovery(config) {
875
+ if (!config) return true;
876
+ return !config.tokenEndpoint || !config.jwksEndpoint;
877
+ }
878
+
879
+ //#endregion
880
+ //#region src/oidc/errors.ts
881
+ /**
882
+ * OIDC Discovery Error Mapping
883
+ *
884
+ * Maps DiscoveryError codes to appropriate APIError responses.
885
+ * Used at the boundary between the discovery pipeline and HTTP handlers.
886
+ */
887
+ /**
888
+ * Maps a DiscoveryError to an appropriate APIError for HTTP responses.
889
+ *
890
+ * Error code mapping:
891
+ * - discovery_invalid_url → 400 BAD_REQUEST
892
+ * - discovery_not_found → 400 BAD_REQUEST
893
+ * - discovery_invalid_json → 400 BAD_REQUEST
894
+ * - discovery_incomplete → 400 BAD_REQUEST
895
+ * - issuer_mismatch → 400 BAD_REQUEST
896
+ * - unsupported_token_auth_method → 400 BAD_REQUEST
897
+ * - discovery_timeout → 502 BAD_GATEWAY
898
+ * - discovery_unexpected_error → 502 BAD_GATEWAY
899
+ *
900
+ * @param error - The DiscoveryError to map
901
+ * @returns An APIError with appropriate status and message
902
+ */
903
+ function mapDiscoveryErrorToAPIError(error) {
904
+ switch (error.code) {
905
+ case "discovery_timeout": return new APIError("BAD_GATEWAY", {
906
+ message: `OIDC discovery timed out: ${error.message}`,
907
+ code: error.code
908
+ });
909
+ case "discovery_unexpected_error": return new APIError("BAD_GATEWAY", {
910
+ message: `OIDC discovery failed: ${error.message}`,
911
+ code: error.code
912
+ });
913
+ case "discovery_not_found": return new APIError("BAD_REQUEST", {
914
+ message: `OIDC discovery endpoint not found. The issuer may not support OIDC discovery, or the URL is incorrect. ${error.message}`,
915
+ code: error.code
916
+ });
917
+ case "discovery_invalid_url": return new APIError("BAD_REQUEST", {
918
+ message: `Invalid OIDC discovery URL: ${error.message}`,
919
+ code: error.code
920
+ });
921
+ case "discovery_untrusted_origin": return new APIError("BAD_REQUEST", {
922
+ message: `Untrusted OIDC discovery URL: ${error.message}`,
923
+ code: error.code
924
+ });
925
+ case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
926
+ message: `OIDC discovery returned invalid data: ${error.message}`,
927
+ code: error.code
928
+ });
929
+ case "discovery_incomplete": return new APIError("BAD_REQUEST", {
930
+ message: `OIDC discovery document is missing required fields: ${error.message}`,
931
+ code: error.code
932
+ });
933
+ case "issuer_mismatch": return new APIError("BAD_REQUEST", {
934
+ message: `OIDC issuer mismatch: ${error.message}`,
935
+ code: error.code
936
+ });
937
+ case "unsupported_token_auth_method": return new APIError("BAD_REQUEST", {
938
+ message: `Incompatible OIDC provider: ${error.message}`,
939
+ code: error.code
940
+ });
941
+ default:
942
+ error.code;
943
+ return new APIError("INTERNAL_SERVER_ERROR", {
944
+ message: `Unexpected discovery error: ${error.message}`,
945
+ code: "discovery_unexpected_error"
946
+ });
947
+ }
948
+ }
949
+
950
+ //#endregion
951
+ //#region src/routes/sso.ts
952
+ const ssoProviderBodySchema = z.object({
953
+ providerId: z.string({}).meta({ description: "The ID of the provider. This is used to identify the provider during login and callback" }),
954
+ issuer: z.string({}).meta({ description: "The issuer of the provider" }),
955
+ 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')" }),
956
+ oidcConfig: z.object({
957
+ clientId: z.string({}).meta({ description: "The client ID" }),
958
+ clientSecret: z.string({}).meta({ description: "The client secret" }),
959
+ authorizationEndpoint: z.string({}).meta({ description: "The authorization endpoint" }).optional(),
960
+ tokenEndpoint: z.string({}).meta({ description: "The token endpoint" }).optional(),
961
+ userInfoEndpoint: z.string({}).meta({ description: "The user info endpoint" }).optional(),
962
+ tokenEndpointAuthentication: z.enum(["client_secret_post", "client_secret_basic"]).optional(),
963
+ jwksEndpoint: z.string({}).meta({ description: "The JWKS endpoint" }).optional(),
964
+ discoveryEndpoint: z.string().optional(),
965
+ skipDiscovery: z.boolean().meta({ description: "Skip OIDC discovery during registration. When true, you must provide authorizationEndpoint, tokenEndpoint, and jwksEndpoint manually." }).optional(),
966
+ scopes: z.array(z.string(), {}).meta({ description: "The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']" }).optional(),
967
+ pkce: z.boolean({}).meta({ description: "Whether to use PKCE for the authorization flow" }).default(true).optional(),
968
+ mapping: z.object({
969
+ id: z.string({}).meta({ description: "Field mapping for user ID (defaults to 'sub')" }),
970
+ email: z.string({}).meta({ description: "Field mapping for email (defaults to 'email')" }),
971
+ emailVerified: z.string({}).meta({ description: "Field mapping for email verification (defaults to 'email_verified')" }).optional(),
972
+ name: z.string({}).meta({ description: "Field mapping for name (defaults to 'name')" }),
973
+ image: z.string({}).meta({ description: "Field mapping for image (defaults to 'picture')" }).optional(),
974
+ extraFields: z.record(z.string(), z.any()).optional()
975
+ }).optional()
976
+ }),
977
+ organizationId: z.string({}).meta({ description: "If organization plugin is enabled, the organization id to link the provider to" }).optional(),
978
+ overrideUserInfo: z.boolean({}).meta({ description: "Override user info with the provider info. Defaults to false" }).default(false).optional()
979
+ });
980
+ const registerSSOProvider = (options) => {
981
+ return createAuthEndpoint("/sso/register", {
982
+ method: "POST",
983
+ body: ssoProviderBodySchema,
984
+ use: [sessionMiddleware],
985
+ metadata: { openapi: {
986
+ operationId: "registerSSOProvider",
987
+ summary: "Register an OIDC provider",
988
+ description: "This endpoint is used to register an OIDC provider. This is used to configure the provider and link it to an organization",
989
+ responses: { "200": { description: "OIDC provider created successfully" } }
990
+ } }
991
+ }, async (ctx) => {
992
+ const user = ctx.context.session?.user;
993
+ if (!user) throw new APIError("UNAUTHORIZED");
994
+ const limit = typeof options?.providersLimit === "function" ? await options.providersLimit(user) : options?.providersLimit ?? 10;
995
+ if (!limit) throw new APIError("FORBIDDEN", { message: "SSO provider registration is disabled" });
996
+ if ((await ctx.context.adapter.findMany({
997
+ model: "ssoProvider",
998
+ where: [{
999
+ field: "userId",
1000
+ value: user.id
1001
+ }]
1002
+ })).length >= limit) throw new APIError("FORBIDDEN", { message: "You have reached the maximum number of SSO providers" });
1003
+ const body = ctx.body;
1004
+ if (z.string().url().safeParse(body.issuer).error) throw new APIError("BAD_REQUEST", { message: "Invalid issuer. Must be a valid URL" });
1005
+ if (ctx.body.organizationId) {
1006
+ if (!await ctx.context.adapter.findOne({
1007
+ model: "member",
1008
+ where: [{
1009
+ field: "userId",
1010
+ value: user.id
1011
+ }, {
1012
+ field: "organizationId",
1013
+ value: ctx.body.organizationId
1014
+ }]
1015
+ })) throw new APIError("BAD_REQUEST", { message: "You are not a member of the organization" });
1016
+ }
1017
+ if (await ctx.context.adapter.findOne({
1018
+ model: "ssoProvider",
1019
+ where: [{
1020
+ field: "providerId",
1021
+ value: body.providerId
1022
+ }]
1023
+ })) {
1024
+ ctx.context.logger.info(`SSO provider creation attempt with existing providerId: ${body.providerId}`);
1025
+ throw new APIError("UNPROCESSABLE_ENTITY", { message: "SSO provider with this providerId already exists" });
1026
+ }
1027
+ let hydratedOIDCConfig = null;
1028
+ if (!body.oidcConfig.skipDiscovery) try {
1029
+ hydratedOIDCConfig = await discoverOIDCConfig({
1030
+ issuer: body.issuer,
1031
+ existingConfig: {
1032
+ discoveryEndpoint: body.oidcConfig.discoveryEndpoint,
1033
+ authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
1034
+ tokenEndpoint: body.oidcConfig.tokenEndpoint,
1035
+ jwksEndpoint: body.oidcConfig.jwksEndpoint,
1036
+ userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
1037
+ tokenEndpointAuthentication: body.oidcConfig.tokenEndpointAuthentication
1038
+ },
1039
+ isTrustedOrigin: (url) => ctx.context.isTrustedOrigin(url)
1040
+ });
1041
+ } catch (error) {
1042
+ if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
1043
+ throw error;
1044
+ }
1045
+ const buildOIDCConfig = () => {
1046
+ if (body.oidcConfig.skipDiscovery) return JSON.stringify({
1047
+ issuer: body.issuer,
1048
+ clientId: body.oidcConfig.clientId,
1049
+ clientSecret: body.oidcConfig.clientSecret,
1050
+ authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
1051
+ tokenEndpoint: body.oidcConfig.tokenEndpoint,
1052
+ tokenEndpointAuthentication: body.oidcConfig.tokenEndpointAuthentication || "client_secret_basic",
1053
+ jwksEndpoint: body.oidcConfig.jwksEndpoint,
1054
+ pkce: body.oidcConfig.pkce,
1055
+ discoveryEndpoint: body.oidcConfig.discoveryEndpoint || `${body.issuer}/.well-known/openid-configuration`,
1056
+ mapping: body.oidcConfig.mapping,
1057
+ scopes: body.oidcConfig.scopes,
1058
+ userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
1059
+ overrideUserInfo: ctx.body.overrideUserInfo || options?.defaultOverrideUserInfo || false
1060
+ });
1061
+ if (!hydratedOIDCConfig) return null;
1062
+ return JSON.stringify({
1063
+ issuer: hydratedOIDCConfig.issuer,
1064
+ clientId: body.oidcConfig.clientId,
1065
+ clientSecret: body.oidcConfig.clientSecret,
1066
+ authorizationEndpoint: hydratedOIDCConfig.authorizationEndpoint,
1067
+ tokenEndpoint: hydratedOIDCConfig.tokenEndpoint,
1068
+ tokenEndpointAuthentication: hydratedOIDCConfig.tokenEndpointAuthentication,
1069
+ jwksEndpoint: hydratedOIDCConfig.jwksEndpoint,
1070
+ pkce: body.oidcConfig.pkce,
1071
+ discoveryEndpoint: hydratedOIDCConfig.discoveryEndpoint,
1072
+ mapping: body.oidcConfig.mapping,
1073
+ scopes: body.oidcConfig.scopes,
1074
+ userInfoEndpoint: hydratedOIDCConfig.userInfoEndpoint,
1075
+ overrideUserInfo: ctx.body.overrideUserInfo || options?.defaultOverrideUserInfo || false
1076
+ });
1077
+ };
1078
+ const provider = await ctx.context.adapter.create({
1079
+ model: "ssoProvider",
1080
+ data: {
1081
+ issuer: body.issuer,
1082
+ domain: body.domain,
1083
+ domainVerified: false,
1084
+ oidcConfig: buildOIDCConfig(),
1085
+ organizationId: body.organizationId,
1086
+ userId: ctx.context.session.user.id,
1087
+ providerId: body.providerId
1088
+ }
1089
+ });
1090
+ let domainVerificationToken;
1091
+ let domainVerified;
1092
+ if (options?.domainVerification?.enabled) {
1093
+ domainVerified = false;
1094
+ domainVerificationToken = generateRandomString(24);
1095
+ await ctx.context.adapter.create({
1096
+ model: "verification",
1097
+ data: {
1098
+ identifier: getVerificationIdentifier(options, provider.providerId),
1099
+ createdAt: /* @__PURE__ */ new Date(),
1100
+ updatedAt: /* @__PURE__ */ new Date(),
1101
+ value: domainVerificationToken,
1102
+ expiresAt: new Date(Date.now() + 3600 * 24 * 7 * 1e3)
1103
+ }
1104
+ });
1105
+ }
1106
+ const result = {
1107
+ ...provider,
1108
+ oidcConfig: safeJsonParse(provider.oidcConfig),
1109
+ redirectURI: `${ctx.context.baseURL}/sso/callback/${provider.providerId}`,
1110
+ ...options?.domainVerification?.enabled ? { domainVerified } : {},
1111
+ ...options?.domainVerification?.enabled ? { domainVerificationToken } : {}
1112
+ };
1113
+ return ctx.json(result);
1114
+ });
1115
+ };
1116
+ const signInSSOBodySchema = z.object({
1117
+ 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(),
1118
+ organizationSlug: z.string({}).meta({ description: "The slug of the organization to sign in with" }).optional(),
1119
+ providerId: z.string({}).meta({ description: "The ID of the provider to sign in with. This can be provided instead of email or issuer" }).optional(),
1120
+ domain: z.string({}).meta({ description: "The domain of the provider." }).optional(),
1121
+ callbackURL: z.string({}).meta({ description: "The URL to redirect to after login" }),
1122
+ errorCallbackURL: z.string({}).meta({ description: "The URL to redirect to after login" }).optional(),
1123
+ newUserCallbackURL: z.string({}).meta({ description: "The URL to redirect to after login if the user is new" }).optional(),
1124
+ scopes: z.array(z.string(), {}).meta({ description: "Scopes to request from the provider." }).optional(),
1125
+ 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(),
1126
+ requestSignUp: z.boolean({}).meta({ description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider" }).optional()
1127
+ });
1128
+ const signInSSO = (options) => {
1129
+ return createAuthEndpoint("/sign-in/sso", {
1130
+ method: "POST",
1131
+ body: signInSSOBodySchema,
1132
+ metadata: { openapi: {
1133
+ operationId: "signInWithSSO",
1134
+ summary: "Sign in with SSO provider",
1135
+ description: "This endpoint is used to sign in with an SSO provider. It redirects to the provider's authorization URL",
1136
+ responses: { "200": { description: "Authorization URL generated successfully for SSO sign-in" } }
1137
+ } }
1138
+ }, async (ctx) => {
1139
+ const body = ctx.body;
1140
+ let { email, organizationSlug, providerId, domain } = body;
1141
+ if (!options?.defaultSSO?.length && !email && !organizationSlug && !domain && !providerId) throw new APIError("BAD_REQUEST", { message: "email, organizationSlug, domain or providerId is required" });
1142
+ domain = body.domain || email?.split("@")[1];
1143
+ let orgId = "";
1144
+ if (organizationSlug) orgId = await ctx.context.adapter.findOne({
1145
+ model: "organization",
1146
+ where: [{
1147
+ field: "slug",
1148
+ value: organizationSlug
1149
+ }]
1150
+ }).then((res) => {
1151
+ if (!res) return "";
1152
+ return res.id;
1153
+ });
1154
+ let provider = null;
1155
+ if (options?.defaultSSO?.length) {
1156
+ const matchingDefault = providerId ? options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId) : options.defaultSSO.find((defaultProvider) => defaultProvider.domain === domain);
1157
+ if (matchingDefault) provider = {
1158
+ issuer: matchingDefault.oidcConfig?.issuer || "",
1159
+ providerId: matchingDefault.providerId,
1160
+ userId: "default",
1161
+ oidcConfig: matchingDefault.oidcConfig,
1162
+ domain: matchingDefault.domain,
1163
+ ...options.domainVerification?.enabled ? { domainVerified: true } : {}
1164
+ };
1165
+ }
1166
+ if (!providerId && !orgId && !domain) throw new APIError("BAD_REQUEST", { message: "providerId, orgId or domain is required" });
1167
+ if (!provider) {
1168
+ const parseProvider = (res) => {
1169
+ if (!res) return null;
1170
+ return {
1171
+ ...res,
1172
+ oidcConfig: res.oidcConfig ? safeJsonParse(res.oidcConfig) || void 0 : void 0
1173
+ };
1174
+ };
1175
+ if (providerId || orgId) provider = parseProvider(await ctx.context.adapter.findOne({
1176
+ model: "ssoProvider",
1177
+ where: [{
1178
+ field: providerId ? "providerId" : "organizationId",
1179
+ value: providerId || orgId
1180
+ }]
1181
+ }));
1182
+ else if (domain) {
1183
+ provider = parseProvider(await ctx.context.adapter.findOne({
1184
+ model: "ssoProvider",
1185
+ where: [{
1186
+ field: "domain",
1187
+ value: domain
1188
+ }]
1189
+ }));
1190
+ if (!provider) provider = parseProvider((await ctx.context.adapter.findMany({ model: "ssoProvider" })).find((p) => domainMatches(domain, p.domain)) ?? null);
1191
+ }
1192
+ }
1193
+ if (!provider) throw new APIError("NOT_FOUND", { message: "No provider found for the issuer" });
1194
+ if (!provider.oidcConfig) throw new APIError("BAD_REQUEST", { message: "OIDC provider is not configured" });
1195
+ if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
1196
+ let finalAuthUrl = provider.oidcConfig.authorizationEndpoint;
1197
+ if (!finalAuthUrl && provider.oidcConfig.discoveryEndpoint) {
1198
+ const discovery = await betterFetch(provider.oidcConfig.discoveryEndpoint, { method: "GET" });
1199
+ if (discovery.data) finalAuthUrl = discovery.data.authorization_endpoint;
1200
+ }
1201
+ if (!finalAuthUrl) throw new APIError("BAD_REQUEST", { message: "Invalid OIDC configuration. Authorization URL not found." });
1202
+ const state = await generateState(ctx, void 0, false);
1203
+ const redirectURI = `${ctx.context.baseURL}/sso/callback/${provider.providerId}`;
1204
+ const authorizationURL = await createAuthorizationURL({
1205
+ id: provider.issuer,
1206
+ options: {
1207
+ clientId: provider.oidcConfig.clientId,
1208
+ clientSecret: provider.oidcConfig.clientSecret
1209
+ },
1210
+ redirectURI,
1211
+ state: state.state,
1212
+ codeVerifier: provider.oidcConfig.pkce ? state.codeVerifier : void 0,
1213
+ scopes: ctx.body.scopes || provider.oidcConfig.scopes || [
1214
+ "openid",
1215
+ "email",
1216
+ "profile",
1217
+ "offline_access"
1218
+ ],
1219
+ loginHint: ctx.body.loginHint || email,
1220
+ authorizationEndpoint: finalAuthUrl
1221
+ });
1222
+ return ctx.json({
1223
+ url: authorizationURL.toString(),
1224
+ redirect: true
1225
+ });
1226
+ });
1227
+ };
1228
+ const callbackSSOQuerySchema = z.object({
1229
+ code: z.string().optional(),
1230
+ state: z.string(),
1231
+ error: z.string().optional(),
1232
+ error_description: z.string().optional()
1233
+ });
1234
+ const callbackSSO = (options) => {
1235
+ return createAuthEndpoint("/sso/callback/:providerId", {
1236
+ method: "GET",
1237
+ query: callbackSSOQuerySchema,
1238
+ allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
1239
+ metadata: {
1240
+ ...HIDE_METADATA,
1241
+ openapi: {
1242
+ operationId: "handleSSOCallback",
1243
+ summary: "Callback URL for SSO provider",
1244
+ description: "This endpoint is used as the callback URL for SSO providers. It handles the authorization code and exchanges it for an access token",
1245
+ responses: { "302": { description: "Redirects to the callback URL" } }
1246
+ }
1247
+ }
1248
+ }, async (ctx) => {
1249
+ const { code, error, error_description } = ctx.query;
1250
+ const stateData = await parseState(ctx);
1251
+ if (!stateData) {
1252
+ const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
1253
+ throw ctx.redirect(`${errorURL}?error=invalid_state`);
1254
+ }
1255
+ const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
1256
+ if (!code || error) throw ctx.redirect(`${errorURL || callbackURL}?error=${error}&error_description=${error_description}`);
1257
+ let provider = null;
1258
+ if (options?.defaultSSO?.length) {
1259
+ const matchingDefault = options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === ctx.params.providerId);
1260
+ if (matchingDefault) provider = {
1261
+ ...matchingDefault,
1262
+ issuer: matchingDefault.oidcConfig?.issuer || "",
1263
+ userId: "default",
1264
+ ...options.domainVerification?.enabled ? { domainVerified: true } : {}
1265
+ };
1266
+ }
1267
+ if (!provider) provider = await ctx.context.adapter.findOne({
1268
+ model: "ssoProvider",
1269
+ where: [{
1270
+ field: "providerId",
1271
+ value: ctx.params.providerId
1272
+ }]
1273
+ }).then((res) => {
1274
+ if (!res) return null;
1275
+ return {
1276
+ ...res,
1277
+ oidcConfig: safeJsonParse(res.oidcConfig) || void 0
1278
+ };
1279
+ });
1280
+ if (!provider) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
1281
+ if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
1282
+ let config = provider.oidcConfig;
1283
+ if (!config) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
1284
+ const discovery = await betterFetch(config.discoveryEndpoint);
1285
+ if (discovery.data) config = {
1286
+ tokenEndpoint: discovery.data.token_endpoint,
1287
+ tokenEndpointAuthentication: discovery.data.token_endpoint_auth_method,
1288
+ userInfoEndpoint: discovery.data.userinfo_endpoint,
1289
+ scopes: [
1290
+ "openid",
1291
+ "email",
1292
+ "profile",
1293
+ "offline_access"
1294
+ ],
1295
+ ...config
1296
+ };
1297
+ if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_endpoint_not_found`);
1298
+ const tokenResponse = await validateAuthorizationCode({
1299
+ code,
1300
+ codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
1301
+ redirectURI: `${ctx.context.baseURL}/sso/callback/${provider.providerId}`,
1302
+ options: {
1303
+ clientId: config.clientId,
1304
+ clientSecret: config.clientSecret
1305
+ },
1306
+ tokenEndpoint: config.tokenEndpoint,
1307
+ authentication: config.tokenEndpointAuthentication === "client_secret_post" ? "post" : "basic"
1308
+ }).catch((e) => {
1309
+ if (e instanceof BetterFetchError) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${e.message}`);
1310
+ return null;
1311
+ });
1312
+ if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_response_not_found`);
1313
+ let userInfo = null;
1314
+ if (tokenResponse.idToken) {
1315
+ const idToken = decodeJwt(tokenResponse.idToken);
1316
+ if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=jwks_endpoint_not_found`);
1317
+ const verified = await validateToken(tokenResponse.idToken, config.jwksEndpoint, {
1318
+ audience: config.clientId,
1319
+ issuer: provider.issuer
1320
+ }).catch((e) => {
1321
+ ctx.context.logger.error(e);
1322
+ return null;
1323
+ });
1324
+ if (!verified) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_not_verified`);
1325
+ const mapping = config.mapping || {};
1326
+ userInfo = {
1327
+ ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, verified.payload[value]])),
1328
+ id: idToken[mapping.id || "sub"],
1329
+ email: idToken[mapping.email || "email"],
1330
+ emailVerified: options?.trustEmailVerified ? idToken[mapping.emailVerified || "email_verified"] : false,
1331
+ name: idToken[mapping.name || "name"],
1332
+ image: idToken[mapping.image || "picture"]
1333
+ };
1334
+ }
1335
+ if (!userInfo) {
1336
+ if (!config.userInfoEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=user_info_endpoint_not_found`);
1337
+ const userInfoResponse = await betterFetch(config.userInfoEndpoint, { headers: { Authorization: `Bearer ${tokenResponse.accessToken}` } });
1338
+ if (userInfoResponse.error) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${userInfoResponse.error.message}`);
1339
+ userInfo = userInfoResponse.data;
1340
+ }
1341
+ if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=missing_user_info`);
1342
+ const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfo.email, provider.domain);
1343
+ const linked = await handleOAuthUserInfo(ctx, {
1344
+ userInfo: {
1345
+ email: userInfo.email,
1346
+ name: userInfo.name || "",
1347
+ id: userInfo.id,
1348
+ image: userInfo.image,
1349
+ emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
1350
+ },
1351
+ account: {
1352
+ idToken: tokenResponse.idToken,
1353
+ accessToken: tokenResponse.accessToken,
1354
+ refreshToken: tokenResponse.refreshToken,
1355
+ accountId: userInfo.id,
1356
+ providerId: provider.providerId,
1357
+ accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
1358
+ refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
1359
+ scope: tokenResponse.scopes?.join(",")
1360
+ },
1361
+ callbackURL,
1362
+ disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
1363
+ overrideUserInfo: config.overrideUserInfo,
1364
+ isTrustedProvider
1365
+ });
1366
+ if (linked.error) throw ctx.redirect(`${errorURL || callbackURL}?error=${linked.error}`);
1367
+ const { session, user } = linked.data;
1368
+ if (options?.provisionUser && linked.isRegister) await options.provisionUser({
1369
+ user,
1370
+ userInfo,
1371
+ token: tokenResponse,
1372
+ provider
1373
+ });
1374
+ await assignOrganizationFromProvider(ctx, {
1375
+ user,
1376
+ profile: {
1377
+ providerType: "oidc",
1378
+ providerId: provider.providerId,
1379
+ accountId: userInfo.id,
1380
+ email: userInfo.email,
1381
+ emailVerified: Boolean(userInfo.emailVerified),
1382
+ rawAttributes: userInfo
1383
+ },
1384
+ provider,
1385
+ token: tokenResponse,
1386
+ provisioningOptions: options?.organizationProvisioning
1387
+ });
1388
+ await setSessionCookie(ctx, {
1389
+ session,
1390
+ user
1391
+ });
1392
+ let toRedirectTo;
1393
+ try {
1394
+ toRedirectTo = (linked.isRegister ? newUserURL || callbackURL : callbackURL).toString();
1395
+ } catch {
1396
+ toRedirectTo = linked.isRegister ? newUserURL || callbackURL : callbackURL;
1397
+ }
1398
+ throw ctx.redirect(toRedirectTo);
1399
+ });
1400
+ };
1401
+
1402
+ //#endregion
1403
+ //#region src/index.ts
1404
+ function oidcSso(options) {
1405
+ const optionsWithStore = options;
1406
+ let endpoints = {
1407
+ registerSSOProvider: registerSSOProvider(optionsWithStore),
1408
+ signInSSO: signInSSO(optionsWithStore),
1409
+ callbackSSO: callbackSSO(optionsWithStore),
1410
+ listSSOProviders: listSSOProviders(),
1411
+ getSSOProvider: getSSOProvider(),
1412
+ updateSSOProvider: updateSSOProvider(optionsWithStore),
1413
+ deleteSSOProvider: deleteSSOProvider()
1414
+ };
1415
+ if (options?.domainVerification?.enabled) {
1416
+ const domainVerificationEndpoints = {
1417
+ requestDomainVerification: requestDomainVerification(optionsWithStore),
1418
+ verifyDomain: verifyDomain(optionsWithStore)
1419
+ };
1420
+ endpoints = {
1421
+ ...endpoints,
1422
+ ...domainVerificationEndpoints
1423
+ };
1424
+ }
1425
+ return {
1426
+ id: "oidc-sso",
1427
+ endpoints,
1428
+ hooks: { after: [{
1429
+ matcher(context) {
1430
+ return context.path?.startsWith("/callback/") ?? false;
1431
+ },
1432
+ handler: createAuthMiddleware(async (ctx) => {
1433
+ const newSession = ctx.context.newSession;
1434
+ if (!newSession?.user) return;
1435
+ if (!ctx.context.hasPlugin("organization")) return;
1436
+ await assignOrganizationByDomain(ctx, {
1437
+ user: newSession.user,
1438
+ provisioningOptions: options?.organizationProvisioning,
1439
+ domainVerification: options?.domainVerification
1440
+ });
1441
+ })
1442
+ }] },
1443
+ schema: { ssoProvider: {
1444
+ modelName: options?.modelName ?? "ssoProvider",
1445
+ fields: {
1446
+ issuer: {
1447
+ type: "string",
1448
+ required: true,
1449
+ fieldName: options?.fields?.issuer ?? "issuer"
1450
+ },
1451
+ oidcConfig: {
1452
+ type: "string",
1453
+ required: false,
1454
+ fieldName: options?.fields?.oidcConfig ?? "oidcConfig"
1455
+ },
1456
+ userId: {
1457
+ type: "string",
1458
+ references: {
1459
+ model: "user",
1460
+ field: "id"
1461
+ },
1462
+ fieldName: options?.fields?.userId ?? "userId"
1463
+ },
1464
+ providerId: {
1465
+ type: "string",
1466
+ required: true,
1467
+ unique: true,
1468
+ fieldName: options?.fields?.providerId ?? "providerId"
1469
+ },
1470
+ organizationId: {
1471
+ type: "string",
1472
+ required: false,
1473
+ fieldName: options?.fields?.organizationId ?? "organizationId"
1474
+ },
1475
+ domain: {
1476
+ type: "string",
1477
+ required: true,
1478
+ fieldName: options?.fields?.domain ?? "domain"
1479
+ },
1480
+ ...options?.domainVerification?.enabled ? { domainVerified: {
1481
+ type: "boolean",
1482
+ required: false
1483
+ } } : {}
1484
+ }
1485
+ } },
1486
+ options
1487
+ };
1488
+ }
1489
+
1490
+ //#endregion
1491
+ export { DiscoveryError, REQUIRED_DISCOVERY_FIELDS, computeDiscoveryUrl, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, oidcSso, selectTokenEndpointAuthMethod, validateDiscoveryDocument, validateDiscoveryUrl };
1492
+ //# sourceMappingURL=index.js.map