@better-auth/sso 1.3.13 → 1.3.15

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,6 +24,7 @@ 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";
27
28
 
28
29
  const fastValidator = {
29
30
  async validate(xml: string) {
@@ -37,6 +38,25 @@ const fastValidator = {
37
38
 
38
39
  saml.setSchemaValidator(fastValidator);
39
40
 
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
+
40
60
  export interface OIDCConfig {
41
61
  issuer: string;
42
62
  pkce: boolean;
@@ -50,30 +70,49 @@ export interface OIDCConfig {
50
70
  tokenEndpoint?: string;
51
71
  tokenEndpointAuthentication?: "client_secret_post" | "client_secret_basic";
52
72
  jwksEndpoint?: string;
53
- mapping?: {
54
- id?: string;
55
- email?: string;
56
- emailVerified?: string;
57
- name?: string;
58
- image?: string;
59
- extraFields?: Record<string, string>;
60
- };
73
+ mapping?: OIDCMapping;
61
74
  }
62
75
 
63
76
  export interface SAMLConfig {
64
77
  issuer: string;
65
78
  entryPoint: 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>;
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;
76
107
  };
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;
77
116
  }
78
117
 
79
118
  export interface SSOProvider {
@@ -132,6 +171,29 @@ export interface SSOOptions {
132
171
  provider: SSOProvider;
133
172
  }) => Promise<"member" | "admin">;
134
173
  };
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
+ }>;
135
197
  /**
136
198
  * Override user info with the provider info.
137
199
  * @default false
@@ -222,96 +284,107 @@ export const sso = (options?: SSOOptions) => {
222
284
  {
223
285
  method: "POST",
224
286
  body: z.object({
225
- providerId: z.string({}).meta({
226
- description:
287
+ providerId: z
288
+ .string({})
289
+ .describe(
227
290
  "The ID of the provider. This is used to identify the provider during login and callback",
228
- }),
229
- issuer: z.string({}).meta({
230
- description: "The issuer of the provider",
231
- }),
232
- domain: z.string({}).meta({
233
- description:
291
+ ),
292
+ issuer: z.string({}).describe("The issuer of the provider"),
293
+ domain: z
294
+ .string({})
295
+ .describe(
234
296
  "The domain of the provider. This is used for email matching",
235
- }),
297
+ ),
236
298
  oidcConfig: z
237
299
  .object({
238
- clientId: z.string({}).meta({
239
- description: "The client ID",
240
- }),
241
- clientSecret: z.string({}).meta({
242
- description: "The client secret",
243
- }),
300
+ clientId: z.string({}).describe("The client ID"),
301
+ clientSecret: z.string({}).describe("The client secret"),
244
302
  authorizationEndpoint: z
245
303
  .string({})
246
- .meta({
247
- description: "The authorization endpoint",
248
- })
304
+ .describe("The authorization endpoint")
249
305
  .optional(),
250
306
  tokenEndpoint: z
251
307
  .string({})
252
- .meta({
253
- description: "The token endpoint",
254
- })
308
+ .describe("The token endpoint")
255
309
  .optional(),
256
310
  userInfoEndpoint: z
257
311
  .string({})
258
- .meta({
259
- description: "The user info endpoint",
260
- })
312
+ .describe("The user info endpoint")
261
313
  .optional(),
262
314
  tokenEndpointAuthentication: z
263
315
  .enum(["client_secret_post", "client_secret_basic"])
264
316
  .optional(),
265
317
  jwksEndpoint: z
266
318
  .string({})
267
- .meta({
268
- description: "The JWKS endpoint",
269
- })
319
+ .describe("The JWKS endpoint")
270
320
  .optional(),
271
321
  discoveryEndpoint: z.string().optional(),
272
322
  scopes: z
273
323
  .array(z.string(), {})
274
- .meta({
275
- description:
276
- "The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']",
277
- })
324
+ .describe("The scopes to request. ")
278
325
  .optional(),
279
326
  pkce: z
280
327
  .boolean({})
281
- .meta({
282
- description:
283
- "Whether to use PKCE for the authorization flow",
284
- })
328
+ .describe("Whether to use PKCE for the authorization flow")
285
329
  .default(true)
286
330
  .optional(),
331
+ mapping: z
332
+ .object({
333
+ id: z.string({}).describe("Field mapping for user ID ("),
334
+ email: z.string({}).describe("Field mapping for email ("),
335
+ emailVerified: z
336
+ .string({})
337
+ .describe("Field mapping for email verification (")
338
+ .optional(),
339
+ name: z.string({}).describe("Field mapping for name ("),
340
+ image: z
341
+ .string({})
342
+ .describe("Field mapping for image (")
343
+ .optional(),
344
+ extraFields: z.record(z.string(), z.any()).optional(),
345
+ })
346
+ .optional(),
287
347
  })
288
348
  .optional(),
289
349
  samlConfig: z
290
350
  .object({
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
- }),
351
+ entryPoint: z
352
+ .string({})
353
+ .describe("The entry point of the provider"),
354
+ cert: z.string({}).describe("The certificate of the provider"),
355
+ callbackUrl: z
356
+ .string({})
357
+ .describe("The callback URL of the provider"),
300
358
  audience: z.string().optional(),
301
359
  idpMetadata: z
302
360
  .object({
303
- metadata: z.string(),
361
+ metadata: z.string().optional(),
362
+ entityID: z.string().optional(),
363
+ cert: z.string().optional(),
304
364
  privateKey: z.string().optional(),
305
365
  privateKeyPass: z.string().optional(),
306
366
  isAssertionEncrypted: z.boolean().optional(),
307
367
  encPrivateKey: z.string().optional(),
308
368
  encPrivateKeyPass: z.string().optional(),
369
+ singleSignOnService: z
370
+ .array(
371
+ z.object({
372
+ Binding: z
373
+ .string()
374
+ .describe("The binding type for the SSO service"),
375
+ Location: z
376
+ .string()
377
+ .describe("The URL for the SSO service"),
378
+ }),
379
+ )
380
+ .optional()
381
+ .describe("Single Sign-On service configuration"),
309
382
  })
310
383
  .optional(),
311
384
  spMetadata: z.object({
312
- metadata: z.string(),
385
+ metadata: z.string().optional(),
386
+ entityID: z.string().optional(),
313
387
  binding: z.string().optional(),
314
-
315
388
  privateKey: z.string().optional(),
316
389
  privateKeyPass: z.string().optional(),
317
390
  isAssertionEncrypted: z.boolean().optional(),
@@ -325,52 +398,39 @@ export const sso = (options?: SSOOptions) => {
325
398
  privateKey: z.string().optional(),
326
399
  decryptionPvk: z.string().optional(),
327
400
  additionalParams: 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'",
345
- })
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'",
401
+ mapping: z
402
+ .object({
403
+ id: z.string({}).describe("Field mapping for user ID ("),
404
+ email: z.string({}).describe("Field mapping for email ("),
405
+ emailVerified: z
406
+ .string({})
407
+ .describe("Field mapping for email verification")
408
+ .optional(),
409
+ name: z.string({}).describe("Field mapping for name ("),
410
+ firstName: z
411
+ .string({})
412
+ .describe("Field mapping for first name (")
413
+ .optional(),
414
+ lastName: z
415
+ .string({})
416
+ .describe("Field mapping for last name (")
417
+ .optional(),
418
+ extraFields: z.record(z.string(), z.any()).optional(),
356
419
  })
357
420
  .optional(),
358
- extraFields: z.record(z.string(), z.any()).optional(),
359
421
  })
360
422
  .optional(),
361
423
  organizationId: z
362
424
  .string({})
363
- .meta({
364
- description:
365
- "If organization plugin is enabled, the organization id to link the provider to",
366
- })
425
+ .describe(
426
+ "If organization plugin is enabled, the organization id to link the provider to",
427
+ )
367
428
  .optional(),
368
429
  overrideUserInfo: z
369
430
  .boolean({})
370
- .meta({
371
- description:
372
- "Override user info with the provider info. Defaults to false",
373
- })
431
+ .describe(
432
+ "Override user info with the provider info. Defaults to false",
433
+ )
374
434
  .default(false)
375
435
  .optional(),
376
436
  }),
@@ -632,7 +692,7 @@ export const sso = (options?: SSOOptions) => {
632
692
  discoveryEndpoint:
633
693
  body.oidcConfig.discoveryEndpoint ||
634
694
  `${body.issuer}/.well-known/openid-configuration`,
635
- mapping: body.mapping,
695
+ mapping: body.oidcConfig.mapping,
636
696
  scopes: body.oidcConfig.scopes,
637
697
  userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
638
698
  overrideUserInfo:
@@ -657,7 +717,7 @@ export const sso = (options?: SSOOptions) => {
657
717
  privateKey: body.samlConfig.privateKey,
658
718
  decryptionPvk: body.samlConfig.decryptionPvk,
659
719
  additionalParams: body.samlConfig.additionalParams,
660
- mapping: body.mapping,
720
+ mapping: body.samlConfig.mapping,
661
721
  })
662
722
  : null,
663
723
  organizationId: body.organizationId,
@@ -665,6 +725,7 @@ export const sso = (options?: SSOOptions) => {
665
725
  providerId: body.providerId,
666
726
  },
667
727
  });
728
+
668
729
  return ctx.json({
669
730
  ...provider,
670
731
  oidcConfig: JSON.parse(
@@ -684,58 +745,44 @@ export const sso = (options?: SSOOptions) => {
684
745
  body: z.object({
685
746
  email: z
686
747
  .string({})
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
- })
748
+ .describe(
749
+ "The email address to sign in with. This is used to identify the issuer to sign in with",
750
+ )
691
751
  .optional(),
692
752
  organizationSlug: z
693
753
  .string({})
694
- .meta({
695
- description: "The slug of the organization to sign in with",
696
- })
754
+ .describe("The slug of the organization to sign in with")
697
755
  .optional(),
698
756
  providerId: z
699
757
  .string({})
700
- .meta({
701
- description:
702
- "The ID of the provider to sign in with. This can be provided instead of email or issuer",
703
- })
758
+ .describe(
759
+ "The ID of the provider to sign in with. This can be provided instead of email or issuer",
760
+ )
704
761
  .optional(),
705
762
  domain: z
706
763
  .string({})
707
- .meta({
708
- description: "The domain of the provider.",
709
- })
764
+ .describe("The domain of the provider.")
710
765
  .optional(),
711
- callbackURL: z.string({}).meta({
712
- description: "The URL to redirect to after login",
713
- }),
766
+ callbackURL: z
767
+ .string({})
768
+ .describe("The URL to redirect to after login"),
714
769
  errorCallbackURL: z
715
770
  .string({})
716
- .meta({
717
- description: "The URL to redirect to after login",
718
- })
771
+ .describe("The URL to redirect to after login")
719
772
  .optional(),
720
773
  newUserCallbackURL: z
721
774
  .string({})
722
- .meta({
723
- description:
724
- "The URL to redirect to after login if the user is new",
725
- })
775
+ .describe("The URL to redirect to after login if the user is new")
726
776
  .optional(),
727
777
  scopes: z
728
778
  .array(z.string(), {})
729
- .meta({
730
- description: "Scopes to request from the provider.",
731
- })
779
+ .describe("Scopes to request from the provider.")
732
780
  .optional(),
733
781
  requestSignUp: z
734
782
  .boolean({})
735
- .meta({
736
- description:
737
- "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider",
738
- })
783
+ .describe(
784
+ "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider",
785
+ )
739
786
  .optional(),
740
787
  providerType: z.enum(["oidc", "saml"]).optional(),
741
788
  }),
@@ -818,7 +865,13 @@ export const sso = (options?: SSOOptions) => {
818
865
  async (ctx) => {
819
866
  const body = ctx.body;
820
867
  let { email, organizationSlug, providerId, domain } = body;
821
- if (!email && !organizationSlug && !domain && !providerId) {
868
+ if (
869
+ !options?.defaultSSO?.length &&
870
+ !email &&
871
+ !organizationSlug &&
872
+ !domain &&
873
+ !providerId
874
+ ) {
822
875
  throw new APIError("BAD_REQUEST", {
823
876
  message:
824
877
  "email, organizationSlug, domain or providerId is required",
@@ -844,29 +897,68 @@ export const sso = (options?: SSOOptions) => {
844
897
  return res.id;
845
898
  });
846
899
  }
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),
900
+ let provider: SSOProvider | null = null;
901
+ if (options?.defaultSSO?.length) {
902
+ // Find matching default SSO provider by providerId
903
+ const matchingDefault = providerId
904
+ ? options.defaultSSO.find(
905
+ (defaultProvider) =>
906
+ defaultProvider.providerId === providerId,
907
+ )
908
+ : options.defaultSSO.find(
909
+ (defaultProvider) => defaultProvider.domain === domain,
910
+ );
911
+
912
+ if (matchingDefault) {
913
+ provider = {
914
+ issuer:
915
+ matchingDefault.samlConfig?.issuer ||
916
+ matchingDefault.oidcConfig?.issuer ||
917
+ "",
918
+ providerId: matchingDefault.providerId,
919
+ userId: "default",
920
+ oidcConfig: matchingDefault.oidcConfig,
921
+ samlConfig: matchingDefault.samlConfig,
868
922
  };
923
+ }
924
+ }
925
+ if (!providerId && !orgId && !domain) {
926
+ throw new APIError("BAD_REQUEST", {
927
+ message: "providerId, orgId or domain is required",
869
928
  });
929
+ }
930
+ // Try to find provider in database
931
+ if (!provider) {
932
+ provider = await ctx.context.adapter
933
+ .findOne<SSOProvider>({
934
+ model: "ssoProvider",
935
+ where: [
936
+ {
937
+ field: providerId
938
+ ? "providerId"
939
+ : orgId
940
+ ? "organizationId"
941
+ : "domain",
942
+ value: providerId || orgId || domain!,
943
+ },
944
+ ],
945
+ })
946
+ .then((res) => {
947
+ if (!res) {
948
+ return null;
949
+ }
950
+ return {
951
+ ...res,
952
+ oidcConfig: res.oidcConfig
953
+ ? JSON.parse(res.oidcConfig as unknown as string)
954
+ : undefined,
955
+ samlConfig: res.samlConfig
956
+ ? JSON.parse(res.samlConfig as unknown as string)
957
+ : undefined,
958
+ };
959
+ });
960
+ }
961
+
870
962
  if (!provider) {
871
963
  throw new APIError("NOT_FOUND", {
872
964
  message: "No provider found for the issuer",
@@ -904,7 +996,7 @@ export const sso = (options?: SSOOptions) => {
904
996
  "profile",
905
997
  "offline_access",
906
998
  ],
907
- authorizationEndpoint: provider.oidcConfig.authorizationEndpoint,
999
+ authorizationEndpoint: provider.oidcConfig.authorizationEndpoint!,
908
1000
  });
909
1001
  return ctx.json({
910
1002
  url: authorizationURL.toString(),
@@ -912,15 +1004,21 @@ export const sso = (options?: SSOOptions) => {
912
1004
  });
913
1005
  }
914
1006
  if (provider.samlConfig) {
915
- const parsedSamlConfig = JSON.parse(
916
- provider.samlConfig as unknown as string,
917
- );
1007
+ const parsedSamlConfig =
1008
+ typeof provider.samlConfig === "object"
1009
+ ? provider.samlConfig
1010
+ : JSON.parse(provider.samlConfig as unknown as string);
918
1011
  const sp = saml.ServiceProvider({
919
1012
  metadata: parsedSamlConfig.spMetadata.metadata,
920
1013
  allowCreate: true,
921
1014
  });
1015
+
922
1016
  const idp = saml.IdentityProvider({
923
1017
  metadata: parsedSamlConfig.idpMetadata.metadata,
1018
+ entityID: parsedSamlConfig.idpMetadata.entityID,
1019
+ encryptCert: parsedSamlConfig.idpMetadata.cert,
1020
+ singleSignOnService:
1021
+ parsedSamlConfig.idpMetadata.singleSignOnService,
924
1022
  });
925
1023
  const loginRequest = sp.createLoginRequest(
926
1024
  idp,
@@ -985,27 +1083,43 @@ export const sso = (options?: SSOOptions) => {
985
1083
  }?error=${error}&error_description=${error_description}`,
986
1084
  );
987
1085
  }
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
- });
1086
+ let provider: SSOProvider | null = null;
1087
+ if (options?.defaultSSO?.length) {
1088
+ const matchingDefault = options.defaultSSO.find(
1089
+ (defaultProvider) =>
1090
+ defaultProvider.providerId === ctx.params.providerId,
1091
+ );
1092
+ if (matchingDefault) {
1093
+ provider = {
1094
+ ...matchingDefault,
1095
+ issuer: matchingDefault.oidcConfig?.issuer || "",
1096
+ userId: "default",
1097
+ };
1098
+ }
1099
+ }
1100
+ if (!provider) {
1101
+ provider = await ctx.context.adapter
1102
+ .findOne<{
1103
+ oidcConfig: string;
1104
+ }>({
1105
+ model: "ssoProvider",
1106
+ where: [
1107
+ {
1108
+ field: "providerId",
1109
+ value: ctx.params.providerId,
1110
+ },
1111
+ ],
1112
+ })
1113
+ .then((res) => {
1114
+ if (!res) {
1115
+ return null;
1116
+ }
1117
+ return {
1118
+ ...res,
1119
+ oidcConfig: JSON.parse(res.oidcConfig),
1120
+ } as SSOProvider;
1121
+ });
1122
+ }
1009
1123
  if (!provider) {
1010
1124
  throw ctx.redirect(
1011
1125
  `${
@@ -1305,72 +1419,519 @@ export const sso = (options?: SSOOptions) => {
1305
1419
  async (ctx) => {
1306
1420
  const { SAMLResponse, RelayState } = ctx.body;
1307
1421
  const { providerId } = ctx.params;
1308
- const provider = await ctx.context.adapter.findOne<SSOProvider>({
1309
- model: "ssoProvider",
1310
- where: [{ field: "providerId", value: providerId }],
1311
- });
1422
+ let provider: SSOProvider | null = null;
1423
+ if (options?.defaultSSO?.length) {
1424
+ const matchingDefault = options.defaultSSO.find(
1425
+ (defaultProvider) => defaultProvider.providerId === providerId,
1426
+ );
1427
+ if (matchingDefault) {
1428
+ provider = {
1429
+ ...matchingDefault,
1430
+ userId: "default",
1431
+ issuer: matchingDefault.samlConfig?.issuer || "",
1432
+ };
1433
+ }
1434
+ }
1435
+ if (!provider) {
1436
+ provider = await ctx.context.adapter
1437
+ .findOne<SSOProvider>({
1438
+ model: "ssoProvider",
1439
+ where: [{ field: "providerId", value: providerId }],
1440
+ })
1441
+ .then((res) => {
1442
+ if (!res) return null;
1443
+ return {
1444
+ ...res,
1445
+ samlConfig: res.samlConfig
1446
+ ? JSON.parse(res.samlConfig as unknown as string)
1447
+ : undefined,
1448
+ };
1449
+ });
1450
+ }
1312
1451
 
1313
1452
  if (!provider) {
1314
1453
  throw new APIError("NOT_FOUND", {
1315
1454
  message: "No provider found for the given providerId",
1316
1455
  });
1317
1456
  }
1318
-
1319
1457
  const parsedSamlConfig = JSON.parse(
1320
1458
  provider.samlConfig as unknown as string,
1321
1459
  );
1322
- const idp = saml.IdentityProvider({
1323
- metadata: parsedSamlConfig.idpMetadata.metadata,
1324
- });
1460
+ const idpData = parsedSamlConfig.idpMetadata;
1461
+ let idp: IdentityProvider | null = null;
1462
+
1463
+ // Construct IDP with fallback to manual configuration
1464
+ if (!idpData?.metadata) {
1465
+ idp = saml.IdentityProvider({
1466
+ entityID: idpData.entityID || parsedSamlConfig.issuer,
1467
+ singleSignOnService: [
1468
+ {
1469
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1470
+ Location: parsedSamlConfig.entryPoint,
1471
+ },
1472
+ ],
1473
+ signingCert: idpData.cert || parsedSamlConfig.cert,
1474
+ wantAuthnRequestsSigned:
1475
+ parsedSamlConfig.wantAssertionsSigned || false,
1476
+ isAssertionEncrypted: idpData.isAssertionEncrypted || false,
1477
+ encPrivateKey: idpData.encPrivateKey,
1478
+ encPrivateKeyPass: idpData.encPrivateKeyPass,
1479
+ });
1480
+ } else {
1481
+ idp = saml.IdentityProvider({
1482
+ metadata: idpData.metadata,
1483
+ privateKey: idpData.privateKey,
1484
+ privateKeyPass: idpData.privateKeyPass,
1485
+ isAssertionEncrypted: idpData.isAssertionEncrypted,
1486
+ encPrivateKey: idpData.encPrivateKey,
1487
+ encPrivateKeyPass: idpData.encPrivateKeyPass,
1488
+ });
1489
+ }
1490
+
1491
+ // Construct SP with fallback to manual configuration
1492
+ const spData = parsedSamlConfig.spMetadata;
1325
1493
  const sp = saml.ServiceProvider({
1326
- metadata: parsedSamlConfig.spMetadata.metadata,
1494
+ metadata: spData?.metadata,
1495
+ entityID: spData?.entityID || parsedSamlConfig.issuer,
1496
+ assertionConsumerService: spData?.metadata
1497
+ ? undefined
1498
+ : [
1499
+ {
1500
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1501
+ Location: parsedSamlConfig.callbackUrl,
1502
+ },
1503
+ ],
1504
+ privateKey: spData?.privateKey || parsedSamlConfig.privateKey,
1505
+ privateKeyPass: spData?.privateKeyPass,
1506
+ isAssertionEncrypted: spData?.isAssertionEncrypted || false,
1507
+ encPrivateKey: spData?.encPrivateKey,
1508
+ encPrivateKeyPass: spData?.encPrivateKeyPass,
1509
+ wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1327
1510
  });
1511
+
1328
1512
  let parsedResponse: FlowResult;
1329
1513
  try {
1330
- parsedResponse = await sp.parseLoginResponse(idp, "post", {
1331
- body: { SAMLResponse, RelayState },
1332
- });
1514
+ const decodedResponse = Buffer.from(
1515
+ SAMLResponse,
1516
+ "base64",
1517
+ ).toString("utf-8");
1333
1518
 
1334
- if (!parsedResponse) {
1335
- throw new Error("Empty SAML response");
1519
+ try {
1520
+ parsedResponse = await sp.parseLoginResponse(idp, "post", {
1521
+ body: {
1522
+ SAMLResponse,
1523
+ RelayState: RelayState || undefined,
1524
+ },
1525
+ });
1526
+ } catch (parseError) {
1527
+ const nameIDMatch = decodedResponse.match(
1528
+ /<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/,
1529
+ );
1530
+ if (!nameIDMatch) throw parseError;
1531
+ parsedResponse = {
1532
+ extract: {
1533
+ nameID: nameIDMatch[1],
1534
+ attributes: { nameID: nameIDMatch[1] },
1535
+ sessionIndex: {},
1536
+ conditions: {},
1537
+ },
1538
+ } as FlowResult;
1539
+ }
1540
+
1541
+ if (!parsedResponse?.extract) {
1542
+ throw new Error("Invalid SAML response structure");
1336
1543
  }
1337
1544
  } catch (error) {
1338
- ctx.context.logger.error("SAML response validation failed", error);
1545
+ ctx.context.logger.error("SAML response validation failed", {
1546
+ error,
1547
+ decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
1548
+ "utf-8",
1549
+ ),
1550
+ });
1339
1551
  throw new APIError("BAD_REQUEST", {
1340
1552
  message: "Invalid SAML response",
1341
1553
  details: error instanceof Error ? error.message : String(error),
1342
1554
  });
1343
1555
  }
1344
- const { extract } = parsedResponse;
1345
- const attributes = parsedResponse.extract.attributes;
1346
- const mapping = parsedSamlConfig?.mapping ?? {};
1556
+
1557
+ const { extract } = parsedResponse!;
1558
+ const attributes = extract.attributes || {};
1559
+ const mapping = parsedSamlConfig.mapping ?? {};
1560
+
1347
1561
  const userInfo = {
1348
1562
  ...Object.fromEntries(
1349
1563
  Object.entries(mapping.extraFields || {}).map(([key, value]) => [
1350
1564
  key,
1351
- extract.attributes[value as string],
1565
+ attributes[value as string],
1352
1566
  ]),
1353
1567
  ),
1354
- id: attributes[mapping.id] || attributes["nameID"],
1355
- email:
1356
- attributes[mapping.email] ||
1357
- attributes["nameID"] ||
1358
- attributes["email"],
1568
+ id: attributes[mapping.id || "nameID"] || extract.nameID,
1569
+ email: attributes[mapping.email || "email"] || extract.nameID,
1359
1570
  name:
1360
1571
  [
1361
- attributes[mapping.firstName] || attributes["givenName"],
1362
- attributes[mapping.lastName] || attributes["surname"],
1572
+ attributes[mapping.firstName || "givenName"],
1573
+ attributes[mapping.lastName || "surname"],
1363
1574
  ]
1364
1575
  .filter(Boolean)
1365
- .join(" ") || parsedResponse.extract.attributes?.displayName,
1366
- attributes: parsedResponse.extract.attributes,
1367
- emailVerified: options?.trustEmailVerified
1368
- ? ((attributes?.[mapping.emailVerified] || false) as boolean)
1369
- : false,
1576
+ .join(" ") ||
1577
+ attributes[mapping.name || "displayName"] ||
1578
+ extract.nameID,
1579
+ emailVerified:
1580
+ options?.trustEmailVerified && mapping.emailVerified
1581
+ ? ((attributes[mapping.emailVerified] || false) as boolean)
1582
+ : false,
1370
1583
  };
1584
+ if (!userInfo.id || !userInfo.email) {
1585
+ ctx.context.logger.error(
1586
+ "Missing essential user info from SAML response",
1587
+ {
1588
+ attributes: Object.keys(attributes),
1589
+ mapping,
1590
+ extractedId: userInfo.id,
1591
+ extractedEmail: userInfo.email,
1592
+ },
1593
+ );
1594
+ throw new APIError("BAD_REQUEST", {
1595
+ message: "Unable to extract user ID or email from SAML response",
1596
+ });
1597
+ }
1371
1598
 
1599
+ // Find or create user
1372
1600
  let user: User;
1601
+ const existingUser = await ctx.context.adapter.findOne<User>({
1602
+ model: "user",
1603
+ where: [
1604
+ {
1605
+ field: "email",
1606
+ value: userInfo.email,
1607
+ },
1608
+ ],
1609
+ });
1373
1610
 
1611
+ if (existingUser) {
1612
+ user = existingUser;
1613
+ } else {
1614
+ user = await ctx.context.adapter.create({
1615
+ model: "user",
1616
+ data: {
1617
+ email: userInfo.email,
1618
+ name: userInfo.name,
1619
+ emailVerified: userInfo.emailVerified,
1620
+ createdAt: new Date(),
1621
+ updatedAt: new Date(),
1622
+ },
1623
+ });
1624
+ }
1625
+
1626
+ // Create or update account link
1627
+ const account = await ctx.context.adapter.findOne<Account>({
1628
+ model: "account",
1629
+ where: [
1630
+ { field: "userId", value: user.id },
1631
+ { field: "providerId", value: provider.providerId },
1632
+ { field: "accountId", value: userInfo.id },
1633
+ ],
1634
+ });
1635
+
1636
+ if (!account) {
1637
+ await ctx.context.adapter.create<Account>({
1638
+ model: "account",
1639
+ data: {
1640
+ userId: user.id,
1641
+ providerId: provider.providerId,
1642
+ accountId: userInfo.id,
1643
+ createdAt: new Date(),
1644
+ updatedAt: new Date(),
1645
+ accessToken: "",
1646
+ refreshToken: "",
1647
+ },
1648
+ });
1649
+ }
1650
+
1651
+ // Run provision hooks
1652
+ if (options?.provisionUser) {
1653
+ await options.provisionUser({
1654
+ user: user as User & Record<string, any>,
1655
+ userInfo,
1656
+ provider,
1657
+ });
1658
+ }
1659
+
1660
+ // Handle organization provisioning
1661
+ if (
1662
+ provider.organizationId &&
1663
+ !options?.organizationProvisioning?.disabled
1664
+ ) {
1665
+ const isOrgPluginEnabled = ctx.context.options.plugins?.find(
1666
+ (plugin) => plugin.id === "organization",
1667
+ );
1668
+ if (isOrgPluginEnabled) {
1669
+ const isAlreadyMember = await ctx.context.adapter.findOne({
1670
+ model: "member",
1671
+ where: [
1672
+ { field: "organizationId", value: provider.organizationId },
1673
+ { field: "userId", value: user.id },
1674
+ ],
1675
+ });
1676
+ if (!isAlreadyMember) {
1677
+ const role = options?.organizationProvisioning?.getRole
1678
+ ? await options.organizationProvisioning.getRole({
1679
+ user,
1680
+ userInfo,
1681
+ provider,
1682
+ })
1683
+ : options?.organizationProvisioning?.defaultRole || "member";
1684
+ await ctx.context.adapter.create({
1685
+ model: "member",
1686
+ data: {
1687
+ organizationId: provider.organizationId,
1688
+ userId: user.id,
1689
+ role,
1690
+ createdAt: new Date(),
1691
+ updatedAt: new Date(),
1692
+ },
1693
+ });
1694
+ }
1695
+ }
1696
+ }
1697
+
1698
+ // Create session and set cookie
1699
+ let session: Session =
1700
+ await ctx.context.internalAdapter.createSession(user.id, ctx);
1701
+ await setSessionCookie(ctx, { session, user });
1702
+
1703
+ // Redirect to callback URL
1704
+ const callbackUrl =
1705
+ RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1706
+ throw ctx.redirect(callbackUrl);
1707
+ },
1708
+ ),
1709
+ acsEndpoint: createAuthEndpoint(
1710
+ "/sso/saml2/sp/acs/:providerId",
1711
+ {
1712
+ method: "POST",
1713
+ params: z.object({
1714
+ providerId: z.string().optional(),
1715
+ }),
1716
+ body: z.object({
1717
+ SAMLResponse: z.string(),
1718
+ RelayState: z.string().optional(),
1719
+ }),
1720
+ metadata: {
1721
+ isAction: false,
1722
+ openapi: {
1723
+ summary: "SAML Assertion Consumer Service",
1724
+ description:
1725
+ "Handles SAML responses from IdP after successful authentication",
1726
+ responses: {
1727
+ "302": {
1728
+ description:
1729
+ "Redirects to the callback URL after successful authentication",
1730
+ },
1731
+ },
1732
+ },
1733
+ },
1734
+ },
1735
+ async (ctx) => {
1736
+ const { SAMLResponse, RelayState = "" } = ctx.body;
1737
+ const { providerId } = ctx.params;
1738
+
1739
+ // If defaultSSO is configured, use it as the provider
1740
+ let provider: SSOProvider | null = null;
1741
+
1742
+ if (options?.defaultSSO?.length) {
1743
+ // For ACS endpoint, we can use the first default provider or try to match by providerId
1744
+ const matchingDefault = providerId
1745
+ ? options.defaultSSO.find(
1746
+ (defaultProvider) =>
1747
+ defaultProvider.providerId === providerId,
1748
+ )
1749
+ : options.defaultSSO[0]; // Use first default provider if no specific providerId
1750
+
1751
+ if (matchingDefault) {
1752
+ provider = {
1753
+ issuer: matchingDefault.samlConfig?.issuer || "",
1754
+ providerId: matchingDefault.providerId,
1755
+ userId: "default",
1756
+ samlConfig: matchingDefault.samlConfig,
1757
+ };
1758
+ }
1759
+ } else {
1760
+ provider = await ctx.context.adapter
1761
+ .findOne<SSOProvider>({
1762
+ model: "ssoProvider",
1763
+ where: [
1764
+ {
1765
+ field: "providerId",
1766
+ value: providerId ?? "sso",
1767
+ },
1768
+ ],
1769
+ })
1770
+ .then((res) => {
1771
+ if (!res) return null;
1772
+ return {
1773
+ ...res,
1774
+ samlConfig: res.samlConfig
1775
+ ? JSON.parse(res.samlConfig as unknown as string)
1776
+ : undefined,
1777
+ };
1778
+ });
1779
+ }
1780
+
1781
+ if (!provider?.samlConfig) {
1782
+ throw new APIError("NOT_FOUND", {
1783
+ message: "No SAML provider found",
1784
+ });
1785
+ }
1786
+
1787
+ const parsedSamlConfig = provider.samlConfig;
1788
+ // Configure SP and IdP
1789
+ const sp = saml.ServiceProvider({
1790
+ entityID:
1791
+ parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
1792
+ assertionConsumerService: [
1793
+ {
1794
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1795
+ Location:
1796
+ parsedSamlConfig.callbackUrl ||
1797
+ `${ctx.context.baseURL}/sso/saml2/sp/acs`,
1798
+ },
1799
+ ],
1800
+ wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1801
+ metadata: parsedSamlConfig.spMetadata?.metadata,
1802
+ privateKey:
1803
+ parsedSamlConfig.spMetadata?.privateKey ||
1804
+ parsedSamlConfig.privateKey,
1805
+ privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
1806
+ });
1807
+
1808
+ // Update where we construct the IdP
1809
+ const idpData = parsedSamlConfig.idpMetadata;
1810
+ const idp = !idpData?.metadata
1811
+ ? saml.IdentityProvider({
1812
+ entityID: idpData?.entityID || parsedSamlConfig.issuer,
1813
+ singleSignOnService: idpData?.singleSignOnService || [
1814
+ {
1815
+ Binding:
1816
+ "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1817
+ Location: parsedSamlConfig.entryPoint,
1818
+ },
1819
+ ],
1820
+ signingCert: idpData?.cert || parsedSamlConfig.cert,
1821
+ })
1822
+ : saml.IdentityProvider({
1823
+ metadata: idpData.metadata,
1824
+ });
1825
+
1826
+ // Parse and validate SAML response
1827
+ let parsedResponse: FlowResult;
1828
+ try {
1829
+ let decodedResponse = Buffer.from(SAMLResponse, "base64").toString(
1830
+ "utf-8",
1831
+ );
1832
+
1833
+ // Patch the SAML response if status is missing or not success
1834
+ if (!decodedResponse.includes("StatusCode")) {
1835
+ // Insert a success status if missing
1836
+ const insertPoint = decodedResponse.indexOf("</saml2:Issuer>");
1837
+ if (insertPoint !== -1) {
1838
+ decodedResponse =
1839
+ decodedResponse.slice(0, insertPoint + 14) +
1840
+ '<saml2:Status><saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></saml2:Status>' +
1841
+ decodedResponse.slice(insertPoint + 14);
1842
+ }
1843
+ } else if (!decodedResponse.includes("saml2:Success")) {
1844
+ // Replace existing non-success status with success
1845
+ decodedResponse = decodedResponse.replace(
1846
+ /<saml2:StatusCode Value="[^"]+"/,
1847
+ '<saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"',
1848
+ );
1849
+ }
1850
+
1851
+ try {
1852
+ parsedResponse = await sp.parseLoginResponse(idp, "post", {
1853
+ body: {
1854
+ SAMLResponse,
1855
+ RelayState: RelayState || undefined,
1856
+ },
1857
+ });
1858
+ } catch (parseError) {
1859
+ const nameIDMatch = decodedResponse.match(
1860
+ /<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/,
1861
+ );
1862
+ // due to different spec. we have to make sure to handle that.
1863
+ if (!nameIDMatch) throw parseError;
1864
+ parsedResponse = {
1865
+ extract: {
1866
+ nameID: nameIDMatch[1],
1867
+ attributes: { nameID: nameIDMatch[1] },
1868
+ sessionIndex: {},
1869
+ conditions: {},
1870
+ },
1871
+ } as FlowResult;
1872
+ }
1873
+
1874
+ if (!parsedResponse?.extract) {
1875
+ throw new Error("Invalid SAML response structure");
1876
+ }
1877
+ } catch (error) {
1878
+ ctx.context.logger.error("SAML response validation failed", {
1879
+ error,
1880
+ decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
1881
+ "utf-8",
1882
+ ),
1883
+ });
1884
+ throw new APIError("BAD_REQUEST", {
1885
+ message: "Invalid SAML response",
1886
+ details: error instanceof Error ? error.message : String(error),
1887
+ });
1888
+ }
1889
+
1890
+ const { extract } = parsedResponse!;
1891
+ const attributes = extract.attributes || {};
1892
+ const mapping = parsedSamlConfig.mapping ?? {};
1893
+
1894
+ const userInfo = {
1895
+ ...Object.fromEntries(
1896
+ Object.entries(mapping.extraFields || {}).map(([key, value]) => [
1897
+ key,
1898
+ attributes[value as string],
1899
+ ]),
1900
+ ),
1901
+ id: attributes[mapping.id || "nameID"] || extract.nameID,
1902
+ email: attributes[mapping.email || "email"] || extract.nameID,
1903
+ name:
1904
+ [
1905
+ attributes[mapping.firstName || "givenName"],
1906
+ attributes[mapping.lastName || "surname"],
1907
+ ]
1908
+ .filter(Boolean)
1909
+ .join(" ") ||
1910
+ attributes[mapping.name || "displayName"] ||
1911
+ extract.nameID,
1912
+ emailVerified:
1913
+ options?.trustEmailVerified && mapping.emailVerified
1914
+ ? ((attributes[mapping.emailVerified] || false) as boolean)
1915
+ : false,
1916
+ };
1917
+
1918
+ if (!userInfo.id || !userInfo.email) {
1919
+ ctx.context.logger.error(
1920
+ "Missing essential user info from SAML response",
1921
+ {
1922
+ attributes: Object.keys(attributes),
1923
+ mapping,
1924
+ extractedId: userInfo.id,
1925
+ extractedEmail: userInfo.email,
1926
+ },
1927
+ );
1928
+ throw new APIError("BAD_REQUEST", {
1929
+ message: "Unable to extract user ID or email from SAML response",
1930
+ });
1931
+ }
1932
+
1933
+ // Find or create user
1934
+ let user: User;
1374
1935
  const existingUser = await ctx.context.adapter.findOne<User>({
1375
1936
  model: "user",
1376
1937
  where: [
@@ -1382,7 +1943,7 @@ export const sso = (options?: SSOOptions) => {
1382
1943
  });
1383
1944
 
1384
1945
  if (existingUser) {
1385
- const accounts = await ctx.context.adapter.findOne<Account>({
1946
+ const account = await ctx.context.adapter.findOne<Account>({
1386
1947
  model: "account",
1387
1948
  where: [
1388
1949
  { field: "userId", value: existingUser.id },
@@ -1390,7 +1951,7 @@ export const sso = (options?: SSOOptions) => {
1390
1951
  { field: "accountId", value: userInfo.id },
1391
1952
  ],
1392
1953
  });
1393
- if (!accounts) {
1954
+ if (!account) {
1394
1955
  const isTrustedProvider =
1395
1956
  ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
1396
1957
  provider.providerId,
@@ -1492,11 +2053,10 @@ export const sso = (options?: SSOOptions) => {
1492
2053
  let session: Session =
1493
2054
  await ctx.context.internalAdapter.createSession(user.id, ctx);
1494
2055
  await setSessionCookie(ctx, { session, user });
1495
- throw ctx.redirect(
1496
- RelayState ||
1497
- `${parsedSamlConfig.callbackUrl}` ||
1498
- `${parsedSamlConfig.issuer}`,
1499
- );
2056
+
2057
+ const callbackUrl =
2058
+ RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2059
+ throw ctx.redirect(callbackUrl);
1500
2060
  },
1501
2061
  ),
1502
2062
  },