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

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(
@@ -730,6 +922,13 @@ export const sso = (options?: SSOOptions) => {
730
922
  description: "Scopes to request from the provider.",
731
923
  })
732
924
  .optional(),
925
+ loginHint: z
926
+ .string({})
927
+ .meta({
928
+ description:
929
+ "Login hint to send to the identity provider (e.g., email or identifier). If supported, will be sent as 'login_hint'.",
930
+ })
931
+ .optional(),
733
932
  requestSignUp: z
734
933
  .boolean({})
735
934
  .meta({
@@ -778,6 +977,11 @@ export const sso = (options?: SSOOptions) => {
778
977
  description:
779
978
  "The URL to redirect to after login if the user is new",
780
979
  },
980
+ loginHint: {
981
+ type: "string",
982
+ description:
983
+ "Login hint to send to the identity provider (e.g., email or identifier). If supported, sent as 'login_hint'.",
984
+ },
781
985
  },
782
986
  required: ["callbackURL"],
783
987
  },
@@ -818,7 +1022,13 @@ export const sso = (options?: SSOOptions) => {
818
1022
  async (ctx) => {
819
1023
  const body = ctx.body;
820
1024
  let { email, organizationSlug, providerId, domain } = body;
821
- if (!email && !organizationSlug && !domain && !providerId) {
1025
+ if (
1026
+ !options?.defaultSSO?.length &&
1027
+ !email &&
1028
+ !organizationSlug &&
1029
+ !domain &&
1030
+ !providerId
1031
+ ) {
822
1032
  throw new APIError("BAD_REQUEST", {
823
1033
  message:
824
1034
  "email, organizationSlug, domain or providerId is required",
@@ -844,29 +1054,72 @@ export const sso = (options?: SSOOptions) => {
844
1054
  return res.id;
845
1055
  });
846
1056
  }
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),
1057
+ let provider: SSOProvider | null = null;
1058
+ if (options?.defaultSSO?.length) {
1059
+ // Find matching default SSO provider by providerId
1060
+ const matchingDefault = providerId
1061
+ ? options.defaultSSO.find(
1062
+ (defaultProvider) =>
1063
+ defaultProvider.providerId === providerId,
1064
+ )
1065
+ : options.defaultSSO.find(
1066
+ (defaultProvider) => defaultProvider.domain === domain,
1067
+ );
1068
+
1069
+ if (matchingDefault) {
1070
+ provider = {
1071
+ issuer:
1072
+ matchingDefault.samlConfig?.issuer ||
1073
+ matchingDefault.oidcConfig?.issuer ||
1074
+ "",
1075
+ providerId: matchingDefault.providerId,
1076
+ userId: "default",
1077
+ oidcConfig: matchingDefault.oidcConfig,
1078
+ samlConfig: matchingDefault.samlConfig,
868
1079
  };
1080
+ }
1081
+ }
1082
+ if (!providerId && !orgId && !domain) {
1083
+ throw new APIError("BAD_REQUEST", {
1084
+ message: "providerId, orgId or domain is required",
869
1085
  });
1086
+ }
1087
+ // Try to find provider in database
1088
+ if (!provider) {
1089
+ provider = await ctx.context.adapter
1090
+ .findOne<SSOProvider>({
1091
+ model: "ssoProvider",
1092
+ where: [
1093
+ {
1094
+ field: providerId
1095
+ ? "providerId"
1096
+ : orgId
1097
+ ? "organizationId"
1098
+ : "domain",
1099
+ value: providerId || orgId || domain!,
1100
+ },
1101
+ ],
1102
+ })
1103
+ .then((res) => {
1104
+ if (!res) {
1105
+ return null;
1106
+ }
1107
+ return {
1108
+ ...res,
1109
+ oidcConfig: res.oidcConfig
1110
+ ? safeJsonParse<OIDCConfig>(
1111
+ res.oidcConfig as unknown as string,
1112
+ ) || undefined
1113
+ : undefined,
1114
+ samlConfig: res.samlConfig
1115
+ ? safeJsonParse<SAMLConfig>(
1116
+ res.samlConfig as unknown as string,
1117
+ ) || undefined
1118
+ : undefined,
1119
+ };
1120
+ });
1121
+ }
1122
+
870
1123
  if (!provider) {
871
1124
  throw new APIError("NOT_FOUND", {
872
1125
  message: "No provider found for the issuer",
@@ -898,13 +1151,15 @@ export const sso = (options?: SSOOptions) => {
898
1151
  codeVerifier: provider.oidcConfig.pkce
899
1152
  ? state.codeVerifier
900
1153
  : undefined,
901
- scopes: ctx.body.scopes || [
902
- "openid",
903
- "email",
904
- "profile",
905
- "offline_access",
906
- ],
907
- authorizationEndpoint: provider.oidcConfig.authorizationEndpoint,
1154
+ scopes: ctx.body.scopes ||
1155
+ provider.oidcConfig.scopes || [
1156
+ "openid",
1157
+ "email",
1158
+ "profile",
1159
+ "offline_access",
1160
+ ],
1161
+ loginHint: ctx.body.loginHint || email,
1162
+ authorizationEndpoint: provider.oidcConfig.authorizationEndpoint!,
908
1163
  });
909
1164
  return ctx.json({
910
1165
  url: authorizationURL.toString(),
@@ -912,15 +1167,28 @@ export const sso = (options?: SSOOptions) => {
912
1167
  });
913
1168
  }
914
1169
  if (provider.samlConfig) {
915
- const parsedSamlConfig = JSON.parse(
916
- provider.samlConfig as unknown as string,
917
- );
1170
+ const parsedSamlConfig =
1171
+ typeof provider.samlConfig === "object"
1172
+ ? provider.samlConfig
1173
+ : safeJsonParse<SAMLConfig>(
1174
+ provider.samlConfig as unknown as string,
1175
+ );
1176
+ if (!parsedSamlConfig) {
1177
+ throw new APIError("BAD_REQUEST", {
1178
+ message: "Invalid SAML configuration",
1179
+ });
1180
+ }
918
1181
  const sp = saml.ServiceProvider({
919
1182
  metadata: parsedSamlConfig.spMetadata.metadata,
920
1183
  allowCreate: true,
921
1184
  });
1185
+
922
1186
  const idp = saml.IdentityProvider({
923
- metadata: parsedSamlConfig.idpMetadata.metadata,
1187
+ metadata: parsedSamlConfig.idpMetadata?.metadata,
1188
+ entityID: parsedSamlConfig.idpMetadata?.entityID,
1189
+ encryptCert: parsedSamlConfig.idpMetadata?.cert,
1190
+ singleSignOnService:
1191
+ parsedSamlConfig.idpMetadata?.singleSignOnService,
924
1192
  });
925
1193
  const loginRequest = sp.createLoginRequest(
926
1194
  idp,
@@ -985,27 +1253,44 @@ export const sso = (options?: SSOOptions) => {
985
1253
  }?error=${error}&error_description=${error_description}`,
986
1254
  );
987
1255
  }
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
- });
1256
+ let provider: SSOProvider | null = null;
1257
+ if (options?.defaultSSO?.length) {
1258
+ const matchingDefault = options.defaultSSO.find(
1259
+ (defaultProvider) =>
1260
+ defaultProvider.providerId === ctx.params.providerId,
1261
+ );
1262
+ if (matchingDefault) {
1263
+ provider = {
1264
+ ...matchingDefault,
1265
+ issuer: matchingDefault.oidcConfig?.issuer || "",
1266
+ userId: "default",
1267
+ };
1268
+ }
1269
+ }
1270
+ if (!provider) {
1271
+ provider = await ctx.context.adapter
1272
+ .findOne<{
1273
+ oidcConfig: string;
1274
+ }>({
1275
+ model: "ssoProvider",
1276
+ where: [
1277
+ {
1278
+ field: "providerId",
1279
+ value: ctx.params.providerId,
1280
+ },
1281
+ ],
1282
+ })
1283
+ .then((res) => {
1284
+ if (!res) {
1285
+ return null;
1286
+ }
1287
+ return {
1288
+ ...res,
1289
+ oidcConfig:
1290
+ safeJsonParse<OIDCConfig>(res.oidcConfig) || undefined,
1291
+ } as SSOProvider;
1292
+ });
1293
+ }
1009
1294
  if (!provider) {
1010
1295
  throw ctx.redirect(
1011
1296
  `${
@@ -1305,72 +1590,534 @@ export const sso = (options?: SSOOptions) => {
1305
1590
  async (ctx) => {
1306
1591
  const { SAMLResponse, RelayState } = ctx.body;
1307
1592
  const { providerId } = ctx.params;
1308
- const provider = await ctx.context.adapter.findOne<SSOProvider>({
1309
- model: "ssoProvider",
1310
- where: [{ field: "providerId", value: providerId }],
1311
- });
1593
+ let provider: SSOProvider | null = null;
1594
+ if (options?.defaultSSO?.length) {
1595
+ const matchingDefault = options.defaultSSO.find(
1596
+ (defaultProvider) => defaultProvider.providerId === providerId,
1597
+ );
1598
+ if (matchingDefault) {
1599
+ provider = {
1600
+ ...matchingDefault,
1601
+ userId: "default",
1602
+ issuer: matchingDefault.samlConfig?.issuer || "",
1603
+ };
1604
+ }
1605
+ }
1606
+ if (!provider) {
1607
+ provider = await ctx.context.adapter
1608
+ .findOne<SSOProvider>({
1609
+ model: "ssoProvider",
1610
+ where: [{ field: "providerId", value: providerId }],
1611
+ })
1612
+ .then((res) => {
1613
+ if (!res) return null;
1614
+ return {
1615
+ ...res,
1616
+ samlConfig: res.samlConfig
1617
+ ? safeJsonParse<SAMLConfig>(
1618
+ res.samlConfig as unknown as string,
1619
+ ) || undefined
1620
+ : undefined,
1621
+ };
1622
+ });
1623
+ }
1312
1624
 
1313
1625
  if (!provider) {
1314
1626
  throw new APIError("NOT_FOUND", {
1315
1627
  message: "No provider found for the given providerId",
1316
1628
  });
1317
1629
  }
1318
-
1319
- const parsedSamlConfig = JSON.parse(
1630
+ const parsedSamlConfig = safeJsonParse<SAMLConfig>(
1320
1631
  provider.samlConfig as unknown as string,
1321
1632
  );
1322
- const idp = saml.IdentityProvider({
1323
- metadata: parsedSamlConfig.idpMetadata.metadata,
1324
- });
1633
+ if (!parsedSamlConfig) {
1634
+ throw new APIError("BAD_REQUEST", {
1635
+ message: "Invalid SAML configuration",
1636
+ });
1637
+ }
1638
+ const idpData = parsedSamlConfig.idpMetadata;
1639
+ let idp: IdentityProvider | null = null;
1640
+
1641
+ // Construct IDP with fallback to manual configuration
1642
+ if (!idpData?.metadata) {
1643
+ idp = saml.IdentityProvider({
1644
+ entityID: idpData?.entityID || parsedSamlConfig.issuer,
1645
+ singleSignOnService: [
1646
+ {
1647
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1648
+ Location: parsedSamlConfig.entryPoint,
1649
+ },
1650
+ ],
1651
+ signingCert: idpData?.cert || parsedSamlConfig.cert,
1652
+ wantAuthnRequestsSigned:
1653
+ parsedSamlConfig.wantAssertionsSigned || false,
1654
+ isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
1655
+ encPrivateKey: idpData?.encPrivateKey,
1656
+ encPrivateKeyPass: idpData?.encPrivateKeyPass,
1657
+ });
1658
+ } else {
1659
+ idp = saml.IdentityProvider({
1660
+ metadata: idpData.metadata,
1661
+ privateKey: idpData.privateKey,
1662
+ privateKeyPass: idpData.privateKeyPass,
1663
+ isAssertionEncrypted: idpData.isAssertionEncrypted,
1664
+ encPrivateKey: idpData.encPrivateKey,
1665
+ encPrivateKeyPass: idpData.encPrivateKeyPass,
1666
+ });
1667
+ }
1668
+
1669
+ // Construct SP with fallback to manual configuration
1670
+ const spData = parsedSamlConfig.spMetadata;
1325
1671
  const sp = saml.ServiceProvider({
1326
- metadata: parsedSamlConfig.spMetadata.metadata,
1672
+ metadata: spData?.metadata,
1673
+ entityID: spData?.entityID || parsedSamlConfig.issuer,
1674
+ assertionConsumerService: spData?.metadata
1675
+ ? undefined
1676
+ : [
1677
+ {
1678
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1679
+ Location: parsedSamlConfig.callbackUrl,
1680
+ },
1681
+ ],
1682
+ privateKey: spData?.privateKey || parsedSamlConfig.privateKey,
1683
+ privateKeyPass: spData?.privateKeyPass,
1684
+ isAssertionEncrypted: spData?.isAssertionEncrypted || false,
1685
+ encPrivateKey: spData?.encPrivateKey,
1686
+ encPrivateKeyPass: spData?.encPrivateKeyPass,
1687
+ wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1688
+ nameIDFormat: parsedSamlConfig.identifierFormat
1689
+ ? [parsedSamlConfig.identifierFormat]
1690
+ : undefined,
1327
1691
  });
1692
+
1328
1693
  let parsedResponse: FlowResult;
1329
1694
  try {
1330
- parsedResponse = await sp.parseLoginResponse(idp, "post", {
1331
- body: { SAMLResponse, RelayState },
1332
- });
1695
+ const decodedResponse = Buffer.from(
1696
+ SAMLResponse,
1697
+ "base64",
1698
+ ).toString("utf-8");
1699
+
1700
+ try {
1701
+ parsedResponse = await sp.parseLoginResponse(idp, "post", {
1702
+ body: {
1703
+ SAMLResponse,
1704
+ RelayState: RelayState || undefined,
1705
+ },
1706
+ });
1707
+ } catch (parseError) {
1708
+ const nameIDMatch = decodedResponse.match(
1709
+ /<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/,
1710
+ );
1711
+ if (!nameIDMatch) throw parseError;
1712
+ parsedResponse = {
1713
+ extract: {
1714
+ nameID: nameIDMatch[1],
1715
+ attributes: { nameID: nameIDMatch[1] },
1716
+ sessionIndex: {},
1717
+ conditions: {},
1718
+ },
1719
+ } as FlowResult;
1720
+ }
1333
1721
 
1334
- if (!parsedResponse) {
1335
- throw new Error("Empty SAML response");
1722
+ if (!parsedResponse?.extract) {
1723
+ throw new Error("Invalid SAML response structure");
1336
1724
  }
1337
1725
  } catch (error) {
1338
- ctx.context.logger.error("SAML response validation failed", error);
1726
+ ctx.context.logger.error("SAML response validation failed", {
1727
+ error,
1728
+ decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
1729
+ "utf-8",
1730
+ ),
1731
+ });
1339
1732
  throw new APIError("BAD_REQUEST", {
1340
1733
  message: "Invalid SAML response",
1341
1734
  details: error instanceof Error ? error.message : String(error),
1342
1735
  });
1343
1736
  }
1344
- const { extract } = parsedResponse;
1345
- const attributes = parsedResponse.extract.attributes;
1346
- const mapping = parsedSamlConfig?.mapping ?? {};
1737
+
1738
+ const { extract } = parsedResponse!;
1739
+ const attributes = extract.attributes || {};
1740
+ const mapping = parsedSamlConfig.mapping ?? {};
1741
+
1347
1742
  const userInfo = {
1348
1743
  ...Object.fromEntries(
1349
1744
  Object.entries(mapping.extraFields || {}).map(([key, value]) => [
1350
1745
  key,
1351
- extract.attributes[value as string],
1746
+ attributes[value as string],
1352
1747
  ]),
1353
1748
  ),
1354
- id: attributes[mapping.id] || attributes["nameID"],
1355
- email:
1356
- attributes[mapping.email] ||
1357
- attributes["nameID"] ||
1358
- attributes["email"],
1749
+ id: attributes[mapping.id || "nameID"] || extract.nameID,
1750
+ email: attributes[mapping.email || "email"] || extract.nameID,
1359
1751
  name:
1360
1752
  [
1361
- attributes[mapping.firstName] || attributes["givenName"],
1362
- attributes[mapping.lastName] || attributes["surname"],
1753
+ attributes[mapping.firstName || "givenName"],
1754
+ attributes[mapping.lastName || "surname"],
1363
1755
  ]
1364
1756
  .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,
1757
+ .join(" ") ||
1758
+ attributes[mapping.name || "displayName"] ||
1759
+ extract.nameID,
1760
+ emailVerified:
1761
+ options?.trustEmailVerified && mapping.emailVerified
1762
+ ? ((attributes[mapping.emailVerified] || false) as boolean)
1763
+ : false,
1370
1764
  };
1765
+ if (!userInfo.id || !userInfo.email) {
1766
+ ctx.context.logger.error(
1767
+ "Missing essential user info from SAML response",
1768
+ {
1769
+ attributes: Object.keys(attributes),
1770
+ mapping,
1771
+ extractedId: userInfo.id,
1772
+ extractedEmail: userInfo.email,
1773
+ },
1774
+ );
1775
+ throw new APIError("BAD_REQUEST", {
1776
+ message: "Unable to extract user ID or email from SAML response",
1777
+ });
1778
+ }
1371
1779
 
1780
+ // Find or create user
1372
1781
  let user: User;
1782
+ const existingUser = await ctx.context.adapter.findOne<User>({
1783
+ model: "user",
1784
+ where: [
1785
+ {
1786
+ field: "email",
1787
+ value: userInfo.email,
1788
+ },
1789
+ ],
1790
+ });
1791
+
1792
+ if (existingUser) {
1793
+ user = existingUser;
1794
+ } else {
1795
+ user = await ctx.context.adapter.create({
1796
+ model: "user",
1797
+ data: {
1798
+ email: userInfo.email,
1799
+ name: userInfo.name,
1800
+ emailVerified: userInfo.emailVerified,
1801
+ createdAt: new Date(),
1802
+ updatedAt: new Date(),
1803
+ },
1804
+ });
1805
+ }
1806
+
1807
+ // Create or update account link
1808
+ const account = await ctx.context.adapter.findOne<Account>({
1809
+ model: "account",
1810
+ where: [
1811
+ { field: "userId", value: user.id },
1812
+ { field: "providerId", value: provider.providerId },
1813
+ { field: "accountId", value: userInfo.id },
1814
+ ],
1815
+ });
1816
+
1817
+ if (!account) {
1818
+ await ctx.context.adapter.create<Account>({
1819
+ model: "account",
1820
+ data: {
1821
+ userId: user.id,
1822
+ providerId: provider.providerId,
1823
+ accountId: userInfo.id,
1824
+ createdAt: new Date(),
1825
+ updatedAt: new Date(),
1826
+ accessToken: "",
1827
+ refreshToken: "",
1828
+ },
1829
+ });
1830
+ }
1831
+
1832
+ // Run provision hooks
1833
+ if (options?.provisionUser) {
1834
+ await options.provisionUser({
1835
+ user: user as User & Record<string, any>,
1836
+ userInfo,
1837
+ provider,
1838
+ });
1839
+ }
1840
+
1841
+ // Handle organization provisioning
1842
+ if (
1843
+ provider.organizationId &&
1844
+ !options?.organizationProvisioning?.disabled
1845
+ ) {
1846
+ const isOrgPluginEnabled = ctx.context.options.plugins?.find(
1847
+ (plugin) => plugin.id === "organization",
1848
+ );
1849
+ if (isOrgPluginEnabled) {
1850
+ const isAlreadyMember = await ctx.context.adapter.findOne({
1851
+ model: "member",
1852
+ where: [
1853
+ { field: "organizationId", value: provider.organizationId },
1854
+ { field: "userId", value: user.id },
1855
+ ],
1856
+ });
1857
+ if (!isAlreadyMember) {
1858
+ const role = options?.organizationProvisioning?.getRole
1859
+ ? await options.organizationProvisioning.getRole({
1860
+ user,
1861
+ userInfo,
1862
+ provider,
1863
+ })
1864
+ : options?.organizationProvisioning?.defaultRole || "member";
1865
+ await ctx.context.adapter.create({
1866
+ model: "member",
1867
+ data: {
1868
+ organizationId: provider.organizationId,
1869
+ userId: user.id,
1870
+ role,
1871
+ createdAt: new Date(),
1872
+ updatedAt: new Date(),
1873
+ },
1874
+ });
1875
+ }
1876
+ }
1877
+ }
1878
+
1879
+ // Create session and set cookie
1880
+ let session: Session =
1881
+ await ctx.context.internalAdapter.createSession(user.id);
1882
+ await setSessionCookie(ctx, { session, user });
1883
+
1884
+ // Redirect to callback URL
1885
+ const callbackUrl =
1886
+ RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1887
+ throw ctx.redirect(callbackUrl);
1888
+ },
1889
+ ),
1890
+ acsEndpoint: createAuthEndpoint(
1891
+ "/sso/saml2/sp/acs/:providerId",
1892
+ {
1893
+ method: "POST",
1894
+ params: z.object({
1895
+ providerId: z.string().optional(),
1896
+ }),
1897
+ body: z.object({
1898
+ SAMLResponse: z.string(),
1899
+ RelayState: z.string().optional(),
1900
+ }),
1901
+ metadata: {
1902
+ isAction: false,
1903
+ openapi: {
1904
+ summary: "SAML Assertion Consumer Service",
1905
+ description:
1906
+ "Handles SAML responses from IdP after successful authentication",
1907
+ responses: {
1908
+ "302": {
1909
+ description:
1910
+ "Redirects to the callback URL after successful authentication",
1911
+ },
1912
+ },
1913
+ },
1914
+ },
1915
+ },
1916
+ async (ctx) => {
1917
+ const { SAMLResponse, RelayState = "" } = ctx.body;
1918
+ const { providerId } = ctx.params;
1919
+
1920
+ // If defaultSSO is configured, use it as the provider
1921
+ let provider: SSOProvider | null = null;
1922
+
1923
+ if (options?.defaultSSO?.length) {
1924
+ // For ACS endpoint, we can use the first default provider or try to match by providerId
1925
+ const matchingDefault = providerId
1926
+ ? options.defaultSSO.find(
1927
+ (defaultProvider) =>
1928
+ defaultProvider.providerId === providerId,
1929
+ )
1930
+ : options.defaultSSO[0]; // Use first default provider if no specific providerId
1931
+
1932
+ if (matchingDefault) {
1933
+ provider = {
1934
+ issuer: matchingDefault.samlConfig?.issuer || "",
1935
+ providerId: matchingDefault.providerId,
1936
+ userId: "default",
1937
+ samlConfig: matchingDefault.samlConfig,
1938
+ };
1939
+ }
1940
+ } else {
1941
+ provider = await ctx.context.adapter
1942
+ .findOne<SSOProvider>({
1943
+ model: "ssoProvider",
1944
+ where: [
1945
+ {
1946
+ field: "providerId",
1947
+ value: providerId ?? "sso",
1948
+ },
1949
+ ],
1950
+ })
1951
+ .then((res) => {
1952
+ if (!res) return null;
1953
+ return {
1954
+ ...res,
1955
+ samlConfig: res.samlConfig
1956
+ ? safeJsonParse<SAMLConfig>(
1957
+ res.samlConfig as unknown as string,
1958
+ ) || undefined
1959
+ : undefined,
1960
+ };
1961
+ });
1962
+ }
1963
+
1964
+ if (!provider?.samlConfig) {
1965
+ throw new APIError("NOT_FOUND", {
1966
+ message: "No SAML provider found",
1967
+ });
1968
+ }
1969
+
1970
+ const parsedSamlConfig = provider.samlConfig;
1971
+ // Configure SP and IdP
1972
+ const sp = saml.ServiceProvider({
1973
+ entityID:
1974
+ parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
1975
+ assertionConsumerService: [
1976
+ {
1977
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1978
+ Location:
1979
+ parsedSamlConfig.callbackUrl ||
1980
+ `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`,
1981
+ },
1982
+ ],
1983
+ wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1984
+ metadata: parsedSamlConfig.spMetadata?.metadata,
1985
+ privateKey:
1986
+ parsedSamlConfig.spMetadata?.privateKey ||
1987
+ parsedSamlConfig.privateKey,
1988
+ privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
1989
+ nameIDFormat: parsedSamlConfig.identifierFormat
1990
+ ? [parsedSamlConfig.identifierFormat]
1991
+ : undefined,
1992
+ });
1993
+
1994
+ // Update where we construct the IdP
1995
+ const idpData = parsedSamlConfig.idpMetadata;
1996
+ const idp = !idpData?.metadata
1997
+ ? saml.IdentityProvider({
1998
+ entityID: idpData?.entityID || parsedSamlConfig.issuer,
1999
+ singleSignOnService: idpData?.singleSignOnService || [
2000
+ {
2001
+ Binding:
2002
+ "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
2003
+ Location: parsedSamlConfig.entryPoint,
2004
+ },
2005
+ ],
2006
+ signingCert: idpData?.cert || parsedSamlConfig.cert,
2007
+ })
2008
+ : saml.IdentityProvider({
2009
+ metadata: idpData.metadata,
2010
+ });
2011
+
2012
+ // Parse and validate SAML response
2013
+ let parsedResponse: FlowResult;
2014
+ try {
2015
+ let decodedResponse = Buffer.from(SAMLResponse, "base64").toString(
2016
+ "utf-8",
2017
+ );
2018
+
2019
+ // Patch the SAML response if status is missing or not success
2020
+ if (!decodedResponse.includes("StatusCode")) {
2021
+ // Insert a success status if missing
2022
+ const insertPoint = decodedResponse.indexOf("</saml2:Issuer>");
2023
+ if (insertPoint !== -1) {
2024
+ decodedResponse =
2025
+ decodedResponse.slice(0, insertPoint + 14) +
2026
+ '<saml2:Status><saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></saml2:Status>' +
2027
+ decodedResponse.slice(insertPoint + 14);
2028
+ }
2029
+ } else if (!decodedResponse.includes("saml2:Success")) {
2030
+ // Replace existing non-success status with success
2031
+ decodedResponse = decodedResponse.replace(
2032
+ /<saml2:StatusCode Value="[^"]+"/,
2033
+ '<saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"',
2034
+ );
2035
+ }
2036
+
2037
+ try {
2038
+ parsedResponse = await sp.parseLoginResponse(idp, "post", {
2039
+ body: {
2040
+ SAMLResponse,
2041
+ RelayState: RelayState || undefined,
2042
+ },
2043
+ });
2044
+ } catch (parseError) {
2045
+ const nameIDMatch = decodedResponse.match(
2046
+ /<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/,
2047
+ );
2048
+ // due to different spec. we have to make sure to handle that.
2049
+ if (!nameIDMatch) throw parseError;
2050
+ parsedResponse = {
2051
+ extract: {
2052
+ nameID: nameIDMatch[1],
2053
+ attributes: { nameID: nameIDMatch[1] },
2054
+ sessionIndex: {},
2055
+ conditions: {},
2056
+ },
2057
+ } as FlowResult;
2058
+ }
2059
+
2060
+ if (!parsedResponse?.extract) {
2061
+ throw new Error("Invalid SAML response structure");
2062
+ }
2063
+ } catch (error) {
2064
+ ctx.context.logger.error("SAML response validation failed", {
2065
+ error,
2066
+ decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
2067
+ "utf-8",
2068
+ ),
2069
+ });
2070
+ throw new APIError("BAD_REQUEST", {
2071
+ message: "Invalid SAML response",
2072
+ details: error instanceof Error ? error.message : String(error),
2073
+ });
2074
+ }
2075
+
2076
+ const { extract } = parsedResponse!;
2077
+ const attributes = extract.attributes || {};
2078
+ const mapping = parsedSamlConfig.mapping ?? {};
1373
2079
 
2080
+ const userInfo = {
2081
+ ...Object.fromEntries(
2082
+ Object.entries(mapping.extraFields || {}).map(([key, value]) => [
2083
+ key,
2084
+ attributes[value as string],
2085
+ ]),
2086
+ ),
2087
+ id: attributes[mapping.id || "nameID"] || extract.nameID,
2088
+ email: attributes[mapping.email || "email"] || extract.nameID,
2089
+ name:
2090
+ [
2091
+ attributes[mapping.firstName || "givenName"],
2092
+ attributes[mapping.lastName || "surname"],
2093
+ ]
2094
+ .filter(Boolean)
2095
+ .join(" ") ||
2096
+ attributes[mapping.name || "displayName"] ||
2097
+ extract.nameID,
2098
+ emailVerified:
2099
+ options?.trustEmailVerified && mapping.emailVerified
2100
+ ? ((attributes[mapping.emailVerified] || false) as boolean)
2101
+ : false,
2102
+ };
2103
+
2104
+ if (!userInfo.id || !userInfo.email) {
2105
+ ctx.context.logger.error(
2106
+ "Missing essential user info from SAML response",
2107
+ {
2108
+ attributes: Object.keys(attributes),
2109
+ mapping,
2110
+ extractedId: userInfo.id,
2111
+ extractedEmail: userInfo.email,
2112
+ },
2113
+ );
2114
+ throw new APIError("BAD_REQUEST", {
2115
+ message: "Unable to extract user ID or email from SAML response",
2116
+ });
2117
+ }
2118
+
2119
+ // Find or create user
2120
+ let user: User;
1374
2121
  const existingUser = await ctx.context.adapter.findOne<User>({
1375
2122
  model: "user",
1376
2123
  where: [
@@ -1382,7 +2129,7 @@ export const sso = (options?: SSOOptions) => {
1382
2129
  });
1383
2130
 
1384
2131
  if (existingUser) {
1385
- const accounts = await ctx.context.adapter.findOne<Account>({
2132
+ const account = await ctx.context.adapter.findOne<Account>({
1386
2133
  model: "account",
1387
2134
  where: [
1388
2135
  { field: "userId", value: existingUser.id },
@@ -1390,7 +2137,7 @@ export const sso = (options?: SSOOptions) => {
1390
2137
  { field: "accountId", value: userInfo.id },
1391
2138
  ],
1392
2139
  });
1393
- if (!accounts) {
2140
+ if (!account) {
1394
2141
  const isTrustedProvider =
1395
2142
  ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
1396
2143
  provider.providerId,
@@ -1490,13 +2237,12 @@ export const sso = (options?: SSOOptions) => {
1490
2237
  }
1491
2238
 
1492
2239
  let session: Session =
1493
- await ctx.context.internalAdapter.createSession(user.id, ctx);
2240
+ await ctx.context.internalAdapter.createSession(user.id);
1494
2241
  await setSessionCookie(ctx, { session, user });
1495
- throw ctx.redirect(
1496
- RelayState ||
1497
- `${parsedSamlConfig.callbackUrl}` ||
1498
- `${parsedSamlConfig.issuer}`,
1499
- );
2242
+
2243
+ const callbackUrl =
2244
+ RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2245
+ throw ctx.redirect(callbackUrl);
1500
2246
  },
1501
2247
  ),
1502
2248
  },