@better-auth/sso 1.4.0-beta.8 → 1.4.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.mjs CHANGED
@@ -1,1655 +1,1487 @@
1
- import { generateState } from 'better-auth';
2
- import { APIError, sessionMiddleware } from 'better-auth/api';
3
- import { parseState, validateAuthorizationCode, validateToken, handleOAuthUserInfo, createAuthorizationURL } from 'better-auth/oauth2';
4
- import { createAuthEndpoint } from 'better-auth/plugins';
5
- import * as z from 'zod/v4';
6
- import * as saml from 'samlify';
7
- import { betterFetch, BetterFetchError } from '@better-fetch/fetch';
8
- import { decodeJwt } from 'jose';
9
- import { setSessionCookie } from 'better-auth/cookies';
10
- import { XMLValidator } from 'fast-xml-parser';
1
+ import { XMLValidator } from "fast-xml-parser";
2
+ import * as saml from "samlify";
3
+ import { APIError, createAuthEndpoint, sessionMiddleware } from "better-auth/api";
4
+ import { generateRandomString } from "better-auth/crypto";
5
+ import * as z from "zod/v4";
6
+ import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
7
+ import { createAuthorizationURL, generateState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
8
+ import { setSessionCookie } from "better-auth/cookies";
9
+ import { handleOAuthUserInfo } from "better-auth/oauth2";
10
+ import { decodeJwt } from "jose";
11
11
 
12
- const fastValidator = {
13
- async validate(xml) {
14
- const isValid = XMLValidator.validate(xml, {
15
- allowBooleanAttributes: true
16
- });
17
- if (isValid === true) return "SUCCESS_VALIDATE_XML";
18
- throw "ERR_INVALID_XML";
19
- }
12
+ //#region src/routes/domain-verification.ts
13
+ const requestDomainVerification = (options) => {
14
+ return createAuthEndpoint("/sso/request-domain-verification", {
15
+ method: "POST",
16
+ body: z.object({ providerId: z.string() }),
17
+ metadata: { openapi: {
18
+ summary: "Request a domain verification",
19
+ description: "Request a domain verification for the given SSO provider",
20
+ responses: {
21
+ "404": { description: "Provider not found" },
22
+ "409": { description: "Domain has already been verified" },
23
+ "201": { description: "Domain submitted for verification" }
24
+ }
25
+ } },
26
+ use: [sessionMiddleware]
27
+ }, async (ctx) => {
28
+ const body = ctx.body;
29
+ const provider = await ctx.context.adapter.findOne({
30
+ model: "ssoProvider",
31
+ where: [{
32
+ field: "providerId",
33
+ value: body.providerId
34
+ }]
35
+ });
36
+ if (!provider) throw new APIError("NOT_FOUND", {
37
+ message: "Provider not found",
38
+ code: "PROVIDER_NOT_FOUND"
39
+ });
40
+ const userId = ctx.context.session.user.id;
41
+ let isOrgMember = true;
42
+ if (provider.organizationId) isOrgMember = await ctx.context.adapter.count({
43
+ model: "member",
44
+ where: [{
45
+ field: "userId",
46
+ value: userId
47
+ }, {
48
+ field: "organizationId",
49
+ value: provider.organizationId
50
+ }]
51
+ }) > 0;
52
+ if (provider.userId !== userId || !isOrgMember) throw new APIError("FORBIDDEN", {
53
+ message: "User must be owner of or belong to the SSO provider organization",
54
+ code: "INSUFICCIENT_ACCESS"
55
+ });
56
+ if ("domainVerified" in provider && provider.domainVerified) throw new APIError("CONFLICT", {
57
+ message: "Domain has already been verified",
58
+ code: "DOMAIN_VERIFIED"
59
+ });
60
+ const activeVerification = await ctx.context.adapter.findOne({
61
+ model: "verification",
62
+ where: [{
63
+ field: "identifier",
64
+ value: options.domainVerification?.tokenPrefix ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}` : `better-auth-token-${provider.providerId}`
65
+ }, {
66
+ field: "expiresAt",
67
+ value: /* @__PURE__ */ new Date(),
68
+ operator: "gt"
69
+ }]
70
+ });
71
+ if (activeVerification) {
72
+ ctx.setStatus(201);
73
+ return ctx.json({ domainVerificationToken: activeVerification.value });
74
+ }
75
+ const domainVerificationToken = generateRandomString(24);
76
+ await ctx.context.adapter.create({
77
+ model: "verification",
78
+ data: {
79
+ identifier: options.domainVerification?.tokenPrefix ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}` : `better-auth-token-${provider.providerId}`,
80
+ createdAt: /* @__PURE__ */ new Date(),
81
+ updatedAt: /* @__PURE__ */ new Date(),
82
+ value: domainVerificationToken,
83
+ expiresAt: new Date(Date.now() + 3600 * 24 * 7 * 1e3)
84
+ }
85
+ });
86
+ ctx.setStatus(201);
87
+ return ctx.json({ domainVerificationToken });
88
+ });
20
89
  };
21
- saml.setSchemaValidator(fastValidator);
90
+ const verifyDomain = (options) => {
91
+ return createAuthEndpoint("/sso/verify-domain", {
92
+ method: "POST",
93
+ body: z.object({ providerId: z.string() }),
94
+ metadata: { openapi: {
95
+ summary: "Verify the provider domain ownership",
96
+ description: "Verify the provider domain ownership via DNS records",
97
+ responses: {
98
+ "404": { description: "Provider not found" },
99
+ "409": { description: "Domain has already been verified or no pending verification exists" },
100
+ "502": { description: "Unable to verify domain ownership due to upstream validator error" },
101
+ "204": { description: "Domain ownership was verified" }
102
+ }
103
+ } },
104
+ use: [sessionMiddleware]
105
+ }, async (ctx) => {
106
+ const body = ctx.body;
107
+ const provider = await ctx.context.adapter.findOne({
108
+ model: "ssoProvider",
109
+ where: [{
110
+ field: "providerId",
111
+ value: body.providerId
112
+ }]
113
+ });
114
+ if (!provider) throw new APIError("NOT_FOUND", {
115
+ message: "Provider not found",
116
+ code: "PROVIDER_NOT_FOUND"
117
+ });
118
+ const userId = ctx.context.session.user.id;
119
+ let isOrgMember = true;
120
+ if (provider.organizationId) isOrgMember = await ctx.context.adapter.count({
121
+ model: "member",
122
+ where: [{
123
+ field: "userId",
124
+ value: userId
125
+ }, {
126
+ field: "organizationId",
127
+ value: provider.organizationId
128
+ }]
129
+ }) > 0;
130
+ if (provider.userId !== userId || !isOrgMember) throw new APIError("FORBIDDEN", {
131
+ message: "User must be owner of or belong to the SSO provider organization",
132
+ code: "INSUFICCIENT_ACCESS"
133
+ });
134
+ if ("domainVerified" in provider && provider.domainVerified) throw new APIError("CONFLICT", {
135
+ message: "Domain has already been verified",
136
+ code: "DOMAIN_VERIFIED"
137
+ });
138
+ const activeVerification = await ctx.context.adapter.findOne({
139
+ model: "verification",
140
+ where: [{
141
+ field: "identifier",
142
+ value: options.domainVerification?.tokenPrefix ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}` : `better-auth-token-${provider.providerId}`
143
+ }, {
144
+ field: "expiresAt",
145
+ value: /* @__PURE__ */ new Date(),
146
+ operator: "gt"
147
+ }]
148
+ });
149
+ if (!activeVerification) throw new APIError("NOT_FOUND", {
150
+ message: "No pending domain verification exists",
151
+ code: "NO_PENDING_VERIFICATION"
152
+ });
153
+ let records = [];
154
+ let dns;
155
+ try {
156
+ dns = await import("node:dns/promises");
157
+ } catch (error) {
158
+ ctx.context.logger.error("The core node:dns module is required for the domain verification feature", error);
159
+ throw new APIError("INTERNAL_SERVER_ERROR", {
160
+ message: "Unable to verify domain ownership due to server error",
161
+ code: "DOMAIN_VERIFICATION_FAILED"
162
+ });
163
+ }
164
+ try {
165
+ records = (await dns.resolveTxt(new URL(provider.domain).hostname)).flat();
166
+ } catch (error) {
167
+ ctx.context.logger.warn("DNS resolution failure while validating domain ownership", error);
168
+ }
169
+ if (!records.find((record) => record.includes(`${activeVerification.identifier}=${activeVerification.value}`))) throw new APIError("BAD_GATEWAY", {
170
+ message: "Unable to verify domain ownership. Try again later",
171
+ code: "DOMAIN_VERIFICATION_FAILED"
172
+ });
173
+ await ctx.context.adapter.update({
174
+ model: "ssoProvider",
175
+ where: [{
176
+ field: "providerId",
177
+ value: provider.providerId
178
+ }],
179
+ update: { domainVerified: true }
180
+ });
181
+ ctx.setStatus(204);
182
+ });
183
+ };
184
+
185
+ //#endregion
186
+ //#region src/utils.ts
187
+ const validateEmailDomain = (email, domain) => {
188
+ const emailDomain = email.split("@")[1]?.toLowerCase();
189
+ const providerDomain = domain.toLowerCase();
190
+ if (!emailDomain || !providerDomain) return false;
191
+ return emailDomain === providerDomain || emailDomain.endsWith(`.${providerDomain}`);
192
+ };
193
+
194
+ //#endregion
195
+ //#region src/routes/sso.ts
196
+ /**
197
+ * Safely parses a value that might be a JSON string or already a parsed object
198
+ * This handles cases where ORMs like Drizzle might return already parsed objects
199
+ * instead of JSON strings from TEXT/JSON columns
200
+ */
22
201
  function safeJsonParse(value) {
23
- if (!value) return null;
24
- if (typeof value === "object") {
25
- return value;
26
- }
27
- if (typeof value === "string") {
28
- try {
29
- return JSON.parse(value);
30
- } catch (error) {
31
- throw new Error(
32
- `Failed to parse JSON: ${error instanceof Error ? error.message : "Unknown error"}`
33
- );
34
- }
35
- }
36
- return null;
202
+ if (!value) return null;
203
+ if (typeof value === "object") return value;
204
+ if (typeof value === "string") try {
205
+ return JSON.parse(value);
206
+ } catch (error) {
207
+ throw new Error(`Failed to parse JSON: ${error instanceof Error ? error.message : "Unknown error"}`);
208
+ }
209
+ return null;
37
210
  }
38
- const sso = (options) => {
39
- return {
40
- id: "sso",
41
- endpoints: {
42
- spMetadata: createAuthEndpoint(
43
- "/sso/saml2/sp/metadata",
44
- {
45
- method: "GET",
46
- query: z.object({
47
- providerId: z.string(),
48
- format: z.enum(["xml", "json"]).default("xml")
49
- }),
50
- metadata: {
51
- openapi: {
52
- summary: "Get Service Provider metadata",
53
- description: "Returns the SAML metadata for the Service Provider",
54
- responses: {
55
- "200": {
56
- description: "SAML metadata in XML format"
57
- }
58
- }
59
- }
60
- }
61
- },
62
- async (ctx) => {
63
- const provider = await ctx.context.adapter.findOne({
64
- model: "ssoProvider",
65
- where: [
66
- {
67
- field: "providerId",
68
- value: ctx.query.providerId
69
- }
70
- ]
71
- });
72
- if (!provider) {
73
- throw new APIError("NOT_FOUND", {
74
- message: "No provider found for the given providerId"
75
- });
76
- }
77
- const parsedSamlConfig = safeJsonParse(
78
- provider.samlConfig
79
- );
80
- if (!parsedSamlConfig) {
81
- throw new APIError("BAD_REQUEST", {
82
- message: "Invalid SAML configuration"
83
- });
84
- }
85
- const sp = parsedSamlConfig.spMetadata.metadata ? saml.ServiceProvider({
86
- metadata: parsedSamlConfig.spMetadata.metadata
87
- }) : saml.SPMetadata({
88
- entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
89
- assertionConsumerService: [
90
- {
91
- Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
92
- Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.id}`
93
- }
94
- ],
95
- wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
96
- nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
97
- });
98
- return new Response(sp.getMetadata(), {
99
- headers: {
100
- "Content-Type": "application/xml"
101
- }
102
- });
103
- }
104
- ),
105
- registerSSOProvider: createAuthEndpoint(
106
- "/sso/register",
107
- {
108
- method: "POST",
109
- body: z.object({
110
- providerId: z.string({}).meta({
111
- description: "The ID of the provider. This is used to identify the provider during login and callback"
112
- }),
113
- issuer: z.string({}).meta({
114
- description: "The issuer of the provider"
115
- }),
116
- domain: z.string({}).meta({
117
- description: "The domain of the provider. This is used for email matching"
118
- }),
119
- oidcConfig: z.object({
120
- clientId: z.string({}).meta({
121
- description: "The client ID"
122
- }),
123
- clientSecret: z.string({}).meta({
124
- description: "The client secret"
125
- }),
126
- authorizationEndpoint: z.string({}).meta({
127
- description: "The authorization endpoint"
128
- }).optional(),
129
- tokenEndpoint: z.string({}).meta({
130
- description: "The token endpoint"
131
- }).optional(),
132
- userInfoEndpoint: z.string({}).meta({
133
- description: "The user info endpoint"
134
- }).optional(),
135
- tokenEndpointAuthentication: z.enum(["client_secret_post", "client_secret_basic"]).optional(),
136
- jwksEndpoint: z.string({}).meta({
137
- description: "The JWKS endpoint"
138
- }).optional(),
139
- discoveryEndpoint: z.string().optional(),
140
- scopes: z.array(z.string(), {}).meta({
141
- description: "The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']"
142
- }).optional(),
143
- pkce: z.boolean({}).meta({
144
- description: "Whether to use PKCE for the authorization flow"
145
- }).default(true).optional(),
146
- mapping: z.object({
147
- id: z.string({}).meta({
148
- description: "Field mapping for user ID (defaults to 'sub')"
149
- }),
150
- email: z.string({}).meta({
151
- description: "Field mapping for email (defaults to 'email')"
152
- }),
153
- emailVerified: z.string({}).meta({
154
- description: "Field mapping for email verification (defaults to 'email_verified')"
155
- }).optional(),
156
- name: z.string({}).meta({
157
- description: "Field mapping for name (defaults to 'name')"
158
- }),
159
- image: z.string({}).meta({
160
- description: "Field mapping for image (defaults to 'picture')"
161
- }).optional(),
162
- extraFields: z.record(z.string(), z.any()).optional()
163
- }).optional()
164
- }).optional(),
165
- samlConfig: z.object({
166
- entryPoint: z.string({}).meta({
167
- description: "The entry point of the provider"
168
- }),
169
- cert: z.string({}).meta({
170
- description: "The certificate of the provider"
171
- }),
172
- callbackUrl: z.string({}).meta({
173
- description: "The callback URL of the provider"
174
- }),
175
- audience: z.string().optional(),
176
- idpMetadata: z.object({
177
- metadata: z.string().optional(),
178
- entityID: z.string().optional(),
179
- cert: z.string().optional(),
180
- privateKey: z.string().optional(),
181
- privateKeyPass: z.string().optional(),
182
- isAssertionEncrypted: z.boolean().optional(),
183
- encPrivateKey: z.string().optional(),
184
- encPrivateKeyPass: z.string().optional(),
185
- singleSignOnService: z.array(
186
- z.object({
187
- Binding: z.string().meta({
188
- description: "The binding type for the SSO service"
189
- }),
190
- Location: z.string().meta({
191
- description: "The URL for the SSO service"
192
- })
193
- })
194
- ).optional().meta({
195
- description: "Single Sign-On service configuration"
196
- })
197
- }).optional(),
198
- spMetadata: z.object({
199
- metadata: z.string().optional(),
200
- entityID: z.string().optional(),
201
- binding: z.string().optional(),
202
- privateKey: z.string().optional(),
203
- privateKeyPass: z.string().optional(),
204
- isAssertionEncrypted: z.boolean().optional(),
205
- encPrivateKey: z.string().optional(),
206
- encPrivateKeyPass: z.string().optional()
207
- }),
208
- wantAssertionsSigned: z.boolean().optional(),
209
- signatureAlgorithm: z.string().optional(),
210
- digestAlgorithm: z.string().optional(),
211
- identifierFormat: z.string().optional(),
212
- privateKey: z.string().optional(),
213
- decryptionPvk: z.string().optional(),
214
- additionalParams: z.record(z.string(), z.any()).optional(),
215
- mapping: z.object({
216
- id: z.string({}).meta({
217
- description: "Field mapping for user ID (defaults to 'nameID')"
218
- }),
219
- email: z.string({}).meta({
220
- description: "Field mapping for email (defaults to 'email')"
221
- }),
222
- emailVerified: z.string({}).meta({
223
- description: "Field mapping for email verification"
224
- }).optional(),
225
- name: z.string({}).meta({
226
- description: "Field mapping for name (defaults to 'displayName')"
227
- }),
228
- firstName: z.string({}).meta({
229
- description: "Field mapping for first name (defaults to 'givenName')"
230
- }).optional(),
231
- lastName: z.string({}).meta({
232
- description: "Field mapping for last name (defaults to 'surname')"
233
- }).optional(),
234
- extraFields: z.record(z.string(), z.any()).optional()
235
- }).optional()
236
- }).optional(),
237
- organizationId: z.string({}).meta({
238
- description: "If organization plugin is enabled, the organization id to link the provider to"
239
- }).optional(),
240
- overrideUserInfo: z.boolean({}).meta({
241
- description: "Override user info with the provider info. Defaults to false"
242
- }).default(false).optional()
243
- }),
244
- use: [sessionMiddleware],
245
- metadata: {
246
- openapi: {
247
- summary: "Register an OIDC provider",
248
- description: "This endpoint is used to register an OIDC provider. This is used to configure the provider and link it to an organization",
249
- responses: {
250
- "200": {
251
- description: "OIDC provider created successfully",
252
- content: {
253
- "application/json": {
254
- schema: {
255
- type: "object",
256
- properties: {
257
- issuer: {
258
- type: "string",
259
- format: "uri",
260
- description: "The issuer URL of the provider"
261
- },
262
- domain: {
263
- type: "string",
264
- description: "The domain of the provider, used for email matching"
265
- },
266
- oidcConfig: {
267
- type: "object",
268
- properties: {
269
- issuer: {
270
- type: "string",
271
- format: "uri",
272
- description: "The issuer URL of the provider"
273
- },
274
- pkce: {
275
- type: "boolean",
276
- description: "Whether PKCE is enabled for the authorization flow"
277
- },
278
- clientId: {
279
- type: "string",
280
- description: "The client ID for the provider"
281
- },
282
- clientSecret: {
283
- type: "string",
284
- description: "The client secret for the provider"
285
- },
286
- authorizationEndpoint: {
287
- type: "string",
288
- format: "uri",
289
- nullable: true,
290
- description: "The authorization endpoint URL"
291
- },
292
- discoveryEndpoint: {
293
- type: "string",
294
- format: "uri",
295
- description: "The discovery endpoint URL"
296
- },
297
- userInfoEndpoint: {
298
- type: "string",
299
- format: "uri",
300
- nullable: true,
301
- description: "The user info endpoint URL"
302
- },
303
- scopes: {
304
- type: "array",
305
- items: { type: "string" },
306
- nullable: true,
307
- description: "The scopes requested from the provider"
308
- },
309
- tokenEndpoint: {
310
- type: "string",
311
- format: "uri",
312
- nullable: true,
313
- description: "The token endpoint URL"
314
- },
315
- tokenEndpointAuthentication: {
316
- type: "string",
317
- enum: [
318
- "client_secret_post",
319
- "client_secret_basic"
320
- ],
321
- nullable: true,
322
- description: "Authentication method for the token endpoint"
323
- },
324
- jwksEndpoint: {
325
- type: "string",
326
- format: "uri",
327
- nullable: true,
328
- description: "The JWKS endpoint URL"
329
- },
330
- mapping: {
331
- type: "object",
332
- nullable: true,
333
- properties: {
334
- id: {
335
- type: "string",
336
- description: "Field mapping for user ID (defaults to 'sub')"
337
- },
338
- email: {
339
- type: "string",
340
- description: "Field mapping for email (defaults to 'email')"
341
- },
342
- emailVerified: {
343
- type: "string",
344
- nullable: true,
345
- description: "Field mapping for email verification (defaults to 'email_verified')"
346
- },
347
- name: {
348
- type: "string",
349
- description: "Field mapping for name (defaults to 'name')"
350
- },
351
- image: {
352
- type: "string",
353
- nullable: true,
354
- description: "Field mapping for image (defaults to 'picture')"
355
- },
356
- extraFields: {
357
- type: "object",
358
- additionalProperties: { type: "string" },
359
- nullable: true,
360
- description: "Additional field mappings"
361
- }
362
- },
363
- required: ["id", "email", "name"]
364
- }
365
- },
366
- required: [
367
- "issuer",
368
- "pkce",
369
- "clientId",
370
- "clientSecret",
371
- "discoveryEndpoint"
372
- ],
373
- description: "OIDC configuration for the provider"
374
- },
375
- organizationId: {
376
- type: "string",
377
- nullable: true,
378
- description: "ID of the linked organization, if any"
379
- },
380
- userId: {
381
- type: "string",
382
- description: "ID of the user who registered the provider"
383
- },
384
- providerId: {
385
- type: "string",
386
- description: "Unique identifier for the provider"
387
- },
388
- redirectURI: {
389
- type: "string",
390
- format: "uri",
391
- description: "The redirect URI for the provider callback"
392
- }
393
- },
394
- required: [
395
- "issuer",
396
- "domain",
397
- "oidcConfig",
398
- "userId",
399
- "providerId",
400
- "redirectURI"
401
- ]
402
- }
403
- }
404
- }
405
- }
406
- }
407
- }
408
- }
409
- },
410
- async (ctx) => {
411
- const user = ctx.context.session?.user;
412
- if (!user) {
413
- throw new APIError("UNAUTHORIZED");
414
- }
415
- const limit = typeof options?.providersLimit === "function" ? await options.providersLimit(user) : options?.providersLimit ?? 10;
416
- if (!limit) {
417
- throw new APIError("FORBIDDEN", {
418
- message: "SSO provider registration is disabled"
419
- });
420
- }
421
- const providers = await ctx.context.adapter.findMany({
422
- model: "ssoProvider",
423
- where: [{ field: "userId", value: user.id }]
424
- });
425
- if (providers.length >= limit) {
426
- throw new APIError("FORBIDDEN", {
427
- message: "You have reached the maximum number of SSO providers"
428
- });
429
- }
430
- const body = ctx.body;
431
- const issuerValidator = z.string().url();
432
- if (issuerValidator.safeParse(body.issuer).error) {
433
- throw new APIError("BAD_REQUEST", {
434
- message: "Invalid issuer. Must be a valid URL"
435
- });
436
- }
437
- if (ctx.body.organizationId) {
438
- const organization = await ctx.context.adapter.findOne({
439
- model: "member",
440
- where: [
441
- {
442
- field: "userId",
443
- value: user.id
444
- },
445
- {
446
- field: "organizationId",
447
- value: ctx.body.organizationId
448
- }
449
- ]
450
- });
451
- if (!organization) {
452
- throw new APIError("BAD_REQUEST", {
453
- message: "You are not a member of the organization"
454
- });
455
- }
456
- }
457
- const existingProvider = await ctx.context.adapter.findOne({
458
- model: "ssoProvider",
459
- where: [
460
- {
461
- field: "providerId",
462
- value: body.providerId
463
- }
464
- ]
465
- });
466
- if (existingProvider) {
467
- ctx.context.logger.info(
468
- `SSO provider creation attempt with existing providerId: ${body.providerId}`
469
- );
470
- throw new APIError("UNPROCESSABLE_ENTITY", {
471
- message: "SSO provider with this providerId already exists"
472
- });
473
- }
474
- const provider = await ctx.context.adapter.create({
475
- model: "ssoProvider",
476
- data: {
477
- issuer: body.issuer,
478
- domain: body.domain,
479
- oidcConfig: body.oidcConfig ? JSON.stringify({
480
- issuer: body.issuer,
481
- clientId: body.oidcConfig.clientId,
482
- clientSecret: body.oidcConfig.clientSecret,
483
- authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
484
- tokenEndpoint: body.oidcConfig.tokenEndpoint,
485
- tokenEndpointAuthentication: body.oidcConfig.tokenEndpointAuthentication,
486
- jwksEndpoint: body.oidcConfig.jwksEndpoint,
487
- pkce: body.oidcConfig.pkce,
488
- discoveryEndpoint: body.oidcConfig.discoveryEndpoint || `${body.issuer}/.well-known/openid-configuration`,
489
- mapping: body.oidcConfig.mapping,
490
- scopes: body.oidcConfig.scopes,
491
- userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
492
- overrideUserInfo: ctx.body.overrideUserInfo || options?.defaultOverrideUserInfo || false
493
- }) : null,
494
- samlConfig: body.samlConfig ? JSON.stringify({
495
- issuer: body.issuer,
496
- entryPoint: body.samlConfig.entryPoint,
497
- cert: body.samlConfig.cert,
498
- callbackUrl: body.samlConfig.callbackUrl,
499
- audience: body.samlConfig.audience,
500
- idpMetadata: body.samlConfig.idpMetadata,
501
- spMetadata: body.samlConfig.spMetadata,
502
- wantAssertionsSigned: body.samlConfig.wantAssertionsSigned,
503
- signatureAlgorithm: body.samlConfig.signatureAlgorithm,
504
- digestAlgorithm: body.samlConfig.digestAlgorithm,
505
- identifierFormat: body.samlConfig.identifierFormat,
506
- privateKey: body.samlConfig.privateKey,
507
- decryptionPvk: body.samlConfig.decryptionPvk,
508
- additionalParams: body.samlConfig.additionalParams,
509
- mapping: body.samlConfig.mapping
510
- }) : null,
511
- organizationId: body.organizationId,
512
- userId: ctx.context.session.user.id,
513
- providerId: body.providerId
514
- }
515
- });
516
- return ctx.json({
517
- ...provider,
518
- oidcConfig: JSON.parse(
519
- provider.oidcConfig
520
- ),
521
- samlConfig: JSON.parse(
522
- provider.samlConfig
523
- ),
524
- redirectURI: `${ctx.context.baseURL}/sso/callback/${provider.providerId}`
525
- });
526
- }
527
- ),
528
- signInSSO: createAuthEndpoint(
529
- "/sign-in/sso",
530
- {
531
- method: "POST",
532
- body: z.object({
533
- email: z.string({}).meta({
534
- 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"
535
- }).optional(),
536
- organizationSlug: z.string({}).meta({
537
- description: "The slug of the organization to sign in with"
538
- }).optional(),
539
- providerId: z.string({}).meta({
540
- description: "The ID of the provider to sign in with. This can be provided instead of email or issuer"
541
- }).optional(),
542
- domain: z.string({}).meta({
543
- description: "The domain of the provider."
544
- }).optional(),
545
- callbackURL: z.string({}).meta({
546
- description: "The URL to redirect to after login"
547
- }),
548
- errorCallbackURL: z.string({}).meta({
549
- description: "The URL to redirect to after login"
550
- }).optional(),
551
- newUserCallbackURL: z.string({}).meta({
552
- description: "The URL to redirect to after login if the user is new"
553
- }).optional(),
554
- scopes: z.array(z.string(), {}).meta({
555
- description: "Scopes to request from the provider."
556
- }).optional(),
557
- requestSignUp: z.boolean({}).meta({
558
- description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider"
559
- }).optional(),
560
- providerType: z.enum(["oidc", "saml"]).optional()
561
- }),
562
- metadata: {
563
- openapi: {
564
- summary: "Sign in with SSO provider",
565
- description: "This endpoint is used to sign in with an SSO provider. It redirects to the provider's authorization URL",
566
- requestBody: {
567
- content: {
568
- "application/json": {
569
- schema: {
570
- type: "object",
571
- properties: {
572
- email: {
573
- type: "string",
574
- 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"
575
- },
576
- issuer: {
577
- type: "string",
578
- 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"
579
- },
580
- providerId: {
581
- type: "string",
582
- description: "The ID of the provider to sign in with. This can be provided instead of email or issuer"
583
- },
584
- callbackURL: {
585
- type: "string",
586
- description: "The URL to redirect to after login"
587
- },
588
- errorCallbackURL: {
589
- type: "string",
590
- description: "The URL to redirect to after login"
591
- },
592
- newUserCallbackURL: {
593
- type: "string",
594
- description: "The URL to redirect to after login if the user is new"
595
- }
596
- },
597
- required: ["callbackURL"]
598
- }
599
- }
600
- }
601
- },
602
- responses: {
603
- "200": {
604
- description: "Authorization URL generated successfully for SSO sign-in",
605
- content: {
606
- "application/json": {
607
- schema: {
608
- type: "object",
609
- properties: {
610
- url: {
611
- type: "string",
612
- format: "uri",
613
- description: "The authorization URL to redirect the user to for SSO sign-in"
614
- },
615
- redirect: {
616
- type: "boolean",
617
- description: "Indicates that the client should redirect to the provided URL",
618
- enum: [true]
619
- }
620
- },
621
- required: ["url", "redirect"]
622
- }
623
- }
624
- }
625
- }
626
- }
627
- }
628
- }
629
- },
630
- async (ctx) => {
631
- const body = ctx.body;
632
- let { email, organizationSlug, providerId, domain } = body;
633
- if (!options?.defaultSSO?.length && !email && !organizationSlug && !domain && !providerId) {
634
- throw new APIError("BAD_REQUEST", {
635
- message: "email, organizationSlug, domain or providerId is required"
636
- });
637
- }
638
- domain = body.domain || email?.split("@")[1];
639
- let orgId = "";
640
- if (organizationSlug) {
641
- orgId = await ctx.context.adapter.findOne({
642
- model: "organization",
643
- where: [
644
- {
645
- field: "slug",
646
- value: organizationSlug
647
- }
648
- ]
649
- }).then((res) => {
650
- if (!res) {
651
- return "";
652
- }
653
- return res.id;
654
- });
655
- }
656
- let provider = null;
657
- if (options?.defaultSSO?.length) {
658
- const matchingDefault = providerId ? options.defaultSSO.find(
659
- (defaultProvider) => defaultProvider.providerId === providerId
660
- ) : options.defaultSSO.find(
661
- (defaultProvider) => defaultProvider.domain === domain
662
- );
663
- if (matchingDefault) {
664
- provider = {
665
- issuer: matchingDefault.samlConfig?.issuer || matchingDefault.oidcConfig?.issuer || "",
666
- providerId: matchingDefault.providerId,
667
- userId: "default",
668
- oidcConfig: matchingDefault.oidcConfig,
669
- samlConfig: matchingDefault.samlConfig
670
- };
671
- }
672
- }
673
- if (!providerId && !orgId && !domain) {
674
- throw new APIError("BAD_REQUEST", {
675
- message: "providerId, orgId or domain is required"
676
- });
677
- }
678
- if (!provider) {
679
- provider = await ctx.context.adapter.findOne({
680
- model: "ssoProvider",
681
- where: [
682
- {
683
- field: providerId ? "providerId" : orgId ? "organizationId" : "domain",
684
- value: providerId || orgId || domain
685
- }
686
- ]
687
- }).then((res) => {
688
- if (!res) {
689
- return null;
690
- }
691
- return {
692
- ...res,
693
- oidcConfig: res.oidcConfig ? safeJsonParse(
694
- res.oidcConfig
695
- ) || void 0 : void 0,
696
- samlConfig: res.samlConfig ? safeJsonParse(
697
- res.samlConfig
698
- ) || void 0 : void 0
699
- };
700
- });
701
- }
702
- if (!provider) {
703
- throw new APIError("NOT_FOUND", {
704
- message: "No provider found for the issuer"
705
- });
706
- }
707
- if (body.providerType) {
708
- if (body.providerType === "oidc" && !provider.oidcConfig) {
709
- throw new APIError("BAD_REQUEST", {
710
- message: "OIDC provider is not configured"
711
- });
712
- }
713
- if (body.providerType === "saml" && !provider.samlConfig) {
714
- throw new APIError("BAD_REQUEST", {
715
- message: "SAML provider is not configured"
716
- });
717
- }
718
- }
719
- if (provider.oidcConfig && body.providerType !== "saml") {
720
- const state = await generateState(ctx);
721
- const redirectURI = `${ctx.context.baseURL}/sso/callback/${provider.providerId}`;
722
- const authorizationURL = await createAuthorizationURL({
723
- id: provider.issuer,
724
- options: {
725
- clientId: provider.oidcConfig.clientId,
726
- clientSecret: provider.oidcConfig.clientSecret
727
- },
728
- redirectURI,
729
- state: state.state,
730
- codeVerifier: provider.oidcConfig.pkce ? state.codeVerifier : void 0,
731
- scopes: ctx.body.scopes || provider.oidcConfig.scopes || [
732
- "openid",
733
- "email",
734
- "profile",
735
- "offline_access"
736
- ],
737
- authorizationEndpoint: provider.oidcConfig.authorizationEndpoint
738
- });
739
- return ctx.json({
740
- url: authorizationURL.toString(),
741
- redirect: true
742
- });
743
- }
744
- if (provider.samlConfig) {
745
- const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(
746
- provider.samlConfig
747
- );
748
- if (!parsedSamlConfig) {
749
- throw new APIError("BAD_REQUEST", {
750
- message: "Invalid SAML configuration"
751
- });
752
- }
753
- const sp = saml.ServiceProvider({
754
- metadata: parsedSamlConfig.spMetadata.metadata,
755
- allowCreate: true
756
- });
757
- const idp = saml.IdentityProvider({
758
- metadata: parsedSamlConfig.idpMetadata?.metadata,
759
- entityID: parsedSamlConfig.idpMetadata?.entityID,
760
- encryptCert: parsedSamlConfig.idpMetadata?.cert,
761
- singleSignOnService: parsedSamlConfig.idpMetadata?.singleSignOnService
762
- });
763
- const loginRequest = sp.createLoginRequest(
764
- idp,
765
- "redirect"
766
- );
767
- if (!loginRequest) {
768
- throw new APIError("BAD_REQUEST", {
769
- message: "Invalid SAML request"
770
- });
771
- }
772
- return ctx.json({
773
- url: `${loginRequest.context}&RelayState=${encodeURIComponent(
774
- body.callbackURL
775
- )}`,
776
- redirect: true
777
- });
778
- }
779
- throw new APIError("BAD_REQUEST", {
780
- message: "Invalid SSO provider"
781
- });
782
- }
783
- ),
784
- callbackSSO: createAuthEndpoint(
785
- "/sso/callback/:providerId",
786
- {
787
- method: "GET",
788
- query: z.object({
789
- code: z.string().optional(),
790
- state: z.string(),
791
- error: z.string().optional(),
792
- error_description: z.string().optional()
793
- }),
794
- metadata: {
795
- isAction: false,
796
- openapi: {
797
- summary: "Callback URL for SSO provider",
798
- description: "This endpoint is used as the callback URL for SSO providers. It handles the authorization code and exchanges it for an access token",
799
- responses: {
800
- "302": {
801
- description: "Redirects to the callback URL"
802
- }
803
- }
804
- }
805
- }
806
- },
807
- async (ctx) => {
808
- const { code, state, error, error_description } = ctx.query;
809
- const stateData = await parseState(ctx);
810
- if (!stateData) {
811
- const errorURL2 = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
812
- throw ctx.redirect(`${errorURL2}?error=invalid_state`);
813
- }
814
- const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
815
- if (!code || error) {
816
- throw ctx.redirect(
817
- `${errorURL || callbackURL}?error=${error}&error_description=${error_description}`
818
- );
819
- }
820
- let provider = null;
821
- if (options?.defaultSSO?.length) {
822
- const matchingDefault = options.defaultSSO.find(
823
- (defaultProvider) => defaultProvider.providerId === ctx.params.providerId
824
- );
825
- if (matchingDefault) {
826
- provider = {
827
- ...matchingDefault,
828
- issuer: matchingDefault.oidcConfig?.issuer || "",
829
- userId: "default"
830
- };
831
- }
832
- }
833
- if (!provider) {
834
- provider = await ctx.context.adapter.findOne({
835
- model: "ssoProvider",
836
- where: [
837
- {
838
- field: "providerId",
839
- value: ctx.params.providerId
840
- }
841
- ]
842
- }).then((res) => {
843
- if (!res) {
844
- return null;
845
- }
846
- return {
847
- ...res,
848
- oidcConfig: safeJsonParse(res.oidcConfig) || void 0
849
- };
850
- });
851
- }
852
- if (!provider) {
853
- throw ctx.redirect(
854
- `${errorURL || callbackURL}/error?error=invalid_provider&error_description=provider not found`
855
- );
856
- }
857
- let config = provider.oidcConfig;
858
- if (!config) {
859
- throw ctx.redirect(
860
- `${errorURL || callbackURL}/error?error=invalid_provider&error_description=provider not found`
861
- );
862
- }
863
- const discovery = await betterFetch(config.discoveryEndpoint);
864
- if (discovery.data) {
865
- config = {
866
- tokenEndpoint: discovery.data.token_endpoint,
867
- tokenEndpointAuthentication: discovery.data.token_endpoint_auth_method,
868
- userInfoEndpoint: discovery.data.userinfo_endpoint,
869
- scopes: ["openid", "email", "profile", "offline_access"],
870
- ...config
871
- };
872
- }
873
- if (!config.tokenEndpoint) {
874
- throw ctx.redirect(
875
- `${errorURL || callbackURL}/error?error=invalid_provider&error_description=token_endpoint_not_found`
876
- );
877
- }
878
- const tokenResponse = await validateAuthorizationCode({
879
- code,
880
- codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
881
- redirectURI: `${ctx.context.baseURL}/sso/callback/${provider.providerId}`,
882
- options: {
883
- clientId: config.clientId,
884
- clientSecret: config.clientSecret
885
- },
886
- tokenEndpoint: config.tokenEndpoint,
887
- authentication: config.tokenEndpointAuthentication === "client_secret_post" ? "post" : "basic"
888
- }).catch((e) => {
889
- if (e instanceof BetterFetchError) {
890
- throw ctx.redirect(
891
- `${errorURL || callbackURL}?error=invalid_provider&error_description=${e.message}`
892
- );
893
- }
894
- return null;
895
- });
896
- if (!tokenResponse) {
897
- throw ctx.redirect(
898
- `${errorURL || callbackURL}/error?error=invalid_provider&error_description=token_response_not_found`
899
- );
900
- }
901
- let userInfo = null;
902
- if (tokenResponse.idToken) {
903
- const idToken = decodeJwt(tokenResponse.idToken);
904
- if (!config.jwksEndpoint) {
905
- throw ctx.redirect(
906
- `${errorURL || callbackURL}/error?error=invalid_provider&error_description=jwks_endpoint_not_found`
907
- );
908
- }
909
- const verified = await validateToken(
910
- tokenResponse.idToken,
911
- config.jwksEndpoint
912
- ).catch((e) => {
913
- ctx.context.logger.error(e);
914
- return null;
915
- });
916
- if (!verified) {
917
- throw ctx.redirect(
918
- `${errorURL || callbackURL}/error?error=invalid_provider&error_description=token_not_verified`
919
- );
920
- }
921
- if (verified.payload.iss !== provider.issuer) {
922
- throw ctx.redirect(
923
- `${errorURL || callbackURL}/error?error=invalid_provider&error_description=issuer_mismatch`
924
- );
925
- }
926
- const mapping = config.mapping || {};
927
- userInfo = {
928
- ...Object.fromEntries(
929
- Object.entries(mapping.extraFields || {}).map(
930
- ([key, value]) => [key, verified.payload[value]]
931
- )
932
- ),
933
- id: idToken[mapping.id || "sub"],
934
- email: idToken[mapping.email || "email"],
935
- emailVerified: options?.trustEmailVerified ? idToken[mapping.emailVerified || "email_verified"] : false,
936
- name: idToken[mapping.name || "name"],
937
- image: idToken[mapping.image || "picture"]
938
- };
939
- }
940
- if (!userInfo) {
941
- if (!config.userInfoEndpoint) {
942
- throw ctx.redirect(
943
- `${errorURL || callbackURL}/error?error=invalid_provider&error_description=user_info_endpoint_not_found`
944
- );
945
- }
946
- const userInfoResponse = await betterFetch(config.userInfoEndpoint, {
947
- headers: {
948
- Authorization: `Bearer ${tokenResponse.accessToken}`
949
- }
950
- });
951
- if (userInfoResponse.error) {
952
- throw ctx.redirect(
953
- `${errorURL || callbackURL}/error?error=invalid_provider&error_description=${userInfoResponse.error.message}`
954
- );
955
- }
956
- userInfo = userInfoResponse.data;
957
- }
958
- if (!userInfo.email || !userInfo.id) {
959
- throw ctx.redirect(
960
- `${errorURL || callbackURL}/error?error=invalid_provider&error_description=missing_user_info`
961
- );
962
- }
963
- const linked = await handleOAuthUserInfo(ctx, {
964
- userInfo: {
965
- email: userInfo.email,
966
- name: userInfo.name || userInfo.email,
967
- id: userInfo.id,
968
- image: userInfo.image,
969
- emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
970
- },
971
- account: {
972
- idToken: tokenResponse.idToken,
973
- accessToken: tokenResponse.accessToken,
974
- refreshToken: tokenResponse.refreshToken,
975
- accountId: userInfo.id,
976
- providerId: provider.providerId,
977
- accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
978
- refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
979
- scope: tokenResponse.scopes?.join(",")
980
- },
981
- callbackURL,
982
- disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
983
- overrideUserInfo: config.overrideUserInfo
984
- });
985
- if (linked.error) {
986
- throw ctx.redirect(
987
- `${errorURL || callbackURL}/error?error=${linked.error}`
988
- );
989
- }
990
- const { session, user } = linked.data;
991
- if (options?.provisionUser) {
992
- await options.provisionUser({
993
- user,
994
- userInfo,
995
- token: tokenResponse,
996
- provider
997
- });
998
- }
999
- if (provider.organizationId && !options?.organizationProvisioning?.disabled) {
1000
- const isOrgPluginEnabled = ctx.context.options.plugins?.find(
1001
- (plugin) => plugin.id === "organization"
1002
- );
1003
- if (isOrgPluginEnabled) {
1004
- const isAlreadyMember = await ctx.context.adapter.findOne({
1005
- model: "member",
1006
- where: [
1007
- { field: "organizationId", value: provider.organizationId },
1008
- { field: "userId", value: user.id }
1009
- ]
1010
- });
1011
- if (!isAlreadyMember) {
1012
- const role = options?.organizationProvisioning?.getRole ? await options.organizationProvisioning.getRole({
1013
- user,
1014
- userInfo,
1015
- token: tokenResponse,
1016
- provider
1017
- }) : options?.organizationProvisioning?.defaultRole || "member";
1018
- await ctx.context.adapter.create({
1019
- model: "member",
1020
- data: {
1021
- organizationId: provider.organizationId,
1022
- userId: user.id,
1023
- role,
1024
- createdAt: /* @__PURE__ */ new Date(),
1025
- updatedAt: /* @__PURE__ */ new Date()
1026
- }
1027
- });
1028
- }
1029
- }
1030
- }
1031
- await setSessionCookie(ctx, {
1032
- session,
1033
- user
1034
- });
1035
- let toRedirectTo;
1036
- try {
1037
- const url = linked.isRegister ? newUserURL || callbackURL : callbackURL;
1038
- toRedirectTo = url.toString();
1039
- } catch {
1040
- toRedirectTo = linked.isRegister ? newUserURL || callbackURL : callbackURL;
1041
- }
1042
- throw ctx.redirect(toRedirectTo);
1043
- }
1044
- ),
1045
- callbackSSOSAML: createAuthEndpoint(
1046
- "/sso/saml2/callback/:providerId",
1047
- {
1048
- method: "POST",
1049
- body: z.object({
1050
- SAMLResponse: z.string(),
1051
- RelayState: z.string().optional()
1052
- }),
1053
- metadata: {
1054
- isAction: false,
1055
- openapi: {
1056
- summary: "Callback URL for SAML provider",
1057
- description: "This endpoint is used as the callback URL for SAML providers.",
1058
- responses: {
1059
- "302": {
1060
- description: "Redirects to the callback URL"
1061
- },
1062
- "400": {
1063
- description: "Invalid SAML response"
1064
- },
1065
- "401": {
1066
- description: "Unauthorized - SAML authentication failed"
1067
- }
1068
- }
1069
- }
1070
- }
1071
- },
1072
- async (ctx) => {
1073
- const { SAMLResponse, RelayState } = ctx.body;
1074
- const { providerId } = ctx.params;
1075
- let provider = null;
1076
- if (options?.defaultSSO?.length) {
1077
- const matchingDefault = options.defaultSSO.find(
1078
- (defaultProvider) => defaultProvider.providerId === providerId
1079
- );
1080
- if (matchingDefault) {
1081
- provider = {
1082
- ...matchingDefault,
1083
- userId: "default",
1084
- issuer: matchingDefault.samlConfig?.issuer || ""
1085
- };
1086
- }
1087
- }
1088
- if (!provider) {
1089
- provider = await ctx.context.adapter.findOne({
1090
- model: "ssoProvider",
1091
- where: [{ field: "providerId", value: providerId }]
1092
- }).then((res) => {
1093
- if (!res) return null;
1094
- return {
1095
- ...res,
1096
- samlConfig: res.samlConfig ? safeJsonParse(
1097
- res.samlConfig
1098
- ) || void 0 : void 0
1099
- };
1100
- });
1101
- }
1102
- if (!provider) {
1103
- throw new APIError("NOT_FOUND", {
1104
- message: "No provider found for the given providerId"
1105
- });
1106
- }
1107
- const parsedSamlConfig = safeJsonParse(
1108
- provider.samlConfig
1109
- );
1110
- if (!parsedSamlConfig) {
1111
- throw new APIError("BAD_REQUEST", {
1112
- message: "Invalid SAML configuration"
1113
- });
1114
- }
1115
- const idpData = parsedSamlConfig.idpMetadata;
1116
- let idp = null;
1117
- if (!idpData?.metadata) {
1118
- idp = saml.IdentityProvider({
1119
- entityID: idpData?.entityID || parsedSamlConfig.issuer,
1120
- singleSignOnService: [
1121
- {
1122
- Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1123
- Location: parsedSamlConfig.entryPoint
1124
- }
1125
- ],
1126
- signingCert: idpData?.cert || parsedSamlConfig.cert,
1127
- wantAuthnRequestsSigned: parsedSamlConfig.wantAssertionsSigned || false,
1128
- isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
1129
- encPrivateKey: idpData?.encPrivateKey,
1130
- encPrivateKeyPass: idpData?.encPrivateKeyPass
1131
- });
1132
- } else {
1133
- idp = saml.IdentityProvider({
1134
- metadata: idpData.metadata,
1135
- privateKey: idpData.privateKey,
1136
- privateKeyPass: idpData.privateKeyPass,
1137
- isAssertionEncrypted: idpData.isAssertionEncrypted,
1138
- encPrivateKey: idpData.encPrivateKey,
1139
- encPrivateKeyPass: idpData.encPrivateKeyPass
1140
- });
1141
- }
1142
- const spData = parsedSamlConfig.spMetadata;
1143
- const sp = saml.ServiceProvider({
1144
- metadata: spData?.metadata,
1145
- entityID: spData?.entityID || parsedSamlConfig.issuer,
1146
- assertionConsumerService: spData?.metadata ? void 0 : [
1147
- {
1148
- Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1149
- Location: parsedSamlConfig.callbackUrl
1150
- }
1151
- ],
1152
- privateKey: spData?.privateKey || parsedSamlConfig.privateKey,
1153
- privateKeyPass: spData?.privateKeyPass,
1154
- isAssertionEncrypted: spData?.isAssertionEncrypted || false,
1155
- encPrivateKey: spData?.encPrivateKey,
1156
- encPrivateKeyPass: spData?.encPrivateKeyPass,
1157
- wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1158
- nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
1159
- });
1160
- let parsedResponse;
1161
- try {
1162
- const decodedResponse = Buffer.from(
1163
- SAMLResponse,
1164
- "base64"
1165
- ).toString("utf-8");
1166
- try {
1167
- parsedResponse = await sp.parseLoginResponse(idp, "post", {
1168
- body: {
1169
- SAMLResponse,
1170
- RelayState: RelayState || void 0
1171
- }
1172
- });
1173
- } catch (parseError) {
1174
- const nameIDMatch = decodedResponse.match(
1175
- /<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/
1176
- );
1177
- if (!nameIDMatch) throw parseError;
1178
- parsedResponse = {
1179
- extract: {
1180
- nameID: nameIDMatch[1],
1181
- attributes: { nameID: nameIDMatch[1] },
1182
- sessionIndex: {},
1183
- conditions: {}
1184
- }
1185
- };
1186
- }
1187
- if (!parsedResponse?.extract) {
1188
- throw new Error("Invalid SAML response structure");
1189
- }
1190
- } catch (error) {
1191
- ctx.context.logger.error("SAML response validation failed", {
1192
- error,
1193
- decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
1194
- "utf-8"
1195
- )
1196
- });
1197
- throw new APIError("BAD_REQUEST", {
1198
- message: "Invalid SAML response",
1199
- details: error instanceof Error ? error.message : String(error)
1200
- });
1201
- }
1202
- const { extract } = parsedResponse;
1203
- const attributes = extract.attributes || {};
1204
- const mapping = parsedSamlConfig.mapping ?? {};
1205
- const userInfo = {
1206
- ...Object.fromEntries(
1207
- Object.entries(mapping.extraFields || {}).map(([key, value]) => [
1208
- key,
1209
- attributes[value]
1210
- ])
1211
- ),
1212
- id: attributes[mapping.id || "nameID"] || extract.nameID,
1213
- email: attributes[mapping.email || "email"] || extract.nameID,
1214
- name: [
1215
- attributes[mapping.firstName || "givenName"],
1216
- attributes[mapping.lastName || "surname"]
1217
- ].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
1218
- emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
1219
- };
1220
- if (!userInfo.id || !userInfo.email) {
1221
- ctx.context.logger.error(
1222
- "Missing essential user info from SAML response",
1223
- {
1224
- attributes: Object.keys(attributes),
1225
- mapping,
1226
- extractedId: userInfo.id,
1227
- extractedEmail: userInfo.email
1228
- }
1229
- );
1230
- throw new APIError("BAD_REQUEST", {
1231
- message: "Unable to extract user ID or email from SAML response"
1232
- });
1233
- }
1234
- let user;
1235
- const existingUser = await ctx.context.adapter.findOne({
1236
- model: "user",
1237
- where: [
1238
- {
1239
- field: "email",
1240
- value: userInfo.email
1241
- }
1242
- ]
1243
- });
1244
- if (existingUser) {
1245
- user = existingUser;
1246
- } else {
1247
- user = await ctx.context.adapter.create({
1248
- model: "user",
1249
- data: {
1250
- email: userInfo.email,
1251
- name: userInfo.name,
1252
- emailVerified: userInfo.emailVerified,
1253
- createdAt: /* @__PURE__ */ new Date(),
1254
- updatedAt: /* @__PURE__ */ new Date()
1255
- }
1256
- });
1257
- }
1258
- const account = await ctx.context.adapter.findOne({
1259
- model: "account",
1260
- where: [
1261
- { field: "userId", value: user.id },
1262
- { field: "providerId", value: provider.providerId },
1263
- { field: "accountId", value: userInfo.id }
1264
- ]
1265
- });
1266
- if (!account) {
1267
- await ctx.context.adapter.create({
1268
- model: "account",
1269
- data: {
1270
- userId: user.id,
1271
- providerId: provider.providerId,
1272
- accountId: userInfo.id,
1273
- createdAt: /* @__PURE__ */ new Date(),
1274
- updatedAt: /* @__PURE__ */ new Date(),
1275
- accessToken: "",
1276
- refreshToken: ""
1277
- }
1278
- });
1279
- }
1280
- if (options?.provisionUser) {
1281
- await options.provisionUser({
1282
- user,
1283
- userInfo,
1284
- provider
1285
- });
1286
- }
1287
- if (provider.organizationId && !options?.organizationProvisioning?.disabled) {
1288
- const isOrgPluginEnabled = ctx.context.options.plugins?.find(
1289
- (plugin) => plugin.id === "organization"
1290
- );
1291
- if (isOrgPluginEnabled) {
1292
- const isAlreadyMember = await ctx.context.adapter.findOne({
1293
- model: "member",
1294
- where: [
1295
- { field: "organizationId", value: provider.organizationId },
1296
- { field: "userId", value: user.id }
1297
- ]
1298
- });
1299
- if (!isAlreadyMember) {
1300
- const role = options?.organizationProvisioning?.getRole ? await options.organizationProvisioning.getRole({
1301
- user,
1302
- userInfo,
1303
- provider
1304
- }) : options?.organizationProvisioning?.defaultRole || "member";
1305
- await ctx.context.adapter.create({
1306
- model: "member",
1307
- data: {
1308
- organizationId: provider.organizationId,
1309
- userId: user.id,
1310
- role,
1311
- createdAt: /* @__PURE__ */ new Date(),
1312
- updatedAt: /* @__PURE__ */ new Date()
1313
- }
1314
- });
1315
- }
1316
- }
1317
- }
1318
- let session = await ctx.context.internalAdapter.createSession(user.id, ctx);
1319
- await setSessionCookie(ctx, { session, user });
1320
- const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1321
- throw ctx.redirect(callbackUrl);
1322
- }
1323
- ),
1324
- acsEndpoint: createAuthEndpoint(
1325
- "/sso/saml2/sp/acs/:providerId",
1326
- {
1327
- method: "POST",
1328
- params: z.object({
1329
- providerId: z.string().optional()
1330
- }),
1331
- body: z.object({
1332
- SAMLResponse: z.string(),
1333
- RelayState: z.string().optional()
1334
- }),
1335
- metadata: {
1336
- isAction: false,
1337
- openapi: {
1338
- summary: "SAML Assertion Consumer Service",
1339
- description: "Handles SAML responses from IdP after successful authentication",
1340
- responses: {
1341
- "302": {
1342
- description: "Redirects to the callback URL after successful authentication"
1343
- }
1344
- }
1345
- }
1346
- }
1347
- },
1348
- async (ctx) => {
1349
- const { SAMLResponse, RelayState = "" } = ctx.body;
1350
- const { providerId } = ctx.params;
1351
- let provider = null;
1352
- if (options?.defaultSSO?.length) {
1353
- const matchingDefault = providerId ? options.defaultSSO.find(
1354
- (defaultProvider) => defaultProvider.providerId === providerId
1355
- ) : options.defaultSSO[0];
1356
- if (matchingDefault) {
1357
- provider = {
1358
- issuer: matchingDefault.samlConfig?.issuer || "",
1359
- providerId: matchingDefault.providerId,
1360
- userId: "default",
1361
- samlConfig: matchingDefault.samlConfig
1362
- };
1363
- }
1364
- } else {
1365
- provider = await ctx.context.adapter.findOne({
1366
- model: "ssoProvider",
1367
- where: [
1368
- {
1369
- field: "providerId",
1370
- value: providerId ?? "sso"
1371
- }
1372
- ]
1373
- }).then((res) => {
1374
- if (!res) return null;
1375
- return {
1376
- ...res,
1377
- samlConfig: res.samlConfig ? safeJsonParse(
1378
- res.samlConfig
1379
- ) || void 0 : void 0
1380
- };
1381
- });
1382
- }
1383
- if (!provider?.samlConfig) {
1384
- throw new APIError("NOT_FOUND", {
1385
- message: "No SAML provider found"
1386
- });
1387
- }
1388
- const parsedSamlConfig = provider.samlConfig;
1389
- const sp = saml.ServiceProvider({
1390
- entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
1391
- assertionConsumerService: [
1392
- {
1393
- Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1394
- Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`
1395
- }
1396
- ],
1397
- wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1398
- metadata: parsedSamlConfig.spMetadata?.metadata,
1399
- privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
1400
- privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
1401
- nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
1402
- });
1403
- const idpData = parsedSamlConfig.idpMetadata;
1404
- const idp = !idpData?.metadata ? saml.IdentityProvider({
1405
- entityID: idpData?.entityID || parsedSamlConfig.issuer,
1406
- singleSignOnService: idpData?.singleSignOnService || [
1407
- {
1408
- Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1409
- Location: parsedSamlConfig.entryPoint
1410
- }
1411
- ],
1412
- signingCert: idpData?.cert || parsedSamlConfig.cert
1413
- }) : saml.IdentityProvider({
1414
- metadata: idpData.metadata
1415
- });
1416
- let parsedResponse;
1417
- try {
1418
- let decodedResponse = Buffer.from(SAMLResponse, "base64").toString(
1419
- "utf-8"
1420
- );
1421
- if (!decodedResponse.includes("StatusCode")) {
1422
- const insertPoint = decodedResponse.indexOf("</saml2:Issuer>");
1423
- if (insertPoint !== -1) {
1424
- decodedResponse = decodedResponse.slice(0, insertPoint + 14) + '<saml2:Status><saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></saml2:Status>' + decodedResponse.slice(insertPoint + 14);
1425
- }
1426
- } else if (!decodedResponse.includes("saml2:Success")) {
1427
- decodedResponse = decodedResponse.replace(
1428
- /<saml2:StatusCode Value="[^"]+"/,
1429
- '<saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"'
1430
- );
1431
- }
1432
- try {
1433
- parsedResponse = await sp.parseLoginResponse(idp, "post", {
1434
- body: {
1435
- SAMLResponse,
1436
- RelayState: RelayState || void 0
1437
- }
1438
- });
1439
- } catch (parseError) {
1440
- const nameIDMatch = decodedResponse.match(
1441
- /<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/
1442
- );
1443
- if (!nameIDMatch) throw parseError;
1444
- parsedResponse = {
1445
- extract: {
1446
- nameID: nameIDMatch[1],
1447
- attributes: { nameID: nameIDMatch[1] },
1448
- sessionIndex: {},
1449
- conditions: {}
1450
- }
1451
- };
1452
- }
1453
- if (!parsedResponse?.extract) {
1454
- throw new Error("Invalid SAML response structure");
1455
- }
1456
- } catch (error) {
1457
- ctx.context.logger.error("SAML response validation failed", {
1458
- error,
1459
- decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
1460
- "utf-8"
1461
- )
1462
- });
1463
- throw new APIError("BAD_REQUEST", {
1464
- message: "Invalid SAML response",
1465
- details: error instanceof Error ? error.message : String(error)
1466
- });
1467
- }
1468
- const { extract } = parsedResponse;
1469
- const attributes = extract.attributes || {};
1470
- const mapping = parsedSamlConfig.mapping ?? {};
1471
- const userInfo = {
1472
- ...Object.fromEntries(
1473
- Object.entries(mapping.extraFields || {}).map(([key, value]) => [
1474
- key,
1475
- attributes[value]
1476
- ])
1477
- ),
1478
- id: attributes[mapping.id || "nameID"] || extract.nameID,
1479
- email: attributes[mapping.email || "email"] || extract.nameID,
1480
- name: [
1481
- attributes[mapping.firstName || "givenName"],
1482
- attributes[mapping.lastName || "surname"]
1483
- ].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
1484
- emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
1485
- };
1486
- if (!userInfo.id || !userInfo.email) {
1487
- ctx.context.logger.error(
1488
- "Missing essential user info from SAML response",
1489
- {
1490
- attributes: Object.keys(attributes),
1491
- mapping,
1492
- extractedId: userInfo.id,
1493
- extractedEmail: userInfo.email
1494
- }
1495
- );
1496
- throw new APIError("BAD_REQUEST", {
1497
- message: "Unable to extract user ID or email from SAML response"
1498
- });
1499
- }
1500
- let user;
1501
- const existingUser = await ctx.context.adapter.findOne({
1502
- model: "user",
1503
- where: [
1504
- {
1505
- field: "email",
1506
- value: userInfo.email
1507
- }
1508
- ]
1509
- });
1510
- if (existingUser) {
1511
- const account = await ctx.context.adapter.findOne({
1512
- model: "account",
1513
- where: [
1514
- { field: "userId", value: existingUser.id },
1515
- { field: "providerId", value: provider.providerId },
1516
- { field: "accountId", value: userInfo.id }
1517
- ]
1518
- });
1519
- if (!account) {
1520
- const isTrustedProvider = ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
1521
- provider.providerId
1522
- );
1523
- if (!isTrustedProvider) {
1524
- throw ctx.redirect(
1525
- `${parsedSamlConfig.callbackUrl}?error=account_not_found`
1526
- );
1527
- }
1528
- await ctx.context.adapter.create({
1529
- model: "account",
1530
- data: {
1531
- userId: existingUser.id,
1532
- providerId: provider.providerId,
1533
- accountId: userInfo.id,
1534
- createdAt: /* @__PURE__ */ new Date(),
1535
- updatedAt: /* @__PURE__ */ new Date(),
1536
- accessToken: "",
1537
- refreshToken: ""
1538
- }
1539
- });
1540
- }
1541
- user = existingUser;
1542
- } else {
1543
- user = await ctx.context.adapter.create({
1544
- model: "user",
1545
- data: {
1546
- email: userInfo.email,
1547
- name: userInfo.name,
1548
- emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false,
1549
- createdAt: /* @__PURE__ */ new Date(),
1550
- updatedAt: /* @__PURE__ */ new Date()
1551
- }
1552
- });
1553
- await ctx.context.adapter.create({
1554
- model: "account",
1555
- data: {
1556
- userId: user.id,
1557
- providerId: provider.providerId,
1558
- accountId: userInfo.id,
1559
- accessToken: "",
1560
- refreshToken: "",
1561
- accessTokenExpiresAt: /* @__PURE__ */ new Date(),
1562
- refreshTokenExpiresAt: /* @__PURE__ */ new Date(),
1563
- scope: "",
1564
- createdAt: /* @__PURE__ */ new Date(),
1565
- updatedAt: /* @__PURE__ */ new Date()
1566
- }
1567
- });
1568
- }
1569
- if (options?.provisionUser) {
1570
- await options.provisionUser({
1571
- user,
1572
- userInfo,
1573
- provider
1574
- });
1575
- }
1576
- if (provider.organizationId && !options?.organizationProvisioning?.disabled) {
1577
- const isOrgPluginEnabled = ctx.context.options.plugins?.find(
1578
- (plugin) => plugin.id === "organization"
1579
- );
1580
- if (isOrgPluginEnabled) {
1581
- const isAlreadyMember = await ctx.context.adapter.findOne({
1582
- model: "member",
1583
- where: [
1584
- { field: "organizationId", value: provider.organizationId },
1585
- { field: "userId", value: user.id }
1586
- ]
1587
- });
1588
- if (!isAlreadyMember) {
1589
- const role = options?.organizationProvisioning?.getRole ? await options.organizationProvisioning.getRole({
1590
- user,
1591
- userInfo,
1592
- provider
1593
- }) : options?.organizationProvisioning?.defaultRole || "member";
1594
- await ctx.context.adapter.create({
1595
- model: "member",
1596
- data: {
1597
- organizationId: provider.organizationId,
1598
- userId: user.id,
1599
- role,
1600
- createdAt: /* @__PURE__ */ new Date(),
1601
- updatedAt: /* @__PURE__ */ new Date()
1602
- }
1603
- });
1604
- }
1605
- }
1606
- }
1607
- let session = await ctx.context.internalAdapter.createSession(user.id, ctx);
1608
- await setSessionCookie(ctx, { session, user });
1609
- const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1610
- throw ctx.redirect(callbackUrl);
1611
- }
1612
- )
1613
- },
1614
- schema: {
1615
- ssoProvider: {
1616
- fields: {
1617
- issuer: {
1618
- type: "string",
1619
- required: true
1620
- },
1621
- oidcConfig: {
1622
- type: "string",
1623
- required: false
1624
- },
1625
- samlConfig: {
1626
- type: "string",
1627
- required: false
1628
- },
1629
- userId: {
1630
- type: "string",
1631
- references: {
1632
- model: "user",
1633
- field: "id"
1634
- }
1635
- },
1636
- providerId: {
1637
- type: "string",
1638
- required: true,
1639
- unique: true
1640
- },
1641
- organizationId: {
1642
- type: "string",
1643
- required: false
1644
- },
1645
- domain: {
1646
- type: "string",
1647
- required: true
1648
- }
1649
- }
1650
- }
1651
- }
1652
- };
211
+ const spMetadata = () => {
212
+ return createAuthEndpoint("/sso/saml2/sp/metadata", {
213
+ method: "GET",
214
+ query: z.object({
215
+ providerId: z.string(),
216
+ format: z.enum(["xml", "json"]).default("xml")
217
+ }),
218
+ metadata: { openapi: {
219
+ operationId: "getSSOServiceProviderMetadata",
220
+ summary: "Get Service Provider metadata",
221
+ description: "Returns the SAML metadata for the Service Provider",
222
+ responses: { "200": { description: "SAML metadata in XML format" } }
223
+ } }
224
+ }, async (ctx) => {
225
+ const provider = await ctx.context.adapter.findOne({
226
+ model: "ssoProvider",
227
+ where: [{
228
+ field: "providerId",
229
+ value: ctx.query.providerId
230
+ }]
231
+ });
232
+ if (!provider) throw new APIError("NOT_FOUND", { message: "No provider found for the given providerId" });
233
+ const parsedSamlConfig = safeJsonParse(provider.samlConfig);
234
+ if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
235
+ const sp = parsedSamlConfig.spMetadata.metadata ? saml.ServiceProvider({ metadata: parsedSamlConfig.spMetadata.metadata }) : saml.SPMetadata({
236
+ entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
237
+ assertionConsumerService: [{
238
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
239
+ Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.id}`
240
+ }],
241
+ wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
242
+ nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
243
+ });
244
+ return new Response(sp.getMetadata(), { headers: { "Content-Type": "application/xml" } });
245
+ });
246
+ };
247
+ const registerSSOProvider = (options) => {
248
+ return createAuthEndpoint("/sso/register", {
249
+ method: "POST",
250
+ body: z.object({
251
+ providerId: z.string({}).meta({ description: "The ID of the provider. This is used to identify the provider during login and callback" }),
252
+ issuer: z.string({}).meta({ description: "The issuer of the provider" }),
253
+ domain: z.string({}).meta({ description: "The domain of the provider. This is used for email matching" }),
254
+ oidcConfig: z.object({
255
+ clientId: z.string({}).meta({ description: "The client ID" }),
256
+ clientSecret: z.string({}).meta({ description: "The client secret" }),
257
+ authorizationEndpoint: z.string({}).meta({ description: "The authorization endpoint" }).optional(),
258
+ tokenEndpoint: z.string({}).meta({ description: "The token endpoint" }).optional(),
259
+ userInfoEndpoint: z.string({}).meta({ description: "The user info endpoint" }).optional(),
260
+ tokenEndpointAuthentication: z.enum(["client_secret_post", "client_secret_basic"]).optional(),
261
+ jwksEndpoint: z.string({}).meta({ description: "The JWKS endpoint" }).optional(),
262
+ discoveryEndpoint: z.string().optional(),
263
+ scopes: z.array(z.string(), {}).meta({ description: "The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']" }).optional(),
264
+ pkce: z.boolean({}).meta({ description: "Whether to use PKCE for the authorization flow" }).default(true).optional(),
265
+ mapping: z.object({
266
+ id: z.string({}).meta({ description: "Field mapping for user ID (defaults to 'sub')" }),
267
+ email: z.string({}).meta({ description: "Field mapping for email (defaults to 'email')" }),
268
+ emailVerified: z.string({}).meta({ description: "Field mapping for email verification (defaults to 'email_verified')" }).optional(),
269
+ name: z.string({}).meta({ description: "Field mapping for name (defaults to 'name')" }),
270
+ image: z.string({}).meta({ description: "Field mapping for image (defaults to 'picture')" }).optional(),
271
+ extraFields: z.record(z.string(), z.any()).optional()
272
+ }).optional()
273
+ }).optional(),
274
+ samlConfig: z.object({
275
+ entryPoint: z.string({}).meta({ description: "The entry point of the provider" }),
276
+ cert: z.string({}).meta({ description: "The certificate of the provider" }),
277
+ callbackUrl: z.string({}).meta({ description: "The callback URL of the provider" }),
278
+ audience: z.string().optional(),
279
+ idpMetadata: z.object({
280
+ metadata: z.string().optional(),
281
+ entityID: z.string().optional(),
282
+ cert: z.string().optional(),
283
+ privateKey: z.string().optional(),
284
+ privateKeyPass: z.string().optional(),
285
+ isAssertionEncrypted: z.boolean().optional(),
286
+ encPrivateKey: z.string().optional(),
287
+ encPrivateKeyPass: z.string().optional(),
288
+ singleSignOnService: z.array(z.object({
289
+ Binding: z.string().meta({ description: "The binding type for the SSO service" }),
290
+ Location: z.string().meta({ description: "The URL for the SSO service" })
291
+ })).optional().meta({ description: "Single Sign-On service configuration" })
292
+ }).optional(),
293
+ spMetadata: z.object({
294
+ metadata: z.string().optional(),
295
+ entityID: z.string().optional(),
296
+ binding: z.string().optional(),
297
+ privateKey: z.string().optional(),
298
+ privateKeyPass: z.string().optional(),
299
+ isAssertionEncrypted: z.boolean().optional(),
300
+ encPrivateKey: z.string().optional(),
301
+ encPrivateKeyPass: z.string().optional()
302
+ }),
303
+ wantAssertionsSigned: z.boolean().optional(),
304
+ signatureAlgorithm: z.string().optional(),
305
+ digestAlgorithm: z.string().optional(),
306
+ identifierFormat: z.string().optional(),
307
+ privateKey: z.string().optional(),
308
+ decryptionPvk: z.string().optional(),
309
+ additionalParams: z.record(z.string(), z.any()).optional(),
310
+ mapping: z.object({
311
+ id: z.string({}).meta({ description: "Field mapping for user ID (defaults to 'nameID')" }),
312
+ email: z.string({}).meta({ description: "Field mapping for email (defaults to 'email')" }),
313
+ emailVerified: z.string({}).meta({ description: "Field mapping for email verification" }).optional(),
314
+ name: z.string({}).meta({ description: "Field mapping for name (defaults to 'displayName')" }),
315
+ firstName: z.string({}).meta({ description: "Field mapping for first name (defaults to 'givenName')" }).optional(),
316
+ lastName: z.string({}).meta({ description: "Field mapping for last name (defaults to 'surname')" }).optional(),
317
+ extraFields: z.record(z.string(), z.any()).optional()
318
+ }).optional()
319
+ }).optional(),
320
+ organizationId: z.string({}).meta({ description: "If organization plugin is enabled, the organization id to link the provider to" }).optional(),
321
+ overrideUserInfo: z.boolean({}).meta({ description: "Override user info with the provider info. Defaults to false" }).default(false).optional()
322
+ }),
323
+ use: [sessionMiddleware],
324
+ metadata: { openapi: {
325
+ operationId: "registerSSOProvider",
326
+ summary: "Register an OIDC provider",
327
+ description: "This endpoint is used to register an OIDC provider. This is used to configure the provider and link it to an organization",
328
+ responses: { "200": {
329
+ description: "OIDC provider created successfully",
330
+ content: { "application/json": { schema: {
331
+ type: "object",
332
+ properties: {
333
+ issuer: {
334
+ type: "string",
335
+ format: "uri",
336
+ description: "The issuer URL of the provider"
337
+ },
338
+ domain: {
339
+ type: "string",
340
+ description: "The domain of the provider, used for email matching"
341
+ },
342
+ domainVerified: {
343
+ type: "boolean",
344
+ description: "A boolean indicating whether the domain has been verified or not"
345
+ },
346
+ domainVerificationToken: {
347
+ type: "string",
348
+ description: "Domain verification token. It can be used to prove ownership over the SSO domain"
349
+ },
350
+ oidcConfig: {
351
+ type: "object",
352
+ properties: {
353
+ issuer: {
354
+ type: "string",
355
+ format: "uri",
356
+ description: "The issuer URL of the provider"
357
+ },
358
+ pkce: {
359
+ type: "boolean",
360
+ description: "Whether PKCE is enabled for the authorization flow"
361
+ },
362
+ clientId: {
363
+ type: "string",
364
+ description: "The client ID for the provider"
365
+ },
366
+ clientSecret: {
367
+ type: "string",
368
+ description: "The client secret for the provider"
369
+ },
370
+ authorizationEndpoint: {
371
+ type: "string",
372
+ format: "uri",
373
+ nullable: true,
374
+ description: "The authorization endpoint URL"
375
+ },
376
+ discoveryEndpoint: {
377
+ type: "string",
378
+ format: "uri",
379
+ description: "The discovery endpoint URL"
380
+ },
381
+ userInfoEndpoint: {
382
+ type: "string",
383
+ format: "uri",
384
+ nullable: true,
385
+ description: "The user info endpoint URL"
386
+ },
387
+ scopes: {
388
+ type: "array",
389
+ items: { type: "string" },
390
+ nullable: true,
391
+ description: "The scopes requested from the provider"
392
+ },
393
+ tokenEndpoint: {
394
+ type: "string",
395
+ format: "uri",
396
+ nullable: true,
397
+ description: "The token endpoint URL"
398
+ },
399
+ tokenEndpointAuthentication: {
400
+ type: "string",
401
+ enum: ["client_secret_post", "client_secret_basic"],
402
+ nullable: true,
403
+ description: "Authentication method for the token endpoint"
404
+ },
405
+ jwksEndpoint: {
406
+ type: "string",
407
+ format: "uri",
408
+ nullable: true,
409
+ description: "The JWKS endpoint URL"
410
+ },
411
+ mapping: {
412
+ type: "object",
413
+ nullable: true,
414
+ properties: {
415
+ id: {
416
+ type: "string",
417
+ description: "Field mapping for user ID (defaults to 'sub')"
418
+ },
419
+ email: {
420
+ type: "string",
421
+ description: "Field mapping for email (defaults to 'email')"
422
+ },
423
+ emailVerified: {
424
+ type: "string",
425
+ nullable: true,
426
+ description: "Field mapping for email verification (defaults to 'email_verified')"
427
+ },
428
+ name: {
429
+ type: "string",
430
+ description: "Field mapping for name (defaults to 'name')"
431
+ },
432
+ image: {
433
+ type: "string",
434
+ nullable: true,
435
+ description: "Field mapping for image (defaults to 'picture')"
436
+ },
437
+ extraFields: {
438
+ type: "object",
439
+ additionalProperties: { type: "string" },
440
+ nullable: true,
441
+ description: "Additional field mappings"
442
+ }
443
+ },
444
+ required: [
445
+ "id",
446
+ "email",
447
+ "name"
448
+ ]
449
+ }
450
+ },
451
+ required: [
452
+ "issuer",
453
+ "pkce",
454
+ "clientId",
455
+ "clientSecret",
456
+ "discoveryEndpoint"
457
+ ],
458
+ description: "OIDC configuration for the provider"
459
+ },
460
+ organizationId: {
461
+ type: "string",
462
+ nullable: true,
463
+ description: "ID of the linked organization, if any"
464
+ },
465
+ userId: {
466
+ type: "string",
467
+ description: "ID of the user who registered the provider"
468
+ },
469
+ providerId: {
470
+ type: "string",
471
+ description: "Unique identifier for the provider"
472
+ },
473
+ redirectURI: {
474
+ type: "string",
475
+ format: "uri",
476
+ description: "The redirect URI for the provider callback"
477
+ }
478
+ },
479
+ required: [
480
+ "issuer",
481
+ "domain",
482
+ "oidcConfig",
483
+ "userId",
484
+ "providerId",
485
+ "redirectURI"
486
+ ]
487
+ } } }
488
+ } }
489
+ } }
490
+ }, async (ctx) => {
491
+ const user = ctx.context.session?.user;
492
+ if (!user) throw new APIError("UNAUTHORIZED");
493
+ const limit = typeof options?.providersLimit === "function" ? await options.providersLimit(user) : options?.providersLimit ?? 10;
494
+ if (!limit) throw new APIError("FORBIDDEN", { message: "SSO provider registration is disabled" });
495
+ if ((await ctx.context.adapter.findMany({
496
+ model: "ssoProvider",
497
+ where: [{
498
+ field: "userId",
499
+ value: user.id
500
+ }]
501
+ })).length >= limit) throw new APIError("FORBIDDEN", { message: "You have reached the maximum number of SSO providers" });
502
+ const body = ctx.body;
503
+ if (z.string().url().safeParse(body.issuer).error) throw new APIError("BAD_REQUEST", { message: "Invalid issuer. Must be a valid URL" });
504
+ if (ctx.body.organizationId) {
505
+ if (!await ctx.context.adapter.findOne({
506
+ model: "member",
507
+ where: [{
508
+ field: "userId",
509
+ value: user.id
510
+ }, {
511
+ field: "organizationId",
512
+ value: ctx.body.organizationId
513
+ }]
514
+ })) throw new APIError("BAD_REQUEST", { message: "You are not a member of the organization" });
515
+ }
516
+ if (await ctx.context.adapter.findOne({
517
+ model: "ssoProvider",
518
+ where: [{
519
+ field: "providerId",
520
+ value: body.providerId
521
+ }]
522
+ })) {
523
+ ctx.context.logger.info(`SSO provider creation attempt with existing providerId: ${body.providerId}`);
524
+ throw new APIError("UNPROCESSABLE_ENTITY", { message: "SSO provider with this providerId already exists" });
525
+ }
526
+ const provider = await ctx.context.adapter.create({
527
+ model: "ssoProvider",
528
+ data: {
529
+ issuer: body.issuer,
530
+ domain: body.domain,
531
+ domainVerified: false,
532
+ oidcConfig: body.oidcConfig ? JSON.stringify({
533
+ issuer: body.issuer,
534
+ clientId: body.oidcConfig.clientId,
535
+ clientSecret: body.oidcConfig.clientSecret,
536
+ authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
537
+ tokenEndpoint: body.oidcConfig.tokenEndpoint,
538
+ tokenEndpointAuthentication: body.oidcConfig.tokenEndpointAuthentication,
539
+ jwksEndpoint: body.oidcConfig.jwksEndpoint,
540
+ pkce: body.oidcConfig.pkce,
541
+ discoveryEndpoint: body.oidcConfig.discoveryEndpoint || `${body.issuer}/.well-known/openid-configuration`,
542
+ mapping: body.oidcConfig.mapping,
543
+ scopes: body.oidcConfig.scopes,
544
+ userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
545
+ overrideUserInfo: ctx.body.overrideUserInfo || options?.defaultOverrideUserInfo || false
546
+ }) : null,
547
+ samlConfig: body.samlConfig ? JSON.stringify({
548
+ issuer: body.issuer,
549
+ entryPoint: body.samlConfig.entryPoint,
550
+ cert: body.samlConfig.cert,
551
+ callbackUrl: body.samlConfig.callbackUrl,
552
+ audience: body.samlConfig.audience,
553
+ idpMetadata: body.samlConfig.idpMetadata,
554
+ spMetadata: body.samlConfig.spMetadata,
555
+ wantAssertionsSigned: body.samlConfig.wantAssertionsSigned,
556
+ signatureAlgorithm: body.samlConfig.signatureAlgorithm,
557
+ digestAlgorithm: body.samlConfig.digestAlgorithm,
558
+ identifierFormat: body.samlConfig.identifierFormat,
559
+ privateKey: body.samlConfig.privateKey,
560
+ decryptionPvk: body.samlConfig.decryptionPvk,
561
+ additionalParams: body.samlConfig.additionalParams,
562
+ mapping: body.samlConfig.mapping
563
+ }) : null,
564
+ organizationId: body.organizationId,
565
+ userId: ctx.context.session.user.id,
566
+ providerId: body.providerId
567
+ }
568
+ });
569
+ let domainVerificationToken;
570
+ let domainVerified;
571
+ if (options?.domainVerification?.enabled) {
572
+ domainVerified = false;
573
+ domainVerificationToken = generateRandomString(24);
574
+ await ctx.context.adapter.create({
575
+ model: "verification",
576
+ data: {
577
+ identifier: options.domainVerification?.tokenPrefix ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}` : `better-auth-token-${provider.providerId}`,
578
+ createdAt: /* @__PURE__ */ new Date(),
579
+ updatedAt: /* @__PURE__ */ new Date(),
580
+ value: domainVerificationToken,
581
+ expiresAt: new Date(Date.now() + 3600 * 24 * 7 * 1e3)
582
+ }
583
+ });
584
+ }
585
+ return ctx.json({
586
+ ...provider,
587
+ oidcConfig: JSON.parse(provider.oidcConfig),
588
+ samlConfig: JSON.parse(provider.samlConfig),
589
+ redirectURI: `${ctx.context.baseURL}/sso/callback/${provider.providerId}`,
590
+ ...options?.domainVerification?.enabled ? { domainVerified } : {},
591
+ ...options?.domainVerification?.enabled ? { domainVerificationToken } : {}
592
+ });
593
+ });
594
+ };
595
+ const signInSSO = (options) => {
596
+ return createAuthEndpoint("/sign-in/sso", {
597
+ method: "POST",
598
+ body: z.object({
599
+ 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(),
600
+ organizationSlug: z.string({}).meta({ description: "The slug of the organization to sign in with" }).optional(),
601
+ providerId: z.string({}).meta({ description: "The ID of the provider to sign in with. This can be provided instead of email or issuer" }).optional(),
602
+ domain: z.string({}).meta({ description: "The domain of the provider." }).optional(),
603
+ callbackURL: z.string({}).meta({ description: "The URL to redirect to after login" }),
604
+ errorCallbackURL: z.string({}).meta({ description: "The URL to redirect to after login" }).optional(),
605
+ newUserCallbackURL: z.string({}).meta({ description: "The URL to redirect to after login if the user is new" }).optional(),
606
+ scopes: z.array(z.string(), {}).meta({ description: "Scopes to request from the provider." }).optional(),
607
+ 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(),
608
+ requestSignUp: z.boolean({}).meta({ description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider" }).optional(),
609
+ providerType: z.enum(["oidc", "saml"]).optional()
610
+ }),
611
+ metadata: { openapi: {
612
+ operationId: "signInWithSSO",
613
+ summary: "Sign in with SSO provider",
614
+ description: "This endpoint is used to sign in with an SSO provider. It redirects to the provider's authorization URL",
615
+ requestBody: { content: { "application/json": { schema: {
616
+ type: "object",
617
+ properties: {
618
+ email: {
619
+ type: "string",
620
+ 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"
621
+ },
622
+ issuer: {
623
+ type: "string",
624
+ 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"
625
+ },
626
+ providerId: {
627
+ type: "string",
628
+ description: "The ID of the provider to sign in with. This can be provided instead of email or issuer"
629
+ },
630
+ callbackURL: {
631
+ type: "string",
632
+ description: "The URL to redirect to after login"
633
+ },
634
+ errorCallbackURL: {
635
+ type: "string",
636
+ description: "The URL to redirect to after login"
637
+ },
638
+ newUserCallbackURL: {
639
+ type: "string",
640
+ description: "The URL to redirect to after login if the user is new"
641
+ },
642
+ loginHint: {
643
+ type: "string",
644
+ description: "Login hint to send to the identity provider (e.g., email or identifier). If supported, sent as 'login_hint'."
645
+ }
646
+ },
647
+ required: ["callbackURL"]
648
+ } } } },
649
+ responses: { "200": {
650
+ description: "Authorization URL generated successfully for SSO sign-in",
651
+ content: { "application/json": { schema: {
652
+ type: "object",
653
+ properties: {
654
+ url: {
655
+ type: "string",
656
+ format: "uri",
657
+ description: "The authorization URL to redirect the user to for SSO sign-in"
658
+ },
659
+ redirect: {
660
+ type: "boolean",
661
+ description: "Indicates that the client should redirect to the provided URL",
662
+ enum: [true]
663
+ }
664
+ },
665
+ required: ["url", "redirect"]
666
+ } } }
667
+ } }
668
+ } }
669
+ }, async (ctx) => {
670
+ const body = ctx.body;
671
+ let { email, organizationSlug, providerId, domain } = body;
672
+ if (!options?.defaultSSO?.length && !email && !organizationSlug && !domain && !providerId) throw new APIError("BAD_REQUEST", { message: "email, organizationSlug, domain or providerId is required" });
673
+ domain = body.domain || email?.split("@")[1];
674
+ let orgId = "";
675
+ if (organizationSlug) orgId = await ctx.context.adapter.findOne({
676
+ model: "organization",
677
+ where: [{
678
+ field: "slug",
679
+ value: organizationSlug
680
+ }]
681
+ }).then((res) => {
682
+ if (!res) return "";
683
+ return res.id;
684
+ });
685
+ let provider = null;
686
+ if (options?.defaultSSO?.length) {
687
+ const matchingDefault = providerId ? options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId) : options.defaultSSO.find((defaultProvider) => defaultProvider.domain === domain);
688
+ if (matchingDefault) provider = {
689
+ issuer: matchingDefault.samlConfig?.issuer || matchingDefault.oidcConfig?.issuer || "",
690
+ providerId: matchingDefault.providerId,
691
+ userId: "default",
692
+ oidcConfig: matchingDefault.oidcConfig,
693
+ samlConfig: matchingDefault.samlConfig,
694
+ domain: matchingDefault.domain,
695
+ ...options.domainVerification?.enabled ? { domainVerified: true } : {}
696
+ };
697
+ }
698
+ if (!providerId && !orgId && !domain) throw new APIError("BAD_REQUEST", { message: "providerId, orgId or domain is required" });
699
+ if (!provider) provider = await ctx.context.adapter.findOne({
700
+ model: "ssoProvider",
701
+ where: [{
702
+ field: providerId ? "providerId" : orgId ? "organizationId" : "domain",
703
+ value: providerId || orgId || domain
704
+ }]
705
+ }).then((res) => {
706
+ if (!res) return null;
707
+ return {
708
+ ...res,
709
+ oidcConfig: res.oidcConfig ? safeJsonParse(res.oidcConfig) || void 0 : void 0,
710
+ samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
711
+ };
712
+ });
713
+ if (!provider) throw new APIError("NOT_FOUND", { message: "No provider found for the issuer" });
714
+ if (body.providerType) {
715
+ if (body.providerType === "oidc" && !provider.oidcConfig) throw new APIError("BAD_REQUEST", { message: "OIDC provider is not configured" });
716
+ if (body.providerType === "saml" && !provider.samlConfig) throw new APIError("BAD_REQUEST", { message: "SAML provider is not configured" });
717
+ }
718
+ if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
719
+ if (provider.oidcConfig && body.providerType !== "saml") {
720
+ let finalAuthUrl = provider.oidcConfig.authorizationEndpoint;
721
+ if (!finalAuthUrl && provider.oidcConfig.discoveryEndpoint) {
722
+ const discovery = await betterFetch(provider.oidcConfig.discoveryEndpoint, { method: "GET" });
723
+ if (discovery.data) finalAuthUrl = discovery.data.authorization_endpoint;
724
+ }
725
+ if (!finalAuthUrl) throw new APIError("BAD_REQUEST", { message: "Invalid OIDC configuration. Authorization URL not found." });
726
+ const state = await generateState(ctx, void 0, false);
727
+ const redirectURI = `${ctx.context.baseURL}/sso/callback/${provider.providerId}`;
728
+ const authorizationURL = await createAuthorizationURL({
729
+ id: provider.issuer,
730
+ options: {
731
+ clientId: provider.oidcConfig.clientId,
732
+ clientSecret: provider.oidcConfig.clientSecret
733
+ },
734
+ redirectURI,
735
+ state: state.state,
736
+ codeVerifier: provider.oidcConfig.pkce ? state.codeVerifier : void 0,
737
+ scopes: ctx.body.scopes || provider.oidcConfig.scopes || [
738
+ "openid",
739
+ "email",
740
+ "profile",
741
+ "offline_access"
742
+ ],
743
+ loginHint: ctx.body.loginHint || email,
744
+ authorizationEndpoint: finalAuthUrl
745
+ });
746
+ return ctx.json({
747
+ url: authorizationURL.toString(),
748
+ redirect: true
749
+ });
750
+ }
751
+ if (provider.samlConfig) {
752
+ const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
753
+ if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
754
+ let metadata = parsedSamlConfig.spMetadata.metadata;
755
+ if (!metadata) metadata = saml.SPMetadata({
756
+ entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
757
+ assertionConsumerService: [{
758
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
759
+ Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.providerId}`
760
+ }],
761
+ wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
762
+ nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
763
+ }).getMetadata() || "";
764
+ const sp = saml.ServiceProvider({
765
+ metadata,
766
+ allowCreate: true
767
+ });
768
+ const idp = saml.IdentityProvider({
769
+ metadata: parsedSamlConfig.idpMetadata?.metadata,
770
+ entityID: parsedSamlConfig.idpMetadata?.entityID,
771
+ encryptCert: parsedSamlConfig.idpMetadata?.cert,
772
+ singleSignOnService: parsedSamlConfig.idpMetadata?.singleSignOnService
773
+ });
774
+ const loginRequest = sp.createLoginRequest(idp, "redirect");
775
+ if (!loginRequest) throw new APIError("BAD_REQUEST", { message: "Invalid SAML request" });
776
+ return ctx.json({
777
+ url: `${loginRequest.context}&RelayState=${encodeURIComponent(body.callbackURL)}`,
778
+ redirect: true
779
+ });
780
+ }
781
+ throw new APIError("BAD_REQUEST", { message: "Invalid SSO provider" });
782
+ });
1653
783
  };
784
+ const callbackSSO = (options) => {
785
+ return createAuthEndpoint("/sso/callback/:providerId", {
786
+ method: "GET",
787
+ query: z.object({
788
+ code: z.string().optional(),
789
+ state: z.string(),
790
+ error: z.string().optional(),
791
+ error_description: z.string().optional()
792
+ }),
793
+ allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
794
+ metadata: {
795
+ isAction: false,
796
+ openapi: {
797
+ operationId: "handleSSOCallback",
798
+ summary: "Callback URL for SSO provider",
799
+ description: "This endpoint is used as the callback URL for SSO providers. It handles the authorization code and exchanges it for an access token",
800
+ responses: { "302": { description: "Redirects to the callback URL" } }
801
+ }
802
+ }
803
+ }, async (ctx) => {
804
+ const { code, state, error, error_description } = ctx.query;
805
+ const stateData = await parseState(ctx);
806
+ if (!stateData) {
807
+ const errorURL$1 = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
808
+ throw ctx.redirect(`${errorURL$1}?error=invalid_state`);
809
+ }
810
+ const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
811
+ if (!code || error) throw ctx.redirect(`${errorURL || callbackURL}?error=${error}&error_description=${error_description}`);
812
+ let provider = null;
813
+ if (options?.defaultSSO?.length) {
814
+ const matchingDefault = options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === ctx.params.providerId);
815
+ if (matchingDefault) provider = {
816
+ ...matchingDefault,
817
+ issuer: matchingDefault.oidcConfig?.issuer || "",
818
+ userId: "default",
819
+ ...options.domainVerification?.enabled ? { domainVerified: true } : {}
820
+ };
821
+ }
822
+ if (!provider) provider = await ctx.context.adapter.findOne({
823
+ model: "ssoProvider",
824
+ where: [{
825
+ field: "providerId",
826
+ value: ctx.params.providerId
827
+ }]
828
+ }).then((res) => {
829
+ if (!res) return null;
830
+ return {
831
+ ...res,
832
+ oidcConfig: safeJsonParse(res.oidcConfig) || void 0
833
+ };
834
+ });
835
+ if (!provider) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=provider not found`);
836
+ if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
837
+ let config = provider.oidcConfig;
838
+ if (!config) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=provider not found`);
839
+ const discovery = await betterFetch(config.discoveryEndpoint);
840
+ if (discovery.data) config = {
841
+ tokenEndpoint: discovery.data.token_endpoint,
842
+ tokenEndpointAuthentication: discovery.data.token_endpoint_auth_method,
843
+ userInfoEndpoint: discovery.data.userinfo_endpoint,
844
+ scopes: [
845
+ "openid",
846
+ "email",
847
+ "profile",
848
+ "offline_access"
849
+ ],
850
+ ...config
851
+ };
852
+ if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=token_endpoint_not_found`);
853
+ const tokenResponse = await validateAuthorizationCode({
854
+ code,
855
+ codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
856
+ redirectURI: `${ctx.context.baseURL}/sso/callback/${provider.providerId}`,
857
+ options: {
858
+ clientId: config.clientId,
859
+ clientSecret: config.clientSecret
860
+ },
861
+ tokenEndpoint: config.tokenEndpoint,
862
+ authentication: config.tokenEndpointAuthentication === "client_secret_post" ? "post" : "basic"
863
+ }).catch((e) => {
864
+ if (e instanceof BetterFetchError) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${e.message}`);
865
+ return null;
866
+ });
867
+ if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=token_response_not_found`);
868
+ let userInfo = null;
869
+ if (tokenResponse.idToken) {
870
+ const idToken = decodeJwt(tokenResponse.idToken);
871
+ if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=jwks_endpoint_not_found`);
872
+ const verified = await validateToken(tokenResponse.idToken, config.jwksEndpoint).catch((e) => {
873
+ ctx.context.logger.error(e);
874
+ return null;
875
+ });
876
+ if (!verified) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=token_not_verified`);
877
+ if (verified.payload.iss !== provider.issuer) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=issuer_mismatch`);
878
+ const mapping = config.mapping || {};
879
+ userInfo = {
880
+ ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, verified.payload[value]])),
881
+ id: idToken[mapping.id || "sub"],
882
+ email: idToken[mapping.email || "email"],
883
+ emailVerified: options?.trustEmailVerified ? idToken[mapping.emailVerified || "email_verified"] : false,
884
+ name: idToken[mapping.name || "name"],
885
+ image: idToken[mapping.image || "picture"]
886
+ };
887
+ }
888
+ if (!userInfo) {
889
+ if (!config.userInfoEndpoint) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=user_info_endpoint_not_found`);
890
+ const userInfoResponse = await betterFetch(config.userInfoEndpoint, { headers: { Authorization: `Bearer ${tokenResponse.accessToken}` } });
891
+ if (userInfoResponse.error) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=${userInfoResponse.error.message}`);
892
+ userInfo = userInfoResponse.data;
893
+ }
894
+ if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=missing_user_info`);
895
+ const linked = await handleOAuthUserInfo(ctx, {
896
+ userInfo: {
897
+ email: userInfo.email,
898
+ name: userInfo.name || userInfo.email,
899
+ id: userInfo.id,
900
+ image: userInfo.image,
901
+ emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
902
+ },
903
+ account: {
904
+ idToken: tokenResponse.idToken,
905
+ accessToken: tokenResponse.accessToken,
906
+ refreshToken: tokenResponse.refreshToken,
907
+ accountId: userInfo.id,
908
+ providerId: provider.providerId,
909
+ accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
910
+ refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
911
+ scope: tokenResponse.scopes?.join(",")
912
+ },
913
+ callbackURL,
914
+ disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
915
+ overrideUserInfo: config.overrideUserInfo
916
+ });
917
+ if (linked.error) throw ctx.redirect(`${errorURL || callbackURL}/error?error=${linked.error}`);
918
+ const { session, user } = linked.data;
919
+ if (options?.provisionUser) await options.provisionUser({
920
+ user,
921
+ userInfo,
922
+ token: tokenResponse,
923
+ provider
924
+ });
925
+ if (provider.organizationId && !options?.organizationProvisioning?.disabled) {
926
+ if (ctx.context.options.plugins?.find((plugin) => plugin.id === "organization")) {
927
+ if (!await ctx.context.adapter.findOne({
928
+ model: "member",
929
+ where: [{
930
+ field: "organizationId",
931
+ value: provider.organizationId
932
+ }, {
933
+ field: "userId",
934
+ value: user.id
935
+ }]
936
+ })) {
937
+ const role = options?.organizationProvisioning?.getRole ? await options.organizationProvisioning.getRole({
938
+ user,
939
+ userInfo,
940
+ token: tokenResponse,
941
+ provider
942
+ }) : options?.organizationProvisioning?.defaultRole || "member";
943
+ await ctx.context.adapter.create({
944
+ model: "member",
945
+ data: {
946
+ organizationId: provider.organizationId,
947
+ userId: user.id,
948
+ role,
949
+ createdAt: /* @__PURE__ */ new Date(),
950
+ updatedAt: /* @__PURE__ */ new Date()
951
+ }
952
+ });
953
+ }
954
+ }
955
+ }
956
+ await setSessionCookie(ctx, {
957
+ session,
958
+ user
959
+ });
960
+ let toRedirectTo;
961
+ try {
962
+ toRedirectTo = (linked.isRegister ? newUserURL || callbackURL : callbackURL).toString();
963
+ } catch {
964
+ toRedirectTo = linked.isRegister ? newUserURL || callbackURL : callbackURL;
965
+ }
966
+ throw ctx.redirect(toRedirectTo);
967
+ });
968
+ };
969
+ const callbackSSOSAML = (options) => {
970
+ return createAuthEndpoint("/sso/saml2/callback/:providerId", {
971
+ method: "POST",
972
+ body: z.object({
973
+ SAMLResponse: z.string(),
974
+ RelayState: z.string().optional()
975
+ }),
976
+ metadata: {
977
+ isAction: false,
978
+ allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
979
+ openapi: {
980
+ operationId: "handleSAMLCallback",
981
+ summary: "Callback URL for SAML provider",
982
+ description: "This endpoint is used as the callback URL for SAML providers.",
983
+ responses: {
984
+ "302": { description: "Redirects to the callback URL" },
985
+ "400": { description: "Invalid SAML response" },
986
+ "401": { description: "Unauthorized - SAML authentication failed" }
987
+ }
988
+ }
989
+ }
990
+ }, async (ctx) => {
991
+ const { SAMLResponse, RelayState } = ctx.body;
992
+ const { providerId } = ctx.params;
993
+ let provider = null;
994
+ if (options?.defaultSSO?.length) {
995
+ const matchingDefault = options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId);
996
+ if (matchingDefault) provider = {
997
+ ...matchingDefault,
998
+ userId: "default",
999
+ issuer: matchingDefault.samlConfig?.issuer || "",
1000
+ ...options.domainVerification?.enabled ? { domainVerified: true } : {}
1001
+ };
1002
+ }
1003
+ if (!provider) provider = await ctx.context.adapter.findOne({
1004
+ model: "ssoProvider",
1005
+ where: [{
1006
+ field: "providerId",
1007
+ value: providerId
1008
+ }]
1009
+ }).then((res) => {
1010
+ if (!res) return null;
1011
+ return {
1012
+ ...res,
1013
+ samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
1014
+ };
1015
+ });
1016
+ if (!provider) throw new APIError("NOT_FOUND", { message: "No provider found for the given providerId" });
1017
+ if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
1018
+ const parsedSamlConfig = safeJsonParse(provider.samlConfig);
1019
+ if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
1020
+ const idpData = parsedSamlConfig.idpMetadata;
1021
+ let idp = null;
1022
+ if (!idpData?.metadata) idp = saml.IdentityProvider({
1023
+ entityID: idpData?.entityID || parsedSamlConfig.issuer,
1024
+ singleSignOnService: [{
1025
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1026
+ Location: parsedSamlConfig.entryPoint
1027
+ }],
1028
+ signingCert: idpData?.cert || parsedSamlConfig.cert,
1029
+ wantAuthnRequestsSigned: parsedSamlConfig.wantAssertionsSigned || false,
1030
+ isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
1031
+ encPrivateKey: idpData?.encPrivateKey,
1032
+ encPrivateKeyPass: idpData?.encPrivateKeyPass
1033
+ });
1034
+ else idp = saml.IdentityProvider({
1035
+ metadata: idpData.metadata,
1036
+ privateKey: idpData.privateKey,
1037
+ privateKeyPass: idpData.privateKeyPass,
1038
+ isAssertionEncrypted: idpData.isAssertionEncrypted,
1039
+ encPrivateKey: idpData.encPrivateKey,
1040
+ encPrivateKeyPass: idpData.encPrivateKeyPass
1041
+ });
1042
+ const spData = parsedSamlConfig.spMetadata;
1043
+ const sp = saml.ServiceProvider({
1044
+ metadata: spData?.metadata,
1045
+ entityID: spData?.entityID || parsedSamlConfig.issuer,
1046
+ assertionConsumerService: spData?.metadata ? void 0 : [{
1047
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1048
+ Location: parsedSamlConfig.callbackUrl
1049
+ }],
1050
+ privateKey: spData?.privateKey || parsedSamlConfig.privateKey,
1051
+ privateKeyPass: spData?.privateKeyPass,
1052
+ isAssertionEncrypted: spData?.isAssertionEncrypted || false,
1053
+ encPrivateKey: spData?.encPrivateKey,
1054
+ encPrivateKeyPass: spData?.encPrivateKeyPass,
1055
+ wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1056
+ nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
1057
+ });
1058
+ let parsedResponse;
1059
+ try {
1060
+ const decodedResponse = Buffer.from(SAMLResponse, "base64").toString("utf-8");
1061
+ try {
1062
+ parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
1063
+ SAMLResponse,
1064
+ RelayState: RelayState || void 0
1065
+ } });
1066
+ } catch (parseError) {
1067
+ const nameIDMatch = decodedResponse.match(/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/);
1068
+ if (!nameIDMatch) throw parseError;
1069
+ parsedResponse = { extract: {
1070
+ nameID: nameIDMatch[1],
1071
+ attributes: { nameID: nameIDMatch[1] },
1072
+ sessionIndex: {},
1073
+ conditions: {}
1074
+ } };
1075
+ }
1076
+ if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
1077
+ } catch (error) {
1078
+ ctx.context.logger.error("SAML response validation failed", {
1079
+ error,
1080
+ decodedResponse: Buffer.from(SAMLResponse, "base64").toString("utf-8")
1081
+ });
1082
+ throw new APIError("BAD_REQUEST", {
1083
+ message: "Invalid SAML response",
1084
+ details: error instanceof Error ? error.message : String(error)
1085
+ });
1086
+ }
1087
+ const { extract } = parsedResponse;
1088
+ const attributes = extract.attributes || {};
1089
+ const mapping = parsedSamlConfig.mapping ?? {};
1090
+ const userInfo = {
1091
+ ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
1092
+ id: attributes[mapping.id || "nameID"] || extract.nameID,
1093
+ email: attributes[mapping.email || "email"] || extract.nameID,
1094
+ name: [attributes[mapping.firstName || "givenName"], attributes[mapping.lastName || "surname"]].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
1095
+ emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
1096
+ };
1097
+ if (!userInfo.id || !userInfo.email) {
1098
+ ctx.context.logger.error("Missing essential user info from SAML response", {
1099
+ attributes: Object.keys(attributes),
1100
+ mapping,
1101
+ extractedId: userInfo.id,
1102
+ extractedEmail: userInfo.email
1103
+ });
1104
+ throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
1105
+ }
1106
+ let user;
1107
+ const existingUser = await ctx.context.adapter.findOne({
1108
+ model: "user",
1109
+ where: [{
1110
+ field: "email",
1111
+ value: userInfo.email
1112
+ }]
1113
+ });
1114
+ if (existingUser) user = existingUser;
1115
+ else {
1116
+ if (options?.disableImplicitSignUp) throw new APIError("UNAUTHORIZED", { message: "User not found and implicit sign up is disabled for this provider" });
1117
+ user = await ctx.context.internalAdapter.createUser({
1118
+ email: userInfo.email,
1119
+ name: userInfo.name,
1120
+ emailVerified: userInfo.emailVerified
1121
+ });
1122
+ }
1123
+ if (!await ctx.context.adapter.findOne({
1124
+ model: "account",
1125
+ where: [
1126
+ {
1127
+ field: "userId",
1128
+ value: user.id
1129
+ },
1130
+ {
1131
+ field: "providerId",
1132
+ value: provider.providerId
1133
+ },
1134
+ {
1135
+ field: "accountId",
1136
+ value: userInfo.id
1137
+ }
1138
+ ]
1139
+ })) await ctx.context.internalAdapter.createAccount({
1140
+ userId: user.id,
1141
+ providerId: provider.providerId,
1142
+ accountId: userInfo.id,
1143
+ accessToken: "",
1144
+ refreshToken: ""
1145
+ });
1146
+ if (options?.provisionUser) await options.provisionUser({
1147
+ user,
1148
+ userInfo,
1149
+ provider
1150
+ });
1151
+ if (provider.organizationId && !options?.organizationProvisioning?.disabled) {
1152
+ if (ctx.context.options.plugins?.find((plugin) => plugin.id === "organization")) {
1153
+ if (!await ctx.context.adapter.findOne({
1154
+ model: "member",
1155
+ where: [{
1156
+ field: "organizationId",
1157
+ value: provider.organizationId
1158
+ }, {
1159
+ field: "userId",
1160
+ value: user.id
1161
+ }]
1162
+ })) {
1163
+ const role = options?.organizationProvisioning?.getRole ? await options.organizationProvisioning.getRole({
1164
+ user,
1165
+ userInfo,
1166
+ provider
1167
+ }) : options?.organizationProvisioning?.defaultRole || "member";
1168
+ await ctx.context.adapter.create({
1169
+ model: "member",
1170
+ data: {
1171
+ organizationId: provider.organizationId,
1172
+ userId: user.id,
1173
+ role,
1174
+ createdAt: /* @__PURE__ */ new Date(),
1175
+ updatedAt: /* @__PURE__ */ new Date()
1176
+ }
1177
+ });
1178
+ }
1179
+ }
1180
+ }
1181
+ await setSessionCookie(ctx, {
1182
+ session: await ctx.context.internalAdapter.createSession(user.id),
1183
+ user
1184
+ });
1185
+ const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1186
+ throw ctx.redirect(callbackUrl);
1187
+ });
1188
+ };
1189
+ const acsEndpoint = (options) => {
1190
+ return createAuthEndpoint("/sso/saml2/sp/acs/:providerId", {
1191
+ method: "POST",
1192
+ params: z.object({ providerId: z.string().optional() }),
1193
+ body: z.object({
1194
+ SAMLResponse: z.string(),
1195
+ RelayState: z.string().optional()
1196
+ }),
1197
+ metadata: {
1198
+ isAction: false,
1199
+ allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
1200
+ openapi: {
1201
+ operationId: "handleSAMLAssertionConsumerService",
1202
+ summary: "SAML Assertion Consumer Service",
1203
+ description: "Handles SAML responses from IdP after successful authentication",
1204
+ responses: { "302": { description: "Redirects to the callback URL after successful authentication" } }
1205
+ }
1206
+ }
1207
+ }, async (ctx) => {
1208
+ const { SAMLResponse, RelayState = "" } = ctx.body;
1209
+ const { providerId } = ctx.params;
1210
+ let provider = null;
1211
+ if (options?.defaultSSO?.length) {
1212
+ const matchingDefault = providerId ? options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId) : options.defaultSSO[0];
1213
+ if (matchingDefault) provider = {
1214
+ issuer: matchingDefault.samlConfig?.issuer || "",
1215
+ providerId: matchingDefault.providerId,
1216
+ userId: "default",
1217
+ samlConfig: matchingDefault.samlConfig,
1218
+ domain: matchingDefault.domain,
1219
+ ...options.domainVerification?.enabled ? { domainVerified: true } : {}
1220
+ };
1221
+ } else provider = await ctx.context.adapter.findOne({
1222
+ model: "ssoProvider",
1223
+ where: [{
1224
+ field: "providerId",
1225
+ value: providerId ?? "sso"
1226
+ }]
1227
+ }).then((res) => {
1228
+ if (!res) return null;
1229
+ return {
1230
+ ...res,
1231
+ samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
1232
+ };
1233
+ });
1234
+ if (!provider?.samlConfig) throw new APIError("NOT_FOUND", { message: "No SAML provider found" });
1235
+ if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
1236
+ const parsedSamlConfig = provider.samlConfig;
1237
+ const sp = saml.ServiceProvider({
1238
+ entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
1239
+ assertionConsumerService: [{
1240
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1241
+ Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`
1242
+ }],
1243
+ wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1244
+ metadata: parsedSamlConfig.spMetadata?.metadata,
1245
+ privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
1246
+ privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
1247
+ nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
1248
+ });
1249
+ const idpData = parsedSamlConfig.idpMetadata;
1250
+ const idp = !idpData?.metadata ? saml.IdentityProvider({
1251
+ entityID: idpData?.entityID || parsedSamlConfig.issuer,
1252
+ singleSignOnService: idpData?.singleSignOnService || [{
1253
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1254
+ Location: parsedSamlConfig.entryPoint
1255
+ }],
1256
+ signingCert: idpData?.cert || parsedSamlConfig.cert
1257
+ }) : saml.IdentityProvider({ metadata: idpData.metadata });
1258
+ let parsedResponse;
1259
+ try {
1260
+ let decodedResponse = Buffer.from(SAMLResponse, "base64").toString("utf-8");
1261
+ if (!decodedResponse.includes("StatusCode")) {
1262
+ const insertPoint = decodedResponse.indexOf("</saml2:Issuer>");
1263
+ if (insertPoint !== -1) decodedResponse = decodedResponse.slice(0, insertPoint + 14) + "<saml2:Status><saml2:StatusCode Value=\"urn:oasis:names:tc:SAML:2.0:status:Success\"/></saml2:Status>" + decodedResponse.slice(insertPoint + 14);
1264
+ } else if (!decodedResponse.includes("saml2:Success")) decodedResponse = decodedResponse.replace(/<saml2:StatusCode Value="[^"]+"/, "<saml2:StatusCode Value=\"urn:oasis:names:tc:SAML:2.0:status:Success\"");
1265
+ try {
1266
+ parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
1267
+ SAMLResponse,
1268
+ RelayState: RelayState || void 0
1269
+ } });
1270
+ } catch (parseError) {
1271
+ const nameIDMatch = decodedResponse.match(/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/);
1272
+ if (!nameIDMatch) throw parseError;
1273
+ parsedResponse = { extract: {
1274
+ nameID: nameIDMatch[1],
1275
+ attributes: { nameID: nameIDMatch[1] },
1276
+ sessionIndex: {},
1277
+ conditions: {}
1278
+ } };
1279
+ }
1280
+ if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
1281
+ } catch (error) {
1282
+ ctx.context.logger.error("SAML response validation failed", {
1283
+ error,
1284
+ decodedResponse: Buffer.from(SAMLResponse, "base64").toString("utf-8")
1285
+ });
1286
+ throw new APIError("BAD_REQUEST", {
1287
+ message: "Invalid SAML response",
1288
+ details: error instanceof Error ? error.message : String(error)
1289
+ });
1290
+ }
1291
+ const { extract } = parsedResponse;
1292
+ const attributes = extract.attributes || {};
1293
+ const mapping = parsedSamlConfig.mapping ?? {};
1294
+ const userInfo = {
1295
+ ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
1296
+ id: attributes[mapping.id || "nameID"] || extract.nameID,
1297
+ email: attributes[mapping.email || "email"] || extract.nameID,
1298
+ name: [attributes[mapping.firstName || "givenName"], attributes[mapping.lastName || "surname"]].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
1299
+ emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
1300
+ };
1301
+ if (!userInfo.id || !userInfo.email) {
1302
+ ctx.context.logger.error("Missing essential user info from SAML response", {
1303
+ attributes: Object.keys(attributes),
1304
+ mapping,
1305
+ extractedId: userInfo.id,
1306
+ extractedEmail: userInfo.email
1307
+ });
1308
+ throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
1309
+ }
1310
+ let user;
1311
+ const existingUser = await ctx.context.adapter.findOne({
1312
+ model: "user",
1313
+ where: [{
1314
+ field: "email",
1315
+ value: userInfo.email
1316
+ }]
1317
+ });
1318
+ if (existingUser) {
1319
+ if (!await ctx.context.adapter.findOne({
1320
+ model: "account",
1321
+ where: [
1322
+ {
1323
+ field: "userId",
1324
+ value: existingUser.id
1325
+ },
1326
+ {
1327
+ field: "providerId",
1328
+ value: provider.providerId
1329
+ },
1330
+ {
1331
+ field: "accountId",
1332
+ value: userInfo.id
1333
+ }
1334
+ ]
1335
+ })) {
1336
+ if (!(ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId) || "domainVerified" in provider && provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain))) throw ctx.redirect(`${parsedSamlConfig.callbackUrl}?error=account_not_found`);
1337
+ await ctx.context.internalAdapter.createAccount({
1338
+ userId: existingUser.id,
1339
+ providerId: provider.providerId,
1340
+ accountId: userInfo.id,
1341
+ accessToken: "",
1342
+ refreshToken: ""
1343
+ });
1344
+ }
1345
+ user = existingUser;
1346
+ } else {
1347
+ user = await ctx.context.internalAdapter.createUser({
1348
+ email: userInfo.email,
1349
+ name: userInfo.name,
1350
+ emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
1351
+ });
1352
+ await ctx.context.internalAdapter.createAccount({
1353
+ userId: user.id,
1354
+ providerId: provider.providerId,
1355
+ accountId: userInfo.id,
1356
+ accessToken: "",
1357
+ refreshToken: "",
1358
+ accessTokenExpiresAt: /* @__PURE__ */ new Date(),
1359
+ refreshTokenExpiresAt: /* @__PURE__ */ new Date(),
1360
+ scope: ""
1361
+ });
1362
+ }
1363
+ if (options?.provisionUser) await options.provisionUser({
1364
+ user,
1365
+ userInfo,
1366
+ provider
1367
+ });
1368
+ if (provider.organizationId && !options?.organizationProvisioning?.disabled) {
1369
+ if (ctx.context.options.plugins?.find((plugin) => plugin.id === "organization")) {
1370
+ if (!await ctx.context.adapter.findOne({
1371
+ model: "member",
1372
+ where: [{
1373
+ field: "organizationId",
1374
+ value: provider.organizationId
1375
+ }, {
1376
+ field: "userId",
1377
+ value: user.id
1378
+ }]
1379
+ })) {
1380
+ const role = options?.organizationProvisioning?.getRole ? await options.organizationProvisioning.getRole({
1381
+ user,
1382
+ userInfo,
1383
+ provider
1384
+ }) : options?.organizationProvisioning?.defaultRole || "member";
1385
+ await ctx.context.adapter.create({
1386
+ model: "member",
1387
+ data: {
1388
+ organizationId: provider.organizationId,
1389
+ userId: user.id,
1390
+ role,
1391
+ createdAt: /* @__PURE__ */ new Date(),
1392
+ updatedAt: /* @__PURE__ */ new Date()
1393
+ }
1394
+ });
1395
+ }
1396
+ }
1397
+ }
1398
+ await setSessionCookie(ctx, {
1399
+ session: await ctx.context.internalAdapter.createSession(user.id),
1400
+ user
1401
+ });
1402
+ const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1403
+ throw ctx.redirect(callbackUrl);
1404
+ });
1405
+ };
1406
+
1407
+ //#endregion
1408
+ //#region src/index.ts
1409
+ saml.setSchemaValidator({ async validate(xml) {
1410
+ if (XMLValidator.validate(xml, { allowBooleanAttributes: true }) === true) return "SUCCESS_VALIDATE_XML";
1411
+ throw "ERR_INVALID_XML";
1412
+ } });
1413
+ function sso(options) {
1414
+ let endpoints = {
1415
+ spMetadata: spMetadata(),
1416
+ registerSSOProvider: registerSSOProvider(options),
1417
+ signInSSO: signInSSO(options),
1418
+ callbackSSO: callbackSSO(options),
1419
+ callbackSSOSAML: callbackSSOSAML(options),
1420
+ acsEndpoint: acsEndpoint(options)
1421
+ };
1422
+ if (options?.domainVerification?.enabled) {
1423
+ const domainVerificationEndpoints = {
1424
+ requestDomainVerification: requestDomainVerification(options),
1425
+ verifyDomain: verifyDomain(options)
1426
+ };
1427
+ endpoints = {
1428
+ ...endpoints,
1429
+ ...domainVerificationEndpoints
1430
+ };
1431
+ }
1432
+ return {
1433
+ id: "sso",
1434
+ endpoints,
1435
+ schema: { ssoProvider: {
1436
+ modelName: options?.modelName ?? "ssoProvider",
1437
+ fields: {
1438
+ issuer: {
1439
+ type: "string",
1440
+ required: true,
1441
+ fieldName: options?.fields?.issuer ?? "issuer"
1442
+ },
1443
+ oidcConfig: {
1444
+ type: "string",
1445
+ required: false,
1446
+ fieldName: options?.fields?.oidcConfig ?? "oidcConfig"
1447
+ },
1448
+ samlConfig: {
1449
+ type: "string",
1450
+ required: false,
1451
+ fieldName: options?.fields?.samlConfig ?? "samlConfig"
1452
+ },
1453
+ userId: {
1454
+ type: "string",
1455
+ references: {
1456
+ model: "user",
1457
+ field: "id"
1458
+ },
1459
+ fieldName: options?.fields?.userId ?? "userId"
1460
+ },
1461
+ providerId: {
1462
+ type: "string",
1463
+ required: true,
1464
+ unique: true,
1465
+ fieldName: options?.fields?.providerId ?? "providerId"
1466
+ },
1467
+ organizationId: {
1468
+ type: "string",
1469
+ required: false,
1470
+ fieldName: options?.fields?.organizationId ?? "organizationId"
1471
+ },
1472
+ domain: {
1473
+ type: "string",
1474
+ required: true,
1475
+ fieldName: options?.fields?.domain ?? "domain"
1476
+ },
1477
+ ...options?.domainVerification?.enabled ? { domainVerified: {
1478
+ type: "boolean",
1479
+ required: false
1480
+ } } : {}
1481
+ }
1482
+ } }
1483
+ };
1484
+ }
1654
1485
 
1655
- export { sso };
1486
+ //#endregion
1487
+ export { sso };