@better-auth/sso 1.3.13 → 1.3.14

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/dist/index.mjs CHANGED
@@ -109,7 +109,25 @@ const sso = (options) => {
109
109
  }).optional(),
110
110
  pkce: z.boolean({}).meta({
111
111
  description: "Whether to use PKCE for the authorization flow"
112
- }).default(true).optional()
112
+ }).default(true).optional(),
113
+ mapping: z.object({
114
+ id: z.string({}).meta({
115
+ description: "Field mapping for user ID (defaults to 'sub')"
116
+ }),
117
+ email: z.string({}).meta({
118
+ description: "Field mapping for email (defaults to 'email')"
119
+ }),
120
+ emailVerified: z.string({}).meta({
121
+ description: "Field mapping for email verification (defaults to 'email_verified')"
122
+ }).optional(),
123
+ name: z.string({}).meta({
124
+ description: "Field mapping for name (defaults to 'name')"
125
+ }),
126
+ image: z.string({}).meta({
127
+ description: "Field mapping for image (defaults to 'picture')"
128
+ }).optional(),
129
+ extraFields: z.record(z.string(), z.any()).optional()
130
+ }).optional()
113
131
  }).optional(),
114
132
  samlConfig: z.object({
115
133
  entryPoint: z.string({}).meta({
@@ -123,15 +141,30 @@ const sso = (options) => {
123
141
  }),
124
142
  audience: z.string().optional(),
125
143
  idpMetadata: z.object({
126
- metadata: z.string(),
144
+ metadata: z.string().optional(),
145
+ entityID: z.string().optional(),
146
+ cert: z.string().optional(),
127
147
  privateKey: z.string().optional(),
128
148
  privateKeyPass: z.string().optional(),
129
149
  isAssertionEncrypted: z.boolean().optional(),
130
150
  encPrivateKey: z.string().optional(),
131
- encPrivateKeyPass: z.string().optional()
151
+ encPrivateKeyPass: z.string().optional(),
152
+ singleSignOnService: z.array(
153
+ z.object({
154
+ Binding: z.string().meta({
155
+ description: "The binding type for the SSO service"
156
+ }),
157
+ Location: z.string().meta({
158
+ description: "The URL for the SSO service"
159
+ })
160
+ })
161
+ ).optional().meta({
162
+ description: "Single Sign-On service configuration"
163
+ })
132
164
  }).optional(),
133
165
  spMetadata: z.object({
134
- metadata: z.string(),
166
+ metadata: z.string().optional(),
167
+ entityID: z.string().optional(),
135
168
  binding: z.string().optional(),
136
169
  privateKey: z.string().optional(),
137
170
  privateKeyPass: z.string().optional(),
@@ -145,25 +178,28 @@ const sso = (options) => {
145
178
  identifierFormat: z.string().optional(),
146
179
  privateKey: z.string().optional(),
147
180
  decryptionPvk: z.string().optional(),
148
- additionalParams: z.record(z.string(), z.any()).optional()
149
- }).optional(),
150
- mapping: z.object({
151
- id: z.string({}).meta({
152
- description: "The field in the user info response that contains the id. Defaults to 'sub'"
153
- }),
154
- email: z.string({}).meta({
155
- description: "The field in the user info response that contains the email. Defaults to 'email'"
156
- }),
157
- emailVerified: z.string({}).meta({
158
- description: "The field in the user info response that contains whether the email is verified. defaults to 'email_verified'"
159
- }).optional(),
160
- name: z.string({}).meta({
161
- description: "The field in the user info response that contains the name. Defaults to 'name'"
162
- }),
163
- image: z.string({}).meta({
164
- description: "The field in the user info response that contains the image. Defaults to 'picture'"
165
- }).optional(),
166
- extraFields: z.record(z.string(), z.any()).optional()
181
+ additionalParams: z.record(z.string(), z.any()).optional(),
182
+ mapping: z.object({
183
+ id: z.string({}).meta({
184
+ description: "Field mapping for user ID (defaults to 'nameID')"
185
+ }),
186
+ email: z.string({}).meta({
187
+ description: "Field mapping for email (defaults to 'email')"
188
+ }),
189
+ emailVerified: z.string({}).meta({
190
+ description: "Field mapping for email verification"
191
+ }).optional(),
192
+ name: z.string({}).meta({
193
+ description: "Field mapping for name (defaults to 'displayName')"
194
+ }),
195
+ firstName: z.string({}).meta({
196
+ description: "Field mapping for first name (defaults to 'givenName')"
197
+ }).optional(),
198
+ lastName: z.string({}).meta({
199
+ description: "Field mapping for last name (defaults to 'surname')"
200
+ }).optional(),
201
+ extraFields: z.record(z.string(), z.any()).optional()
202
+ }).optional()
167
203
  }).optional(),
168
204
  organizationId: z.string({}).meta({
169
205
  description: "If organization plugin is enabled, the organization id to link the provider to"
@@ -400,7 +436,7 @@ const sso = (options) => {
400
436
  jwksEndpoint: body.oidcConfig.jwksEndpoint,
401
437
  pkce: body.oidcConfig.pkce,
402
438
  discoveryEndpoint: body.oidcConfig.discoveryEndpoint || `${body.issuer}/.well-known/openid-configuration`,
403
- mapping: body.mapping,
439
+ mapping: body.oidcConfig.mapping,
404
440
  scopes: body.oidcConfig.scopes,
405
441
  userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
406
442
  overrideUserInfo: ctx.body.overrideUserInfo || options?.defaultOverrideUserInfo || false
@@ -420,7 +456,7 @@ const sso = (options) => {
420
456
  privateKey: body.samlConfig.privateKey,
421
457
  decryptionPvk: body.samlConfig.decryptionPvk,
422
458
  additionalParams: body.samlConfig.additionalParams,
423
- mapping: body.mapping
459
+ mapping: body.samlConfig.mapping
424
460
  }) : null,
425
461
  organizationId: body.organizationId,
426
462
  userId: ctx.context.session.user.id,
@@ -544,7 +580,7 @@ const sso = (options) => {
544
580
  async (ctx) => {
545
581
  const body = ctx.body;
546
582
  let { email, organizationSlug, providerId, domain } = body;
547
- if (!email && !organizationSlug && !domain && !providerId) {
583
+ if (!options?.defaultSSO?.length && !email && !organizationSlug && !domain && !providerId) {
548
584
  throw new APIError("BAD_REQUEST", {
549
585
  message: "email, organizationSlug, domain or providerId is required"
550
586
  });
@@ -567,23 +603,48 @@ const sso = (options) => {
567
603
  return res.id;
568
604
  });
569
605
  }
570
- const provider = await ctx.context.adapter.findOne({
571
- model: "ssoProvider",
572
- where: [
573
- {
574
- field: providerId ? "providerId" : orgId ? "organizationId" : "domain",
575
- value: providerId || orgId || domain
576
- }
577
- ]
578
- }).then((res) => {
579
- if (!res) {
580
- return null;
606
+ let provider = null;
607
+ if (options?.defaultSSO?.length) {
608
+ const matchingDefault = providerId ? options.defaultSSO.find(
609
+ (defaultProvider) => defaultProvider.providerId === providerId
610
+ ) : options.defaultSSO.find(
611
+ (defaultProvider) => defaultProvider.domain === domain
612
+ );
613
+ if (matchingDefault) {
614
+ provider = {
615
+ issuer: matchingDefault.samlConfig?.issuer || matchingDefault.oidcConfig?.issuer || "",
616
+ providerId: matchingDefault.providerId,
617
+ userId: "default",
618
+ oidcConfig: matchingDefault.oidcConfig,
619
+ samlConfig: matchingDefault.samlConfig
620
+ };
581
621
  }
582
- return {
583
- ...res,
584
- oidcConfig: JSON.parse(res.oidcConfig)
585
- };
586
- });
622
+ }
623
+ if (!providerId && !orgId && !domain) {
624
+ throw new APIError("BAD_REQUEST", {
625
+ message: "providerId, orgId or domain is required"
626
+ });
627
+ }
628
+ if (!provider) {
629
+ provider = await ctx.context.adapter.findOne({
630
+ model: "ssoProvider",
631
+ where: [
632
+ {
633
+ field: providerId ? "providerId" : orgId ? "organizationId" : "domain",
634
+ value: providerId || orgId || domain
635
+ }
636
+ ]
637
+ }).then((res) => {
638
+ if (!res) {
639
+ return null;
640
+ }
641
+ return {
642
+ ...res,
643
+ oidcConfig: res.oidcConfig ? JSON.parse(res.oidcConfig) : void 0,
644
+ samlConfig: res.samlConfig ? JSON.parse(res.samlConfig) : void 0
645
+ };
646
+ });
647
+ }
587
648
  if (!provider) {
588
649
  throw new APIError("NOT_FOUND", {
589
650
  message: "No provider found for the issuer"
@@ -627,15 +688,16 @@ const sso = (options) => {
627
688
  });
628
689
  }
629
690
  if (provider.samlConfig) {
630
- const parsedSamlConfig = JSON.parse(
631
- provider.samlConfig
632
- );
691
+ const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : JSON.parse(provider.samlConfig);
633
692
  const sp = saml.ServiceProvider({
634
693
  metadata: parsedSamlConfig.spMetadata.metadata,
635
694
  allowCreate: true
636
695
  });
637
696
  const idp = saml.IdentityProvider({
638
- metadata: parsedSamlConfig.idpMetadata.metadata
697
+ metadata: parsedSamlConfig.idpMetadata.metadata,
698
+ entityID: parsedSamlConfig.idpMetadata.entityID,
699
+ encryptCert: parsedSamlConfig.idpMetadata.cert,
700
+ singleSignOnService: parsedSamlConfig.idpMetadata.singleSignOnService
639
701
  });
640
702
  const loginRequest = sp.createLoginRequest(
641
703
  idp,
@@ -694,23 +756,38 @@ const sso = (options) => {
694
756
  `${errorURL || callbackURL}?error=${error}&error_description=${error_description}`
695
757
  );
696
758
  }
697
- const provider = await ctx.context.adapter.findOne({
698
- model: "ssoProvider",
699
- where: [
700
- {
701
- field: "providerId",
702
- value: ctx.params.providerId
703
- }
704
- ]
705
- }).then((res) => {
706
- if (!res) {
707
- return null;
759
+ let provider = null;
760
+ if (options?.defaultSSO?.length) {
761
+ const matchingDefault = options.defaultSSO.find(
762
+ (defaultProvider) => defaultProvider.providerId === ctx.params.providerId
763
+ );
764
+ if (matchingDefault) {
765
+ provider = {
766
+ ...matchingDefault,
767
+ issuer: matchingDefault.oidcConfig?.issuer || "",
768
+ userId: "default"
769
+ };
708
770
  }
709
- return {
710
- ...res,
711
- oidcConfig: JSON.parse(res.oidcConfig)
712
- };
713
- });
771
+ }
772
+ if (!provider) {
773
+ provider = await ctx.context.adapter.findOne({
774
+ model: "ssoProvider",
775
+ where: [
776
+ {
777
+ field: "providerId",
778
+ value: ctx.params.providerId
779
+ }
780
+ ]
781
+ }).then((res) => {
782
+ if (!res) {
783
+ return null;
784
+ }
785
+ return {
786
+ ...res,
787
+ oidcConfig: JSON.parse(res.oidcConfig)
788
+ };
789
+ });
790
+ }
714
791
  if (!provider) {
715
792
  throw ctx.redirect(
716
793
  `${errorURL || callbackURL}/error?error=invalid_provider&error_description=provider not found`
@@ -934,10 +1011,31 @@ const sso = (options) => {
934
1011
  async (ctx) => {
935
1012
  const { SAMLResponse, RelayState } = ctx.body;
936
1013
  const { providerId } = ctx.params;
937
- const provider = await ctx.context.adapter.findOne({
938
- model: "ssoProvider",
939
- where: [{ field: "providerId", value: providerId }]
940
- });
1014
+ let provider = null;
1015
+ if (options?.defaultSSO?.length) {
1016
+ const matchingDefault = options.defaultSSO.find(
1017
+ (defaultProvider) => defaultProvider.providerId === providerId
1018
+ );
1019
+ if (matchingDefault) {
1020
+ provider = {
1021
+ ...matchingDefault,
1022
+ userId: "default",
1023
+ issuer: matchingDefault.samlConfig?.issuer || ""
1024
+ };
1025
+ }
1026
+ }
1027
+ if (!provider) {
1028
+ provider = await ctx.context.adapter.findOne({
1029
+ model: "ssoProvider",
1030
+ where: [{ field: "providerId", value: providerId }]
1031
+ }).then((res) => {
1032
+ if (!res) return null;
1033
+ return {
1034
+ ...res,
1035
+ samlConfig: res.samlConfig ? JSON.parse(res.samlConfig) : void 0
1036
+ };
1037
+ });
1038
+ }
941
1039
  if (!provider) {
942
1040
  throw new APIError("NOT_FOUND", {
943
1041
  message: "No provider found for the given providerId"
@@ -946,46 +1044,387 @@ const sso = (options) => {
946
1044
  const parsedSamlConfig = JSON.parse(
947
1045
  provider.samlConfig
948
1046
  );
949
- const idp = saml.IdentityProvider({
950
- metadata: parsedSamlConfig.idpMetadata.metadata
951
- });
1047
+ const idpData = parsedSamlConfig.idpMetadata;
1048
+ let idp = null;
1049
+ if (!idpData?.metadata) {
1050
+ idp = saml.IdentityProvider({
1051
+ entityID: idpData.entityID || parsedSamlConfig.issuer,
1052
+ singleSignOnService: [
1053
+ {
1054
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1055
+ Location: parsedSamlConfig.entryPoint
1056
+ }
1057
+ ],
1058
+ signingCert: idpData.cert || parsedSamlConfig.cert,
1059
+ wantAuthnRequestsSigned: parsedSamlConfig.wantAssertionsSigned || false,
1060
+ isAssertionEncrypted: idpData.isAssertionEncrypted || false,
1061
+ encPrivateKey: idpData.encPrivateKey,
1062
+ encPrivateKeyPass: idpData.encPrivateKeyPass
1063
+ });
1064
+ } else {
1065
+ idp = saml.IdentityProvider({
1066
+ metadata: idpData.metadata,
1067
+ privateKey: idpData.privateKey,
1068
+ privateKeyPass: idpData.privateKeyPass,
1069
+ isAssertionEncrypted: idpData.isAssertionEncrypted,
1070
+ encPrivateKey: idpData.encPrivateKey,
1071
+ encPrivateKeyPass: idpData.encPrivateKeyPass
1072
+ });
1073
+ }
1074
+ const spData = parsedSamlConfig.spMetadata;
952
1075
  const sp = saml.ServiceProvider({
953
- metadata: parsedSamlConfig.spMetadata.metadata
1076
+ metadata: spData?.metadata,
1077
+ entityID: spData?.entityID || parsedSamlConfig.issuer,
1078
+ assertionConsumerService: spData?.metadata ? void 0 : [
1079
+ {
1080
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1081
+ Location: parsedSamlConfig.callbackUrl
1082
+ }
1083
+ ],
1084
+ privateKey: spData?.privateKey || parsedSamlConfig.privateKey,
1085
+ privateKeyPass: spData?.privateKeyPass,
1086
+ isAssertionEncrypted: spData?.isAssertionEncrypted || false,
1087
+ encPrivateKey: spData?.encPrivateKey,
1088
+ encPrivateKeyPass: spData?.encPrivateKeyPass,
1089
+ wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false
954
1090
  });
955
1091
  let parsedResponse;
956
1092
  try {
957
- parsedResponse = await sp.parseLoginResponse(idp, "post", {
958
- body: { SAMLResponse, RelayState }
1093
+ const decodedResponse = Buffer.from(
1094
+ SAMLResponse,
1095
+ "base64"
1096
+ ).toString("utf-8");
1097
+ try {
1098
+ parsedResponse = await sp.parseLoginResponse(idp, "post", {
1099
+ body: {
1100
+ SAMLResponse,
1101
+ RelayState: RelayState || void 0
1102
+ }
1103
+ });
1104
+ } catch (parseError) {
1105
+ const nameIDMatch = decodedResponse.match(
1106
+ /<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/
1107
+ );
1108
+ if (!nameIDMatch) throw parseError;
1109
+ parsedResponse = {
1110
+ extract: {
1111
+ nameID: nameIDMatch[1],
1112
+ attributes: { nameID: nameIDMatch[1] },
1113
+ sessionIndex: {},
1114
+ conditions: {}
1115
+ }
1116
+ };
1117
+ }
1118
+ if (!parsedResponse?.extract) {
1119
+ throw new Error("Invalid SAML response structure");
1120
+ }
1121
+ } catch (error) {
1122
+ ctx.context.logger.error("SAML response validation failed", {
1123
+ error,
1124
+ decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
1125
+ "utf-8"
1126
+ )
1127
+ });
1128
+ throw new APIError("BAD_REQUEST", {
1129
+ message: "Invalid SAML response",
1130
+ details: error instanceof Error ? error.message : String(error)
1131
+ });
1132
+ }
1133
+ const { extract } = parsedResponse;
1134
+ const attributes = extract.attributes || {};
1135
+ const mapping = parsedSamlConfig.mapping ?? {};
1136
+ const userInfo = {
1137
+ ...Object.fromEntries(
1138
+ Object.entries(mapping.extraFields || {}).map(([key, value]) => [
1139
+ key,
1140
+ attributes[value]
1141
+ ])
1142
+ ),
1143
+ id: attributes[mapping.id || "nameID"] || extract.nameID,
1144
+ email: attributes[mapping.email || "email"] || extract.nameID,
1145
+ name: [
1146
+ attributes[mapping.firstName || "givenName"],
1147
+ attributes[mapping.lastName || "surname"]
1148
+ ].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
1149
+ emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
1150
+ };
1151
+ if (!userInfo.id || !userInfo.email) {
1152
+ ctx.context.logger.error(
1153
+ "Missing essential user info from SAML response",
1154
+ {
1155
+ attributes: Object.keys(attributes),
1156
+ mapping,
1157
+ extractedId: userInfo.id,
1158
+ extractedEmail: userInfo.email
1159
+ }
1160
+ );
1161
+ throw new APIError("BAD_REQUEST", {
1162
+ message: "Unable to extract user ID or email from SAML response"
959
1163
  });
960
- if (!parsedResponse) {
961
- throw new Error("Empty SAML response");
1164
+ }
1165
+ let user;
1166
+ const existingUser = await ctx.context.adapter.findOne({
1167
+ model: "user",
1168
+ where: [
1169
+ {
1170
+ field: "email",
1171
+ value: userInfo.email
1172
+ }
1173
+ ]
1174
+ });
1175
+ if (existingUser) {
1176
+ user = existingUser;
1177
+ } else {
1178
+ user = await ctx.context.adapter.create({
1179
+ model: "user",
1180
+ data: {
1181
+ email: userInfo.email,
1182
+ name: userInfo.name,
1183
+ emailVerified: userInfo.emailVerified,
1184
+ createdAt: /* @__PURE__ */ new Date(),
1185
+ updatedAt: /* @__PURE__ */ new Date()
1186
+ }
1187
+ });
1188
+ }
1189
+ const account = await ctx.context.adapter.findOne({
1190
+ model: "account",
1191
+ where: [
1192
+ { field: "userId", value: user.id },
1193
+ { field: "providerId", value: provider.providerId },
1194
+ { field: "accountId", value: userInfo.id }
1195
+ ]
1196
+ });
1197
+ if (!account) {
1198
+ await ctx.context.adapter.create({
1199
+ model: "account",
1200
+ data: {
1201
+ userId: user.id,
1202
+ providerId: provider.providerId,
1203
+ accountId: userInfo.id,
1204
+ createdAt: /* @__PURE__ */ new Date(),
1205
+ updatedAt: /* @__PURE__ */ new Date(),
1206
+ accessToken: "",
1207
+ refreshToken: ""
1208
+ }
1209
+ });
1210
+ }
1211
+ if (options?.provisionUser) {
1212
+ await options.provisionUser({
1213
+ user,
1214
+ userInfo,
1215
+ provider
1216
+ });
1217
+ }
1218
+ if (provider.organizationId && !options?.organizationProvisioning?.disabled) {
1219
+ const isOrgPluginEnabled = ctx.context.options.plugins?.find(
1220
+ (plugin) => plugin.id === "organization"
1221
+ );
1222
+ if (isOrgPluginEnabled) {
1223
+ const isAlreadyMember = await ctx.context.adapter.findOne({
1224
+ model: "member",
1225
+ where: [
1226
+ { field: "organizationId", value: provider.organizationId },
1227
+ { field: "userId", value: user.id }
1228
+ ]
1229
+ });
1230
+ if (!isAlreadyMember) {
1231
+ const role = options?.organizationProvisioning?.getRole ? await options.organizationProvisioning.getRole({
1232
+ user,
1233
+ userInfo,
1234
+ provider
1235
+ }) : options?.organizationProvisioning?.defaultRole || "member";
1236
+ await ctx.context.adapter.create({
1237
+ model: "member",
1238
+ data: {
1239
+ organizationId: provider.organizationId,
1240
+ userId: user.id,
1241
+ role,
1242
+ createdAt: /* @__PURE__ */ new Date(),
1243
+ updatedAt: /* @__PURE__ */ new Date()
1244
+ }
1245
+ });
1246
+ }
1247
+ }
1248
+ }
1249
+ let session = await ctx.context.internalAdapter.createSession(user.id, ctx);
1250
+ await setSessionCookie(ctx, { session, user });
1251
+ const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1252
+ throw ctx.redirect(callbackUrl);
1253
+ }
1254
+ ),
1255
+ acsEndpoint: createAuthEndpoint(
1256
+ "/sso/saml2/sp/acs/:providerId",
1257
+ {
1258
+ method: "POST",
1259
+ params: z.object({
1260
+ providerId: z.string().optional()
1261
+ }),
1262
+ body: z.object({
1263
+ SAMLResponse: z.string(),
1264
+ RelayState: z.string().optional()
1265
+ }),
1266
+ metadata: {
1267
+ isAction: false,
1268
+ openapi: {
1269
+ summary: "SAML Assertion Consumer Service",
1270
+ description: "Handles SAML responses from IdP after successful authentication",
1271
+ responses: {
1272
+ "302": {
1273
+ description: "Redirects to the callback URL after successful authentication"
1274
+ }
1275
+ }
1276
+ }
1277
+ }
1278
+ },
1279
+ async (ctx) => {
1280
+ const { SAMLResponse, RelayState = "" } = ctx.body;
1281
+ const { providerId } = ctx.params;
1282
+ let provider = null;
1283
+ if (options?.defaultSSO?.length) {
1284
+ const matchingDefault = providerId ? options.defaultSSO.find(
1285
+ (defaultProvider) => defaultProvider.providerId === providerId
1286
+ ) : options.defaultSSO[0];
1287
+ if (matchingDefault) {
1288
+ provider = {
1289
+ issuer: matchingDefault.samlConfig?.issuer || "",
1290
+ providerId: matchingDefault.providerId,
1291
+ userId: "default",
1292
+ samlConfig: matchingDefault.samlConfig
1293
+ };
1294
+ }
1295
+ } else {
1296
+ provider = await ctx.context.adapter.findOne({
1297
+ model: "ssoProvider",
1298
+ where: [
1299
+ {
1300
+ field: "providerId",
1301
+ value: providerId ?? "sso"
1302
+ }
1303
+ ]
1304
+ }).then((res) => {
1305
+ if (!res) return null;
1306
+ return {
1307
+ ...res,
1308
+ samlConfig: res.samlConfig ? JSON.parse(res.samlConfig) : void 0
1309
+ };
1310
+ });
1311
+ }
1312
+ if (!provider?.samlConfig) {
1313
+ throw new APIError("NOT_FOUND", {
1314
+ message: "No SAML provider found"
1315
+ });
1316
+ }
1317
+ const parsedSamlConfig = provider.samlConfig;
1318
+ const sp = saml.ServiceProvider({
1319
+ entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
1320
+ assertionConsumerService: [
1321
+ {
1322
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1323
+ Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs`
1324
+ }
1325
+ ],
1326
+ wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1327
+ metadata: parsedSamlConfig.spMetadata?.metadata,
1328
+ privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
1329
+ privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass
1330
+ });
1331
+ const idpData = parsedSamlConfig.idpMetadata;
1332
+ const idp = !idpData?.metadata ? saml.IdentityProvider({
1333
+ entityID: idpData?.entityID || parsedSamlConfig.issuer,
1334
+ singleSignOnService: idpData?.singleSignOnService || [
1335
+ {
1336
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1337
+ Location: parsedSamlConfig.entryPoint
1338
+ }
1339
+ ],
1340
+ signingCert: idpData?.cert || parsedSamlConfig.cert
1341
+ }) : saml.IdentityProvider({
1342
+ metadata: idpData.metadata
1343
+ });
1344
+ let parsedResponse;
1345
+ try {
1346
+ let decodedResponse = Buffer.from(SAMLResponse, "base64").toString(
1347
+ "utf-8"
1348
+ );
1349
+ if (!decodedResponse.includes("StatusCode")) {
1350
+ const insertPoint = decodedResponse.indexOf("</saml2:Issuer>");
1351
+ if (insertPoint !== -1) {
1352
+ decodedResponse = decodedResponse.slice(0, insertPoint + 14) + '<saml2:Status><saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></saml2:Status>' + decodedResponse.slice(insertPoint + 14);
1353
+ }
1354
+ } else if (!decodedResponse.includes("saml2:Success")) {
1355
+ decodedResponse = decodedResponse.replace(
1356
+ /<saml2:StatusCode Value="[^"]+"/,
1357
+ '<saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"'
1358
+ );
1359
+ }
1360
+ try {
1361
+ parsedResponse = await sp.parseLoginResponse(idp, "post", {
1362
+ body: {
1363
+ SAMLResponse,
1364
+ RelayState: RelayState || void 0
1365
+ }
1366
+ });
1367
+ } catch (parseError) {
1368
+ const nameIDMatch = decodedResponse.match(
1369
+ /<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/
1370
+ );
1371
+ if (!nameIDMatch) throw parseError;
1372
+ parsedResponse = {
1373
+ extract: {
1374
+ nameID: nameIDMatch[1],
1375
+ attributes: { nameID: nameIDMatch[1] },
1376
+ sessionIndex: {},
1377
+ conditions: {}
1378
+ }
1379
+ };
1380
+ }
1381
+ if (!parsedResponse?.extract) {
1382
+ throw new Error("Invalid SAML response structure");
962
1383
  }
963
1384
  } catch (error) {
964
- ctx.context.logger.error("SAML response validation failed", error);
1385
+ ctx.context.logger.error("SAML response validation failed", {
1386
+ error,
1387
+ decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
1388
+ "utf-8"
1389
+ )
1390
+ });
965
1391
  throw new APIError("BAD_REQUEST", {
966
1392
  message: "Invalid SAML response",
967
1393
  details: error instanceof Error ? error.message : String(error)
968
1394
  });
969
1395
  }
970
1396
  const { extract } = parsedResponse;
971
- const attributes = parsedResponse.extract.attributes;
972
- const mapping = parsedSamlConfig?.mapping ?? {};
1397
+ const attributes = extract.attributes || {};
1398
+ const mapping = parsedSamlConfig.mapping ?? {};
973
1399
  const userInfo = {
974
1400
  ...Object.fromEntries(
975
1401
  Object.entries(mapping.extraFields || {}).map(([key, value]) => [
976
1402
  key,
977
- extract.attributes[value]
1403
+ attributes[value]
978
1404
  ])
979
1405
  ),
980
- id: attributes[mapping.id] || attributes["nameID"],
981
- email: attributes[mapping.email] || attributes["nameID"] || attributes["email"],
1406
+ id: attributes[mapping.id || "nameID"] || extract.nameID,
1407
+ email: attributes[mapping.email || "email"] || extract.nameID,
982
1408
  name: [
983
- attributes[mapping.firstName] || attributes["givenName"],
984
- attributes[mapping.lastName] || attributes["surname"]
985
- ].filter(Boolean).join(" ") || parsedResponse.extract.attributes?.displayName,
986
- attributes: parsedResponse.extract.attributes,
987
- emailVerified: options?.trustEmailVerified ? attributes?.[mapping.emailVerified] || false : false
1409
+ attributes[mapping.firstName || "givenName"],
1410
+ attributes[mapping.lastName || "surname"]
1411
+ ].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
1412
+ emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
988
1413
  };
1414
+ if (!userInfo.id || !userInfo.email) {
1415
+ ctx.context.logger.error(
1416
+ "Missing essential user info from SAML response",
1417
+ {
1418
+ attributes: Object.keys(attributes),
1419
+ mapping,
1420
+ extractedId: userInfo.id,
1421
+ extractedEmail: userInfo.email
1422
+ }
1423
+ );
1424
+ throw new APIError("BAD_REQUEST", {
1425
+ message: "Unable to extract user ID or email from SAML response"
1426
+ });
1427
+ }
989
1428
  let user;
990
1429
  const existingUser = await ctx.context.adapter.findOne({
991
1430
  model: "user",
@@ -997,7 +1436,7 @@ const sso = (options) => {
997
1436
  ]
998
1437
  });
999
1438
  if (existingUser) {
1000
- const accounts = await ctx.context.adapter.findOne({
1439
+ const account = await ctx.context.adapter.findOne({
1001
1440
  model: "account",
1002
1441
  where: [
1003
1442
  { field: "userId", value: existingUser.id },
@@ -1005,7 +1444,7 @@ const sso = (options) => {
1005
1444
  { field: "accountId", value: userInfo.id }
1006
1445
  ]
1007
1446
  });
1008
- if (!accounts) {
1447
+ if (!account) {
1009
1448
  const isTrustedProvider = ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
1010
1449
  provider.providerId
1011
1450
  );
@@ -1095,9 +1534,8 @@ const sso = (options) => {
1095
1534
  }
1096
1535
  let session = await ctx.context.internalAdapter.createSession(user.id, ctx);
1097
1536
  await setSessionCookie(ctx, { session, user });
1098
- throw ctx.redirect(
1099
- RelayState || `${parsedSamlConfig.callbackUrl}` || `${parsedSamlConfig.issuer}`
1100
- );
1537
+ const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1538
+ throw ctx.redirect(callbackUrl);
1101
1539
  }
1102
1540
  )
1103
1541
  },