@better-auth/sso 1.4.0-beta.9 → 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.
@@ -0,0 +1,2182 @@
1
+ import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
2
+ import type { Account, Session, User, Verification } from "better-auth";
3
+ import {
4
+ createAuthorizationURL,
5
+ generateState,
6
+ parseState,
7
+ validateAuthorizationCode,
8
+ validateToken,
9
+ } from "better-auth";
10
+ import {
11
+ APIError,
12
+ createAuthEndpoint,
13
+ sessionMiddleware,
14
+ } from "better-auth/api";
15
+ import { setSessionCookie } from "better-auth/cookies";
16
+ import { generateRandomString } from "better-auth/crypto";
17
+ import { handleOAuthUserInfo } from "better-auth/oauth2";
18
+ import { decodeJwt } from "jose";
19
+ import * as saml from "samlify";
20
+ import type { BindingContext } from "samlify/types/src/entity";
21
+ import type { IdentityProvider } from "samlify/types/src/entity-idp";
22
+ import type { FlowResult } from "samlify/types/src/flow";
23
+ import * as z from "zod/v4";
24
+ import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "../types";
25
+ import { validateEmailDomain } from "../utils";
26
+
27
+ /**
28
+ * Safely parses a value that might be a JSON string or already a parsed object
29
+ * This handles cases where ORMs like Drizzle might return already parsed objects
30
+ * instead of JSON strings from TEXT/JSON columns
31
+ */
32
+ function safeJsonParse<T>(value: string | T | null | undefined): T | null {
33
+ if (!value) return null;
34
+
35
+ // If it's already an object (not a string), return it as-is
36
+ if (typeof value === "object") {
37
+ return value as T;
38
+ }
39
+
40
+ // If it's a string, try to parse it
41
+ if (typeof value === "string") {
42
+ try {
43
+ return JSON.parse(value) as T;
44
+ } catch (error) {
45
+ // If parsing fails, this might indicate the string is not valid JSON
46
+ throw new Error(
47
+ `Failed to parse JSON: ${error instanceof Error ? error.message : "Unknown error"}`,
48
+ );
49
+ }
50
+ }
51
+
52
+ return null;
53
+ }
54
+
55
+ export const spMetadata = () => {
56
+ return createAuthEndpoint(
57
+ "/sso/saml2/sp/metadata",
58
+ {
59
+ method: "GET",
60
+ query: z.object({
61
+ providerId: z.string(),
62
+ format: z.enum(["xml", "json"]).default("xml"),
63
+ }),
64
+ metadata: {
65
+ openapi: {
66
+ operationId: "getSSOServiceProviderMetadata",
67
+ summary: "Get Service Provider metadata",
68
+ description: "Returns the SAML metadata for the Service Provider",
69
+ responses: {
70
+ "200": {
71
+ description: "SAML metadata in XML format",
72
+ },
73
+ },
74
+ },
75
+ },
76
+ },
77
+ async (ctx) => {
78
+ const provider = await ctx.context.adapter.findOne<{
79
+ id: string;
80
+ samlConfig: string;
81
+ }>({
82
+ model: "ssoProvider",
83
+ where: [
84
+ {
85
+ field: "providerId",
86
+ value: ctx.query.providerId,
87
+ },
88
+ ],
89
+ });
90
+ if (!provider) {
91
+ throw new APIError("NOT_FOUND", {
92
+ message: "No provider found for the given providerId",
93
+ });
94
+ }
95
+
96
+ const parsedSamlConfig = safeJsonParse<SAMLConfig>(provider.samlConfig);
97
+ if (!parsedSamlConfig) {
98
+ throw new APIError("BAD_REQUEST", {
99
+ message: "Invalid SAML configuration",
100
+ });
101
+ }
102
+ const sp = parsedSamlConfig.spMetadata.metadata
103
+ ? saml.ServiceProvider({
104
+ metadata: parsedSamlConfig.spMetadata.metadata,
105
+ })
106
+ : saml.SPMetadata({
107
+ entityID:
108
+ parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
109
+ assertionConsumerService: [
110
+ {
111
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
112
+ Location:
113
+ parsedSamlConfig.callbackUrl ||
114
+ `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.id}`,
115
+ },
116
+ ],
117
+ wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
118
+ nameIDFormat: parsedSamlConfig.identifierFormat
119
+ ? [parsedSamlConfig.identifierFormat]
120
+ : undefined,
121
+ });
122
+ return new Response(sp.getMetadata(), {
123
+ headers: {
124
+ "Content-Type": "application/xml",
125
+ },
126
+ });
127
+ },
128
+ );
129
+ };
130
+
131
+ export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
132
+ return createAuthEndpoint(
133
+ "/sso/register",
134
+ {
135
+ method: "POST",
136
+ body: z.object({
137
+ providerId: z.string({}).meta({
138
+ description:
139
+ "The ID of the provider. This is used to identify the provider during login and callback",
140
+ }),
141
+ issuer: z.string({}).meta({
142
+ description: "The issuer of the provider",
143
+ }),
144
+ domain: z.string({}).meta({
145
+ description:
146
+ "The domain of the provider. This is used for email matching",
147
+ }),
148
+ oidcConfig: z
149
+ .object({
150
+ clientId: z.string({}).meta({
151
+ description: "The client ID",
152
+ }),
153
+ clientSecret: z.string({}).meta({
154
+ description: "The client secret",
155
+ }),
156
+ authorizationEndpoint: z
157
+ .string({})
158
+ .meta({
159
+ description: "The authorization endpoint",
160
+ })
161
+ .optional(),
162
+ tokenEndpoint: z
163
+ .string({})
164
+ .meta({
165
+ description: "The token endpoint",
166
+ })
167
+ .optional(),
168
+ userInfoEndpoint: z
169
+ .string({})
170
+ .meta({
171
+ description: "The user info endpoint",
172
+ })
173
+ .optional(),
174
+ tokenEndpointAuthentication: z
175
+ .enum(["client_secret_post", "client_secret_basic"])
176
+ .optional(),
177
+ jwksEndpoint: z
178
+ .string({})
179
+ .meta({
180
+ description: "The JWKS endpoint",
181
+ })
182
+ .optional(),
183
+ discoveryEndpoint: z.string().optional(),
184
+ scopes: z
185
+ .array(z.string(), {})
186
+ .meta({
187
+ description:
188
+ "The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']",
189
+ })
190
+ .optional(),
191
+ pkce: z
192
+ .boolean({})
193
+ .meta({
194
+ description: "Whether to use PKCE for the authorization flow",
195
+ })
196
+ .default(true)
197
+ .optional(),
198
+ mapping: z
199
+ .object({
200
+ id: z.string({}).meta({
201
+ description: "Field mapping for user ID (defaults to 'sub')",
202
+ }),
203
+ email: z.string({}).meta({
204
+ description: "Field mapping for email (defaults to 'email')",
205
+ }),
206
+ emailVerified: z
207
+ .string({})
208
+ .meta({
209
+ description:
210
+ "Field mapping for email verification (defaults to 'email_verified')",
211
+ })
212
+ .optional(),
213
+ name: z.string({}).meta({
214
+ description: "Field mapping for name (defaults to 'name')",
215
+ }),
216
+ image: z
217
+ .string({})
218
+ .meta({
219
+ description:
220
+ "Field mapping for image (defaults to 'picture')",
221
+ })
222
+ .optional(),
223
+ extraFields: z.record(z.string(), z.any()).optional(),
224
+ })
225
+ .optional(),
226
+ })
227
+ .optional(),
228
+ samlConfig: z
229
+ .object({
230
+ entryPoint: z.string({}).meta({
231
+ description: "The entry point of the provider",
232
+ }),
233
+ cert: z.string({}).meta({
234
+ description: "The certificate of the provider",
235
+ }),
236
+ callbackUrl: z.string({}).meta({
237
+ description: "The callback URL of the provider",
238
+ }),
239
+ audience: z.string().optional(),
240
+ idpMetadata: z
241
+ .object({
242
+ metadata: z.string().optional(),
243
+ entityID: z.string().optional(),
244
+ cert: z.string().optional(),
245
+ privateKey: z.string().optional(),
246
+ privateKeyPass: z.string().optional(),
247
+ isAssertionEncrypted: z.boolean().optional(),
248
+ encPrivateKey: z.string().optional(),
249
+ encPrivateKeyPass: z.string().optional(),
250
+ singleSignOnService: z
251
+ .array(
252
+ z.object({
253
+ Binding: z.string().meta({
254
+ description: "The binding type for the SSO service",
255
+ }),
256
+ Location: z.string().meta({
257
+ description: "The URL for the SSO service",
258
+ }),
259
+ }),
260
+ )
261
+ .optional()
262
+ .meta({
263
+ description: "Single Sign-On service configuration",
264
+ }),
265
+ })
266
+ .optional(),
267
+ spMetadata: z.object({
268
+ metadata: z.string().optional(),
269
+ entityID: z.string().optional(),
270
+ binding: z.string().optional(),
271
+ privateKey: z.string().optional(),
272
+ privateKeyPass: z.string().optional(),
273
+ isAssertionEncrypted: z.boolean().optional(),
274
+ encPrivateKey: z.string().optional(),
275
+ encPrivateKeyPass: z.string().optional(),
276
+ }),
277
+ wantAssertionsSigned: z.boolean().optional(),
278
+ signatureAlgorithm: z.string().optional(),
279
+ digestAlgorithm: z.string().optional(),
280
+ identifierFormat: z.string().optional(),
281
+ privateKey: z.string().optional(),
282
+ decryptionPvk: z.string().optional(),
283
+ additionalParams: z.record(z.string(), z.any()).optional(),
284
+ mapping: z
285
+ .object({
286
+ id: z.string({}).meta({
287
+ description:
288
+ "Field mapping for user ID (defaults to 'nameID')",
289
+ }),
290
+ email: z.string({}).meta({
291
+ description: "Field mapping for email (defaults to 'email')",
292
+ }),
293
+ emailVerified: z
294
+ .string({})
295
+ .meta({
296
+ description: "Field mapping for email verification",
297
+ })
298
+ .optional(),
299
+ name: z.string({}).meta({
300
+ description:
301
+ "Field mapping for name (defaults to 'displayName')",
302
+ }),
303
+ firstName: z
304
+ .string({})
305
+ .meta({
306
+ description:
307
+ "Field mapping for first name (defaults to 'givenName')",
308
+ })
309
+ .optional(),
310
+ lastName: z
311
+ .string({})
312
+ .meta({
313
+ description:
314
+ "Field mapping for last name (defaults to 'surname')",
315
+ })
316
+ .optional(),
317
+ extraFields: z.record(z.string(), z.any()).optional(),
318
+ })
319
+ .optional(),
320
+ })
321
+ .optional(),
322
+ organizationId: z
323
+ .string({})
324
+ .meta({
325
+ description:
326
+ "If organization plugin is enabled, the organization id to link the provider to",
327
+ })
328
+ .optional(),
329
+ overrideUserInfo: z
330
+ .boolean({})
331
+ .meta({
332
+ description:
333
+ "Override user info with the provider info. Defaults to false",
334
+ })
335
+ .default(false)
336
+ .optional(),
337
+ }),
338
+ use: [sessionMiddleware],
339
+ metadata: {
340
+ openapi: {
341
+ operationId: "registerSSOProvider",
342
+ summary: "Register an OIDC provider",
343
+ description:
344
+ "This endpoint is used to register an OIDC provider. This is used to configure the provider and link it to an organization",
345
+ responses: {
346
+ "200": {
347
+ description: "OIDC provider created successfully",
348
+ content: {
349
+ "application/json": {
350
+ schema: {
351
+ type: "object",
352
+ properties: {
353
+ issuer: {
354
+ type: "string",
355
+ format: "uri",
356
+ description: "The issuer URL of the provider",
357
+ },
358
+ domain: {
359
+ type: "string",
360
+ description:
361
+ "The domain of the provider, used for email matching",
362
+ },
363
+ domainVerified: {
364
+ type: "boolean",
365
+ description:
366
+ "A boolean indicating whether the domain has been verified or not",
367
+ },
368
+ domainVerificationToken: {
369
+ type: "string",
370
+ description:
371
+ "Domain verification token. It can be used to prove ownership over the SSO domain",
372
+ },
373
+ oidcConfig: {
374
+ type: "object",
375
+ properties: {
376
+ issuer: {
377
+ type: "string",
378
+ format: "uri",
379
+ description: "The issuer URL of the provider",
380
+ },
381
+ pkce: {
382
+ type: "boolean",
383
+ description:
384
+ "Whether PKCE is enabled for the authorization flow",
385
+ },
386
+ clientId: {
387
+ type: "string",
388
+ description: "The client ID for the provider",
389
+ },
390
+ clientSecret: {
391
+ type: "string",
392
+ description: "The client secret for the provider",
393
+ },
394
+ authorizationEndpoint: {
395
+ type: "string",
396
+ format: "uri",
397
+ nullable: true,
398
+ description: "The authorization endpoint URL",
399
+ },
400
+ discoveryEndpoint: {
401
+ type: "string",
402
+ format: "uri",
403
+ description: "The discovery endpoint URL",
404
+ },
405
+ userInfoEndpoint: {
406
+ type: "string",
407
+ format: "uri",
408
+ nullable: true,
409
+ description: "The user info endpoint URL",
410
+ },
411
+ scopes: {
412
+ type: "array",
413
+ items: { type: "string" },
414
+ nullable: true,
415
+ description:
416
+ "The scopes requested from the provider",
417
+ },
418
+ tokenEndpoint: {
419
+ type: "string",
420
+ format: "uri",
421
+ nullable: true,
422
+ description: "The token endpoint URL",
423
+ },
424
+ tokenEndpointAuthentication: {
425
+ type: "string",
426
+ enum: ["client_secret_post", "client_secret_basic"],
427
+ nullable: true,
428
+ description:
429
+ "Authentication method for the token endpoint",
430
+ },
431
+ jwksEndpoint: {
432
+ type: "string",
433
+ format: "uri",
434
+ nullable: true,
435
+ description: "The JWKS endpoint URL",
436
+ },
437
+ mapping: {
438
+ type: "object",
439
+ nullable: true,
440
+ properties: {
441
+ id: {
442
+ type: "string",
443
+ description:
444
+ "Field mapping for user ID (defaults to 'sub')",
445
+ },
446
+ email: {
447
+ type: "string",
448
+ description:
449
+ "Field mapping for email (defaults to 'email')",
450
+ },
451
+ emailVerified: {
452
+ type: "string",
453
+ nullable: true,
454
+ description:
455
+ "Field mapping for email verification (defaults to 'email_verified')",
456
+ },
457
+ name: {
458
+ type: "string",
459
+ description:
460
+ "Field mapping for name (defaults to 'name')",
461
+ },
462
+ image: {
463
+ type: "string",
464
+ nullable: true,
465
+ description:
466
+ "Field mapping for image (defaults to 'picture')",
467
+ },
468
+ extraFields: {
469
+ type: "object",
470
+ additionalProperties: { type: "string" },
471
+ nullable: true,
472
+ description: "Additional field mappings",
473
+ },
474
+ },
475
+ required: ["id", "email", "name"],
476
+ },
477
+ },
478
+ required: [
479
+ "issuer",
480
+ "pkce",
481
+ "clientId",
482
+ "clientSecret",
483
+ "discoveryEndpoint",
484
+ ],
485
+ description: "OIDC configuration for the provider",
486
+ },
487
+ organizationId: {
488
+ type: "string",
489
+ nullable: true,
490
+ description: "ID of the linked organization, if any",
491
+ },
492
+ userId: {
493
+ type: "string",
494
+ description:
495
+ "ID of the user who registered the provider",
496
+ },
497
+ providerId: {
498
+ type: "string",
499
+ description: "Unique identifier for the provider",
500
+ },
501
+ redirectURI: {
502
+ type: "string",
503
+ format: "uri",
504
+ description:
505
+ "The redirect URI for the provider callback",
506
+ },
507
+ },
508
+ required: [
509
+ "issuer",
510
+ "domain",
511
+ "oidcConfig",
512
+ "userId",
513
+ "providerId",
514
+ "redirectURI",
515
+ ],
516
+ },
517
+ },
518
+ },
519
+ },
520
+ },
521
+ },
522
+ },
523
+ },
524
+ async (ctx) => {
525
+ const user = ctx.context.session?.user;
526
+ if (!user) {
527
+ throw new APIError("UNAUTHORIZED");
528
+ }
529
+
530
+ const limit =
531
+ typeof options?.providersLimit === "function"
532
+ ? await options.providersLimit(user)
533
+ : (options?.providersLimit ?? 10);
534
+
535
+ if (!limit) {
536
+ throw new APIError("FORBIDDEN", {
537
+ message: "SSO provider registration is disabled",
538
+ });
539
+ }
540
+
541
+ const providers = await ctx.context.adapter.findMany({
542
+ model: "ssoProvider",
543
+ where: [{ field: "userId", value: user.id }],
544
+ });
545
+
546
+ if (providers.length >= limit) {
547
+ throw new APIError("FORBIDDEN", {
548
+ message: "You have reached the maximum number of SSO providers",
549
+ });
550
+ }
551
+
552
+ const body = ctx.body;
553
+ const issuerValidator = z.string().url();
554
+ if (issuerValidator.safeParse(body.issuer).error) {
555
+ throw new APIError("BAD_REQUEST", {
556
+ message: "Invalid issuer. Must be a valid URL",
557
+ });
558
+ }
559
+ if (ctx.body.organizationId) {
560
+ const organization = await ctx.context.adapter.findOne({
561
+ model: "member",
562
+ where: [
563
+ {
564
+ field: "userId",
565
+ value: user.id,
566
+ },
567
+ {
568
+ field: "organizationId",
569
+ value: ctx.body.organizationId,
570
+ },
571
+ ],
572
+ });
573
+ if (!organization) {
574
+ throw new APIError("BAD_REQUEST", {
575
+ message: "You are not a member of the organization",
576
+ });
577
+ }
578
+ }
579
+
580
+ const existingProvider = await ctx.context.adapter.findOne({
581
+ model: "ssoProvider",
582
+ where: [
583
+ {
584
+ field: "providerId",
585
+ value: body.providerId,
586
+ },
587
+ ],
588
+ });
589
+
590
+ if (existingProvider) {
591
+ ctx.context.logger.info(
592
+ `SSO provider creation attempt with existing providerId: ${body.providerId}`,
593
+ );
594
+ throw new APIError("UNPROCESSABLE_ENTITY", {
595
+ message: "SSO provider with this providerId already exists",
596
+ });
597
+ }
598
+
599
+ const provider = await ctx.context.adapter.create<
600
+ Record<string, any>,
601
+ SSOProvider<O>
602
+ >({
603
+ model: "ssoProvider",
604
+ data: {
605
+ issuer: body.issuer,
606
+ domain: body.domain,
607
+ domainVerified: false,
608
+ oidcConfig: body.oidcConfig
609
+ ? JSON.stringify({
610
+ issuer: body.issuer,
611
+ clientId: body.oidcConfig.clientId,
612
+ clientSecret: body.oidcConfig.clientSecret,
613
+ authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
614
+ tokenEndpoint: body.oidcConfig.tokenEndpoint,
615
+ tokenEndpointAuthentication:
616
+ body.oidcConfig.tokenEndpointAuthentication,
617
+ jwksEndpoint: body.oidcConfig.jwksEndpoint,
618
+ pkce: body.oidcConfig.pkce,
619
+ discoveryEndpoint:
620
+ body.oidcConfig.discoveryEndpoint ||
621
+ `${body.issuer}/.well-known/openid-configuration`,
622
+ mapping: body.oidcConfig.mapping,
623
+ scopes: body.oidcConfig.scopes,
624
+ userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
625
+ overrideUserInfo:
626
+ ctx.body.overrideUserInfo ||
627
+ options?.defaultOverrideUserInfo ||
628
+ false,
629
+ })
630
+ : null,
631
+ samlConfig: body.samlConfig
632
+ ? JSON.stringify({
633
+ issuer: body.issuer,
634
+ entryPoint: body.samlConfig.entryPoint,
635
+ cert: body.samlConfig.cert,
636
+ callbackUrl: body.samlConfig.callbackUrl,
637
+ audience: body.samlConfig.audience,
638
+ idpMetadata: body.samlConfig.idpMetadata,
639
+ spMetadata: body.samlConfig.spMetadata,
640
+ wantAssertionsSigned: body.samlConfig.wantAssertionsSigned,
641
+ signatureAlgorithm: body.samlConfig.signatureAlgorithm,
642
+ digestAlgorithm: body.samlConfig.digestAlgorithm,
643
+ identifierFormat: body.samlConfig.identifierFormat,
644
+ privateKey: body.samlConfig.privateKey,
645
+ decryptionPvk: body.samlConfig.decryptionPvk,
646
+ additionalParams: body.samlConfig.additionalParams,
647
+ mapping: body.samlConfig.mapping,
648
+ })
649
+ : null,
650
+ organizationId: body.organizationId,
651
+ userId: ctx.context.session.user.id,
652
+ providerId: body.providerId,
653
+ },
654
+ });
655
+
656
+ let domainVerificationToken: string | undefined;
657
+ let domainVerified: boolean | undefined;
658
+
659
+ if (options?.domainVerification?.enabled) {
660
+ domainVerified = false;
661
+ domainVerificationToken = generateRandomString(24);
662
+
663
+ await ctx.context.adapter.create<Verification>({
664
+ model: "verification",
665
+ data: {
666
+ identifier: options.domainVerification?.tokenPrefix
667
+ ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}`
668
+ : `better-auth-token-${provider.providerId}`,
669
+ createdAt: new Date(),
670
+ updatedAt: new Date(),
671
+ value: domainVerificationToken,
672
+ expiresAt: new Date(Date.now() + 3600 * 24 * 7 * 1000), // 1 week
673
+ },
674
+ });
675
+ }
676
+
677
+ type SSOProviderReturn = O["domainVerification"] extends { enabled: true }
678
+ ? {
679
+ domainVerified: boolean;
680
+ domainVerificationToken: string;
681
+ } & SSOProvider<O>
682
+ : SSOProvider<O>;
683
+
684
+ return ctx.json({
685
+ ...provider,
686
+ oidcConfig: JSON.parse(
687
+ provider.oidcConfig as unknown as string,
688
+ ) as OIDCConfig,
689
+ samlConfig: JSON.parse(
690
+ provider.samlConfig as unknown as string,
691
+ ) as SAMLConfig,
692
+ redirectURI: `${ctx.context.baseURL}/sso/callback/${provider.providerId}`,
693
+ ...(options?.domainVerification?.enabled ? { domainVerified } : {}),
694
+ ...(options?.domainVerification?.enabled
695
+ ? { domainVerificationToken }
696
+ : {}),
697
+ } as unknown as SSOProviderReturn);
698
+ },
699
+ );
700
+ };
701
+
702
+ export const signInSSO = (options?: SSOOptions) => {
703
+ return createAuthEndpoint(
704
+ "/sign-in/sso",
705
+ {
706
+ method: "POST",
707
+ body: z.object({
708
+ email: z
709
+ .string({})
710
+ .meta({
711
+ description:
712
+ "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",
713
+ })
714
+ .optional(),
715
+ organizationSlug: z
716
+ .string({})
717
+ .meta({
718
+ description: "The slug of the organization to sign in with",
719
+ })
720
+ .optional(),
721
+ providerId: z
722
+ .string({})
723
+ .meta({
724
+ description:
725
+ "The ID of the provider to sign in with. This can be provided instead of email or issuer",
726
+ })
727
+ .optional(),
728
+ domain: z
729
+ .string({})
730
+ .meta({
731
+ description: "The domain of the provider.",
732
+ })
733
+ .optional(),
734
+ callbackURL: z.string({}).meta({
735
+ description: "The URL to redirect to after login",
736
+ }),
737
+ errorCallbackURL: z
738
+ .string({})
739
+ .meta({
740
+ description: "The URL to redirect to after login",
741
+ })
742
+ .optional(),
743
+ newUserCallbackURL: z
744
+ .string({})
745
+ .meta({
746
+ description:
747
+ "The URL to redirect to after login if the user is new",
748
+ })
749
+ .optional(),
750
+ scopes: z
751
+ .array(z.string(), {})
752
+ .meta({
753
+ description: "Scopes to request from the provider.",
754
+ })
755
+ .optional(),
756
+ loginHint: z
757
+ .string({})
758
+ .meta({
759
+ description:
760
+ "Login hint to send to the identity provider (e.g., email or identifier). If supported, will be sent as 'login_hint'.",
761
+ })
762
+ .optional(),
763
+ requestSignUp: z
764
+ .boolean({})
765
+ .meta({
766
+ description:
767
+ "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider",
768
+ })
769
+ .optional(),
770
+ providerType: z.enum(["oidc", "saml"]).optional(),
771
+ }),
772
+ metadata: {
773
+ openapi: {
774
+ operationId: "signInWithSSO",
775
+ summary: "Sign in with SSO provider",
776
+ description:
777
+ "This endpoint is used to sign in with an SSO provider. It redirects to the provider's authorization URL",
778
+ requestBody: {
779
+ content: {
780
+ "application/json": {
781
+ schema: {
782
+ type: "object",
783
+ properties: {
784
+ email: {
785
+ type: "string",
786
+ description:
787
+ "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",
788
+ },
789
+ issuer: {
790
+ type: "string",
791
+ description:
792
+ "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",
793
+ },
794
+ providerId: {
795
+ type: "string",
796
+ description:
797
+ "The ID of the provider to sign in with. This can be provided instead of email or issuer",
798
+ },
799
+ callbackURL: {
800
+ type: "string",
801
+ description: "The URL to redirect to after login",
802
+ },
803
+ errorCallbackURL: {
804
+ type: "string",
805
+ description: "The URL to redirect to after login",
806
+ },
807
+ newUserCallbackURL: {
808
+ type: "string",
809
+ description:
810
+ "The URL to redirect to after login if the user is new",
811
+ },
812
+ loginHint: {
813
+ type: "string",
814
+ description:
815
+ "Login hint to send to the identity provider (e.g., email or identifier). If supported, sent as 'login_hint'.",
816
+ },
817
+ },
818
+ required: ["callbackURL"],
819
+ },
820
+ },
821
+ },
822
+ },
823
+ responses: {
824
+ "200": {
825
+ description:
826
+ "Authorization URL generated successfully for SSO sign-in",
827
+ content: {
828
+ "application/json": {
829
+ schema: {
830
+ type: "object",
831
+ properties: {
832
+ url: {
833
+ type: "string",
834
+ format: "uri",
835
+ description:
836
+ "The authorization URL to redirect the user to for SSO sign-in",
837
+ },
838
+ redirect: {
839
+ type: "boolean",
840
+ description:
841
+ "Indicates that the client should redirect to the provided URL",
842
+ enum: [true],
843
+ },
844
+ },
845
+ required: ["url", "redirect"],
846
+ },
847
+ },
848
+ },
849
+ },
850
+ },
851
+ },
852
+ },
853
+ },
854
+ async (ctx) => {
855
+ const body = ctx.body;
856
+ let { email, organizationSlug, providerId, domain } = body;
857
+ if (
858
+ !options?.defaultSSO?.length &&
859
+ !email &&
860
+ !organizationSlug &&
861
+ !domain &&
862
+ !providerId
863
+ ) {
864
+ throw new APIError("BAD_REQUEST", {
865
+ message: "email, organizationSlug, domain or providerId is required",
866
+ });
867
+ }
868
+ domain = body.domain || email?.split("@")[1];
869
+ let orgId = "";
870
+ if (organizationSlug) {
871
+ orgId = await ctx.context.adapter
872
+ .findOne<{ id: string }>({
873
+ model: "organization",
874
+ where: [
875
+ {
876
+ field: "slug",
877
+ value: organizationSlug,
878
+ },
879
+ ],
880
+ })
881
+ .then((res) => {
882
+ if (!res) {
883
+ return "";
884
+ }
885
+ return res.id;
886
+ });
887
+ }
888
+ let provider: SSOProvider<SSOOptions> | null = null;
889
+ if (options?.defaultSSO?.length) {
890
+ // Find matching default SSO provider by providerId
891
+ const matchingDefault = providerId
892
+ ? options.defaultSSO.find(
893
+ (defaultProvider) => defaultProvider.providerId === providerId,
894
+ )
895
+ : options.defaultSSO.find(
896
+ (defaultProvider) => defaultProvider.domain === domain,
897
+ );
898
+
899
+ if (matchingDefault) {
900
+ provider = {
901
+ issuer:
902
+ matchingDefault.samlConfig?.issuer ||
903
+ matchingDefault.oidcConfig?.issuer ||
904
+ "",
905
+ providerId: matchingDefault.providerId,
906
+ userId: "default",
907
+ oidcConfig: matchingDefault.oidcConfig,
908
+ samlConfig: matchingDefault.samlConfig,
909
+ domain: matchingDefault.domain,
910
+ ...(options.domainVerification?.enabled
911
+ ? { domainVerified: true }
912
+ : {}),
913
+ } as SSOProvider<SSOOptions>;
914
+ }
915
+ }
916
+ if (!providerId && !orgId && !domain) {
917
+ throw new APIError("BAD_REQUEST", {
918
+ message: "providerId, orgId or domain is required",
919
+ });
920
+ }
921
+ // Try to find provider in database
922
+ if (!provider) {
923
+ provider = await ctx.context.adapter
924
+ .findOne<SSOProvider<SSOOptions>>({
925
+ model: "ssoProvider",
926
+ where: [
927
+ {
928
+ field: providerId
929
+ ? "providerId"
930
+ : orgId
931
+ ? "organizationId"
932
+ : "domain",
933
+ value: providerId || orgId || domain!,
934
+ },
935
+ ],
936
+ })
937
+ .then((res) => {
938
+ if (!res) {
939
+ return null;
940
+ }
941
+ return {
942
+ ...res,
943
+ oidcConfig: res.oidcConfig
944
+ ? safeJsonParse<OIDCConfig>(
945
+ res.oidcConfig as unknown as string,
946
+ ) || undefined
947
+ : undefined,
948
+ samlConfig: res.samlConfig
949
+ ? safeJsonParse<SAMLConfig>(
950
+ res.samlConfig as unknown as string,
951
+ ) || undefined
952
+ : undefined,
953
+ };
954
+ });
955
+ }
956
+
957
+ if (!provider) {
958
+ throw new APIError("NOT_FOUND", {
959
+ message: "No provider found for the issuer",
960
+ });
961
+ }
962
+
963
+ if (body.providerType) {
964
+ if (body.providerType === "oidc" && !provider.oidcConfig) {
965
+ throw new APIError("BAD_REQUEST", {
966
+ message: "OIDC provider is not configured",
967
+ });
968
+ }
969
+ if (body.providerType === "saml" && !provider.samlConfig) {
970
+ throw new APIError("BAD_REQUEST", {
971
+ message: "SAML provider is not configured",
972
+ });
973
+ }
974
+ }
975
+
976
+ if (
977
+ options?.domainVerification?.enabled &&
978
+ !("domainVerified" in provider && provider.domainVerified)
979
+ ) {
980
+ throw new APIError("UNAUTHORIZED", {
981
+ message: "Provider domain has not been verified",
982
+ });
983
+ }
984
+
985
+ if (provider.oidcConfig && body.providerType !== "saml") {
986
+ let finalAuthUrl = provider.oidcConfig.authorizationEndpoint;
987
+ if (!finalAuthUrl && provider.oidcConfig.discoveryEndpoint) {
988
+ const discovery = await betterFetch<{
989
+ authorization_endpoint: string;
990
+ }>(provider.oidcConfig.discoveryEndpoint, {
991
+ method: "GET",
992
+ });
993
+ if (discovery.data) {
994
+ finalAuthUrl = discovery.data.authorization_endpoint;
995
+ }
996
+ }
997
+ if (!finalAuthUrl) {
998
+ throw new APIError("BAD_REQUEST", {
999
+ message: "Invalid OIDC configuration. Authorization URL not found.",
1000
+ });
1001
+ }
1002
+ const state = await generateState(ctx, undefined, false);
1003
+ const redirectURI = `${ctx.context.baseURL}/sso/callback/${provider.providerId}`;
1004
+ const authorizationURL = await createAuthorizationURL({
1005
+ id: provider.issuer,
1006
+ options: {
1007
+ clientId: provider.oidcConfig.clientId,
1008
+ clientSecret: provider.oidcConfig.clientSecret,
1009
+ },
1010
+ redirectURI,
1011
+ state: state.state,
1012
+ codeVerifier: provider.oidcConfig.pkce
1013
+ ? state.codeVerifier
1014
+ : undefined,
1015
+ scopes: ctx.body.scopes ||
1016
+ provider.oidcConfig.scopes || [
1017
+ "openid",
1018
+ "email",
1019
+ "profile",
1020
+ "offline_access",
1021
+ ],
1022
+ loginHint: ctx.body.loginHint || email,
1023
+ authorizationEndpoint: finalAuthUrl,
1024
+ });
1025
+ return ctx.json({
1026
+ url: authorizationURL.toString(),
1027
+ redirect: true,
1028
+ });
1029
+ }
1030
+ if (provider.samlConfig) {
1031
+ const parsedSamlConfig =
1032
+ typeof provider.samlConfig === "object"
1033
+ ? provider.samlConfig
1034
+ : safeJsonParse<SAMLConfig>(
1035
+ provider.samlConfig as unknown as string,
1036
+ );
1037
+ if (!parsedSamlConfig) {
1038
+ throw new APIError("BAD_REQUEST", {
1039
+ message: "Invalid SAML configuration",
1040
+ });
1041
+ }
1042
+
1043
+ let metadata = parsedSamlConfig.spMetadata.metadata;
1044
+
1045
+ if (!metadata) {
1046
+ metadata =
1047
+ saml
1048
+ .SPMetadata({
1049
+ entityID:
1050
+ parsedSamlConfig.spMetadata?.entityID ||
1051
+ parsedSamlConfig.issuer,
1052
+ assertionConsumerService: [
1053
+ {
1054
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1055
+ Location:
1056
+ parsedSamlConfig.callbackUrl ||
1057
+ `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.providerId}`,
1058
+ },
1059
+ ],
1060
+ wantMessageSigned:
1061
+ parsedSamlConfig.wantAssertionsSigned || false,
1062
+ nameIDFormat: parsedSamlConfig.identifierFormat
1063
+ ? [parsedSamlConfig.identifierFormat]
1064
+ : undefined,
1065
+ })
1066
+ .getMetadata() || "";
1067
+ }
1068
+
1069
+ const sp = saml.ServiceProvider({
1070
+ metadata: metadata,
1071
+ allowCreate: true,
1072
+ });
1073
+
1074
+ const idp = saml.IdentityProvider({
1075
+ metadata: parsedSamlConfig.idpMetadata?.metadata,
1076
+ entityID: parsedSamlConfig.idpMetadata?.entityID,
1077
+ encryptCert: parsedSamlConfig.idpMetadata?.cert,
1078
+ singleSignOnService:
1079
+ parsedSamlConfig.idpMetadata?.singleSignOnService,
1080
+ });
1081
+ const loginRequest = sp.createLoginRequest(
1082
+ idp,
1083
+ "redirect",
1084
+ ) as BindingContext & { entityEndpoint: string; type: string };
1085
+ if (!loginRequest) {
1086
+ throw new APIError("BAD_REQUEST", {
1087
+ message: "Invalid SAML request",
1088
+ });
1089
+ }
1090
+ return ctx.json({
1091
+ url: `${loginRequest.context}&RelayState=${encodeURIComponent(
1092
+ body.callbackURL,
1093
+ )}`,
1094
+ redirect: true,
1095
+ });
1096
+ }
1097
+ throw new APIError("BAD_REQUEST", {
1098
+ message: "Invalid SSO provider",
1099
+ });
1100
+ },
1101
+ );
1102
+ };
1103
+
1104
+ export const callbackSSO = (options?: SSOOptions) => {
1105
+ return createAuthEndpoint(
1106
+ "/sso/callback/:providerId",
1107
+ {
1108
+ method: "GET",
1109
+ query: z.object({
1110
+ code: z.string().optional(),
1111
+ state: z.string(),
1112
+ error: z.string().optional(),
1113
+ error_description: z.string().optional(),
1114
+ }),
1115
+ allowedMediaTypes: [
1116
+ "application/x-www-form-urlencoded",
1117
+ "application/json",
1118
+ ],
1119
+ metadata: {
1120
+ isAction: false,
1121
+ openapi: {
1122
+ operationId: "handleSSOCallback",
1123
+ summary: "Callback URL for SSO provider",
1124
+ description:
1125
+ "This endpoint is used as the callback URL for SSO providers. It handles the authorization code and exchanges it for an access token",
1126
+ responses: {
1127
+ "302": {
1128
+ description: "Redirects to the callback URL",
1129
+ },
1130
+ },
1131
+ },
1132
+ },
1133
+ },
1134
+ async (ctx) => {
1135
+ const { code, state, error, error_description } = ctx.query;
1136
+ const stateData = await parseState(ctx);
1137
+ if (!stateData) {
1138
+ const errorURL =
1139
+ ctx.context.options.onAPIError?.errorURL ||
1140
+ `${ctx.context.baseURL}/error`;
1141
+ throw ctx.redirect(`${errorURL}?error=invalid_state`);
1142
+ }
1143
+ const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
1144
+ if (!code || error) {
1145
+ throw ctx.redirect(
1146
+ `${
1147
+ errorURL || callbackURL
1148
+ }?error=${error}&error_description=${error_description}`,
1149
+ );
1150
+ }
1151
+ let provider: SSOProvider<SSOOptions> | null = null;
1152
+ if (options?.defaultSSO?.length) {
1153
+ const matchingDefault = options.defaultSSO.find(
1154
+ (defaultProvider) =>
1155
+ defaultProvider.providerId === ctx.params.providerId,
1156
+ );
1157
+ if (matchingDefault) {
1158
+ provider = {
1159
+ ...matchingDefault,
1160
+ issuer: matchingDefault.oidcConfig?.issuer || "",
1161
+ userId: "default",
1162
+ ...(options.domainVerification?.enabled
1163
+ ? { domainVerified: true }
1164
+ : {}),
1165
+ } as SSOProvider<SSOOptions>;
1166
+ }
1167
+ }
1168
+ if (!provider) {
1169
+ provider = await ctx.context.adapter
1170
+ .findOne<{
1171
+ oidcConfig: string;
1172
+ }>({
1173
+ model: "ssoProvider",
1174
+ where: [
1175
+ {
1176
+ field: "providerId",
1177
+ value: ctx.params.providerId,
1178
+ },
1179
+ ],
1180
+ })
1181
+ .then((res) => {
1182
+ if (!res) {
1183
+ return null;
1184
+ }
1185
+ return {
1186
+ ...res,
1187
+ oidcConfig:
1188
+ safeJsonParse<OIDCConfig>(res.oidcConfig) || undefined,
1189
+ } as SSOProvider<SSOOptions>;
1190
+ });
1191
+ }
1192
+ if (!provider) {
1193
+ throw ctx.redirect(
1194
+ `${
1195
+ errorURL || callbackURL
1196
+ }/error?error=invalid_provider&error_description=provider not found`,
1197
+ );
1198
+ }
1199
+
1200
+ if (
1201
+ options?.domainVerification?.enabled &&
1202
+ !("domainVerified" in provider && provider.domainVerified)
1203
+ ) {
1204
+ throw new APIError("UNAUTHORIZED", {
1205
+ message: "Provider domain has not been verified",
1206
+ });
1207
+ }
1208
+
1209
+ let config = provider.oidcConfig;
1210
+
1211
+ if (!config) {
1212
+ throw ctx.redirect(
1213
+ `${
1214
+ errorURL || callbackURL
1215
+ }/error?error=invalid_provider&error_description=provider not found`,
1216
+ );
1217
+ }
1218
+
1219
+ const discovery = await betterFetch<{
1220
+ token_endpoint: string;
1221
+ userinfo_endpoint: string;
1222
+ token_endpoint_auth_method:
1223
+ | "client_secret_basic"
1224
+ | "client_secret_post";
1225
+ }>(config.discoveryEndpoint);
1226
+
1227
+ if (discovery.data) {
1228
+ config = {
1229
+ tokenEndpoint: discovery.data.token_endpoint,
1230
+ tokenEndpointAuthentication:
1231
+ discovery.data.token_endpoint_auth_method,
1232
+ userInfoEndpoint: discovery.data.userinfo_endpoint,
1233
+ scopes: ["openid", "email", "profile", "offline_access"],
1234
+ ...config,
1235
+ };
1236
+ }
1237
+
1238
+ if (!config.tokenEndpoint) {
1239
+ throw ctx.redirect(
1240
+ `${
1241
+ errorURL || callbackURL
1242
+ }/error?error=invalid_provider&error_description=token_endpoint_not_found`,
1243
+ );
1244
+ }
1245
+
1246
+ const tokenResponse = await validateAuthorizationCode({
1247
+ code,
1248
+ codeVerifier: config.pkce ? stateData.codeVerifier : undefined,
1249
+ redirectURI: `${ctx.context.baseURL}/sso/callback/${provider.providerId}`,
1250
+ options: {
1251
+ clientId: config.clientId,
1252
+ clientSecret: config.clientSecret,
1253
+ },
1254
+ tokenEndpoint: config.tokenEndpoint,
1255
+ authentication:
1256
+ config.tokenEndpointAuthentication === "client_secret_post"
1257
+ ? "post"
1258
+ : "basic",
1259
+ }).catch((e) => {
1260
+ if (e instanceof BetterFetchError) {
1261
+ throw ctx.redirect(
1262
+ `${
1263
+ errorURL || callbackURL
1264
+ }?error=invalid_provider&error_description=${e.message}`,
1265
+ );
1266
+ }
1267
+ return null;
1268
+ });
1269
+ if (!tokenResponse) {
1270
+ throw ctx.redirect(
1271
+ `${
1272
+ errorURL || callbackURL
1273
+ }/error?error=invalid_provider&error_description=token_response_not_found`,
1274
+ );
1275
+ }
1276
+ let userInfo: {
1277
+ id?: string;
1278
+ email?: string;
1279
+ name?: string;
1280
+ image?: string;
1281
+ emailVerified?: boolean;
1282
+ [key: string]: any;
1283
+ } | null = null;
1284
+ if (tokenResponse.idToken) {
1285
+ const idToken = decodeJwt(tokenResponse.idToken);
1286
+ if (!config.jwksEndpoint) {
1287
+ throw ctx.redirect(
1288
+ `${
1289
+ errorURL || callbackURL
1290
+ }/error?error=invalid_provider&error_description=jwks_endpoint_not_found`,
1291
+ );
1292
+ }
1293
+ const verified = await validateToken(
1294
+ tokenResponse.idToken,
1295
+ config.jwksEndpoint,
1296
+ ).catch((e) => {
1297
+ ctx.context.logger.error(e);
1298
+ return null;
1299
+ });
1300
+ if (!verified) {
1301
+ throw ctx.redirect(
1302
+ `${
1303
+ errorURL || callbackURL
1304
+ }/error?error=invalid_provider&error_description=token_not_verified`,
1305
+ );
1306
+ }
1307
+ if (verified.payload.iss !== provider.issuer) {
1308
+ throw ctx.redirect(
1309
+ `${
1310
+ errorURL || callbackURL
1311
+ }/error?error=invalid_provider&error_description=issuer_mismatch`,
1312
+ );
1313
+ }
1314
+
1315
+ const mapping = config.mapping || {};
1316
+ userInfo = {
1317
+ ...Object.fromEntries(
1318
+ Object.entries(mapping.extraFields || {}).map(([key, value]) => [
1319
+ key,
1320
+ verified.payload[value],
1321
+ ]),
1322
+ ),
1323
+ id: idToken[mapping.id || "sub"],
1324
+ email: idToken[mapping.email || "email"],
1325
+ emailVerified: options?.trustEmailVerified
1326
+ ? idToken[mapping.emailVerified || "email_verified"]
1327
+ : false,
1328
+ name: idToken[mapping.name || "name"],
1329
+ image: idToken[mapping.image || "picture"],
1330
+ } as {
1331
+ id?: string;
1332
+ email?: string;
1333
+ name?: string;
1334
+ image?: string;
1335
+ emailVerified?: boolean;
1336
+ };
1337
+ }
1338
+
1339
+ if (!userInfo) {
1340
+ if (!config.userInfoEndpoint) {
1341
+ throw ctx.redirect(
1342
+ `${
1343
+ errorURL || callbackURL
1344
+ }/error?error=invalid_provider&error_description=user_info_endpoint_not_found`,
1345
+ );
1346
+ }
1347
+ const userInfoResponse = await betterFetch<{
1348
+ email?: string;
1349
+ name?: string;
1350
+ id?: string;
1351
+ image?: string;
1352
+ emailVerified?: boolean;
1353
+ }>(config.userInfoEndpoint, {
1354
+ headers: {
1355
+ Authorization: `Bearer ${tokenResponse.accessToken}`,
1356
+ },
1357
+ });
1358
+ if (userInfoResponse.error) {
1359
+ throw ctx.redirect(
1360
+ `${
1361
+ errorURL || callbackURL
1362
+ }/error?error=invalid_provider&error_description=${
1363
+ userInfoResponse.error.message
1364
+ }`,
1365
+ );
1366
+ }
1367
+ userInfo = userInfoResponse.data;
1368
+ }
1369
+
1370
+ if (!userInfo.email || !userInfo.id) {
1371
+ throw ctx.redirect(
1372
+ `${
1373
+ errorURL || callbackURL
1374
+ }/error?error=invalid_provider&error_description=missing_user_info`,
1375
+ );
1376
+ }
1377
+ const linked = await handleOAuthUserInfo(ctx, {
1378
+ userInfo: {
1379
+ email: userInfo.email,
1380
+ name: userInfo.name || userInfo.email,
1381
+ id: userInfo.id,
1382
+ image: userInfo.image,
1383
+ emailVerified: options?.trustEmailVerified
1384
+ ? userInfo.emailVerified || false
1385
+ : false,
1386
+ },
1387
+ account: {
1388
+ idToken: tokenResponse.idToken,
1389
+ accessToken: tokenResponse.accessToken,
1390
+ refreshToken: tokenResponse.refreshToken,
1391
+ accountId: userInfo.id,
1392
+ providerId: provider.providerId,
1393
+ accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
1394
+ refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
1395
+ scope: tokenResponse.scopes?.join(","),
1396
+ },
1397
+ callbackURL,
1398
+ disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
1399
+ overrideUserInfo: config.overrideUserInfo,
1400
+ });
1401
+ if (linked.error) {
1402
+ throw ctx.redirect(
1403
+ `${errorURL || callbackURL}/error?error=${linked.error}`,
1404
+ );
1405
+ }
1406
+ const { session, user } = linked.data!;
1407
+
1408
+ if (options?.provisionUser) {
1409
+ await options.provisionUser({
1410
+ user,
1411
+ userInfo,
1412
+ token: tokenResponse,
1413
+ provider,
1414
+ });
1415
+ }
1416
+ if (
1417
+ provider.organizationId &&
1418
+ !options?.organizationProvisioning?.disabled
1419
+ ) {
1420
+ const isOrgPluginEnabled = ctx.context.options.plugins?.find(
1421
+ (plugin) => plugin.id === "organization",
1422
+ );
1423
+ if (isOrgPluginEnabled) {
1424
+ const isAlreadyMember = await ctx.context.adapter.findOne({
1425
+ model: "member",
1426
+ where: [
1427
+ { field: "organizationId", value: provider.organizationId },
1428
+ { field: "userId", value: user.id },
1429
+ ],
1430
+ });
1431
+ if (!isAlreadyMember) {
1432
+ const role = options?.organizationProvisioning?.getRole
1433
+ ? await options.organizationProvisioning.getRole({
1434
+ user,
1435
+ userInfo,
1436
+ token: tokenResponse,
1437
+ provider,
1438
+ })
1439
+ : options?.organizationProvisioning?.defaultRole || "member";
1440
+ await ctx.context.adapter.create({
1441
+ model: "member",
1442
+ data: {
1443
+ organizationId: provider.organizationId,
1444
+ userId: user.id,
1445
+ role,
1446
+ createdAt: new Date(),
1447
+ updatedAt: new Date(),
1448
+ },
1449
+ });
1450
+ }
1451
+ }
1452
+ }
1453
+ await setSessionCookie(ctx, {
1454
+ session,
1455
+ user,
1456
+ });
1457
+ let toRedirectTo: string;
1458
+ try {
1459
+ const url = linked.isRegister ? newUserURL || callbackURL : callbackURL;
1460
+ toRedirectTo = url.toString();
1461
+ } catch {
1462
+ toRedirectTo = linked.isRegister
1463
+ ? newUserURL || callbackURL
1464
+ : callbackURL;
1465
+ }
1466
+ throw ctx.redirect(toRedirectTo);
1467
+ },
1468
+ );
1469
+ };
1470
+
1471
+ export const callbackSSOSAML = (options?: SSOOptions) => {
1472
+ return createAuthEndpoint(
1473
+ "/sso/saml2/callback/:providerId",
1474
+ {
1475
+ method: "POST",
1476
+ body: z.object({
1477
+ SAMLResponse: z.string(),
1478
+ RelayState: z.string().optional(),
1479
+ }),
1480
+ metadata: {
1481
+ isAction: false,
1482
+ allowedMediaTypes: [
1483
+ "application/x-www-form-urlencoded",
1484
+ "application/json",
1485
+ ],
1486
+ openapi: {
1487
+ operationId: "handleSAMLCallback",
1488
+ summary: "Callback URL for SAML provider",
1489
+ description:
1490
+ "This endpoint is used as the callback URL for SAML providers.",
1491
+ responses: {
1492
+ "302": {
1493
+ description: "Redirects to the callback URL",
1494
+ },
1495
+ "400": {
1496
+ description: "Invalid SAML response",
1497
+ },
1498
+ "401": {
1499
+ description: "Unauthorized - SAML authentication failed",
1500
+ },
1501
+ },
1502
+ },
1503
+ },
1504
+ },
1505
+ async (ctx) => {
1506
+ const { SAMLResponse, RelayState } = ctx.body;
1507
+ const { providerId } = ctx.params;
1508
+ let provider: SSOProvider<SSOOptions> | null = null;
1509
+ if (options?.defaultSSO?.length) {
1510
+ const matchingDefault = options.defaultSSO.find(
1511
+ (defaultProvider) => defaultProvider.providerId === providerId,
1512
+ );
1513
+ if (matchingDefault) {
1514
+ provider = {
1515
+ ...matchingDefault,
1516
+ userId: "default",
1517
+ issuer: matchingDefault.samlConfig?.issuer || "",
1518
+ ...(options.domainVerification?.enabled
1519
+ ? { domainVerified: true }
1520
+ : {}),
1521
+ } as SSOProvider<SSOOptions>;
1522
+ }
1523
+ }
1524
+ if (!provider) {
1525
+ provider = await ctx.context.adapter
1526
+ .findOne<SSOProvider<SSOOptions>>({
1527
+ model: "ssoProvider",
1528
+ where: [{ field: "providerId", value: providerId }],
1529
+ })
1530
+ .then((res) => {
1531
+ if (!res) return null;
1532
+ return {
1533
+ ...res,
1534
+ samlConfig: res.samlConfig
1535
+ ? safeJsonParse<SAMLConfig>(
1536
+ res.samlConfig as unknown as string,
1537
+ ) || undefined
1538
+ : undefined,
1539
+ };
1540
+ });
1541
+ }
1542
+
1543
+ if (!provider) {
1544
+ throw new APIError("NOT_FOUND", {
1545
+ message: "No provider found for the given providerId",
1546
+ });
1547
+ }
1548
+
1549
+ if (
1550
+ options?.domainVerification?.enabled &&
1551
+ !("domainVerified" in provider && provider.domainVerified)
1552
+ ) {
1553
+ throw new APIError("UNAUTHORIZED", {
1554
+ message: "Provider domain has not been verified",
1555
+ });
1556
+ }
1557
+
1558
+ const parsedSamlConfig = safeJsonParse<SAMLConfig>(
1559
+ provider.samlConfig as unknown as string,
1560
+ );
1561
+ if (!parsedSamlConfig) {
1562
+ throw new APIError("BAD_REQUEST", {
1563
+ message: "Invalid SAML configuration",
1564
+ });
1565
+ }
1566
+ const idpData = parsedSamlConfig.idpMetadata;
1567
+ let idp: IdentityProvider | null = null;
1568
+
1569
+ // Construct IDP with fallback to manual configuration
1570
+ if (!idpData?.metadata) {
1571
+ idp = saml.IdentityProvider({
1572
+ entityID: idpData?.entityID || parsedSamlConfig.issuer,
1573
+ singleSignOnService: [
1574
+ {
1575
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1576
+ Location: parsedSamlConfig.entryPoint,
1577
+ },
1578
+ ],
1579
+ signingCert: idpData?.cert || parsedSamlConfig.cert,
1580
+ wantAuthnRequestsSigned:
1581
+ parsedSamlConfig.wantAssertionsSigned || false,
1582
+ isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
1583
+ encPrivateKey: idpData?.encPrivateKey,
1584
+ encPrivateKeyPass: idpData?.encPrivateKeyPass,
1585
+ });
1586
+ } else {
1587
+ idp = saml.IdentityProvider({
1588
+ metadata: idpData.metadata,
1589
+ privateKey: idpData.privateKey,
1590
+ privateKeyPass: idpData.privateKeyPass,
1591
+ isAssertionEncrypted: idpData.isAssertionEncrypted,
1592
+ encPrivateKey: idpData.encPrivateKey,
1593
+ encPrivateKeyPass: idpData.encPrivateKeyPass,
1594
+ });
1595
+ }
1596
+
1597
+ // Construct SP with fallback to manual configuration
1598
+ const spData = parsedSamlConfig.spMetadata;
1599
+ const sp = saml.ServiceProvider({
1600
+ metadata: spData?.metadata,
1601
+ entityID: spData?.entityID || parsedSamlConfig.issuer,
1602
+ assertionConsumerService: spData?.metadata
1603
+ ? undefined
1604
+ : [
1605
+ {
1606
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1607
+ Location: parsedSamlConfig.callbackUrl,
1608
+ },
1609
+ ],
1610
+ privateKey: spData?.privateKey || parsedSamlConfig.privateKey,
1611
+ privateKeyPass: spData?.privateKeyPass,
1612
+ isAssertionEncrypted: spData?.isAssertionEncrypted || false,
1613
+ encPrivateKey: spData?.encPrivateKey,
1614
+ encPrivateKeyPass: spData?.encPrivateKeyPass,
1615
+ wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1616
+ nameIDFormat: parsedSamlConfig.identifierFormat
1617
+ ? [parsedSamlConfig.identifierFormat]
1618
+ : undefined,
1619
+ });
1620
+
1621
+ let parsedResponse: FlowResult;
1622
+ try {
1623
+ const decodedResponse = Buffer.from(SAMLResponse, "base64").toString(
1624
+ "utf-8",
1625
+ );
1626
+
1627
+ try {
1628
+ parsedResponse = await sp.parseLoginResponse(idp, "post", {
1629
+ body: {
1630
+ SAMLResponse,
1631
+ RelayState: RelayState || undefined,
1632
+ },
1633
+ });
1634
+ } catch (parseError) {
1635
+ const nameIDMatch = decodedResponse.match(
1636
+ /<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/,
1637
+ );
1638
+ if (!nameIDMatch) throw parseError;
1639
+ parsedResponse = {
1640
+ extract: {
1641
+ nameID: nameIDMatch[1],
1642
+ attributes: { nameID: nameIDMatch[1] },
1643
+ sessionIndex: {},
1644
+ conditions: {},
1645
+ },
1646
+ } as FlowResult;
1647
+ }
1648
+
1649
+ if (!parsedResponse?.extract) {
1650
+ throw new Error("Invalid SAML response structure");
1651
+ }
1652
+ } catch (error) {
1653
+ ctx.context.logger.error("SAML response validation failed", {
1654
+ error,
1655
+ decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
1656
+ "utf-8",
1657
+ ),
1658
+ });
1659
+ throw new APIError("BAD_REQUEST", {
1660
+ message: "Invalid SAML response",
1661
+ details: error instanceof Error ? error.message : String(error),
1662
+ });
1663
+ }
1664
+
1665
+ const { extract } = parsedResponse!;
1666
+ const attributes = extract.attributes || {};
1667
+ const mapping = parsedSamlConfig.mapping ?? {};
1668
+
1669
+ const userInfo = {
1670
+ ...Object.fromEntries(
1671
+ Object.entries(mapping.extraFields || {}).map(([key, value]) => [
1672
+ key,
1673
+ attributes[value as string],
1674
+ ]),
1675
+ ),
1676
+ id: attributes[mapping.id || "nameID"] || extract.nameID,
1677
+ email: attributes[mapping.email || "email"] || extract.nameID,
1678
+ name:
1679
+ [
1680
+ attributes[mapping.firstName || "givenName"],
1681
+ attributes[mapping.lastName || "surname"],
1682
+ ]
1683
+ .filter(Boolean)
1684
+ .join(" ") ||
1685
+ attributes[mapping.name || "displayName"] ||
1686
+ extract.nameID,
1687
+ emailVerified:
1688
+ options?.trustEmailVerified && mapping.emailVerified
1689
+ ? ((attributes[mapping.emailVerified] || false) as boolean)
1690
+ : false,
1691
+ };
1692
+ if (!userInfo.id || !userInfo.email) {
1693
+ ctx.context.logger.error(
1694
+ "Missing essential user info from SAML response",
1695
+ {
1696
+ attributes: Object.keys(attributes),
1697
+ mapping,
1698
+ extractedId: userInfo.id,
1699
+ extractedEmail: userInfo.email,
1700
+ },
1701
+ );
1702
+ throw new APIError("BAD_REQUEST", {
1703
+ message: "Unable to extract user ID or email from SAML response",
1704
+ });
1705
+ }
1706
+
1707
+ // Find or create user
1708
+ let user: User;
1709
+ const existingUser = await ctx.context.adapter.findOne<User>({
1710
+ model: "user",
1711
+ where: [
1712
+ {
1713
+ field: "email",
1714
+ value: userInfo.email,
1715
+ },
1716
+ ],
1717
+ });
1718
+
1719
+ if (existingUser) {
1720
+ user = existingUser;
1721
+ } else {
1722
+ // if implicit sign up is disabled, we should not create a new user nor a new account.
1723
+ if (options?.disableImplicitSignUp) {
1724
+ throw new APIError("UNAUTHORIZED", {
1725
+ message:
1726
+ "User not found and implicit sign up is disabled for this provider",
1727
+ });
1728
+ }
1729
+
1730
+ user = await ctx.context.internalAdapter.createUser({
1731
+ email: userInfo.email,
1732
+ name: userInfo.name,
1733
+ emailVerified: userInfo.emailVerified,
1734
+ });
1735
+ }
1736
+
1737
+ // Create or update account link
1738
+ const account = await ctx.context.adapter.findOne<Account>({
1739
+ model: "account",
1740
+ where: [
1741
+ { field: "userId", value: user.id },
1742
+ { field: "providerId", value: provider.providerId },
1743
+ { field: "accountId", value: userInfo.id },
1744
+ ],
1745
+ });
1746
+
1747
+ if (!account) {
1748
+ await ctx.context.internalAdapter.createAccount({
1749
+ userId: user.id,
1750
+ providerId: provider.providerId,
1751
+ accountId: userInfo.id,
1752
+ accessToken: "",
1753
+ refreshToken: "",
1754
+ });
1755
+ }
1756
+
1757
+ // Run provision hooks
1758
+ if (options?.provisionUser) {
1759
+ await options.provisionUser({
1760
+ user: user as User & Record<string, any>,
1761
+ userInfo,
1762
+ provider,
1763
+ });
1764
+ }
1765
+
1766
+ // Handle organization provisioning
1767
+ if (
1768
+ provider.organizationId &&
1769
+ !options?.organizationProvisioning?.disabled
1770
+ ) {
1771
+ const isOrgPluginEnabled = ctx.context.options.plugins?.find(
1772
+ (plugin) => plugin.id === "organization",
1773
+ );
1774
+ if (isOrgPluginEnabled) {
1775
+ const isAlreadyMember = await ctx.context.adapter.findOne({
1776
+ model: "member",
1777
+ where: [
1778
+ { field: "organizationId", value: provider.organizationId },
1779
+ { field: "userId", value: user.id },
1780
+ ],
1781
+ });
1782
+ if (!isAlreadyMember) {
1783
+ const role = options?.organizationProvisioning?.getRole
1784
+ ? await options.organizationProvisioning.getRole({
1785
+ user,
1786
+ userInfo,
1787
+ provider,
1788
+ })
1789
+ : options?.organizationProvisioning?.defaultRole || "member";
1790
+ await ctx.context.adapter.create({
1791
+ model: "member",
1792
+ data: {
1793
+ organizationId: provider.organizationId,
1794
+ userId: user.id,
1795
+ role,
1796
+ createdAt: new Date(),
1797
+ updatedAt: new Date(),
1798
+ },
1799
+ });
1800
+ }
1801
+ }
1802
+ }
1803
+
1804
+ // Create session and set cookie
1805
+ let session: Session = await ctx.context.internalAdapter.createSession(
1806
+ user.id,
1807
+ );
1808
+ await setSessionCookie(ctx, { session, user });
1809
+
1810
+ // Redirect to callback URL
1811
+ const callbackUrl =
1812
+ RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1813
+ throw ctx.redirect(callbackUrl);
1814
+ },
1815
+ );
1816
+ };
1817
+
1818
+ export const acsEndpoint = (options?: SSOOptions) => {
1819
+ return createAuthEndpoint(
1820
+ "/sso/saml2/sp/acs/:providerId",
1821
+ {
1822
+ method: "POST",
1823
+ params: z.object({
1824
+ providerId: z.string().optional(),
1825
+ }),
1826
+ body: z.object({
1827
+ SAMLResponse: z.string(),
1828
+ RelayState: z.string().optional(),
1829
+ }),
1830
+ metadata: {
1831
+ isAction: false,
1832
+ allowedMediaTypes: [
1833
+ "application/x-www-form-urlencoded",
1834
+ "application/json",
1835
+ ],
1836
+ openapi: {
1837
+ operationId: "handleSAMLAssertionConsumerService",
1838
+ summary: "SAML Assertion Consumer Service",
1839
+ description:
1840
+ "Handles SAML responses from IdP after successful authentication",
1841
+ responses: {
1842
+ "302": {
1843
+ description:
1844
+ "Redirects to the callback URL after successful authentication",
1845
+ },
1846
+ },
1847
+ },
1848
+ },
1849
+ },
1850
+ async (ctx) => {
1851
+ const { SAMLResponse, RelayState = "" } = ctx.body;
1852
+ const { providerId } = ctx.params;
1853
+
1854
+ // If defaultSSO is configured, use it as the provider
1855
+ let provider: SSOProvider<SSOOptions> | null = null;
1856
+
1857
+ if (options?.defaultSSO?.length) {
1858
+ // For ACS endpoint, we can use the first default provider or try to match by providerId
1859
+ const matchingDefault = providerId
1860
+ ? options.defaultSSO.find(
1861
+ (defaultProvider) => defaultProvider.providerId === providerId,
1862
+ )
1863
+ : options.defaultSSO[0]; // Use first default provider if no specific providerId
1864
+
1865
+ if (matchingDefault) {
1866
+ provider = {
1867
+ issuer: matchingDefault.samlConfig?.issuer || "",
1868
+ providerId: matchingDefault.providerId,
1869
+ userId: "default",
1870
+ samlConfig: matchingDefault.samlConfig,
1871
+ domain: matchingDefault.domain,
1872
+ ...(options.domainVerification?.enabled
1873
+ ? { domainVerified: true }
1874
+ : {}),
1875
+ };
1876
+ }
1877
+ } else {
1878
+ provider = await ctx.context.adapter
1879
+ .findOne<SSOProvider<SSOOptions>>({
1880
+ model: "ssoProvider",
1881
+ where: [
1882
+ {
1883
+ field: "providerId",
1884
+ value: providerId ?? "sso",
1885
+ },
1886
+ ],
1887
+ })
1888
+ .then((res) => {
1889
+ if (!res) return null;
1890
+ return {
1891
+ ...res,
1892
+ samlConfig: res.samlConfig
1893
+ ? safeJsonParse<SAMLConfig>(
1894
+ res.samlConfig as unknown as string,
1895
+ ) || undefined
1896
+ : undefined,
1897
+ };
1898
+ });
1899
+ }
1900
+
1901
+ if (!provider?.samlConfig) {
1902
+ throw new APIError("NOT_FOUND", {
1903
+ message: "No SAML provider found",
1904
+ });
1905
+ }
1906
+
1907
+ if (
1908
+ options?.domainVerification?.enabled &&
1909
+ !("domainVerified" in provider && provider.domainVerified)
1910
+ ) {
1911
+ throw new APIError("UNAUTHORIZED", {
1912
+ message: "Provider domain has not been verified",
1913
+ });
1914
+ }
1915
+
1916
+ const parsedSamlConfig = provider.samlConfig;
1917
+ // Configure SP and IdP
1918
+ const sp = saml.ServiceProvider({
1919
+ entityID:
1920
+ parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
1921
+ assertionConsumerService: [
1922
+ {
1923
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1924
+ Location:
1925
+ parsedSamlConfig.callbackUrl ||
1926
+ `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`,
1927
+ },
1928
+ ],
1929
+ wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1930
+ metadata: parsedSamlConfig.spMetadata?.metadata,
1931
+ privateKey:
1932
+ parsedSamlConfig.spMetadata?.privateKey ||
1933
+ parsedSamlConfig.privateKey,
1934
+ privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
1935
+ nameIDFormat: parsedSamlConfig.identifierFormat
1936
+ ? [parsedSamlConfig.identifierFormat]
1937
+ : undefined,
1938
+ });
1939
+
1940
+ // Update where we construct the IdP
1941
+ const idpData = parsedSamlConfig.idpMetadata;
1942
+ const idp = !idpData?.metadata
1943
+ ? saml.IdentityProvider({
1944
+ entityID: idpData?.entityID || parsedSamlConfig.issuer,
1945
+ singleSignOnService: idpData?.singleSignOnService || [
1946
+ {
1947
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1948
+ Location: parsedSamlConfig.entryPoint,
1949
+ },
1950
+ ],
1951
+ signingCert: idpData?.cert || parsedSamlConfig.cert,
1952
+ })
1953
+ : saml.IdentityProvider({
1954
+ metadata: idpData.metadata,
1955
+ });
1956
+
1957
+ // Parse and validate SAML response
1958
+ let parsedResponse: FlowResult;
1959
+ try {
1960
+ let decodedResponse = Buffer.from(SAMLResponse, "base64").toString(
1961
+ "utf-8",
1962
+ );
1963
+
1964
+ // Patch the SAML response if status is missing or not success
1965
+ if (!decodedResponse.includes("StatusCode")) {
1966
+ // Insert a success status if missing
1967
+ const insertPoint = decodedResponse.indexOf("</saml2:Issuer>");
1968
+ if (insertPoint !== -1) {
1969
+ decodedResponse =
1970
+ decodedResponse.slice(0, insertPoint + 14) +
1971
+ '<saml2:Status><saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></saml2:Status>' +
1972
+ decodedResponse.slice(insertPoint + 14);
1973
+ }
1974
+ } else if (!decodedResponse.includes("saml2:Success")) {
1975
+ // Replace existing non-success status with success
1976
+ decodedResponse = decodedResponse.replace(
1977
+ /<saml2:StatusCode Value="[^"]+"/,
1978
+ '<saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"',
1979
+ );
1980
+ }
1981
+
1982
+ try {
1983
+ parsedResponse = await sp.parseLoginResponse(idp, "post", {
1984
+ body: {
1985
+ SAMLResponse,
1986
+ RelayState: RelayState || undefined,
1987
+ },
1988
+ });
1989
+ } catch (parseError) {
1990
+ const nameIDMatch = decodedResponse.match(
1991
+ /<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/,
1992
+ );
1993
+ // due to different spec. we have to make sure to handle that.
1994
+ if (!nameIDMatch) throw parseError;
1995
+ parsedResponse = {
1996
+ extract: {
1997
+ nameID: nameIDMatch[1],
1998
+ attributes: { nameID: nameIDMatch[1] },
1999
+ sessionIndex: {},
2000
+ conditions: {},
2001
+ },
2002
+ } as FlowResult;
2003
+ }
2004
+
2005
+ if (!parsedResponse?.extract) {
2006
+ throw new Error("Invalid SAML response structure");
2007
+ }
2008
+ } catch (error) {
2009
+ ctx.context.logger.error("SAML response validation failed", {
2010
+ error,
2011
+ decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
2012
+ "utf-8",
2013
+ ),
2014
+ });
2015
+ throw new APIError("BAD_REQUEST", {
2016
+ message: "Invalid SAML response",
2017
+ details: error instanceof Error ? error.message : String(error),
2018
+ });
2019
+ }
2020
+
2021
+ const { extract } = parsedResponse!;
2022
+ const attributes = extract.attributes || {};
2023
+ const mapping = parsedSamlConfig.mapping ?? {};
2024
+
2025
+ const userInfo = {
2026
+ ...Object.fromEntries(
2027
+ Object.entries(mapping.extraFields || {}).map(([key, value]) => [
2028
+ key,
2029
+ attributes[value as string],
2030
+ ]),
2031
+ ),
2032
+ id: attributes[mapping.id || "nameID"] || extract.nameID,
2033
+ email: attributes[mapping.email || "email"] || extract.nameID,
2034
+ name:
2035
+ [
2036
+ attributes[mapping.firstName || "givenName"],
2037
+ attributes[mapping.lastName || "surname"],
2038
+ ]
2039
+ .filter(Boolean)
2040
+ .join(" ") ||
2041
+ attributes[mapping.name || "displayName"] ||
2042
+ extract.nameID,
2043
+ emailVerified:
2044
+ options?.trustEmailVerified && mapping.emailVerified
2045
+ ? ((attributes[mapping.emailVerified] || false) as boolean)
2046
+ : false,
2047
+ };
2048
+
2049
+ if (!userInfo.id || !userInfo.email) {
2050
+ ctx.context.logger.error(
2051
+ "Missing essential user info from SAML response",
2052
+ {
2053
+ attributes: Object.keys(attributes),
2054
+ mapping,
2055
+ extractedId: userInfo.id,
2056
+ extractedEmail: userInfo.email,
2057
+ },
2058
+ );
2059
+ throw new APIError("BAD_REQUEST", {
2060
+ message: "Unable to extract user ID or email from SAML response",
2061
+ });
2062
+ }
2063
+
2064
+ // Find or create user
2065
+ let user: User;
2066
+ const existingUser = await ctx.context.adapter.findOne<User>({
2067
+ model: "user",
2068
+ where: [
2069
+ {
2070
+ field: "email",
2071
+ value: userInfo.email,
2072
+ },
2073
+ ],
2074
+ });
2075
+
2076
+ if (existingUser) {
2077
+ const account = await ctx.context.adapter.findOne<Account>({
2078
+ model: "account",
2079
+ where: [
2080
+ { field: "userId", value: existingUser.id },
2081
+ { field: "providerId", value: provider.providerId },
2082
+ { field: "accountId", value: userInfo.id },
2083
+ ],
2084
+ });
2085
+ if (!account) {
2086
+ const isTrustedProvider =
2087
+ ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
2088
+ provider.providerId,
2089
+ ) ||
2090
+ ("domainVerified" in provider &&
2091
+ provider.domainVerified &&
2092
+ validateEmailDomain(userInfo.email, provider.domain));
2093
+ if (!isTrustedProvider) {
2094
+ throw ctx.redirect(
2095
+ `${parsedSamlConfig.callbackUrl}?error=account_not_found`,
2096
+ );
2097
+ }
2098
+ await ctx.context.internalAdapter.createAccount({
2099
+ userId: existingUser.id,
2100
+ providerId: provider.providerId,
2101
+ accountId: userInfo.id,
2102
+ accessToken: "",
2103
+ refreshToken: "",
2104
+ });
2105
+ }
2106
+ user = existingUser;
2107
+ } else {
2108
+ user = await ctx.context.internalAdapter.createUser({
2109
+ email: userInfo.email,
2110
+ name: userInfo.name,
2111
+ emailVerified: options?.trustEmailVerified
2112
+ ? userInfo.emailVerified || false
2113
+ : false,
2114
+ });
2115
+ await ctx.context.internalAdapter.createAccount({
2116
+ userId: user.id,
2117
+ providerId: provider.providerId,
2118
+ accountId: userInfo.id,
2119
+ accessToken: "",
2120
+ refreshToken: "",
2121
+ accessTokenExpiresAt: new Date(),
2122
+ refreshTokenExpiresAt: new Date(),
2123
+ scope: "",
2124
+ });
2125
+ }
2126
+
2127
+ if (options?.provisionUser) {
2128
+ await options.provisionUser({
2129
+ user: user as User & Record<string, any>,
2130
+ userInfo,
2131
+ provider,
2132
+ });
2133
+ }
2134
+
2135
+ if (
2136
+ provider.organizationId &&
2137
+ !options?.organizationProvisioning?.disabled
2138
+ ) {
2139
+ const isOrgPluginEnabled = ctx.context.options.plugins?.find(
2140
+ (plugin) => plugin.id === "organization",
2141
+ );
2142
+ if (isOrgPluginEnabled) {
2143
+ const isAlreadyMember = await ctx.context.adapter.findOne({
2144
+ model: "member",
2145
+ where: [
2146
+ { field: "organizationId", value: provider.organizationId },
2147
+ { field: "userId", value: user.id },
2148
+ ],
2149
+ });
2150
+ if (!isAlreadyMember) {
2151
+ const role = options?.organizationProvisioning?.getRole
2152
+ ? await options.organizationProvisioning.getRole({
2153
+ user,
2154
+ userInfo,
2155
+ provider,
2156
+ })
2157
+ : options?.organizationProvisioning?.defaultRole || "member";
2158
+ await ctx.context.adapter.create({
2159
+ model: "member",
2160
+ data: {
2161
+ organizationId: provider.organizationId,
2162
+ userId: user.id,
2163
+ role,
2164
+ createdAt: new Date(),
2165
+ updatedAt: new Date(),
2166
+ },
2167
+ });
2168
+ }
2169
+ }
2170
+ }
2171
+
2172
+ let session: Session = await ctx.context.internalAdapter.createSession(
2173
+ user.id,
2174
+ );
2175
+ await setSessionCookie(ctx, { session, user });
2176
+
2177
+ const callbackUrl =
2178
+ RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2179
+ throw ctx.redirect(callbackUrl);
2180
+ },
2181
+ );
2182
+ };