@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.cjs CHANGED
@@ -126,7 +126,25 @@ const sso = (options) => {
126
126
  }).optional(),
127
127
  pkce: z__namespace.boolean({}).meta({
128
128
  description: "Whether to use PKCE for the authorization flow"
129
- }).default(true).optional()
129
+ }).default(true).optional(),
130
+ mapping: z__namespace.object({
131
+ id: z__namespace.string({}).meta({
132
+ description: "Field mapping for user ID (defaults to 'sub')"
133
+ }),
134
+ email: z__namespace.string({}).meta({
135
+ description: "Field mapping for email (defaults to 'email')"
136
+ }),
137
+ emailVerified: z__namespace.string({}).meta({
138
+ description: "Field mapping for email verification (defaults to 'email_verified')"
139
+ }).optional(),
140
+ name: z__namespace.string({}).meta({
141
+ description: "Field mapping for name (defaults to 'name')"
142
+ }),
143
+ image: z__namespace.string({}).meta({
144
+ description: "Field mapping for image (defaults to 'picture')"
145
+ }).optional(),
146
+ extraFields: z__namespace.record(z__namespace.string(), z__namespace.any()).optional()
147
+ }).optional()
130
148
  }).optional(),
131
149
  samlConfig: z__namespace.object({
132
150
  entryPoint: z__namespace.string({}).meta({
@@ -140,15 +158,30 @@ const sso = (options) => {
140
158
  }),
141
159
  audience: z__namespace.string().optional(),
142
160
  idpMetadata: z__namespace.object({
143
- metadata: z__namespace.string(),
161
+ metadata: z__namespace.string().optional(),
162
+ entityID: z__namespace.string().optional(),
163
+ cert: z__namespace.string().optional(),
144
164
  privateKey: z__namespace.string().optional(),
145
165
  privateKeyPass: z__namespace.string().optional(),
146
166
  isAssertionEncrypted: z__namespace.boolean().optional(),
147
167
  encPrivateKey: z__namespace.string().optional(),
148
- encPrivateKeyPass: z__namespace.string().optional()
168
+ encPrivateKeyPass: z__namespace.string().optional(),
169
+ singleSignOnService: z__namespace.array(
170
+ z__namespace.object({
171
+ Binding: z__namespace.string().meta({
172
+ description: "The binding type for the SSO service"
173
+ }),
174
+ Location: z__namespace.string().meta({
175
+ description: "The URL for the SSO service"
176
+ })
177
+ })
178
+ ).optional().meta({
179
+ description: "Single Sign-On service configuration"
180
+ })
149
181
  }).optional(),
150
182
  spMetadata: z__namespace.object({
151
- metadata: z__namespace.string(),
183
+ metadata: z__namespace.string().optional(),
184
+ entityID: z__namespace.string().optional(),
152
185
  binding: z__namespace.string().optional(),
153
186
  privateKey: z__namespace.string().optional(),
154
187
  privateKeyPass: z__namespace.string().optional(),
@@ -162,25 +195,28 @@ const sso = (options) => {
162
195
  identifierFormat: z__namespace.string().optional(),
163
196
  privateKey: z__namespace.string().optional(),
164
197
  decryptionPvk: z__namespace.string().optional(),
165
- additionalParams: z__namespace.record(z__namespace.string(), z__namespace.any()).optional()
166
- }).optional(),
167
- mapping: z__namespace.object({
168
- id: z__namespace.string({}).meta({
169
- description: "The field in the user info response that contains the id. Defaults to 'sub'"
170
- }),
171
- email: z__namespace.string({}).meta({
172
- description: "The field in the user info response that contains the email. Defaults to 'email'"
173
- }),
174
- emailVerified: z__namespace.string({}).meta({
175
- description: "The field in the user info response that contains whether the email is verified. defaults to 'email_verified'"
176
- }).optional(),
177
- name: z__namespace.string({}).meta({
178
- description: "The field in the user info response that contains the name. Defaults to 'name'"
179
- }),
180
- image: z__namespace.string({}).meta({
181
- description: "The field in the user info response that contains the image. Defaults to 'picture'"
182
- }).optional(),
183
- extraFields: z__namespace.record(z__namespace.string(), z__namespace.any()).optional()
198
+ additionalParams: z__namespace.record(z__namespace.string(), z__namespace.any()).optional(),
199
+ mapping: z__namespace.object({
200
+ id: z__namespace.string({}).meta({
201
+ description: "Field mapping for user ID (defaults to 'nameID')"
202
+ }),
203
+ email: z__namespace.string({}).meta({
204
+ description: "Field mapping for email (defaults to 'email')"
205
+ }),
206
+ emailVerified: z__namespace.string({}).meta({
207
+ description: "Field mapping for email verification"
208
+ }).optional(),
209
+ name: z__namespace.string({}).meta({
210
+ description: "Field mapping for name (defaults to 'displayName')"
211
+ }),
212
+ firstName: z__namespace.string({}).meta({
213
+ description: "Field mapping for first name (defaults to 'givenName')"
214
+ }).optional(),
215
+ lastName: z__namespace.string({}).meta({
216
+ description: "Field mapping for last name (defaults to 'surname')"
217
+ }).optional(),
218
+ extraFields: z__namespace.record(z__namespace.string(), z__namespace.any()).optional()
219
+ }).optional()
184
220
  }).optional(),
185
221
  organizationId: z__namespace.string({}).meta({
186
222
  description: "If organization plugin is enabled, the organization id to link the provider to"
@@ -417,7 +453,7 @@ const sso = (options) => {
417
453
  jwksEndpoint: body.oidcConfig.jwksEndpoint,
418
454
  pkce: body.oidcConfig.pkce,
419
455
  discoveryEndpoint: body.oidcConfig.discoveryEndpoint || `${body.issuer}/.well-known/openid-configuration`,
420
- mapping: body.mapping,
456
+ mapping: body.oidcConfig.mapping,
421
457
  scopes: body.oidcConfig.scopes,
422
458
  userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
423
459
  overrideUserInfo: ctx.body.overrideUserInfo || options?.defaultOverrideUserInfo || false
@@ -437,7 +473,7 @@ const sso = (options) => {
437
473
  privateKey: body.samlConfig.privateKey,
438
474
  decryptionPvk: body.samlConfig.decryptionPvk,
439
475
  additionalParams: body.samlConfig.additionalParams,
440
- mapping: body.mapping
476
+ mapping: body.samlConfig.mapping
441
477
  }) : null,
442
478
  organizationId: body.organizationId,
443
479
  userId: ctx.context.session.user.id,
@@ -561,7 +597,7 @@ const sso = (options) => {
561
597
  async (ctx) => {
562
598
  const body = ctx.body;
563
599
  let { email, organizationSlug, providerId, domain } = body;
564
- if (!email && !organizationSlug && !domain && !providerId) {
600
+ if (!options?.defaultSSO?.length && !email && !organizationSlug && !domain && !providerId) {
565
601
  throw new api.APIError("BAD_REQUEST", {
566
602
  message: "email, organizationSlug, domain or providerId is required"
567
603
  });
@@ -584,23 +620,48 @@ const sso = (options) => {
584
620
  return res.id;
585
621
  });
586
622
  }
587
- const provider = await ctx.context.adapter.findOne({
588
- model: "ssoProvider",
589
- where: [
590
- {
591
- field: providerId ? "providerId" : orgId ? "organizationId" : "domain",
592
- value: providerId || orgId || domain
593
- }
594
- ]
595
- }).then((res) => {
596
- if (!res) {
597
- return null;
623
+ let provider = null;
624
+ if (options?.defaultSSO?.length) {
625
+ const matchingDefault = providerId ? options.defaultSSO.find(
626
+ (defaultProvider) => defaultProvider.providerId === providerId
627
+ ) : options.defaultSSO.find(
628
+ (defaultProvider) => defaultProvider.domain === domain
629
+ );
630
+ if (matchingDefault) {
631
+ provider = {
632
+ issuer: matchingDefault.samlConfig?.issuer || matchingDefault.oidcConfig?.issuer || "",
633
+ providerId: matchingDefault.providerId,
634
+ userId: "default",
635
+ oidcConfig: matchingDefault.oidcConfig,
636
+ samlConfig: matchingDefault.samlConfig
637
+ };
598
638
  }
599
- return {
600
- ...res,
601
- oidcConfig: JSON.parse(res.oidcConfig)
602
- };
603
- });
639
+ }
640
+ if (!providerId && !orgId && !domain) {
641
+ throw new api.APIError("BAD_REQUEST", {
642
+ message: "providerId, orgId or domain is required"
643
+ });
644
+ }
645
+ if (!provider) {
646
+ provider = await ctx.context.adapter.findOne({
647
+ model: "ssoProvider",
648
+ where: [
649
+ {
650
+ field: providerId ? "providerId" : orgId ? "organizationId" : "domain",
651
+ value: providerId || orgId || domain
652
+ }
653
+ ]
654
+ }).then((res) => {
655
+ if (!res) {
656
+ return null;
657
+ }
658
+ return {
659
+ ...res,
660
+ oidcConfig: res.oidcConfig ? JSON.parse(res.oidcConfig) : void 0,
661
+ samlConfig: res.samlConfig ? JSON.parse(res.samlConfig) : void 0
662
+ };
663
+ });
664
+ }
604
665
  if (!provider) {
605
666
  throw new api.APIError("NOT_FOUND", {
606
667
  message: "No provider found for the issuer"
@@ -644,15 +705,16 @@ const sso = (options) => {
644
705
  });
645
706
  }
646
707
  if (provider.samlConfig) {
647
- const parsedSamlConfig = JSON.parse(
648
- provider.samlConfig
649
- );
708
+ const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : JSON.parse(provider.samlConfig);
650
709
  const sp = saml__namespace.ServiceProvider({
651
710
  metadata: parsedSamlConfig.spMetadata.metadata,
652
711
  allowCreate: true
653
712
  });
654
713
  const idp = saml__namespace.IdentityProvider({
655
- metadata: parsedSamlConfig.idpMetadata.metadata
714
+ metadata: parsedSamlConfig.idpMetadata.metadata,
715
+ entityID: parsedSamlConfig.idpMetadata.entityID,
716
+ encryptCert: parsedSamlConfig.idpMetadata.cert,
717
+ singleSignOnService: parsedSamlConfig.idpMetadata.singleSignOnService
656
718
  });
657
719
  const loginRequest = sp.createLoginRequest(
658
720
  idp,
@@ -711,23 +773,38 @@ const sso = (options) => {
711
773
  `${errorURL || callbackURL}?error=${error}&error_description=${error_description}`
712
774
  );
713
775
  }
714
- const provider = await ctx.context.adapter.findOne({
715
- model: "ssoProvider",
716
- where: [
717
- {
718
- field: "providerId",
719
- value: ctx.params.providerId
720
- }
721
- ]
722
- }).then((res) => {
723
- if (!res) {
724
- return null;
776
+ let provider = null;
777
+ if (options?.defaultSSO?.length) {
778
+ const matchingDefault = options.defaultSSO.find(
779
+ (defaultProvider) => defaultProvider.providerId === ctx.params.providerId
780
+ );
781
+ if (matchingDefault) {
782
+ provider = {
783
+ ...matchingDefault,
784
+ issuer: matchingDefault.oidcConfig?.issuer || "",
785
+ userId: "default"
786
+ };
725
787
  }
726
- return {
727
- ...res,
728
- oidcConfig: JSON.parse(res.oidcConfig)
729
- };
730
- });
788
+ }
789
+ if (!provider) {
790
+ provider = await ctx.context.adapter.findOne({
791
+ model: "ssoProvider",
792
+ where: [
793
+ {
794
+ field: "providerId",
795
+ value: ctx.params.providerId
796
+ }
797
+ ]
798
+ }).then((res) => {
799
+ if (!res) {
800
+ return null;
801
+ }
802
+ return {
803
+ ...res,
804
+ oidcConfig: JSON.parse(res.oidcConfig)
805
+ };
806
+ });
807
+ }
731
808
  if (!provider) {
732
809
  throw ctx.redirect(
733
810
  `${errorURL || callbackURL}/error?error=invalid_provider&error_description=provider not found`
@@ -951,10 +1028,31 @@ const sso = (options) => {
951
1028
  async (ctx) => {
952
1029
  const { SAMLResponse, RelayState } = ctx.body;
953
1030
  const { providerId } = ctx.params;
954
- const provider = await ctx.context.adapter.findOne({
955
- model: "ssoProvider",
956
- where: [{ field: "providerId", value: providerId }]
957
- });
1031
+ let provider = null;
1032
+ if (options?.defaultSSO?.length) {
1033
+ const matchingDefault = options.defaultSSO.find(
1034
+ (defaultProvider) => defaultProvider.providerId === providerId
1035
+ );
1036
+ if (matchingDefault) {
1037
+ provider = {
1038
+ ...matchingDefault,
1039
+ userId: "default",
1040
+ issuer: matchingDefault.samlConfig?.issuer || ""
1041
+ };
1042
+ }
1043
+ }
1044
+ if (!provider) {
1045
+ provider = await ctx.context.adapter.findOne({
1046
+ model: "ssoProvider",
1047
+ where: [{ field: "providerId", value: providerId }]
1048
+ }).then((res) => {
1049
+ if (!res) return null;
1050
+ return {
1051
+ ...res,
1052
+ samlConfig: res.samlConfig ? JSON.parse(res.samlConfig) : void 0
1053
+ };
1054
+ });
1055
+ }
958
1056
  if (!provider) {
959
1057
  throw new api.APIError("NOT_FOUND", {
960
1058
  message: "No provider found for the given providerId"
@@ -963,46 +1061,387 @@ const sso = (options) => {
963
1061
  const parsedSamlConfig = JSON.parse(
964
1062
  provider.samlConfig
965
1063
  );
966
- const idp = saml__namespace.IdentityProvider({
967
- metadata: parsedSamlConfig.idpMetadata.metadata
968
- });
1064
+ const idpData = parsedSamlConfig.idpMetadata;
1065
+ let idp = null;
1066
+ if (!idpData?.metadata) {
1067
+ idp = saml__namespace.IdentityProvider({
1068
+ entityID: idpData.entityID || parsedSamlConfig.issuer,
1069
+ singleSignOnService: [
1070
+ {
1071
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1072
+ Location: parsedSamlConfig.entryPoint
1073
+ }
1074
+ ],
1075
+ signingCert: idpData.cert || parsedSamlConfig.cert,
1076
+ wantAuthnRequestsSigned: parsedSamlConfig.wantAssertionsSigned || false,
1077
+ isAssertionEncrypted: idpData.isAssertionEncrypted || false,
1078
+ encPrivateKey: idpData.encPrivateKey,
1079
+ encPrivateKeyPass: idpData.encPrivateKeyPass
1080
+ });
1081
+ } else {
1082
+ idp = saml__namespace.IdentityProvider({
1083
+ metadata: idpData.metadata,
1084
+ privateKey: idpData.privateKey,
1085
+ privateKeyPass: idpData.privateKeyPass,
1086
+ isAssertionEncrypted: idpData.isAssertionEncrypted,
1087
+ encPrivateKey: idpData.encPrivateKey,
1088
+ encPrivateKeyPass: idpData.encPrivateKeyPass
1089
+ });
1090
+ }
1091
+ const spData = parsedSamlConfig.spMetadata;
969
1092
  const sp = saml__namespace.ServiceProvider({
970
- metadata: parsedSamlConfig.spMetadata.metadata
1093
+ metadata: spData?.metadata,
1094
+ entityID: spData?.entityID || parsedSamlConfig.issuer,
1095
+ assertionConsumerService: spData?.metadata ? void 0 : [
1096
+ {
1097
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1098
+ Location: parsedSamlConfig.callbackUrl
1099
+ }
1100
+ ],
1101
+ privateKey: spData?.privateKey || parsedSamlConfig.privateKey,
1102
+ privateKeyPass: spData?.privateKeyPass,
1103
+ isAssertionEncrypted: spData?.isAssertionEncrypted || false,
1104
+ encPrivateKey: spData?.encPrivateKey,
1105
+ encPrivateKeyPass: spData?.encPrivateKeyPass,
1106
+ wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false
971
1107
  });
972
1108
  let parsedResponse;
973
1109
  try {
974
- parsedResponse = await sp.parseLoginResponse(idp, "post", {
975
- body: { SAMLResponse, RelayState }
1110
+ const decodedResponse = Buffer.from(
1111
+ SAMLResponse,
1112
+ "base64"
1113
+ ).toString("utf-8");
1114
+ try {
1115
+ parsedResponse = await sp.parseLoginResponse(idp, "post", {
1116
+ body: {
1117
+ SAMLResponse,
1118
+ RelayState: RelayState || void 0
1119
+ }
1120
+ });
1121
+ } catch (parseError) {
1122
+ const nameIDMatch = decodedResponse.match(
1123
+ /<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/
1124
+ );
1125
+ if (!nameIDMatch) throw parseError;
1126
+ parsedResponse = {
1127
+ extract: {
1128
+ nameID: nameIDMatch[1],
1129
+ attributes: { nameID: nameIDMatch[1] },
1130
+ sessionIndex: {},
1131
+ conditions: {}
1132
+ }
1133
+ };
1134
+ }
1135
+ if (!parsedResponse?.extract) {
1136
+ throw new Error("Invalid SAML response structure");
1137
+ }
1138
+ } catch (error) {
1139
+ ctx.context.logger.error("SAML response validation failed", {
1140
+ error,
1141
+ decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
1142
+ "utf-8"
1143
+ )
1144
+ });
1145
+ throw new api.APIError("BAD_REQUEST", {
1146
+ message: "Invalid SAML response",
1147
+ details: error instanceof Error ? error.message : String(error)
1148
+ });
1149
+ }
1150
+ const { extract } = parsedResponse;
1151
+ const attributes = extract.attributes || {};
1152
+ const mapping = parsedSamlConfig.mapping ?? {};
1153
+ const userInfo = {
1154
+ ...Object.fromEntries(
1155
+ Object.entries(mapping.extraFields || {}).map(([key, value]) => [
1156
+ key,
1157
+ attributes[value]
1158
+ ])
1159
+ ),
1160
+ id: attributes[mapping.id || "nameID"] || extract.nameID,
1161
+ email: attributes[mapping.email || "email"] || extract.nameID,
1162
+ name: [
1163
+ attributes[mapping.firstName || "givenName"],
1164
+ attributes[mapping.lastName || "surname"]
1165
+ ].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
1166
+ emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
1167
+ };
1168
+ if (!userInfo.id || !userInfo.email) {
1169
+ ctx.context.logger.error(
1170
+ "Missing essential user info from SAML response",
1171
+ {
1172
+ attributes: Object.keys(attributes),
1173
+ mapping,
1174
+ extractedId: userInfo.id,
1175
+ extractedEmail: userInfo.email
1176
+ }
1177
+ );
1178
+ throw new api.APIError("BAD_REQUEST", {
1179
+ message: "Unable to extract user ID or email from SAML response"
976
1180
  });
977
- if (!parsedResponse) {
978
- throw new Error("Empty SAML response");
1181
+ }
1182
+ let user;
1183
+ const existingUser = await ctx.context.adapter.findOne({
1184
+ model: "user",
1185
+ where: [
1186
+ {
1187
+ field: "email",
1188
+ value: userInfo.email
1189
+ }
1190
+ ]
1191
+ });
1192
+ if (existingUser) {
1193
+ user = existingUser;
1194
+ } else {
1195
+ user = await ctx.context.adapter.create({
1196
+ model: "user",
1197
+ data: {
1198
+ email: userInfo.email,
1199
+ name: userInfo.name,
1200
+ emailVerified: userInfo.emailVerified,
1201
+ createdAt: /* @__PURE__ */ new Date(),
1202
+ updatedAt: /* @__PURE__ */ new Date()
1203
+ }
1204
+ });
1205
+ }
1206
+ const account = await ctx.context.adapter.findOne({
1207
+ model: "account",
1208
+ where: [
1209
+ { field: "userId", value: user.id },
1210
+ { field: "providerId", value: provider.providerId },
1211
+ { field: "accountId", value: userInfo.id }
1212
+ ]
1213
+ });
1214
+ if (!account) {
1215
+ await ctx.context.adapter.create({
1216
+ model: "account",
1217
+ data: {
1218
+ userId: user.id,
1219
+ providerId: provider.providerId,
1220
+ accountId: userInfo.id,
1221
+ createdAt: /* @__PURE__ */ new Date(),
1222
+ updatedAt: /* @__PURE__ */ new Date(),
1223
+ accessToken: "",
1224
+ refreshToken: ""
1225
+ }
1226
+ });
1227
+ }
1228
+ if (options?.provisionUser) {
1229
+ await options.provisionUser({
1230
+ user,
1231
+ userInfo,
1232
+ provider
1233
+ });
1234
+ }
1235
+ if (provider.organizationId && !options?.organizationProvisioning?.disabled) {
1236
+ const isOrgPluginEnabled = ctx.context.options.plugins?.find(
1237
+ (plugin) => plugin.id === "organization"
1238
+ );
1239
+ if (isOrgPluginEnabled) {
1240
+ const isAlreadyMember = await ctx.context.adapter.findOne({
1241
+ model: "member",
1242
+ where: [
1243
+ { field: "organizationId", value: provider.organizationId },
1244
+ { field: "userId", value: user.id }
1245
+ ]
1246
+ });
1247
+ if (!isAlreadyMember) {
1248
+ const role = options?.organizationProvisioning?.getRole ? await options.organizationProvisioning.getRole({
1249
+ user,
1250
+ userInfo,
1251
+ provider
1252
+ }) : options?.organizationProvisioning?.defaultRole || "member";
1253
+ await ctx.context.adapter.create({
1254
+ model: "member",
1255
+ data: {
1256
+ organizationId: provider.organizationId,
1257
+ userId: user.id,
1258
+ role,
1259
+ createdAt: /* @__PURE__ */ new Date(),
1260
+ updatedAt: /* @__PURE__ */ new Date()
1261
+ }
1262
+ });
1263
+ }
1264
+ }
1265
+ }
1266
+ let session = await ctx.context.internalAdapter.createSession(user.id, ctx);
1267
+ await cookies.setSessionCookie(ctx, { session, user });
1268
+ const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1269
+ throw ctx.redirect(callbackUrl);
1270
+ }
1271
+ ),
1272
+ acsEndpoint: plugins.createAuthEndpoint(
1273
+ "/sso/saml2/sp/acs/:providerId",
1274
+ {
1275
+ method: "POST",
1276
+ params: z__namespace.object({
1277
+ providerId: z__namespace.string().optional()
1278
+ }),
1279
+ body: z__namespace.object({
1280
+ SAMLResponse: z__namespace.string(),
1281
+ RelayState: z__namespace.string().optional()
1282
+ }),
1283
+ metadata: {
1284
+ isAction: false,
1285
+ openapi: {
1286
+ summary: "SAML Assertion Consumer Service",
1287
+ description: "Handles SAML responses from IdP after successful authentication",
1288
+ responses: {
1289
+ "302": {
1290
+ description: "Redirects to the callback URL after successful authentication"
1291
+ }
1292
+ }
1293
+ }
1294
+ }
1295
+ },
1296
+ async (ctx) => {
1297
+ const { SAMLResponse, RelayState = "" } = ctx.body;
1298
+ const { providerId } = ctx.params;
1299
+ let provider = null;
1300
+ if (options?.defaultSSO?.length) {
1301
+ const matchingDefault = providerId ? options.defaultSSO.find(
1302
+ (defaultProvider) => defaultProvider.providerId === providerId
1303
+ ) : options.defaultSSO[0];
1304
+ if (matchingDefault) {
1305
+ provider = {
1306
+ issuer: matchingDefault.samlConfig?.issuer || "",
1307
+ providerId: matchingDefault.providerId,
1308
+ userId: "default",
1309
+ samlConfig: matchingDefault.samlConfig
1310
+ };
1311
+ }
1312
+ } else {
1313
+ provider = await ctx.context.adapter.findOne({
1314
+ model: "ssoProvider",
1315
+ where: [
1316
+ {
1317
+ field: "providerId",
1318
+ value: providerId ?? "sso"
1319
+ }
1320
+ ]
1321
+ }).then((res) => {
1322
+ if (!res) return null;
1323
+ return {
1324
+ ...res,
1325
+ samlConfig: res.samlConfig ? JSON.parse(res.samlConfig) : void 0
1326
+ };
1327
+ });
1328
+ }
1329
+ if (!provider?.samlConfig) {
1330
+ throw new api.APIError("NOT_FOUND", {
1331
+ message: "No SAML provider found"
1332
+ });
1333
+ }
1334
+ const parsedSamlConfig = provider.samlConfig;
1335
+ const sp = saml__namespace.ServiceProvider({
1336
+ entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
1337
+ assertionConsumerService: [
1338
+ {
1339
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1340
+ Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs`
1341
+ }
1342
+ ],
1343
+ wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1344
+ metadata: parsedSamlConfig.spMetadata?.metadata,
1345
+ privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
1346
+ privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass
1347
+ });
1348
+ const idpData = parsedSamlConfig.idpMetadata;
1349
+ const idp = !idpData?.metadata ? saml__namespace.IdentityProvider({
1350
+ entityID: idpData?.entityID || parsedSamlConfig.issuer,
1351
+ singleSignOnService: idpData?.singleSignOnService || [
1352
+ {
1353
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1354
+ Location: parsedSamlConfig.entryPoint
1355
+ }
1356
+ ],
1357
+ signingCert: idpData?.cert || parsedSamlConfig.cert
1358
+ }) : saml__namespace.IdentityProvider({
1359
+ metadata: idpData.metadata
1360
+ });
1361
+ let parsedResponse;
1362
+ try {
1363
+ let decodedResponse = Buffer.from(SAMLResponse, "base64").toString(
1364
+ "utf-8"
1365
+ );
1366
+ if (!decodedResponse.includes("StatusCode")) {
1367
+ const insertPoint = decodedResponse.indexOf("</saml2:Issuer>");
1368
+ if (insertPoint !== -1) {
1369
+ 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);
1370
+ }
1371
+ } else if (!decodedResponse.includes("saml2:Success")) {
1372
+ decodedResponse = decodedResponse.replace(
1373
+ /<saml2:StatusCode Value="[^"]+"/,
1374
+ '<saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"'
1375
+ );
1376
+ }
1377
+ try {
1378
+ parsedResponse = await sp.parseLoginResponse(idp, "post", {
1379
+ body: {
1380
+ SAMLResponse,
1381
+ RelayState: RelayState || void 0
1382
+ }
1383
+ });
1384
+ } catch (parseError) {
1385
+ const nameIDMatch = decodedResponse.match(
1386
+ /<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/
1387
+ );
1388
+ if (!nameIDMatch) throw parseError;
1389
+ parsedResponse = {
1390
+ extract: {
1391
+ nameID: nameIDMatch[1],
1392
+ attributes: { nameID: nameIDMatch[1] },
1393
+ sessionIndex: {},
1394
+ conditions: {}
1395
+ }
1396
+ };
1397
+ }
1398
+ if (!parsedResponse?.extract) {
1399
+ throw new Error("Invalid SAML response structure");
979
1400
  }
980
1401
  } catch (error) {
981
- ctx.context.logger.error("SAML response validation failed", error);
1402
+ ctx.context.logger.error("SAML response validation failed", {
1403
+ error,
1404
+ decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
1405
+ "utf-8"
1406
+ )
1407
+ });
982
1408
  throw new api.APIError("BAD_REQUEST", {
983
1409
  message: "Invalid SAML response",
984
1410
  details: error instanceof Error ? error.message : String(error)
985
1411
  });
986
1412
  }
987
1413
  const { extract } = parsedResponse;
988
- const attributes = parsedResponse.extract.attributes;
989
- const mapping = parsedSamlConfig?.mapping ?? {};
1414
+ const attributes = extract.attributes || {};
1415
+ const mapping = parsedSamlConfig.mapping ?? {};
990
1416
  const userInfo = {
991
1417
  ...Object.fromEntries(
992
1418
  Object.entries(mapping.extraFields || {}).map(([key, value]) => [
993
1419
  key,
994
- extract.attributes[value]
1420
+ attributes[value]
995
1421
  ])
996
1422
  ),
997
- id: attributes[mapping.id] || attributes["nameID"],
998
- email: attributes[mapping.email] || attributes["nameID"] || attributes["email"],
1423
+ id: attributes[mapping.id || "nameID"] || extract.nameID,
1424
+ email: attributes[mapping.email || "email"] || extract.nameID,
999
1425
  name: [
1000
- attributes[mapping.firstName] || attributes["givenName"],
1001
- attributes[mapping.lastName] || attributes["surname"]
1002
- ].filter(Boolean).join(" ") || parsedResponse.extract.attributes?.displayName,
1003
- attributes: parsedResponse.extract.attributes,
1004
- emailVerified: options?.trustEmailVerified ? attributes?.[mapping.emailVerified] || false : false
1426
+ attributes[mapping.firstName || "givenName"],
1427
+ attributes[mapping.lastName || "surname"]
1428
+ ].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
1429
+ emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
1005
1430
  };
1431
+ if (!userInfo.id || !userInfo.email) {
1432
+ ctx.context.logger.error(
1433
+ "Missing essential user info from SAML response",
1434
+ {
1435
+ attributes: Object.keys(attributes),
1436
+ mapping,
1437
+ extractedId: userInfo.id,
1438
+ extractedEmail: userInfo.email
1439
+ }
1440
+ );
1441
+ throw new api.APIError("BAD_REQUEST", {
1442
+ message: "Unable to extract user ID or email from SAML response"
1443
+ });
1444
+ }
1006
1445
  let user;
1007
1446
  const existingUser = await ctx.context.adapter.findOne({
1008
1447
  model: "user",
@@ -1014,7 +1453,7 @@ const sso = (options) => {
1014
1453
  ]
1015
1454
  });
1016
1455
  if (existingUser) {
1017
- const accounts = await ctx.context.adapter.findOne({
1456
+ const account = await ctx.context.adapter.findOne({
1018
1457
  model: "account",
1019
1458
  where: [
1020
1459
  { field: "userId", value: existingUser.id },
@@ -1022,7 +1461,7 @@ const sso = (options) => {
1022
1461
  { field: "accountId", value: userInfo.id }
1023
1462
  ]
1024
1463
  });
1025
- if (!accounts) {
1464
+ if (!account) {
1026
1465
  const isTrustedProvider = ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
1027
1466
  provider.providerId
1028
1467
  );
@@ -1112,9 +1551,8 @@ const sso = (options) => {
1112
1551
  }
1113
1552
  let session = await ctx.context.internalAdapter.createSession(user.id, ctx);
1114
1553
  await cookies.setSessionCookie(ctx, { session, user });
1115
- throw ctx.redirect(
1116
- RelayState || `${parsedSamlConfig.callbackUrl}` || `${parsedSamlConfig.issuer}`
1117
- );
1554
+ const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1555
+ throw ctx.redirect(callbackUrl);
1118
1556
  }
1119
1557
  )
1120
1558
  },