@better-auth/sso 1.3.17 → 1.4.0-beta.1

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/src/index.ts CHANGED
@@ -24,7 +24,6 @@ import { decodeJwt } from "jose";
24
24
  import { setSessionCookie } from "better-auth/cookies";
25
25
  import type { FlowResult } from "samlify/types/src/flow";
26
26
  import { XMLValidator } from "fast-xml-parser";
27
- import type { IdentityProvider } from "samlify/types/src/entity-idp";
28
27
 
29
28
  const fastValidator = {
30
29
  async validate(xml: string) {
@@ -38,25 +37,6 @@ const fastValidator = {
38
37
 
39
38
  saml.setSchemaValidator(fastValidator);
40
39
 
41
- export interface OIDCMapping {
42
- id?: string;
43
- email?: string;
44
- emailVerified?: string;
45
- name?: string;
46
- image?: string;
47
- extraFields?: Record<string, string>;
48
- }
49
-
50
- export interface SAMLMapping {
51
- id?: string;
52
- email?: string;
53
- emailVerified?: string;
54
- name?: string;
55
- firstName?: string;
56
- lastName?: string;
57
- extraFields?: Record<string, string>;
58
- }
59
-
60
40
  export interface OIDCConfig {
61
41
  issuer: string;
62
42
  pkce: boolean;
@@ -70,49 +50,30 @@ export interface OIDCConfig {
70
50
  tokenEndpoint?: string;
71
51
  tokenEndpointAuthentication?: "client_secret_post" | "client_secret_basic";
72
52
  jwksEndpoint?: string;
73
- mapping?: OIDCMapping;
53
+ mapping?: {
54
+ id?: string;
55
+ email?: string;
56
+ emailVerified?: string;
57
+ name?: string;
58
+ image?: string;
59
+ extraFields?: Record<string, string>;
60
+ };
74
61
  }
75
62
 
76
63
  export interface SAMLConfig {
77
64
  issuer: string;
78
65
  entryPoint: string;
79
- cert: string;
80
- callbackUrl: string;
81
- audience?: string;
82
- idpMetadata?: {
83
- metadata?: string;
84
- entityID?: string;
85
- entityURL?: string;
86
- redirectURL?: string;
87
- cert?: string;
88
- privateKey?: string;
89
- privateKeyPass?: string;
90
- isAssertionEncrypted?: boolean;
91
- encPrivateKey?: string;
92
- encPrivateKeyPass?: string;
93
- singleSignOnService?: Array<{
94
- Binding: string;
95
- Location: string;
96
- }>;
97
- };
98
- spMetadata: {
99
- metadata?: string;
100
- entityID?: string;
101
- binding?: string;
102
- privateKey?: string;
103
- privateKeyPass?: string;
104
- isAssertionEncrypted?: boolean;
105
- encPrivateKey?: string;
106
- encPrivateKeyPass?: string;
66
+ signingKey: string;
67
+ certificate: string;
68
+ attributeConsumingServiceIndex: number;
69
+ mapping?: {
70
+ id?: string;
71
+ email?: string;
72
+ name?: string;
73
+ firstName?: string;
74
+ lastName?: string;
75
+ extraFields?: Record<string, string>;
107
76
  };
108
- wantAssertionsSigned?: boolean;
109
- signatureAlgorithm?: string;
110
- digestAlgorithm?: string;
111
- identifierFormat?: string;
112
- privateKey?: string;
113
- decryptionPvk?: string;
114
- additionalParams?: Record<string, any>;
115
- mapping?: SAMLMapping;
116
77
  }
117
78
 
118
79
  export interface SSOProvider {
@@ -171,29 +132,6 @@ export interface SSOOptions {
171
132
  provider: SSOProvider;
172
133
  }) => Promise<"member" | "admin">;
173
134
  };
174
- /**
175
- * Default SSO provider configurations for testing.
176
- * These will take the precedence over the database providers.
177
- */
178
- defaultSSO?: Array<{
179
- /**
180
- * The domain to match for this default provider.
181
- * This is only used to match incoming requests to this default provider.
182
- */
183
- domain: string;
184
- /**
185
- * The provider ID to use
186
- */
187
- providerId: string;
188
- /**
189
- * SAML configuration
190
- */
191
- samlConfig?: SAMLConfig;
192
- /**
193
- * OIDC configuration
194
- */
195
- oidcConfig?: OIDCConfig;
196
- }>;
197
135
  /**
198
136
  * Override user info with the provider info.
199
137
  * @default false
@@ -252,7 +190,6 @@ export const sso = (options?: SSOOptions) => {
252
190
  },
253
191
  async (ctx) => {
254
192
  const provider = await ctx.context.adapter.findOne<{
255
- id: string;
256
193
  samlConfig: string;
257
194
  }>({
258
195
  model: "ssoProvider",
@@ -269,29 +206,10 @@ export const sso = (options?: SSOOptions) => {
269
206
  });
270
207
  }
271
208
 
272
- const parsedSamlConfig: SAMLConfig = JSON.parse(provider.samlConfig);
273
- const sp = parsedSamlConfig.spMetadata.metadata
274
- ? saml.ServiceProvider({
275
- metadata: parsedSamlConfig.spMetadata.metadata,
276
- })
277
- : saml.SPMetadata({
278
- entityID:
279
- parsedSamlConfig.spMetadata?.entityID ||
280
- parsedSamlConfig.issuer,
281
- assertionConsumerService: [
282
- {
283
- Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
284
- Location:
285
- parsedSamlConfig.callbackUrl ||
286
- `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.id}`,
287
- },
288
- ],
289
- wantMessageSigned:
290
- parsedSamlConfig.wantAssertionsSigned || false,
291
- nameIDFormat: parsedSamlConfig.identifierFormat
292
- ? [parsedSamlConfig.identifierFormat]
293
- : undefined,
294
- });
209
+ const parsedSamlConfig = JSON.parse(provider.samlConfig);
210
+ const sp = saml.ServiceProvider({
211
+ metadata: parsedSamlConfig.spMetadata.metadata,
212
+ });
295
213
  return new Response(sp.getMetadata(), {
296
214
  headers: {
297
215
  "Content-Type": "application/xml",
@@ -304,107 +222,96 @@ export const sso = (options?: SSOOptions) => {
304
222
  {
305
223
  method: "POST",
306
224
  body: z.object({
307
- providerId: z
308
- .string({})
309
- .describe(
225
+ providerId: z.string({}).meta({
226
+ description:
310
227
  "The ID of the provider. This is used to identify the provider during login and callback",
311
- ),
312
- issuer: z.string({}).describe("The issuer of the provider"),
313
- domain: z
314
- .string({})
315
- .describe(
228
+ }),
229
+ issuer: z.string({}).meta({
230
+ description: "The issuer of the provider",
231
+ }),
232
+ domain: z.string({}).meta({
233
+ description:
316
234
  "The domain of the provider. This is used for email matching",
317
- ),
235
+ }),
318
236
  oidcConfig: z
319
237
  .object({
320
- clientId: z.string({}).describe("The client ID"),
321
- clientSecret: z.string({}).describe("The client secret"),
238
+ clientId: z.string({}).meta({
239
+ description: "The client ID",
240
+ }),
241
+ clientSecret: z.string({}).meta({
242
+ description: "The client secret",
243
+ }),
322
244
  authorizationEndpoint: z
323
245
  .string({})
324
- .describe("The authorization endpoint")
246
+ .meta({
247
+ description: "The authorization endpoint",
248
+ })
325
249
  .optional(),
326
250
  tokenEndpoint: z
327
251
  .string({})
328
- .describe("The token endpoint")
252
+ .meta({
253
+ description: "The token endpoint",
254
+ })
329
255
  .optional(),
330
256
  userInfoEndpoint: z
331
257
  .string({})
332
- .describe("The user info endpoint")
258
+ .meta({
259
+ description: "The user info endpoint",
260
+ })
333
261
  .optional(),
334
262
  tokenEndpointAuthentication: z
335
263
  .enum(["client_secret_post", "client_secret_basic"])
336
264
  .optional(),
337
265
  jwksEndpoint: z
338
266
  .string({})
339
- .describe("The JWKS endpoint")
267
+ .meta({
268
+ description: "The JWKS endpoint",
269
+ })
340
270
  .optional(),
341
271
  discoveryEndpoint: z.string().optional(),
342
272
  scopes: z
343
273
  .array(z.string(), {})
344
- .describe("The scopes to request. ")
274
+ .meta({
275
+ description:
276
+ "The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']",
277
+ })
345
278
  .optional(),
346
279
  pkce: z
347
280
  .boolean({})
348
- .describe("Whether to use PKCE for the authorization flow")
349
- .default(true)
350
- .optional(),
351
- mapping: z
352
- .object({
353
- id: z.string({}).describe("Field mapping for user ID ("),
354
- email: z.string({}).describe("Field mapping for email ("),
355
- emailVerified: z
356
- .string({})
357
- .describe("Field mapping for email verification (")
358
- .optional(),
359
- name: z.string({}).describe("Field mapping for name ("),
360
- image: z
361
- .string({})
362
- .describe("Field mapping for image (")
363
- .optional(),
364
- extraFields: z.record(z.string(), z.any()).optional(),
281
+ .meta({
282
+ description:
283
+ "Whether to use PKCE for the authorization flow",
365
284
  })
285
+ .default(true)
366
286
  .optional(),
367
287
  })
368
288
  .optional(),
369
289
  samlConfig: z
370
290
  .object({
371
- entryPoint: z
372
- .string({})
373
- .describe("The entry point of the provider"),
374
- cert: z.string({}).describe("The certificate of the provider"),
375
- callbackUrl: z
376
- .string({})
377
- .describe("The callback URL of the provider"),
291
+ entryPoint: z.string({}).meta({
292
+ description: "The entry point of the provider",
293
+ }),
294
+ cert: z.string({}).meta({
295
+ description: "The certificate of the provider",
296
+ }),
297
+ callbackUrl: z.string({}).meta({
298
+ description: "The callback URL of the provider",
299
+ }),
378
300
  audience: z.string().optional(),
379
301
  idpMetadata: z
380
302
  .object({
381
- metadata: z.string().optional(),
382
- entityID: z.string().optional(),
383
- cert: z.string().optional(),
303
+ metadata: z.string(),
384
304
  privateKey: z.string().optional(),
385
305
  privateKeyPass: z.string().optional(),
386
306
  isAssertionEncrypted: z.boolean().optional(),
387
307
  encPrivateKey: z.string().optional(),
388
308
  encPrivateKeyPass: z.string().optional(),
389
- singleSignOnService: z
390
- .array(
391
- z.object({
392
- Binding: z
393
- .string()
394
- .describe("The binding type for the SSO service"),
395
- Location: z
396
- .string()
397
- .describe("The URL for the SSO service"),
398
- }),
399
- )
400
- .optional()
401
- .describe("Single Sign-On service configuration"),
402
309
  })
403
310
  .optional(),
404
311
  spMetadata: z.object({
405
- metadata: z.string().optional(),
406
- entityID: z.string().optional(),
312
+ metadata: z.string(),
407
313
  binding: z.string().optional(),
314
+
408
315
  privateKey: z.string().optional(),
409
316
  privateKeyPass: z.string().optional(),
410
317
  isAssertionEncrypted: z.boolean().optional(),
@@ -418,39 +325,52 @@ export const sso = (options?: SSOOptions) => {
418
325
  privateKey: z.string().optional(),
419
326
  decryptionPvk: z.string().optional(),
420
327
  additionalParams: z.record(z.string(), z.any()).optional(),
421
- mapping: z
422
- .object({
423
- id: z.string({}).describe("Field mapping for user ID ("),
424
- email: z.string({}).describe("Field mapping for email ("),
425
- emailVerified: z
426
- .string({})
427
- .describe("Field mapping for email verification")
428
- .optional(),
429
- name: z.string({}).describe("Field mapping for name ("),
430
- firstName: z
431
- .string({})
432
- .describe("Field mapping for first name (")
433
- .optional(),
434
- lastName: z
435
- .string({})
436
- .describe("Field mapping for last name (")
437
- .optional(),
438
- extraFields: z.record(z.string(), z.any()).optional(),
328
+ })
329
+ .optional(),
330
+ mapping: z
331
+ .object({
332
+ id: z.string({}).meta({
333
+ description:
334
+ "The field in the user info response that contains the id. Defaults to 'sub'",
335
+ }),
336
+ email: z.string({}).meta({
337
+ description:
338
+ "The field in the user info response that contains the email. Defaults to 'email'",
339
+ }),
340
+ emailVerified: z
341
+ .string({})
342
+ .meta({
343
+ description:
344
+ "The field in the user info response that contains whether the email is verified. defaults to 'email_verified'",
439
345
  })
440
346
  .optional(),
347
+ name: z.string({}).meta({
348
+ description:
349
+ "The field in the user info response that contains the name. Defaults to 'name'",
350
+ }),
351
+ image: z
352
+ .string({})
353
+ .meta({
354
+ description:
355
+ "The field in the user info response that contains the image. Defaults to 'picture'",
356
+ })
357
+ .optional(),
358
+ extraFields: z.record(z.string(), z.any()).optional(),
441
359
  })
442
360
  .optional(),
443
361
  organizationId: z
444
362
  .string({})
445
- .describe(
446
- "If organization plugin is enabled, the organization id to link the provider to",
447
- )
363
+ .meta({
364
+ description:
365
+ "If organization plugin is enabled, the organization id to link the provider to",
366
+ })
448
367
  .optional(),
449
368
  overrideUserInfo: z
450
369
  .boolean({})
451
- .describe(
452
- "Override user info with the provider info. Defaults to false",
453
- )
370
+ .meta({
371
+ description:
372
+ "Override user info with the provider info. Defaults to false",
373
+ })
454
374
  .default(false)
455
375
  .optional(),
456
376
  }),
@@ -712,7 +632,7 @@ export const sso = (options?: SSOOptions) => {
712
632
  discoveryEndpoint:
713
633
  body.oidcConfig.discoveryEndpoint ||
714
634
  `${body.issuer}/.well-known/openid-configuration`,
715
- mapping: body.oidcConfig.mapping,
635
+ mapping: body.mapping,
716
636
  scopes: body.oidcConfig.scopes,
717
637
  userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
718
638
  overrideUserInfo:
@@ -737,7 +657,7 @@ export const sso = (options?: SSOOptions) => {
737
657
  privateKey: body.samlConfig.privateKey,
738
658
  decryptionPvk: body.samlConfig.decryptionPvk,
739
659
  additionalParams: body.samlConfig.additionalParams,
740
- mapping: body.samlConfig.mapping,
660
+ mapping: body.mapping,
741
661
  })
742
662
  : null,
743
663
  organizationId: body.organizationId,
@@ -745,7 +665,6 @@ export const sso = (options?: SSOOptions) => {
745
665
  providerId: body.providerId,
746
666
  },
747
667
  });
748
-
749
668
  return ctx.json({
750
669
  ...provider,
751
670
  oidcConfig: JSON.parse(
@@ -765,44 +684,58 @@ export const sso = (options?: SSOOptions) => {
765
684
  body: z.object({
766
685
  email: z
767
686
  .string({})
768
- .describe(
769
- "The email address to sign in with. This is used to identify the issuer to sign in with",
770
- )
687
+ .meta({
688
+ description:
689
+ "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",
690
+ })
771
691
  .optional(),
772
692
  organizationSlug: z
773
693
  .string({})
774
- .describe("The slug of the organization to sign in with")
694
+ .meta({
695
+ description: "The slug of the organization to sign in with",
696
+ })
775
697
  .optional(),
776
698
  providerId: z
777
699
  .string({})
778
- .describe(
779
- "The ID of the provider to sign in with. This can be provided instead of email or issuer",
780
- )
700
+ .meta({
701
+ description:
702
+ "The ID of the provider to sign in with. This can be provided instead of email or issuer",
703
+ })
781
704
  .optional(),
782
705
  domain: z
783
706
  .string({})
784
- .describe("The domain of the provider.")
707
+ .meta({
708
+ description: "The domain of the provider.",
709
+ })
785
710
  .optional(),
786
- callbackURL: z
787
- .string({})
788
- .describe("The URL to redirect to after login"),
711
+ callbackURL: z.string({}).meta({
712
+ description: "The URL to redirect to after login",
713
+ }),
789
714
  errorCallbackURL: z
790
715
  .string({})
791
- .describe("The URL to redirect to after login")
716
+ .meta({
717
+ description: "The URL to redirect to after login",
718
+ })
792
719
  .optional(),
793
720
  newUserCallbackURL: z
794
721
  .string({})
795
- .describe("The URL to redirect to after login if the user is new")
722
+ .meta({
723
+ description:
724
+ "The URL to redirect to after login if the user is new",
725
+ })
796
726
  .optional(),
797
727
  scopes: z
798
728
  .array(z.string(), {})
799
- .describe("Scopes to request from the provider.")
729
+ .meta({
730
+ description: "Scopes to request from the provider.",
731
+ })
800
732
  .optional(),
801
733
  requestSignUp: z
802
734
  .boolean({})
803
- .describe(
804
- "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider",
805
- )
735
+ .meta({
736
+ description:
737
+ "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider",
738
+ })
806
739
  .optional(),
807
740
  providerType: z.enum(["oidc", "saml"]).optional(),
808
741
  }),
@@ -885,13 +818,7 @@ export const sso = (options?: SSOOptions) => {
885
818
  async (ctx) => {
886
819
  const body = ctx.body;
887
820
  let { email, organizationSlug, providerId, domain } = body;
888
- if (
889
- !options?.defaultSSO?.length &&
890
- !email &&
891
- !organizationSlug &&
892
- !domain &&
893
- !providerId
894
- ) {
821
+ if (!email && !organizationSlug && !domain && !providerId) {
895
822
  throw new APIError("BAD_REQUEST", {
896
823
  message:
897
824
  "email, organizationSlug, domain or providerId is required",
@@ -917,68 +844,29 @@ export const sso = (options?: SSOOptions) => {
917
844
  return res.id;
918
845
  });
919
846
  }
920
- let provider: SSOProvider | null = null;
921
- if (options?.defaultSSO?.length) {
922
- // Find matching default SSO provider by providerId
923
- const matchingDefault = providerId
924
- ? options.defaultSSO.find(
925
- (defaultProvider) =>
926
- defaultProvider.providerId === providerId,
927
- )
928
- : options.defaultSSO.find(
929
- (defaultProvider) => defaultProvider.domain === domain,
930
- );
931
-
932
- if (matchingDefault) {
933
- provider = {
934
- issuer:
935
- matchingDefault.samlConfig?.issuer ||
936
- matchingDefault.oidcConfig?.issuer ||
937
- "",
938
- providerId: matchingDefault.providerId,
939
- userId: "default",
940
- oidcConfig: matchingDefault.oidcConfig,
941
- samlConfig: matchingDefault.samlConfig,
847
+ const provider = await ctx.context.adapter
848
+ .findOne<SSOProvider>({
849
+ model: "ssoProvider",
850
+ where: [
851
+ {
852
+ field: providerId
853
+ ? "providerId"
854
+ : orgId
855
+ ? "organizationId"
856
+ : "domain",
857
+ value: providerId || orgId || domain!,
858
+ },
859
+ ],
860
+ })
861
+ .then((res) => {
862
+ if (!res) {
863
+ return null;
864
+ }
865
+ return {
866
+ ...res,
867
+ oidcConfig: JSON.parse(res.oidcConfig as unknown as string),
942
868
  };
943
- }
944
- }
945
- if (!providerId && !orgId && !domain) {
946
- throw new APIError("BAD_REQUEST", {
947
- message: "providerId, orgId or domain is required",
948
869
  });
949
- }
950
- // Try to find provider in database
951
- if (!provider) {
952
- provider = await ctx.context.adapter
953
- .findOne<SSOProvider>({
954
- model: "ssoProvider",
955
- where: [
956
- {
957
- field: providerId
958
- ? "providerId"
959
- : orgId
960
- ? "organizationId"
961
- : "domain",
962
- value: providerId || orgId || domain!,
963
- },
964
- ],
965
- })
966
- .then((res) => {
967
- if (!res) {
968
- return null;
969
- }
970
- return {
971
- ...res,
972
- oidcConfig: res.oidcConfig
973
- ? JSON.parse(res.oidcConfig as unknown as string)
974
- : undefined,
975
- samlConfig: res.samlConfig
976
- ? JSON.parse(res.samlConfig as unknown as string)
977
- : undefined,
978
- };
979
- });
980
- }
981
-
982
870
  if (!provider) {
983
871
  throw new APIError("NOT_FOUND", {
984
872
  message: "No provider found for the issuer",
@@ -1016,7 +904,7 @@ export const sso = (options?: SSOOptions) => {
1016
904
  "profile",
1017
905
  "offline_access",
1018
906
  ],
1019
- authorizationEndpoint: provider.oidcConfig.authorizationEndpoint!,
907
+ authorizationEndpoint: provider.oidcConfig.authorizationEndpoint,
1020
908
  });
1021
909
  return ctx.json({
1022
910
  url: authorizationURL.toString(),
@@ -1024,21 +912,15 @@ export const sso = (options?: SSOOptions) => {
1024
912
  });
1025
913
  }
1026
914
  if (provider.samlConfig) {
1027
- const parsedSamlConfig: SAMLConfig =
1028
- typeof provider.samlConfig === "object"
1029
- ? provider.samlConfig
1030
- : JSON.parse(provider.samlConfig as unknown as string);
915
+ const parsedSamlConfig = JSON.parse(
916
+ provider.samlConfig as unknown as string,
917
+ );
1031
918
  const sp = saml.ServiceProvider({
1032
919
  metadata: parsedSamlConfig.spMetadata.metadata,
1033
920
  allowCreate: true,
1034
921
  });
1035
-
1036
922
  const idp = saml.IdentityProvider({
1037
- metadata: parsedSamlConfig.idpMetadata?.metadata,
1038
- entityID: parsedSamlConfig.idpMetadata?.entityID,
1039
- encryptCert: parsedSamlConfig.idpMetadata?.cert,
1040
- singleSignOnService:
1041
- parsedSamlConfig.idpMetadata?.singleSignOnService,
923
+ metadata: parsedSamlConfig.idpMetadata.metadata,
1042
924
  });
1043
925
  const loginRequest = sp.createLoginRequest(
1044
926
  idp,
@@ -1103,43 +985,27 @@ export const sso = (options?: SSOOptions) => {
1103
985
  }?error=${error}&error_description=${error_description}`,
1104
986
  );
1105
987
  }
1106
- let provider: SSOProvider | null = null;
1107
- if (options?.defaultSSO?.length) {
1108
- const matchingDefault = options.defaultSSO.find(
1109
- (defaultProvider) =>
1110
- defaultProvider.providerId === ctx.params.providerId,
1111
- );
1112
- if (matchingDefault) {
1113
- provider = {
1114
- ...matchingDefault,
1115
- issuer: matchingDefault.oidcConfig?.issuer || "",
1116
- userId: "default",
1117
- };
1118
- }
1119
- }
1120
- if (!provider) {
1121
- provider = await ctx.context.adapter
1122
- .findOne<{
1123
- oidcConfig: string;
1124
- }>({
1125
- model: "ssoProvider",
1126
- where: [
1127
- {
1128
- field: "providerId",
1129
- value: ctx.params.providerId,
1130
- },
1131
- ],
1132
- })
1133
- .then((res) => {
1134
- if (!res) {
1135
- return null;
1136
- }
1137
- return {
1138
- ...res,
1139
- oidcConfig: JSON.parse(res.oidcConfig),
1140
- } as SSOProvider;
1141
- });
1142
- }
988
+ const provider = await ctx.context.adapter
989
+ .findOne<{
990
+ oidcConfig: string;
991
+ }>({
992
+ model: "ssoProvider",
993
+ where: [
994
+ {
995
+ field: "providerId",
996
+ value: ctx.params.providerId,
997
+ },
998
+ ],
999
+ })
1000
+ .then((res) => {
1001
+ if (!res) {
1002
+ return null;
1003
+ }
1004
+ return {
1005
+ ...res,
1006
+ oidcConfig: JSON.parse(res.oidcConfig),
1007
+ } as SSOProvider;
1008
+ });
1143
1009
  if (!provider) {
1144
1010
  throw ctx.redirect(
1145
1011
  `${
@@ -1439,525 +1305,72 @@ export const sso = (options?: SSOOptions) => {
1439
1305
  async (ctx) => {
1440
1306
  const { SAMLResponse, RelayState } = ctx.body;
1441
1307
  const { providerId } = ctx.params;
1442
- let provider: SSOProvider | null = null;
1443
- if (options?.defaultSSO?.length) {
1444
- const matchingDefault = options.defaultSSO.find(
1445
- (defaultProvider) => defaultProvider.providerId === providerId,
1446
- );
1447
- if (matchingDefault) {
1448
- provider = {
1449
- ...matchingDefault,
1450
- userId: "default",
1451
- issuer: matchingDefault.samlConfig?.issuer || "",
1452
- };
1453
- }
1454
- }
1455
- if (!provider) {
1456
- provider = await ctx.context.adapter
1457
- .findOne<SSOProvider>({
1458
- model: "ssoProvider",
1459
- where: [{ field: "providerId", value: providerId }],
1460
- })
1461
- .then((res) => {
1462
- if (!res) return null;
1463
- return {
1464
- ...res,
1465
- samlConfig: res.samlConfig
1466
- ? JSON.parse(res.samlConfig as unknown as string)
1467
- : undefined,
1468
- };
1469
- });
1470
- }
1308
+ const provider = await ctx.context.adapter.findOne<SSOProvider>({
1309
+ model: "ssoProvider",
1310
+ where: [{ field: "providerId", value: providerId }],
1311
+ });
1471
1312
 
1472
1313
  if (!provider) {
1473
1314
  throw new APIError("NOT_FOUND", {
1474
1315
  message: "No provider found for the given providerId",
1475
1316
  });
1476
1317
  }
1318
+
1477
1319
  const parsedSamlConfig = JSON.parse(
1478
1320
  provider.samlConfig as unknown as string,
1479
1321
  );
1480
- const idpData = parsedSamlConfig.idpMetadata;
1481
- let idp: IdentityProvider | null = null;
1482
-
1483
- // Construct IDP with fallback to manual configuration
1484
- if (!idpData?.metadata) {
1485
- idp = saml.IdentityProvider({
1486
- entityID: idpData.entityID || parsedSamlConfig.issuer,
1487
- singleSignOnService: [
1488
- {
1489
- Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1490
- Location: parsedSamlConfig.entryPoint,
1491
- },
1492
- ],
1493
- signingCert: idpData.cert || parsedSamlConfig.cert,
1494
- wantAuthnRequestsSigned:
1495
- parsedSamlConfig.wantAssertionsSigned || false,
1496
- isAssertionEncrypted: idpData.isAssertionEncrypted || false,
1497
- encPrivateKey: idpData.encPrivateKey,
1498
- encPrivateKeyPass: idpData.encPrivateKeyPass,
1499
- });
1500
- } else {
1501
- idp = saml.IdentityProvider({
1502
- metadata: idpData.metadata,
1503
- privateKey: idpData.privateKey,
1504
- privateKeyPass: idpData.privateKeyPass,
1505
- isAssertionEncrypted: idpData.isAssertionEncrypted,
1506
- encPrivateKey: idpData.encPrivateKey,
1507
- encPrivateKeyPass: idpData.encPrivateKeyPass,
1508
- });
1509
- }
1510
-
1511
- // Construct SP with fallback to manual configuration
1512
- const spData = parsedSamlConfig.spMetadata;
1513
- const sp = saml.ServiceProvider({
1514
- metadata: spData?.metadata,
1515
- entityID: spData?.entityID || parsedSamlConfig.issuer,
1516
- assertionConsumerService: spData?.metadata
1517
- ? undefined
1518
- : [
1519
- {
1520
- Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1521
- Location: parsedSamlConfig.callbackUrl,
1522
- },
1523
- ],
1524
- privateKey: spData?.privateKey || parsedSamlConfig.privateKey,
1525
- privateKeyPass: spData?.privateKeyPass,
1526
- isAssertionEncrypted: spData?.isAssertionEncrypted || false,
1527
- encPrivateKey: spData?.encPrivateKey,
1528
- encPrivateKeyPass: spData?.encPrivateKeyPass,
1529
- wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1530
- nameIDFormat: parsedSamlConfig.identifierFormat
1531
- ? [parsedSamlConfig.identifierFormat]
1532
- : undefined,
1322
+ const idp = saml.IdentityProvider({
1323
+ metadata: parsedSamlConfig.idpMetadata.metadata,
1533
1324
  });
1534
-
1535
- let parsedResponse: FlowResult;
1536
- try {
1537
- const decodedResponse = Buffer.from(
1538
- SAMLResponse,
1539
- "base64",
1540
- ).toString("utf-8");
1541
-
1542
- try {
1543
- parsedResponse = await sp.parseLoginResponse(idp, "post", {
1544
- body: {
1545
- SAMLResponse,
1546
- RelayState: RelayState || undefined,
1547
- },
1548
- });
1549
- } catch (parseError) {
1550
- const nameIDMatch = decodedResponse.match(
1551
- /<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/,
1552
- );
1553
- if (!nameIDMatch) throw parseError;
1554
- parsedResponse = {
1555
- extract: {
1556
- nameID: nameIDMatch[1],
1557
- attributes: { nameID: nameIDMatch[1] },
1558
- sessionIndex: {},
1559
- conditions: {},
1560
- },
1561
- } as FlowResult;
1562
- }
1563
-
1564
- if (!parsedResponse?.extract) {
1565
- throw new Error("Invalid SAML response structure");
1566
- }
1567
- } catch (error) {
1568
- ctx.context.logger.error("SAML response validation failed", {
1569
- error,
1570
- decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
1571
- "utf-8",
1572
- ),
1573
- });
1574
- throw new APIError("BAD_REQUEST", {
1575
- message: "Invalid SAML response",
1576
- details: error instanceof Error ? error.message : String(error),
1577
- });
1578
- }
1579
-
1580
- const { extract } = parsedResponse!;
1581
- const attributes = extract.attributes || {};
1582
- const mapping = parsedSamlConfig.mapping ?? {};
1583
-
1584
- const userInfo = {
1585
- ...Object.fromEntries(
1586
- Object.entries(mapping.extraFields || {}).map(([key, value]) => [
1587
- key,
1588
- attributes[value as string],
1589
- ]),
1590
- ),
1591
- id: attributes[mapping.id || "nameID"] || extract.nameID,
1592
- email: attributes[mapping.email || "email"] || extract.nameID,
1593
- name:
1594
- [
1595
- attributes[mapping.firstName || "givenName"],
1596
- attributes[mapping.lastName || "surname"],
1597
- ]
1598
- .filter(Boolean)
1599
- .join(" ") ||
1600
- attributes[mapping.name || "displayName"] ||
1601
- extract.nameID,
1602
- emailVerified:
1603
- options?.trustEmailVerified && mapping.emailVerified
1604
- ? ((attributes[mapping.emailVerified] || false) as boolean)
1605
- : false,
1606
- };
1607
- if (!userInfo.id || !userInfo.email) {
1608
- ctx.context.logger.error(
1609
- "Missing essential user info from SAML response",
1610
- {
1611
- attributes: Object.keys(attributes),
1612
- mapping,
1613
- extractedId: userInfo.id,
1614
- extractedEmail: userInfo.email,
1615
- },
1616
- );
1617
- throw new APIError("BAD_REQUEST", {
1618
- message: "Unable to extract user ID or email from SAML response",
1619
- });
1620
- }
1621
-
1622
- // Find or create user
1623
- let user: User;
1624
- const existingUser = await ctx.context.adapter.findOne<User>({
1625
- model: "user",
1626
- where: [
1627
- {
1628
- field: "email",
1629
- value: userInfo.email,
1630
- },
1631
- ],
1632
- });
1633
-
1634
- if (existingUser) {
1635
- user = existingUser;
1636
- } else {
1637
- user = await ctx.context.adapter.create({
1638
- model: "user",
1639
- data: {
1640
- email: userInfo.email,
1641
- name: userInfo.name,
1642
- emailVerified: userInfo.emailVerified,
1643
- createdAt: new Date(),
1644
- updatedAt: new Date(),
1645
- },
1646
- });
1647
- }
1648
-
1649
- // Create or update account link
1650
- const account = await ctx.context.adapter.findOne<Account>({
1651
- model: "account",
1652
- where: [
1653
- { field: "userId", value: user.id },
1654
- { field: "providerId", value: provider.providerId },
1655
- { field: "accountId", value: userInfo.id },
1656
- ],
1657
- });
1658
-
1659
- if (!account) {
1660
- await ctx.context.adapter.create<Account>({
1661
- model: "account",
1662
- data: {
1663
- userId: user.id,
1664
- providerId: provider.providerId,
1665
- accountId: userInfo.id,
1666
- createdAt: new Date(),
1667
- updatedAt: new Date(),
1668
- accessToken: "",
1669
- refreshToken: "",
1670
- },
1671
- });
1672
- }
1673
-
1674
- // Run provision hooks
1675
- if (options?.provisionUser) {
1676
- await options.provisionUser({
1677
- user: user as User & Record<string, any>,
1678
- userInfo,
1679
- provider,
1680
- });
1681
- }
1682
-
1683
- // Handle organization provisioning
1684
- if (
1685
- provider.organizationId &&
1686
- !options?.organizationProvisioning?.disabled
1687
- ) {
1688
- const isOrgPluginEnabled = ctx.context.options.plugins?.find(
1689
- (plugin) => plugin.id === "organization",
1690
- );
1691
- if (isOrgPluginEnabled) {
1692
- const isAlreadyMember = await ctx.context.adapter.findOne({
1693
- model: "member",
1694
- where: [
1695
- { field: "organizationId", value: provider.organizationId },
1696
- { field: "userId", value: user.id },
1697
- ],
1698
- });
1699
- if (!isAlreadyMember) {
1700
- const role = options?.organizationProvisioning?.getRole
1701
- ? await options.organizationProvisioning.getRole({
1702
- user,
1703
- userInfo,
1704
- provider,
1705
- })
1706
- : options?.organizationProvisioning?.defaultRole || "member";
1707
- await ctx.context.adapter.create({
1708
- model: "member",
1709
- data: {
1710
- organizationId: provider.organizationId,
1711
- userId: user.id,
1712
- role,
1713
- createdAt: new Date(),
1714
- updatedAt: new Date(),
1715
- },
1716
- });
1717
- }
1718
- }
1719
- }
1720
-
1721
- // Create session and set cookie
1722
- let session: Session =
1723
- await ctx.context.internalAdapter.createSession(user.id, ctx);
1724
- await setSessionCookie(ctx, { session, user });
1725
-
1726
- // Redirect to callback URL
1727
- const callbackUrl =
1728
- RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1729
- throw ctx.redirect(callbackUrl);
1730
- },
1731
- ),
1732
- acsEndpoint: createAuthEndpoint(
1733
- "/sso/saml2/sp/acs/:providerId",
1734
- {
1735
- method: "POST",
1736
- params: z.object({
1737
- providerId: z.string().optional(),
1738
- }),
1739
- body: z.object({
1740
- SAMLResponse: z.string(),
1741
- RelayState: z.string().optional(),
1742
- }),
1743
- metadata: {
1744
- isAction: false,
1745
- openapi: {
1746
- summary: "SAML Assertion Consumer Service",
1747
- description:
1748
- "Handles SAML responses from IdP after successful authentication",
1749
- responses: {
1750
- "302": {
1751
- description:
1752
- "Redirects to the callback URL after successful authentication",
1753
- },
1754
- },
1755
- },
1756
- },
1757
- },
1758
- async (ctx) => {
1759
- const { SAMLResponse, RelayState = "" } = ctx.body;
1760
- const { providerId } = ctx.params;
1761
-
1762
- // If defaultSSO is configured, use it as the provider
1763
- let provider: SSOProvider | null = null;
1764
-
1765
- if (options?.defaultSSO?.length) {
1766
- // For ACS endpoint, we can use the first default provider or try to match by providerId
1767
- const matchingDefault = providerId
1768
- ? options.defaultSSO.find(
1769
- (defaultProvider) =>
1770
- defaultProvider.providerId === providerId,
1771
- )
1772
- : options.defaultSSO[0]; // Use first default provider if no specific providerId
1773
-
1774
- if (matchingDefault) {
1775
- provider = {
1776
- issuer: matchingDefault.samlConfig?.issuer || "",
1777
- providerId: matchingDefault.providerId,
1778
- userId: "default",
1779
- samlConfig: matchingDefault.samlConfig,
1780
- };
1781
- }
1782
- } else {
1783
- provider = await ctx.context.adapter
1784
- .findOne<SSOProvider>({
1785
- model: "ssoProvider",
1786
- where: [
1787
- {
1788
- field: "providerId",
1789
- value: providerId ?? "sso",
1790
- },
1791
- ],
1792
- })
1793
- .then((res) => {
1794
- if (!res) return null;
1795
- return {
1796
- ...res,
1797
- samlConfig: res.samlConfig
1798
- ? JSON.parse(res.samlConfig as unknown as string)
1799
- : undefined,
1800
- };
1801
- });
1802
- }
1803
-
1804
- if (!provider?.samlConfig) {
1805
- throw new APIError("NOT_FOUND", {
1806
- message: "No SAML provider found",
1807
- });
1808
- }
1809
-
1810
- const parsedSamlConfig = provider.samlConfig;
1811
- // Configure SP and IdP
1812
1325
  const sp = saml.ServiceProvider({
1813
- entityID:
1814
- parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
1815
- assertionConsumerService: [
1816
- {
1817
- Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1818
- Location:
1819
- parsedSamlConfig.callbackUrl ||
1820
- `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`,
1821
- },
1822
- ],
1823
- wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1824
- metadata: parsedSamlConfig.spMetadata?.metadata,
1825
- privateKey:
1826
- parsedSamlConfig.spMetadata?.privateKey ||
1827
- parsedSamlConfig.privateKey,
1828
- privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
1829
- nameIDFormat: parsedSamlConfig.identifierFormat
1830
- ? [parsedSamlConfig.identifierFormat]
1831
- : undefined,
1326
+ metadata: parsedSamlConfig.spMetadata.metadata,
1832
1327
  });
1833
-
1834
- // Update where we construct the IdP
1835
- const idpData = parsedSamlConfig.idpMetadata;
1836
- const idp = !idpData?.metadata
1837
- ? saml.IdentityProvider({
1838
- entityID: idpData?.entityID || parsedSamlConfig.issuer,
1839
- singleSignOnService: idpData?.singleSignOnService || [
1840
- {
1841
- Binding:
1842
- "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1843
- Location: parsedSamlConfig.entryPoint,
1844
- },
1845
- ],
1846
- signingCert: idpData?.cert || parsedSamlConfig.cert,
1847
- })
1848
- : saml.IdentityProvider({
1849
- metadata: idpData.metadata,
1850
- });
1851
-
1852
- // Parse and validate SAML response
1853
1328
  let parsedResponse: FlowResult;
1854
1329
  try {
1855
- let decodedResponse = Buffer.from(SAMLResponse, "base64").toString(
1856
- "utf-8",
1857
- );
1858
-
1859
- // Patch the SAML response if status is missing or not success
1860
- if (!decodedResponse.includes("StatusCode")) {
1861
- // Insert a success status if missing
1862
- const insertPoint = decodedResponse.indexOf("</saml2:Issuer>");
1863
- if (insertPoint !== -1) {
1864
- decodedResponse =
1865
- decodedResponse.slice(0, insertPoint + 14) +
1866
- '<saml2:Status><saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></saml2:Status>' +
1867
- decodedResponse.slice(insertPoint + 14);
1868
- }
1869
- } else if (!decodedResponse.includes("saml2:Success")) {
1870
- // Replace existing non-success status with success
1871
- decodedResponse = decodedResponse.replace(
1872
- /<saml2:StatusCode Value="[^"]+"/,
1873
- '<saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"',
1874
- );
1875
- }
1876
-
1877
- try {
1878
- parsedResponse = await sp.parseLoginResponse(idp, "post", {
1879
- body: {
1880
- SAMLResponse,
1881
- RelayState: RelayState || undefined,
1882
- },
1883
- });
1884
- } catch (parseError) {
1885
- const nameIDMatch = decodedResponse.match(
1886
- /<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/,
1887
- );
1888
- // due to different spec. we have to make sure to handle that.
1889
- if (!nameIDMatch) throw parseError;
1890
- parsedResponse = {
1891
- extract: {
1892
- nameID: nameIDMatch[1],
1893
- attributes: { nameID: nameIDMatch[1] },
1894
- sessionIndex: {},
1895
- conditions: {},
1896
- },
1897
- } as FlowResult;
1898
- }
1330
+ parsedResponse = await sp.parseLoginResponse(idp, "post", {
1331
+ body: { SAMLResponse, RelayState },
1332
+ });
1899
1333
 
1900
- if (!parsedResponse?.extract) {
1901
- throw new Error("Invalid SAML response structure");
1334
+ if (!parsedResponse) {
1335
+ throw new Error("Empty SAML response");
1902
1336
  }
1903
1337
  } catch (error) {
1904
- ctx.context.logger.error("SAML response validation failed", {
1905
- error,
1906
- decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
1907
- "utf-8",
1908
- ),
1909
- });
1338
+ ctx.context.logger.error("SAML response validation failed", error);
1910
1339
  throw new APIError("BAD_REQUEST", {
1911
1340
  message: "Invalid SAML response",
1912
1341
  details: error instanceof Error ? error.message : String(error),
1913
1342
  });
1914
1343
  }
1915
-
1916
- const { extract } = parsedResponse!;
1917
- const attributes = extract.attributes || {};
1918
- const mapping = parsedSamlConfig.mapping ?? {};
1919
-
1344
+ const { extract } = parsedResponse;
1345
+ const attributes = parsedResponse.extract.attributes;
1346
+ const mapping = parsedSamlConfig?.mapping ?? {};
1920
1347
  const userInfo = {
1921
1348
  ...Object.fromEntries(
1922
1349
  Object.entries(mapping.extraFields || {}).map(([key, value]) => [
1923
1350
  key,
1924
- attributes[value as string],
1351
+ extract.attributes[value as string],
1925
1352
  ]),
1926
1353
  ),
1927
- id: attributes[mapping.id || "nameID"] || extract.nameID,
1928
- email: attributes[mapping.email || "email"] || extract.nameID,
1354
+ id: attributes[mapping.id] || attributes["nameID"],
1355
+ email:
1356
+ attributes[mapping.email] ||
1357
+ attributes["nameID"] ||
1358
+ attributes["email"],
1929
1359
  name:
1930
1360
  [
1931
- attributes[mapping.firstName || "givenName"],
1932
- attributes[mapping.lastName || "surname"],
1361
+ attributes[mapping.firstName] || attributes["givenName"],
1362
+ attributes[mapping.lastName] || attributes["surname"],
1933
1363
  ]
1934
1364
  .filter(Boolean)
1935
- .join(" ") ||
1936
- attributes[mapping.name || "displayName"] ||
1937
- extract.nameID,
1938
- emailVerified:
1939
- options?.trustEmailVerified && mapping.emailVerified
1940
- ? ((attributes[mapping.emailVerified] || false) as boolean)
1941
- : false,
1365
+ .join(" ") || parsedResponse.extract.attributes?.displayName,
1366
+ attributes: parsedResponse.extract.attributes,
1367
+ emailVerified: options?.trustEmailVerified
1368
+ ? ((attributes?.[mapping.emailVerified] || false) as boolean)
1369
+ : false,
1942
1370
  };
1943
1371
 
1944
- if (!userInfo.id || !userInfo.email) {
1945
- ctx.context.logger.error(
1946
- "Missing essential user info from SAML response",
1947
- {
1948
- attributes: Object.keys(attributes),
1949
- mapping,
1950
- extractedId: userInfo.id,
1951
- extractedEmail: userInfo.email,
1952
- },
1953
- );
1954
- throw new APIError("BAD_REQUEST", {
1955
- message: "Unable to extract user ID or email from SAML response",
1956
- });
1957
- }
1958
-
1959
- // Find or create user
1960
1372
  let user: User;
1373
+
1961
1374
  const existingUser = await ctx.context.adapter.findOne<User>({
1962
1375
  model: "user",
1963
1376
  where: [
@@ -1969,7 +1382,7 @@ export const sso = (options?: SSOOptions) => {
1969
1382
  });
1970
1383
 
1971
1384
  if (existingUser) {
1972
- const account = await ctx.context.adapter.findOne<Account>({
1385
+ const accounts = await ctx.context.adapter.findOne<Account>({
1973
1386
  model: "account",
1974
1387
  where: [
1975
1388
  { field: "userId", value: existingUser.id },
@@ -1977,7 +1390,7 @@ export const sso = (options?: SSOOptions) => {
1977
1390
  { field: "accountId", value: userInfo.id },
1978
1391
  ],
1979
1392
  });
1980
- if (!account) {
1393
+ if (!accounts) {
1981
1394
  const isTrustedProvider =
1982
1395
  ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
1983
1396
  provider.providerId,
@@ -2079,10 +1492,11 @@ export const sso = (options?: SSOOptions) => {
2079
1492
  let session: Session =
2080
1493
  await ctx.context.internalAdapter.createSession(user.id, ctx);
2081
1494
  await setSessionCookie(ctx, { session, user });
2082
-
2083
- const callbackUrl =
2084
- RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2085
- throw ctx.redirect(callbackUrl);
1495
+ throw ctx.redirect(
1496
+ RelayState ||
1497
+ `${parsedSamlConfig.callbackUrl}` ||
1498
+ `${parsedSamlConfig.issuer}`,
1499
+ );
2086
1500
  },
2087
1501
  ),
2088
1502
  },