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

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,53 @@ const fastValidator = {
37
38
 
38
39
  saml.setSchemaValidator(fastValidator);
39
40
 
41
+ /**
42
+ * Safely parses a value that might be a JSON string or already a parsed object
43
+ * This handles cases where ORMs like Drizzle might return already parsed objects
44
+ * instead of JSON strings from TEXT/JSON columns
45
+ */
46
+ function safeJsonParse<T>(value: string | T | null | undefined): T | null {
47
+ if (!value) return null;
48
+
49
+ // If it's already an object (not a string), return it as-is
50
+ if (typeof value === "object") {
51
+ return value as T;
52
+ }
53
+
54
+ // If it's a string, try to parse it
55
+ if (typeof value === "string") {
56
+ try {
57
+ return JSON.parse(value) as T;
58
+ } catch (error) {
59
+ // If parsing fails, this might indicate the string is not valid JSON
60
+ throw new Error(
61
+ `Failed to parse JSON: ${error instanceof Error ? error.message : "Unknown error"}`,
62
+ );
63
+ }
64
+ }
65
+
66
+ return null;
67
+ }
68
+
69
+ export interface OIDCMapping {
70
+ id?: string;
71
+ email?: string;
72
+ emailVerified?: string;
73
+ name?: string;
74
+ image?: string;
75
+ extraFields?: Record<string, string>;
76
+ }
77
+
78
+ export interface SAMLMapping {
79
+ id?: string;
80
+ email?: string;
81
+ emailVerified?: string;
82
+ name?: string;
83
+ firstName?: string;
84
+ lastName?: string;
85
+ extraFields?: Record<string, string>;
86
+ }
87
+
40
88
  export interface OIDCConfig {
41
89
  issuer: string;
42
90
  pkce: boolean;
@@ -50,30 +98,49 @@ export interface OIDCConfig {
50
98
  tokenEndpoint?: string;
51
99
  tokenEndpointAuthentication?: "client_secret_post" | "client_secret_basic";
52
100
  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
- };
101
+ mapping?: OIDCMapping;
61
102
  }
62
103
 
63
104
  export interface SAMLConfig {
64
105
  issuer: string;
65
106
  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>;
107
+ cert: string;
108
+ callbackUrl: string;
109
+ audience?: string;
110
+ idpMetadata?: {
111
+ metadata?: string;
112
+ entityID?: string;
113
+ entityURL?: string;
114
+ redirectURL?: string;
115
+ cert?: string;
116
+ privateKey?: string;
117
+ privateKeyPass?: string;
118
+ isAssertionEncrypted?: boolean;
119
+ encPrivateKey?: string;
120
+ encPrivateKeyPass?: string;
121
+ singleSignOnService?: Array<{
122
+ Binding: string;
123
+ Location: string;
124
+ }>;
125
+ };
126
+ spMetadata: {
127
+ metadata?: string;
128
+ entityID?: string;
129
+ binding?: string;
130
+ privateKey?: string;
131
+ privateKeyPass?: string;
132
+ isAssertionEncrypted?: boolean;
133
+ encPrivateKey?: string;
134
+ encPrivateKeyPass?: string;
76
135
  };
136
+ wantAssertionsSigned?: boolean;
137
+ signatureAlgorithm?: string;
138
+ digestAlgorithm?: string;
139
+ identifierFormat?: string;
140
+ privateKey?: string;
141
+ decryptionPvk?: string;
142
+ additionalParams?: Record<string, any>;
143
+ mapping?: SAMLMapping;
77
144
  }
78
145
 
79
146
  export interface SSOProvider {
@@ -132,6 +199,29 @@ export interface SSOOptions {
132
199
  provider: SSOProvider;
133
200
  }) => Promise<"member" | "admin">;
134
201
  };
202
+ /**
203
+ * Default SSO provider configurations for testing.
204
+ * These will take the precedence over the database providers.
205
+ */
206
+ defaultSSO?: Array<{
207
+ /**
208
+ * The domain to match for this default provider.
209
+ * This is only used to match incoming requests to this default provider.
210
+ */
211
+ domain: string;
212
+ /**
213
+ * The provider ID to use
214
+ */
215
+ providerId: string;
216
+ /**
217
+ * SAML configuration
218
+ */
219
+ samlConfig?: SAMLConfig;
220
+ /**
221
+ * OIDC configuration
222
+ */
223
+ oidcConfig?: OIDCConfig;
224
+ }>;
135
225
  /**
136
226
  * Override user info with the provider info.
137
227
  * @default false
@@ -190,6 +280,7 @@ export const sso = (options?: SSOOptions) => {
190
280
  },
191
281
  async (ctx) => {
192
282
  const provider = await ctx.context.adapter.findOne<{
283
+ id: string;
193
284
  samlConfig: string;
194
285
  }>({
195
286
  model: "ssoProvider",
@@ -206,10 +297,36 @@ export const sso = (options?: SSOOptions) => {
206
297
  });
207
298
  }
208
299
 
209
- const parsedSamlConfig = JSON.parse(provider.samlConfig);
210
- const sp = saml.ServiceProvider({
211
- metadata: parsedSamlConfig.spMetadata.metadata,
212
- });
300
+ const parsedSamlConfig = safeJsonParse<SAMLConfig>(
301
+ provider.samlConfig,
302
+ );
303
+ if (!parsedSamlConfig) {
304
+ throw new APIError("BAD_REQUEST", {
305
+ message: "Invalid SAML configuration",
306
+ });
307
+ }
308
+ const sp = parsedSamlConfig.spMetadata.metadata
309
+ ? saml.ServiceProvider({
310
+ metadata: parsedSamlConfig.spMetadata.metadata,
311
+ })
312
+ : saml.SPMetadata({
313
+ entityID:
314
+ parsedSamlConfig.spMetadata?.entityID ||
315
+ parsedSamlConfig.issuer,
316
+ assertionConsumerService: [
317
+ {
318
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
319
+ Location:
320
+ parsedSamlConfig.callbackUrl ||
321
+ `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.id}`,
322
+ },
323
+ ],
324
+ wantMessageSigned:
325
+ parsedSamlConfig.wantAssertionsSigned || false,
326
+ nameIDFormat: parsedSamlConfig.identifierFormat
327
+ ? [parsedSamlConfig.identifierFormat]
328
+ : undefined,
329
+ });
213
330
  return new Response(sp.getMetadata(), {
214
331
  headers: {
215
332
  "Content-Type": "application/xml",
@@ -284,6 +401,37 @@ export const sso = (options?: SSOOptions) => {
284
401
  })
285
402
  .default(true)
286
403
  .optional(),
404
+ mapping: z
405
+ .object({
406
+ id: z.string({}).meta({
407
+ description:
408
+ "Field mapping for user ID (defaults to 'sub')",
409
+ }),
410
+ email: z.string({}).meta({
411
+ description:
412
+ "Field mapping for email (defaults to 'email')",
413
+ }),
414
+ emailVerified: z
415
+ .string({})
416
+ .meta({
417
+ description:
418
+ "Field mapping for email verification (defaults to 'email_verified')",
419
+ })
420
+ .optional(),
421
+ name: z.string({}).meta({
422
+ description:
423
+ "Field mapping for name (defaults to 'name')",
424
+ }),
425
+ image: z
426
+ .string({})
427
+ .meta({
428
+ description:
429
+ "Field mapping for image (defaults to 'picture')",
430
+ })
431
+ .optional(),
432
+ extraFields: z.record(z.string(), z.any()).optional(),
433
+ })
434
+ .optional(),
287
435
  })
288
436
  .optional(),
289
437
  samlConfig: z
@@ -300,18 +448,35 @@ export const sso = (options?: SSOOptions) => {
300
448
  audience: z.string().optional(),
301
449
  idpMetadata: z
302
450
  .object({
303
- metadata: z.string(),
451
+ metadata: z.string().optional(),
452
+ entityID: z.string().optional(),
453
+ cert: z.string().optional(),
304
454
  privateKey: z.string().optional(),
305
455
  privateKeyPass: z.string().optional(),
306
456
  isAssertionEncrypted: z.boolean().optional(),
307
457
  encPrivateKey: z.string().optional(),
308
458
  encPrivateKeyPass: z.string().optional(),
459
+ singleSignOnService: z
460
+ .array(
461
+ z.object({
462
+ Binding: z.string().meta({
463
+ description: "The binding type for the SSO service",
464
+ }),
465
+ Location: z.string().meta({
466
+ description: "The URL for the SSO service",
467
+ }),
468
+ }),
469
+ )
470
+ .optional()
471
+ .meta({
472
+ description: "Single Sign-On service configuration",
473
+ }),
309
474
  })
310
475
  .optional(),
311
476
  spMetadata: z.object({
312
- metadata: z.string(),
477
+ metadata: z.string().optional(),
478
+ entityID: z.string().optional(),
313
479
  binding: z.string().optional(),
314
-
315
480
  privateKey: z.string().optional(),
316
481
  privateKeyPass: z.string().optional(),
317
482
  isAssertionEncrypted: z.boolean().optional(),
@@ -325,37 +490,43 @@ export const sso = (options?: SSOOptions) => {
325
490
  privateKey: z.string().optional(),
326
491
  decryptionPvk: z.string().optional(),
327
492
  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'",
493
+ mapping: z
494
+ .object({
495
+ id: z.string({}).meta({
496
+ description:
497
+ "Field mapping for user ID (defaults to 'nameID')",
498
+ }),
499
+ email: z.string({}).meta({
500
+ description:
501
+ "Field mapping for email (defaults to 'email')",
502
+ }),
503
+ emailVerified: z
504
+ .string({})
505
+ .meta({
506
+ description: "Field mapping for email verification",
507
+ })
508
+ .optional(),
509
+ name: z.string({}).meta({
510
+ description:
511
+ "Field mapping for name (defaults to 'displayName')",
512
+ }),
513
+ firstName: z
514
+ .string({})
515
+ .meta({
516
+ description:
517
+ "Field mapping for first name (defaults to 'givenName')",
518
+ })
519
+ .optional(),
520
+ lastName: z
521
+ .string({})
522
+ .meta({
523
+ description:
524
+ "Field mapping for last name (defaults to 'surname')",
525
+ })
526
+ .optional(),
527
+ extraFields: z.record(z.string(), z.any()).optional(),
356
528
  })
357
529
  .optional(),
358
- extraFields: z.record(z.string(), z.any()).optional(),
359
530
  })
360
531
  .optional(),
361
532
  organizationId: z
@@ -609,6 +780,26 @@ export const sso = (options?: SSOOptions) => {
609
780
  });
610
781
  }
611
782
  }
783
+
784
+ const existingProvider = await ctx.context.adapter.findOne({
785
+ model: "ssoProvider",
786
+ where: [
787
+ {
788
+ field: "providerId",
789
+ value: body.providerId,
790
+ },
791
+ ],
792
+ });
793
+
794
+ if (existingProvider) {
795
+ ctx.context.logger.info(
796
+ `SSO provider creation attempt with existing providerId: ${body.providerId}`,
797
+ );
798
+ throw new APIError("UNPROCESSABLE_ENTITY", {
799
+ message: "SSO provider with this providerId already exists",
800
+ });
801
+ }
802
+
612
803
  const provider = await ctx.context.adapter.create<
613
804
  Record<string, any>,
614
805
  SSOProvider
@@ -632,7 +823,7 @@ export const sso = (options?: SSOOptions) => {
632
823
  discoveryEndpoint:
633
824
  body.oidcConfig.discoveryEndpoint ||
634
825
  `${body.issuer}/.well-known/openid-configuration`,
635
- mapping: body.mapping,
826
+ mapping: body.oidcConfig.mapping,
636
827
  scopes: body.oidcConfig.scopes,
637
828
  userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
638
829
  overrideUserInfo:
@@ -657,7 +848,7 @@ export const sso = (options?: SSOOptions) => {
657
848
  privateKey: body.samlConfig.privateKey,
658
849
  decryptionPvk: body.samlConfig.decryptionPvk,
659
850
  additionalParams: body.samlConfig.additionalParams,
660
- mapping: body.mapping,
851
+ mapping: body.samlConfig.mapping,
661
852
  })
662
853
  : null,
663
854
  organizationId: body.organizationId,
@@ -665,6 +856,7 @@ export const sso = (options?: SSOOptions) => {
665
856
  providerId: body.providerId,
666
857
  },
667
858
  });
859
+
668
860
  return ctx.json({
669
861
  ...provider,
670
862
  oidcConfig: JSON.parse(
@@ -818,7 +1010,13 @@ export const sso = (options?: SSOOptions) => {
818
1010
  async (ctx) => {
819
1011
  const body = ctx.body;
820
1012
  let { email, organizationSlug, providerId, domain } = body;
821
- if (!email && !organizationSlug && !domain && !providerId) {
1013
+ if (
1014
+ !options?.defaultSSO?.length &&
1015
+ !email &&
1016
+ !organizationSlug &&
1017
+ !domain &&
1018
+ !providerId
1019
+ ) {
822
1020
  throw new APIError("BAD_REQUEST", {
823
1021
  message:
824
1022
  "email, organizationSlug, domain or providerId is required",
@@ -844,29 +1042,72 @@ export const sso = (options?: SSOOptions) => {
844
1042
  return res.id;
845
1043
  });
846
1044
  }
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),
1045
+ let provider: SSOProvider | null = null;
1046
+ if (options?.defaultSSO?.length) {
1047
+ // Find matching default SSO provider by providerId
1048
+ const matchingDefault = providerId
1049
+ ? options.defaultSSO.find(
1050
+ (defaultProvider) =>
1051
+ defaultProvider.providerId === providerId,
1052
+ )
1053
+ : options.defaultSSO.find(
1054
+ (defaultProvider) => defaultProvider.domain === domain,
1055
+ );
1056
+
1057
+ if (matchingDefault) {
1058
+ provider = {
1059
+ issuer:
1060
+ matchingDefault.samlConfig?.issuer ||
1061
+ matchingDefault.oidcConfig?.issuer ||
1062
+ "",
1063
+ providerId: matchingDefault.providerId,
1064
+ userId: "default",
1065
+ oidcConfig: matchingDefault.oidcConfig,
1066
+ samlConfig: matchingDefault.samlConfig,
868
1067
  };
1068
+ }
1069
+ }
1070
+ if (!providerId && !orgId && !domain) {
1071
+ throw new APIError("BAD_REQUEST", {
1072
+ message: "providerId, orgId or domain is required",
869
1073
  });
1074
+ }
1075
+ // Try to find provider in database
1076
+ if (!provider) {
1077
+ provider = await ctx.context.adapter
1078
+ .findOne<SSOProvider>({
1079
+ model: "ssoProvider",
1080
+ where: [
1081
+ {
1082
+ field: providerId
1083
+ ? "providerId"
1084
+ : orgId
1085
+ ? "organizationId"
1086
+ : "domain",
1087
+ value: providerId || orgId || domain!,
1088
+ },
1089
+ ],
1090
+ })
1091
+ .then((res) => {
1092
+ if (!res) {
1093
+ return null;
1094
+ }
1095
+ return {
1096
+ ...res,
1097
+ oidcConfig: res.oidcConfig
1098
+ ? safeJsonParse<OIDCConfig>(
1099
+ res.oidcConfig as unknown as string,
1100
+ ) || undefined
1101
+ : undefined,
1102
+ samlConfig: res.samlConfig
1103
+ ? safeJsonParse<SAMLConfig>(
1104
+ res.samlConfig as unknown as string,
1105
+ ) || undefined
1106
+ : undefined,
1107
+ };
1108
+ });
1109
+ }
1110
+
870
1111
  if (!provider) {
871
1112
  throw new APIError("NOT_FOUND", {
872
1113
  message: "No provider found for the issuer",
@@ -898,13 +1139,14 @@ export const sso = (options?: SSOOptions) => {
898
1139
  codeVerifier: provider.oidcConfig.pkce
899
1140
  ? state.codeVerifier
900
1141
  : undefined,
901
- scopes: ctx.body.scopes || [
902
- "openid",
903
- "email",
904
- "profile",
905
- "offline_access",
906
- ],
907
- authorizationEndpoint: provider.oidcConfig.authorizationEndpoint,
1142
+ scopes: ctx.body.scopes ||
1143
+ provider.oidcConfig.scopes || [
1144
+ "openid",
1145
+ "email",
1146
+ "profile",
1147
+ "offline_access",
1148
+ ],
1149
+ authorizationEndpoint: provider.oidcConfig.authorizationEndpoint!,
908
1150
  });
909
1151
  return ctx.json({
910
1152
  url: authorizationURL.toString(),
@@ -912,15 +1154,28 @@ export const sso = (options?: SSOOptions) => {
912
1154
  });
913
1155
  }
914
1156
  if (provider.samlConfig) {
915
- const parsedSamlConfig = JSON.parse(
916
- provider.samlConfig as unknown as string,
917
- );
1157
+ const parsedSamlConfig =
1158
+ typeof provider.samlConfig === "object"
1159
+ ? provider.samlConfig
1160
+ : safeJsonParse<SAMLConfig>(
1161
+ provider.samlConfig as unknown as string,
1162
+ );
1163
+ if (!parsedSamlConfig) {
1164
+ throw new APIError("BAD_REQUEST", {
1165
+ message: "Invalid SAML configuration",
1166
+ });
1167
+ }
918
1168
  const sp = saml.ServiceProvider({
919
1169
  metadata: parsedSamlConfig.spMetadata.metadata,
920
1170
  allowCreate: true,
921
1171
  });
1172
+
922
1173
  const idp = saml.IdentityProvider({
923
- metadata: parsedSamlConfig.idpMetadata.metadata,
1174
+ metadata: parsedSamlConfig.idpMetadata?.metadata,
1175
+ entityID: parsedSamlConfig.idpMetadata?.entityID,
1176
+ encryptCert: parsedSamlConfig.idpMetadata?.cert,
1177
+ singleSignOnService:
1178
+ parsedSamlConfig.idpMetadata?.singleSignOnService,
924
1179
  });
925
1180
  const loginRequest = sp.createLoginRequest(
926
1181
  idp,
@@ -985,27 +1240,44 @@ export const sso = (options?: SSOOptions) => {
985
1240
  }?error=${error}&error_description=${error_description}`,
986
1241
  );
987
1242
  }
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
- });
1243
+ let provider: SSOProvider | null = null;
1244
+ if (options?.defaultSSO?.length) {
1245
+ const matchingDefault = options.defaultSSO.find(
1246
+ (defaultProvider) =>
1247
+ defaultProvider.providerId === ctx.params.providerId,
1248
+ );
1249
+ if (matchingDefault) {
1250
+ provider = {
1251
+ ...matchingDefault,
1252
+ issuer: matchingDefault.oidcConfig?.issuer || "",
1253
+ userId: "default",
1254
+ };
1255
+ }
1256
+ }
1257
+ if (!provider) {
1258
+ provider = await ctx.context.adapter
1259
+ .findOne<{
1260
+ oidcConfig: string;
1261
+ }>({
1262
+ model: "ssoProvider",
1263
+ where: [
1264
+ {
1265
+ field: "providerId",
1266
+ value: ctx.params.providerId,
1267
+ },
1268
+ ],
1269
+ })
1270
+ .then((res) => {
1271
+ if (!res) {
1272
+ return null;
1273
+ }
1274
+ return {
1275
+ ...res,
1276
+ oidcConfig:
1277
+ safeJsonParse<OIDCConfig>(res.oidcConfig) || undefined,
1278
+ } as SSOProvider;
1279
+ });
1280
+ }
1009
1281
  if (!provider) {
1010
1282
  throw ctx.redirect(
1011
1283
  `${
@@ -1305,72 +1577,534 @@ export const sso = (options?: SSOOptions) => {
1305
1577
  async (ctx) => {
1306
1578
  const { SAMLResponse, RelayState } = ctx.body;
1307
1579
  const { providerId } = ctx.params;
1308
- const provider = await ctx.context.adapter.findOne<SSOProvider>({
1309
- model: "ssoProvider",
1310
- where: [{ field: "providerId", value: providerId }],
1311
- });
1580
+ let provider: SSOProvider | null = null;
1581
+ if (options?.defaultSSO?.length) {
1582
+ const matchingDefault = options.defaultSSO.find(
1583
+ (defaultProvider) => defaultProvider.providerId === providerId,
1584
+ );
1585
+ if (matchingDefault) {
1586
+ provider = {
1587
+ ...matchingDefault,
1588
+ userId: "default",
1589
+ issuer: matchingDefault.samlConfig?.issuer || "",
1590
+ };
1591
+ }
1592
+ }
1593
+ if (!provider) {
1594
+ provider = await ctx.context.adapter
1595
+ .findOne<SSOProvider>({
1596
+ model: "ssoProvider",
1597
+ where: [{ field: "providerId", value: providerId }],
1598
+ })
1599
+ .then((res) => {
1600
+ if (!res) return null;
1601
+ return {
1602
+ ...res,
1603
+ samlConfig: res.samlConfig
1604
+ ? safeJsonParse<SAMLConfig>(
1605
+ res.samlConfig as unknown as string,
1606
+ ) || undefined
1607
+ : undefined,
1608
+ };
1609
+ });
1610
+ }
1312
1611
 
1313
1612
  if (!provider) {
1314
1613
  throw new APIError("NOT_FOUND", {
1315
1614
  message: "No provider found for the given providerId",
1316
1615
  });
1317
1616
  }
1318
-
1319
- const parsedSamlConfig = JSON.parse(
1617
+ const parsedSamlConfig = safeJsonParse<SAMLConfig>(
1320
1618
  provider.samlConfig as unknown as string,
1321
1619
  );
1322
- const idp = saml.IdentityProvider({
1323
- metadata: parsedSamlConfig.idpMetadata.metadata,
1324
- });
1620
+ if (!parsedSamlConfig) {
1621
+ throw new APIError("BAD_REQUEST", {
1622
+ message: "Invalid SAML configuration",
1623
+ });
1624
+ }
1625
+ const idpData = parsedSamlConfig.idpMetadata;
1626
+ let idp: IdentityProvider | null = null;
1627
+
1628
+ // Construct IDP with fallback to manual configuration
1629
+ if (!idpData?.metadata) {
1630
+ idp = saml.IdentityProvider({
1631
+ entityID: idpData?.entityID || parsedSamlConfig.issuer,
1632
+ singleSignOnService: [
1633
+ {
1634
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1635
+ Location: parsedSamlConfig.entryPoint,
1636
+ },
1637
+ ],
1638
+ signingCert: idpData?.cert || parsedSamlConfig.cert,
1639
+ wantAuthnRequestsSigned:
1640
+ parsedSamlConfig.wantAssertionsSigned || false,
1641
+ isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
1642
+ encPrivateKey: idpData?.encPrivateKey,
1643
+ encPrivateKeyPass: idpData?.encPrivateKeyPass,
1644
+ });
1645
+ } else {
1646
+ idp = saml.IdentityProvider({
1647
+ metadata: idpData.metadata,
1648
+ privateKey: idpData.privateKey,
1649
+ privateKeyPass: idpData.privateKeyPass,
1650
+ isAssertionEncrypted: idpData.isAssertionEncrypted,
1651
+ encPrivateKey: idpData.encPrivateKey,
1652
+ encPrivateKeyPass: idpData.encPrivateKeyPass,
1653
+ });
1654
+ }
1655
+
1656
+ // Construct SP with fallback to manual configuration
1657
+ const spData = parsedSamlConfig.spMetadata;
1325
1658
  const sp = saml.ServiceProvider({
1326
- metadata: parsedSamlConfig.spMetadata.metadata,
1659
+ metadata: spData?.metadata,
1660
+ entityID: spData?.entityID || parsedSamlConfig.issuer,
1661
+ assertionConsumerService: spData?.metadata
1662
+ ? undefined
1663
+ : [
1664
+ {
1665
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1666
+ Location: parsedSamlConfig.callbackUrl,
1667
+ },
1668
+ ],
1669
+ privateKey: spData?.privateKey || parsedSamlConfig.privateKey,
1670
+ privateKeyPass: spData?.privateKeyPass,
1671
+ isAssertionEncrypted: spData?.isAssertionEncrypted || false,
1672
+ encPrivateKey: spData?.encPrivateKey,
1673
+ encPrivateKeyPass: spData?.encPrivateKeyPass,
1674
+ wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1675
+ nameIDFormat: parsedSamlConfig.identifierFormat
1676
+ ? [parsedSamlConfig.identifierFormat]
1677
+ : undefined,
1327
1678
  });
1679
+
1328
1680
  let parsedResponse: FlowResult;
1329
1681
  try {
1330
- parsedResponse = await sp.parseLoginResponse(idp, "post", {
1331
- body: { SAMLResponse, RelayState },
1332
- });
1682
+ const decodedResponse = Buffer.from(
1683
+ SAMLResponse,
1684
+ "base64",
1685
+ ).toString("utf-8");
1333
1686
 
1334
- if (!parsedResponse) {
1335
- throw new Error("Empty SAML response");
1687
+ try {
1688
+ parsedResponse = await sp.parseLoginResponse(idp, "post", {
1689
+ body: {
1690
+ SAMLResponse,
1691
+ RelayState: RelayState || undefined,
1692
+ },
1693
+ });
1694
+ } catch (parseError) {
1695
+ const nameIDMatch = decodedResponse.match(
1696
+ /<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/,
1697
+ );
1698
+ if (!nameIDMatch) throw parseError;
1699
+ parsedResponse = {
1700
+ extract: {
1701
+ nameID: nameIDMatch[1],
1702
+ attributes: { nameID: nameIDMatch[1] },
1703
+ sessionIndex: {},
1704
+ conditions: {},
1705
+ },
1706
+ } as FlowResult;
1707
+ }
1708
+
1709
+ if (!parsedResponse?.extract) {
1710
+ throw new Error("Invalid SAML response structure");
1336
1711
  }
1337
1712
  } catch (error) {
1338
- ctx.context.logger.error("SAML response validation failed", error);
1713
+ ctx.context.logger.error("SAML response validation failed", {
1714
+ error,
1715
+ decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
1716
+ "utf-8",
1717
+ ),
1718
+ });
1339
1719
  throw new APIError("BAD_REQUEST", {
1340
1720
  message: "Invalid SAML response",
1341
1721
  details: error instanceof Error ? error.message : String(error),
1342
1722
  });
1343
1723
  }
1344
- const { extract } = parsedResponse;
1345
- const attributes = parsedResponse.extract.attributes;
1346
- const mapping = parsedSamlConfig?.mapping ?? {};
1724
+
1725
+ const { extract } = parsedResponse!;
1726
+ const attributes = extract.attributes || {};
1727
+ const mapping = parsedSamlConfig.mapping ?? {};
1728
+
1347
1729
  const userInfo = {
1348
1730
  ...Object.fromEntries(
1349
1731
  Object.entries(mapping.extraFields || {}).map(([key, value]) => [
1350
1732
  key,
1351
- extract.attributes[value as string],
1733
+ attributes[value as string],
1352
1734
  ]),
1353
1735
  ),
1354
- id: attributes[mapping.id] || attributes["nameID"],
1355
- email:
1356
- attributes[mapping.email] ||
1357
- attributes["nameID"] ||
1358
- attributes["email"],
1736
+ id: attributes[mapping.id || "nameID"] || extract.nameID,
1737
+ email: attributes[mapping.email || "email"] || extract.nameID,
1359
1738
  name:
1360
1739
  [
1361
- attributes[mapping.firstName] || attributes["givenName"],
1362
- attributes[mapping.lastName] || attributes["surname"],
1740
+ attributes[mapping.firstName || "givenName"],
1741
+ attributes[mapping.lastName || "surname"],
1363
1742
  ]
1364
1743
  .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,
1744
+ .join(" ") ||
1745
+ attributes[mapping.name || "displayName"] ||
1746
+ extract.nameID,
1747
+ emailVerified:
1748
+ options?.trustEmailVerified && mapping.emailVerified
1749
+ ? ((attributes[mapping.emailVerified] || false) as boolean)
1750
+ : false,
1370
1751
  };
1752
+ if (!userInfo.id || !userInfo.email) {
1753
+ ctx.context.logger.error(
1754
+ "Missing essential user info from SAML response",
1755
+ {
1756
+ attributes: Object.keys(attributes),
1757
+ mapping,
1758
+ extractedId: userInfo.id,
1759
+ extractedEmail: userInfo.email,
1760
+ },
1761
+ );
1762
+ throw new APIError("BAD_REQUEST", {
1763
+ message: "Unable to extract user ID or email from SAML response",
1764
+ });
1765
+ }
1371
1766
 
1767
+ // Find or create user
1372
1768
  let user: User;
1769
+ const existingUser = await ctx.context.adapter.findOne<User>({
1770
+ model: "user",
1771
+ where: [
1772
+ {
1773
+ field: "email",
1774
+ value: userInfo.email,
1775
+ },
1776
+ ],
1777
+ });
1373
1778
 
1779
+ if (existingUser) {
1780
+ user = existingUser;
1781
+ } else {
1782
+ user = await ctx.context.adapter.create({
1783
+ model: "user",
1784
+ data: {
1785
+ email: userInfo.email,
1786
+ name: userInfo.name,
1787
+ emailVerified: userInfo.emailVerified,
1788
+ createdAt: new Date(),
1789
+ updatedAt: new Date(),
1790
+ },
1791
+ });
1792
+ }
1793
+
1794
+ // Create or update account link
1795
+ const account = await ctx.context.adapter.findOne<Account>({
1796
+ model: "account",
1797
+ where: [
1798
+ { field: "userId", value: user.id },
1799
+ { field: "providerId", value: provider.providerId },
1800
+ { field: "accountId", value: userInfo.id },
1801
+ ],
1802
+ });
1803
+
1804
+ if (!account) {
1805
+ await ctx.context.adapter.create<Account>({
1806
+ model: "account",
1807
+ data: {
1808
+ userId: user.id,
1809
+ providerId: provider.providerId,
1810
+ accountId: userInfo.id,
1811
+ createdAt: new Date(),
1812
+ updatedAt: new Date(),
1813
+ accessToken: "",
1814
+ refreshToken: "",
1815
+ },
1816
+ });
1817
+ }
1818
+
1819
+ // Run provision hooks
1820
+ if (options?.provisionUser) {
1821
+ await options.provisionUser({
1822
+ user: user as User & Record<string, any>,
1823
+ userInfo,
1824
+ provider,
1825
+ });
1826
+ }
1827
+
1828
+ // Handle organization provisioning
1829
+ if (
1830
+ provider.organizationId &&
1831
+ !options?.organizationProvisioning?.disabled
1832
+ ) {
1833
+ const isOrgPluginEnabled = ctx.context.options.plugins?.find(
1834
+ (plugin) => plugin.id === "organization",
1835
+ );
1836
+ if (isOrgPluginEnabled) {
1837
+ const isAlreadyMember = await ctx.context.adapter.findOne({
1838
+ model: "member",
1839
+ where: [
1840
+ { field: "organizationId", value: provider.organizationId },
1841
+ { field: "userId", value: user.id },
1842
+ ],
1843
+ });
1844
+ if (!isAlreadyMember) {
1845
+ const role = options?.organizationProvisioning?.getRole
1846
+ ? await options.organizationProvisioning.getRole({
1847
+ user,
1848
+ userInfo,
1849
+ provider,
1850
+ })
1851
+ : options?.organizationProvisioning?.defaultRole || "member";
1852
+ await ctx.context.adapter.create({
1853
+ model: "member",
1854
+ data: {
1855
+ organizationId: provider.organizationId,
1856
+ userId: user.id,
1857
+ role,
1858
+ createdAt: new Date(),
1859
+ updatedAt: new Date(),
1860
+ },
1861
+ });
1862
+ }
1863
+ }
1864
+ }
1865
+
1866
+ // Create session and set cookie
1867
+ let session: Session =
1868
+ await ctx.context.internalAdapter.createSession(user.id, ctx);
1869
+ await setSessionCookie(ctx, { session, user });
1870
+
1871
+ // Redirect to callback URL
1872
+ const callbackUrl =
1873
+ RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1874
+ throw ctx.redirect(callbackUrl);
1875
+ },
1876
+ ),
1877
+ acsEndpoint: createAuthEndpoint(
1878
+ "/sso/saml2/sp/acs/:providerId",
1879
+ {
1880
+ method: "POST",
1881
+ params: z.object({
1882
+ providerId: z.string().optional(),
1883
+ }),
1884
+ body: z.object({
1885
+ SAMLResponse: z.string(),
1886
+ RelayState: z.string().optional(),
1887
+ }),
1888
+ metadata: {
1889
+ isAction: false,
1890
+ openapi: {
1891
+ summary: "SAML Assertion Consumer Service",
1892
+ description:
1893
+ "Handles SAML responses from IdP after successful authentication",
1894
+ responses: {
1895
+ "302": {
1896
+ description:
1897
+ "Redirects to the callback URL after successful authentication",
1898
+ },
1899
+ },
1900
+ },
1901
+ },
1902
+ },
1903
+ async (ctx) => {
1904
+ const { SAMLResponse, RelayState = "" } = ctx.body;
1905
+ const { providerId } = ctx.params;
1906
+
1907
+ // If defaultSSO is configured, use it as the provider
1908
+ let provider: SSOProvider | null = null;
1909
+
1910
+ if (options?.defaultSSO?.length) {
1911
+ // For ACS endpoint, we can use the first default provider or try to match by providerId
1912
+ const matchingDefault = providerId
1913
+ ? options.defaultSSO.find(
1914
+ (defaultProvider) =>
1915
+ defaultProvider.providerId === providerId,
1916
+ )
1917
+ : options.defaultSSO[0]; // Use first default provider if no specific providerId
1918
+
1919
+ if (matchingDefault) {
1920
+ provider = {
1921
+ issuer: matchingDefault.samlConfig?.issuer || "",
1922
+ providerId: matchingDefault.providerId,
1923
+ userId: "default",
1924
+ samlConfig: matchingDefault.samlConfig,
1925
+ };
1926
+ }
1927
+ } else {
1928
+ provider = await ctx.context.adapter
1929
+ .findOne<SSOProvider>({
1930
+ model: "ssoProvider",
1931
+ where: [
1932
+ {
1933
+ field: "providerId",
1934
+ value: providerId ?? "sso",
1935
+ },
1936
+ ],
1937
+ })
1938
+ .then((res) => {
1939
+ if (!res) return null;
1940
+ return {
1941
+ ...res,
1942
+ samlConfig: res.samlConfig
1943
+ ? safeJsonParse<SAMLConfig>(
1944
+ res.samlConfig as unknown as string,
1945
+ ) || undefined
1946
+ : undefined,
1947
+ };
1948
+ });
1949
+ }
1950
+
1951
+ if (!provider?.samlConfig) {
1952
+ throw new APIError("NOT_FOUND", {
1953
+ message: "No SAML provider found",
1954
+ });
1955
+ }
1956
+
1957
+ const parsedSamlConfig = provider.samlConfig;
1958
+ // Configure SP and IdP
1959
+ const sp = saml.ServiceProvider({
1960
+ entityID:
1961
+ parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
1962
+ assertionConsumerService: [
1963
+ {
1964
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1965
+ Location:
1966
+ parsedSamlConfig.callbackUrl ||
1967
+ `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`,
1968
+ },
1969
+ ],
1970
+ wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1971
+ metadata: parsedSamlConfig.spMetadata?.metadata,
1972
+ privateKey:
1973
+ parsedSamlConfig.spMetadata?.privateKey ||
1974
+ parsedSamlConfig.privateKey,
1975
+ privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
1976
+ nameIDFormat: parsedSamlConfig.identifierFormat
1977
+ ? [parsedSamlConfig.identifierFormat]
1978
+ : undefined,
1979
+ });
1980
+
1981
+ // Update where we construct the IdP
1982
+ const idpData = parsedSamlConfig.idpMetadata;
1983
+ const idp = !idpData?.metadata
1984
+ ? saml.IdentityProvider({
1985
+ entityID: idpData?.entityID || parsedSamlConfig.issuer,
1986
+ singleSignOnService: idpData?.singleSignOnService || [
1987
+ {
1988
+ Binding:
1989
+ "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1990
+ Location: parsedSamlConfig.entryPoint,
1991
+ },
1992
+ ],
1993
+ signingCert: idpData?.cert || parsedSamlConfig.cert,
1994
+ })
1995
+ : saml.IdentityProvider({
1996
+ metadata: idpData.metadata,
1997
+ });
1998
+
1999
+ // Parse and validate SAML response
2000
+ let parsedResponse: FlowResult;
2001
+ try {
2002
+ let decodedResponse = Buffer.from(SAMLResponse, "base64").toString(
2003
+ "utf-8",
2004
+ );
2005
+
2006
+ // Patch the SAML response if status is missing or not success
2007
+ if (!decodedResponse.includes("StatusCode")) {
2008
+ // Insert a success status if missing
2009
+ const insertPoint = decodedResponse.indexOf("</saml2:Issuer>");
2010
+ if (insertPoint !== -1) {
2011
+ decodedResponse =
2012
+ decodedResponse.slice(0, insertPoint + 14) +
2013
+ '<saml2:Status><saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></saml2:Status>' +
2014
+ decodedResponse.slice(insertPoint + 14);
2015
+ }
2016
+ } else if (!decodedResponse.includes("saml2:Success")) {
2017
+ // Replace existing non-success status with success
2018
+ decodedResponse = decodedResponse.replace(
2019
+ /<saml2:StatusCode Value="[^"]+"/,
2020
+ '<saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"',
2021
+ );
2022
+ }
2023
+
2024
+ try {
2025
+ parsedResponse = await sp.parseLoginResponse(idp, "post", {
2026
+ body: {
2027
+ SAMLResponse,
2028
+ RelayState: RelayState || undefined,
2029
+ },
2030
+ });
2031
+ } catch (parseError) {
2032
+ const nameIDMatch = decodedResponse.match(
2033
+ /<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/,
2034
+ );
2035
+ // due to different spec. we have to make sure to handle that.
2036
+ if (!nameIDMatch) throw parseError;
2037
+ parsedResponse = {
2038
+ extract: {
2039
+ nameID: nameIDMatch[1],
2040
+ attributes: { nameID: nameIDMatch[1] },
2041
+ sessionIndex: {},
2042
+ conditions: {},
2043
+ },
2044
+ } as FlowResult;
2045
+ }
2046
+
2047
+ if (!parsedResponse?.extract) {
2048
+ throw new Error("Invalid SAML response structure");
2049
+ }
2050
+ } catch (error) {
2051
+ ctx.context.logger.error("SAML response validation failed", {
2052
+ error,
2053
+ decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
2054
+ "utf-8",
2055
+ ),
2056
+ });
2057
+ throw new APIError("BAD_REQUEST", {
2058
+ message: "Invalid SAML response",
2059
+ details: error instanceof Error ? error.message : String(error),
2060
+ });
2061
+ }
2062
+
2063
+ const { extract } = parsedResponse!;
2064
+ const attributes = extract.attributes || {};
2065
+ const mapping = parsedSamlConfig.mapping ?? {};
2066
+
2067
+ const userInfo = {
2068
+ ...Object.fromEntries(
2069
+ Object.entries(mapping.extraFields || {}).map(([key, value]) => [
2070
+ key,
2071
+ attributes[value as string],
2072
+ ]),
2073
+ ),
2074
+ id: attributes[mapping.id || "nameID"] || extract.nameID,
2075
+ email: attributes[mapping.email || "email"] || extract.nameID,
2076
+ name:
2077
+ [
2078
+ attributes[mapping.firstName || "givenName"],
2079
+ attributes[mapping.lastName || "surname"],
2080
+ ]
2081
+ .filter(Boolean)
2082
+ .join(" ") ||
2083
+ attributes[mapping.name || "displayName"] ||
2084
+ extract.nameID,
2085
+ emailVerified:
2086
+ options?.trustEmailVerified && mapping.emailVerified
2087
+ ? ((attributes[mapping.emailVerified] || false) as boolean)
2088
+ : false,
2089
+ };
2090
+
2091
+ if (!userInfo.id || !userInfo.email) {
2092
+ ctx.context.logger.error(
2093
+ "Missing essential user info from SAML response",
2094
+ {
2095
+ attributes: Object.keys(attributes),
2096
+ mapping,
2097
+ extractedId: userInfo.id,
2098
+ extractedEmail: userInfo.email,
2099
+ },
2100
+ );
2101
+ throw new APIError("BAD_REQUEST", {
2102
+ message: "Unable to extract user ID or email from SAML response",
2103
+ });
2104
+ }
2105
+
2106
+ // Find or create user
2107
+ let user: User;
1374
2108
  const existingUser = await ctx.context.adapter.findOne<User>({
1375
2109
  model: "user",
1376
2110
  where: [
@@ -1382,7 +2116,7 @@ export const sso = (options?: SSOOptions) => {
1382
2116
  });
1383
2117
 
1384
2118
  if (existingUser) {
1385
- const accounts = await ctx.context.adapter.findOne<Account>({
2119
+ const account = await ctx.context.adapter.findOne<Account>({
1386
2120
  model: "account",
1387
2121
  where: [
1388
2122
  { field: "userId", value: existingUser.id },
@@ -1390,7 +2124,7 @@ export const sso = (options?: SSOOptions) => {
1390
2124
  { field: "accountId", value: userInfo.id },
1391
2125
  ],
1392
2126
  });
1393
- if (!accounts) {
2127
+ if (!account) {
1394
2128
  const isTrustedProvider =
1395
2129
  ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
1396
2130
  provider.providerId,
@@ -1492,11 +2226,10 @@ export const sso = (options?: SSOOptions) => {
1492
2226
  let session: Session =
1493
2227
  await ctx.context.internalAdapter.createSession(user.id, ctx);
1494
2228
  await setSessionCookie(ctx, { session, user });
1495
- throw ctx.redirect(
1496
- RelayState ||
1497
- `${parsedSamlConfig.callbackUrl}` ||
1498
- `${parsedSamlConfig.issuer}`,
1499
- );
2229
+
2230
+ const callbackUrl =
2231
+ RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2232
+ throw ctx.redirect(callbackUrl);
1500
2233
  },
1501
2234
  ),
1502
2235
  },