@better-auth/sso 1.3.13 → 1.3.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -74,64 +74,57 @@ const sso = (options) => {
74
74
  {
75
75
  method: "POST",
76
76
  body: z.object({
77
- providerId: z.string({}).meta({
78
- description: "The ID of the provider. This is used to identify the provider during login and callback"
79
- }),
80
- issuer: z.string({}).meta({
81
- description: "The issuer of the provider"
82
- }),
83
- domain: z.string({}).meta({
84
- description: "The domain of the provider. This is used for email matching"
85
- }),
77
+ providerId: z.string({}).describe(
78
+ "The ID of the provider. This is used to identify the provider during login and callback"
79
+ ),
80
+ issuer: z.string({}).describe("The issuer of the provider"),
81
+ domain: z.string({}).describe(
82
+ "The domain of the provider. This is used for email matching"
83
+ ),
86
84
  oidcConfig: z.object({
87
- clientId: z.string({}).meta({
88
- description: "The client ID"
89
- }),
90
- clientSecret: z.string({}).meta({
91
- description: "The client secret"
92
- }),
93
- authorizationEndpoint: z.string({}).meta({
94
- description: "The authorization endpoint"
95
- }).optional(),
96
- tokenEndpoint: z.string({}).meta({
97
- description: "The token endpoint"
98
- }).optional(),
99
- userInfoEndpoint: z.string({}).meta({
100
- description: "The user info endpoint"
101
- }).optional(),
85
+ clientId: z.string({}).describe("The client ID"),
86
+ clientSecret: z.string({}).describe("The client secret"),
87
+ authorizationEndpoint: z.string({}).describe("The authorization endpoint").optional(),
88
+ tokenEndpoint: z.string({}).describe("The token endpoint").optional(),
89
+ userInfoEndpoint: z.string({}).describe("The user info endpoint").optional(),
102
90
  tokenEndpointAuthentication: z.enum(["client_secret_post", "client_secret_basic"]).optional(),
103
- jwksEndpoint: z.string({}).meta({
104
- description: "The JWKS endpoint"
105
- }).optional(),
91
+ jwksEndpoint: z.string({}).describe("The JWKS endpoint").optional(),
106
92
  discoveryEndpoint: z.string().optional(),
107
- scopes: z.array(z.string(), {}).meta({
108
- description: "The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']"
109
- }).optional(),
110
- pkce: z.boolean({}).meta({
111
- description: "Whether to use PKCE for the authorization flow"
112
- }).default(true).optional()
93
+ scopes: z.array(z.string(), {}).describe("The scopes to request. ").optional(),
94
+ pkce: z.boolean({}).describe("Whether to use PKCE for the authorization flow").default(true).optional(),
95
+ mapping: z.object({
96
+ id: z.string({}).describe("Field mapping for user ID ("),
97
+ email: z.string({}).describe("Field mapping for email ("),
98
+ emailVerified: z.string({}).describe("Field mapping for email verification (").optional(),
99
+ name: z.string({}).describe("Field mapping for name ("),
100
+ image: z.string({}).describe("Field mapping for image (").optional(),
101
+ extraFields: z.record(z.string(), z.any()).optional()
102
+ }).optional()
113
103
  }).optional(),
114
104
  samlConfig: z.object({
115
- entryPoint: z.string({}).meta({
116
- description: "The entry point of the provider"
117
- }),
118
- cert: z.string({}).meta({
119
- description: "The certificate of the provider"
120
- }),
121
- callbackUrl: z.string({}).meta({
122
- description: "The callback URL of the provider"
123
- }),
105
+ entryPoint: z.string({}).describe("The entry point of the provider"),
106
+ cert: z.string({}).describe("The certificate of the provider"),
107
+ callbackUrl: z.string({}).describe("The callback URL of the provider"),
124
108
  audience: z.string().optional(),
125
109
  idpMetadata: z.object({
126
- metadata: z.string(),
110
+ metadata: z.string().optional(),
111
+ entityID: z.string().optional(),
112
+ cert: z.string().optional(),
127
113
  privateKey: z.string().optional(),
128
114
  privateKeyPass: z.string().optional(),
129
115
  isAssertionEncrypted: z.boolean().optional(),
130
116
  encPrivateKey: z.string().optional(),
131
- encPrivateKeyPass: z.string().optional()
117
+ encPrivateKeyPass: z.string().optional(),
118
+ singleSignOnService: z.array(
119
+ z.object({
120
+ Binding: z.string().describe("The binding type for the SSO service"),
121
+ Location: z.string().describe("The URL for the SSO service")
122
+ })
123
+ ).optional().describe("Single Sign-On service configuration")
132
124
  }).optional(),
133
125
  spMetadata: z.object({
134
- metadata: z.string(),
126
+ metadata: z.string().optional(),
127
+ entityID: z.string().optional(),
135
128
  binding: z.string().optional(),
136
129
  privateKey: z.string().optional(),
137
130
  privateKeyPass: z.string().optional(),
@@ -145,32 +138,23 @@ const sso = (options) => {
145
138
  identifierFormat: z.string().optional(),
146
139
  privateKey: z.string().optional(),
147
140
  decryptionPvk: z.string().optional(),
148
- additionalParams: z.record(z.string(), z.any()).optional()
141
+ additionalParams: z.record(z.string(), z.any()).optional(),
142
+ mapping: z.object({
143
+ id: z.string({}).describe("Field mapping for user ID ("),
144
+ email: z.string({}).describe("Field mapping for email ("),
145
+ emailVerified: z.string({}).describe("Field mapping for email verification").optional(),
146
+ name: z.string({}).describe("Field mapping for name ("),
147
+ firstName: z.string({}).describe("Field mapping for first name (").optional(),
148
+ lastName: z.string({}).describe("Field mapping for last name (").optional(),
149
+ extraFields: z.record(z.string(), z.any()).optional()
150
+ }).optional()
149
151
  }).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()
167
- }).optional(),
168
- organizationId: z.string({}).meta({
169
- description: "If organization plugin is enabled, the organization id to link the provider to"
170
- }).optional(),
171
- overrideUserInfo: z.boolean({}).meta({
172
- description: "Override user info with the provider info. Defaults to false"
173
- }).default(false).optional()
152
+ organizationId: z.string({}).describe(
153
+ "If organization plugin is enabled, the organization id to link the provider to"
154
+ ).optional(),
155
+ overrideUserInfo: z.boolean({}).describe(
156
+ "Override user info with the provider info. Defaults to false"
157
+ ).default(false).optional()
174
158
  }),
175
159
  use: [sessionMiddleware],
176
160
  metadata: {
@@ -400,7 +384,7 @@ const sso = (options) => {
400
384
  jwksEndpoint: body.oidcConfig.jwksEndpoint,
401
385
  pkce: body.oidcConfig.pkce,
402
386
  discoveryEndpoint: body.oidcConfig.discoveryEndpoint || `${body.issuer}/.well-known/openid-configuration`,
403
- mapping: body.mapping,
387
+ mapping: body.oidcConfig.mapping,
404
388
  scopes: body.oidcConfig.scopes,
405
389
  userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
406
390
  overrideUserInfo: ctx.body.overrideUserInfo || options?.defaultOverrideUserInfo || false
@@ -420,7 +404,7 @@ const sso = (options) => {
420
404
  privateKey: body.samlConfig.privateKey,
421
405
  decryptionPvk: body.samlConfig.decryptionPvk,
422
406
  additionalParams: body.samlConfig.additionalParams,
423
- mapping: body.mapping
407
+ mapping: body.samlConfig.mapping
424
408
  }) : null,
425
409
  organizationId: body.organizationId,
426
410
  userId: ctx.context.session.user.id,
@@ -444,33 +428,21 @@ const sso = (options) => {
444
428
  {
445
429
  method: "POST",
446
430
  body: z.object({
447
- email: z.string({}).meta({
448
- description: "The email address to sign in with. This is used to identify the issuer to sign in with. It's optional if the issuer is provided"
449
- }).optional(),
450
- organizationSlug: z.string({}).meta({
451
- description: "The slug of the organization to sign in with"
452
- }).optional(),
453
- providerId: z.string({}).meta({
454
- description: "The ID of the provider to sign in with. This can be provided instead of email or issuer"
455
- }).optional(),
456
- domain: z.string({}).meta({
457
- description: "The domain of the provider."
458
- }).optional(),
459
- callbackURL: z.string({}).meta({
460
- description: "The URL to redirect to after login"
461
- }),
462
- errorCallbackURL: z.string({}).meta({
463
- description: "The URL to redirect to after login"
464
- }).optional(),
465
- newUserCallbackURL: z.string({}).meta({
466
- description: "The URL to redirect to after login if the user is new"
467
- }).optional(),
468
- scopes: z.array(z.string(), {}).meta({
469
- description: "Scopes to request from the provider."
470
- }).optional(),
471
- requestSignUp: z.boolean({}).meta({
472
- description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider"
473
- }).optional(),
431
+ email: z.string({}).describe(
432
+ "The email address to sign in with. This is used to identify the issuer to sign in with"
433
+ ).optional(),
434
+ organizationSlug: z.string({}).describe("The slug of the organization to sign in with").optional(),
435
+ providerId: z.string({}).describe(
436
+ "The ID of the provider to sign in with. This can be provided instead of email or issuer"
437
+ ).optional(),
438
+ domain: z.string({}).describe("The domain of the provider.").optional(),
439
+ callbackURL: z.string({}).describe("The URL to redirect to after login"),
440
+ errorCallbackURL: z.string({}).describe("The URL to redirect to after login").optional(),
441
+ newUserCallbackURL: z.string({}).describe("The URL to redirect to after login if the user is new").optional(),
442
+ scopes: z.array(z.string(), {}).describe("Scopes to request from the provider.").optional(),
443
+ requestSignUp: z.boolean({}).describe(
444
+ "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider"
445
+ ).optional(),
474
446
  providerType: z.enum(["oidc", "saml"]).optional()
475
447
  }),
476
448
  metadata: {
@@ -544,7 +516,7 @@ const sso = (options) => {
544
516
  async (ctx) => {
545
517
  const body = ctx.body;
546
518
  let { email, organizationSlug, providerId, domain } = body;
547
- if (!email && !organizationSlug && !domain && !providerId) {
519
+ if (!options?.defaultSSO?.length && !email && !organizationSlug && !domain && !providerId) {
548
520
  throw new APIError("BAD_REQUEST", {
549
521
  message: "email, organizationSlug, domain or providerId is required"
550
522
  });
@@ -567,23 +539,48 @@ const sso = (options) => {
567
539
  return res.id;
568
540
  });
569
541
  }
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;
542
+ let provider = null;
543
+ if (options?.defaultSSO?.length) {
544
+ const matchingDefault = providerId ? options.defaultSSO.find(
545
+ (defaultProvider) => defaultProvider.providerId === providerId
546
+ ) : options.defaultSSO.find(
547
+ (defaultProvider) => defaultProvider.domain === domain
548
+ );
549
+ if (matchingDefault) {
550
+ provider = {
551
+ issuer: matchingDefault.samlConfig?.issuer || matchingDefault.oidcConfig?.issuer || "",
552
+ providerId: matchingDefault.providerId,
553
+ userId: "default",
554
+ oidcConfig: matchingDefault.oidcConfig,
555
+ samlConfig: matchingDefault.samlConfig
556
+ };
581
557
  }
582
- return {
583
- ...res,
584
- oidcConfig: JSON.parse(res.oidcConfig)
585
- };
586
- });
558
+ }
559
+ if (!providerId && !orgId && !domain) {
560
+ throw new APIError("BAD_REQUEST", {
561
+ message: "providerId, orgId or domain is required"
562
+ });
563
+ }
564
+ if (!provider) {
565
+ provider = await ctx.context.adapter.findOne({
566
+ model: "ssoProvider",
567
+ where: [
568
+ {
569
+ field: providerId ? "providerId" : orgId ? "organizationId" : "domain",
570
+ value: providerId || orgId || domain
571
+ }
572
+ ]
573
+ }).then((res) => {
574
+ if (!res) {
575
+ return null;
576
+ }
577
+ return {
578
+ ...res,
579
+ oidcConfig: res.oidcConfig ? JSON.parse(res.oidcConfig) : void 0,
580
+ samlConfig: res.samlConfig ? JSON.parse(res.samlConfig) : void 0
581
+ };
582
+ });
583
+ }
587
584
  if (!provider) {
588
585
  throw new APIError("NOT_FOUND", {
589
586
  message: "No provider found for the issuer"
@@ -627,15 +624,16 @@ const sso = (options) => {
627
624
  });
628
625
  }
629
626
  if (provider.samlConfig) {
630
- const parsedSamlConfig = JSON.parse(
631
- provider.samlConfig
632
- );
627
+ const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : JSON.parse(provider.samlConfig);
633
628
  const sp = saml.ServiceProvider({
634
629
  metadata: parsedSamlConfig.spMetadata.metadata,
635
630
  allowCreate: true
636
631
  });
637
632
  const idp = saml.IdentityProvider({
638
- metadata: parsedSamlConfig.idpMetadata.metadata
633
+ metadata: parsedSamlConfig.idpMetadata.metadata,
634
+ entityID: parsedSamlConfig.idpMetadata.entityID,
635
+ encryptCert: parsedSamlConfig.idpMetadata.cert,
636
+ singleSignOnService: parsedSamlConfig.idpMetadata.singleSignOnService
639
637
  });
640
638
  const loginRequest = sp.createLoginRequest(
641
639
  idp,
@@ -694,23 +692,38 @@ const sso = (options) => {
694
692
  `${errorURL || callbackURL}?error=${error}&error_description=${error_description}`
695
693
  );
696
694
  }
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;
695
+ let provider = null;
696
+ if (options?.defaultSSO?.length) {
697
+ const matchingDefault = options.defaultSSO.find(
698
+ (defaultProvider) => defaultProvider.providerId === ctx.params.providerId
699
+ );
700
+ if (matchingDefault) {
701
+ provider = {
702
+ ...matchingDefault,
703
+ issuer: matchingDefault.oidcConfig?.issuer || "",
704
+ userId: "default"
705
+ };
708
706
  }
709
- return {
710
- ...res,
711
- oidcConfig: JSON.parse(res.oidcConfig)
712
- };
713
- });
707
+ }
708
+ if (!provider) {
709
+ provider = await ctx.context.adapter.findOne({
710
+ model: "ssoProvider",
711
+ where: [
712
+ {
713
+ field: "providerId",
714
+ value: ctx.params.providerId
715
+ }
716
+ ]
717
+ }).then((res) => {
718
+ if (!res) {
719
+ return null;
720
+ }
721
+ return {
722
+ ...res,
723
+ oidcConfig: JSON.parse(res.oidcConfig)
724
+ };
725
+ });
726
+ }
714
727
  if (!provider) {
715
728
  throw ctx.redirect(
716
729
  `${errorURL || callbackURL}/error?error=invalid_provider&error_description=provider not found`
@@ -934,10 +947,31 @@ const sso = (options) => {
934
947
  async (ctx) => {
935
948
  const { SAMLResponse, RelayState } = ctx.body;
936
949
  const { providerId } = ctx.params;
937
- const provider = await ctx.context.adapter.findOne({
938
- model: "ssoProvider",
939
- where: [{ field: "providerId", value: providerId }]
940
- });
950
+ let provider = null;
951
+ if (options?.defaultSSO?.length) {
952
+ const matchingDefault = options.defaultSSO.find(
953
+ (defaultProvider) => defaultProvider.providerId === providerId
954
+ );
955
+ if (matchingDefault) {
956
+ provider = {
957
+ ...matchingDefault,
958
+ userId: "default",
959
+ issuer: matchingDefault.samlConfig?.issuer || ""
960
+ };
961
+ }
962
+ }
963
+ if (!provider) {
964
+ provider = await ctx.context.adapter.findOne({
965
+ model: "ssoProvider",
966
+ where: [{ field: "providerId", value: providerId }]
967
+ }).then((res) => {
968
+ if (!res) return null;
969
+ return {
970
+ ...res,
971
+ samlConfig: res.samlConfig ? JSON.parse(res.samlConfig) : void 0
972
+ };
973
+ });
974
+ }
941
975
  if (!provider) {
942
976
  throw new APIError("NOT_FOUND", {
943
977
  message: "No provider found for the given providerId"
@@ -946,46 +980,387 @@ const sso = (options) => {
946
980
  const parsedSamlConfig = JSON.parse(
947
981
  provider.samlConfig
948
982
  );
949
- const idp = saml.IdentityProvider({
950
- metadata: parsedSamlConfig.idpMetadata.metadata
951
- });
983
+ const idpData = parsedSamlConfig.idpMetadata;
984
+ let idp = null;
985
+ if (!idpData?.metadata) {
986
+ idp = saml.IdentityProvider({
987
+ entityID: idpData.entityID || parsedSamlConfig.issuer,
988
+ singleSignOnService: [
989
+ {
990
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
991
+ Location: parsedSamlConfig.entryPoint
992
+ }
993
+ ],
994
+ signingCert: idpData.cert || parsedSamlConfig.cert,
995
+ wantAuthnRequestsSigned: parsedSamlConfig.wantAssertionsSigned || false,
996
+ isAssertionEncrypted: idpData.isAssertionEncrypted || false,
997
+ encPrivateKey: idpData.encPrivateKey,
998
+ encPrivateKeyPass: idpData.encPrivateKeyPass
999
+ });
1000
+ } else {
1001
+ idp = saml.IdentityProvider({
1002
+ metadata: idpData.metadata,
1003
+ privateKey: idpData.privateKey,
1004
+ privateKeyPass: idpData.privateKeyPass,
1005
+ isAssertionEncrypted: idpData.isAssertionEncrypted,
1006
+ encPrivateKey: idpData.encPrivateKey,
1007
+ encPrivateKeyPass: idpData.encPrivateKeyPass
1008
+ });
1009
+ }
1010
+ const spData = parsedSamlConfig.spMetadata;
952
1011
  const sp = saml.ServiceProvider({
953
- metadata: parsedSamlConfig.spMetadata.metadata
1012
+ metadata: spData?.metadata,
1013
+ entityID: spData?.entityID || parsedSamlConfig.issuer,
1014
+ assertionConsumerService: spData?.metadata ? void 0 : [
1015
+ {
1016
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1017
+ Location: parsedSamlConfig.callbackUrl
1018
+ }
1019
+ ],
1020
+ privateKey: spData?.privateKey || parsedSamlConfig.privateKey,
1021
+ privateKeyPass: spData?.privateKeyPass,
1022
+ isAssertionEncrypted: spData?.isAssertionEncrypted || false,
1023
+ encPrivateKey: spData?.encPrivateKey,
1024
+ encPrivateKeyPass: spData?.encPrivateKeyPass,
1025
+ wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false
954
1026
  });
955
1027
  let parsedResponse;
956
1028
  try {
957
- parsedResponse = await sp.parseLoginResponse(idp, "post", {
958
- body: { SAMLResponse, RelayState }
1029
+ const decodedResponse = Buffer.from(
1030
+ SAMLResponse,
1031
+ "base64"
1032
+ ).toString("utf-8");
1033
+ try {
1034
+ parsedResponse = await sp.parseLoginResponse(idp, "post", {
1035
+ body: {
1036
+ SAMLResponse,
1037
+ RelayState: RelayState || void 0
1038
+ }
1039
+ });
1040
+ } catch (parseError) {
1041
+ const nameIDMatch = decodedResponse.match(
1042
+ /<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/
1043
+ );
1044
+ if (!nameIDMatch) throw parseError;
1045
+ parsedResponse = {
1046
+ extract: {
1047
+ nameID: nameIDMatch[1],
1048
+ attributes: { nameID: nameIDMatch[1] },
1049
+ sessionIndex: {},
1050
+ conditions: {}
1051
+ }
1052
+ };
1053
+ }
1054
+ if (!parsedResponse?.extract) {
1055
+ throw new Error("Invalid SAML response structure");
1056
+ }
1057
+ } catch (error) {
1058
+ ctx.context.logger.error("SAML response validation failed", {
1059
+ error,
1060
+ decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
1061
+ "utf-8"
1062
+ )
1063
+ });
1064
+ throw new APIError("BAD_REQUEST", {
1065
+ message: "Invalid SAML response",
1066
+ details: error instanceof Error ? error.message : String(error)
1067
+ });
1068
+ }
1069
+ const { extract } = parsedResponse;
1070
+ const attributes = extract.attributes || {};
1071
+ const mapping = parsedSamlConfig.mapping ?? {};
1072
+ const userInfo = {
1073
+ ...Object.fromEntries(
1074
+ Object.entries(mapping.extraFields || {}).map(([key, value]) => [
1075
+ key,
1076
+ attributes[value]
1077
+ ])
1078
+ ),
1079
+ id: attributes[mapping.id || "nameID"] || extract.nameID,
1080
+ email: attributes[mapping.email || "email"] || extract.nameID,
1081
+ name: [
1082
+ attributes[mapping.firstName || "givenName"],
1083
+ attributes[mapping.lastName || "surname"]
1084
+ ].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
1085
+ emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
1086
+ };
1087
+ if (!userInfo.id || !userInfo.email) {
1088
+ ctx.context.logger.error(
1089
+ "Missing essential user info from SAML response",
1090
+ {
1091
+ attributes: Object.keys(attributes),
1092
+ mapping,
1093
+ extractedId: userInfo.id,
1094
+ extractedEmail: userInfo.email
1095
+ }
1096
+ );
1097
+ throw new APIError("BAD_REQUEST", {
1098
+ message: "Unable to extract user ID or email from SAML response"
1099
+ });
1100
+ }
1101
+ let user;
1102
+ const existingUser = await ctx.context.adapter.findOne({
1103
+ model: "user",
1104
+ where: [
1105
+ {
1106
+ field: "email",
1107
+ value: userInfo.email
1108
+ }
1109
+ ]
1110
+ });
1111
+ if (existingUser) {
1112
+ user = existingUser;
1113
+ } else {
1114
+ user = await ctx.context.adapter.create({
1115
+ model: "user",
1116
+ data: {
1117
+ email: userInfo.email,
1118
+ name: userInfo.name,
1119
+ emailVerified: userInfo.emailVerified,
1120
+ createdAt: /* @__PURE__ */ new Date(),
1121
+ updatedAt: /* @__PURE__ */ new Date()
1122
+ }
1123
+ });
1124
+ }
1125
+ const account = await ctx.context.adapter.findOne({
1126
+ model: "account",
1127
+ where: [
1128
+ { field: "userId", value: user.id },
1129
+ { field: "providerId", value: provider.providerId },
1130
+ { field: "accountId", value: userInfo.id }
1131
+ ]
1132
+ });
1133
+ if (!account) {
1134
+ await ctx.context.adapter.create({
1135
+ model: "account",
1136
+ data: {
1137
+ userId: user.id,
1138
+ providerId: provider.providerId,
1139
+ accountId: userInfo.id,
1140
+ createdAt: /* @__PURE__ */ new Date(),
1141
+ updatedAt: /* @__PURE__ */ new Date(),
1142
+ accessToken: "",
1143
+ refreshToken: ""
1144
+ }
1145
+ });
1146
+ }
1147
+ if (options?.provisionUser) {
1148
+ await options.provisionUser({
1149
+ user,
1150
+ userInfo,
1151
+ provider
1152
+ });
1153
+ }
1154
+ if (provider.organizationId && !options?.organizationProvisioning?.disabled) {
1155
+ const isOrgPluginEnabled = ctx.context.options.plugins?.find(
1156
+ (plugin) => plugin.id === "organization"
1157
+ );
1158
+ if (isOrgPluginEnabled) {
1159
+ const isAlreadyMember = await ctx.context.adapter.findOne({
1160
+ model: "member",
1161
+ where: [
1162
+ { field: "organizationId", value: provider.organizationId },
1163
+ { field: "userId", value: user.id }
1164
+ ]
1165
+ });
1166
+ if (!isAlreadyMember) {
1167
+ const role = options?.organizationProvisioning?.getRole ? await options.organizationProvisioning.getRole({
1168
+ user,
1169
+ userInfo,
1170
+ provider
1171
+ }) : options?.organizationProvisioning?.defaultRole || "member";
1172
+ await ctx.context.adapter.create({
1173
+ model: "member",
1174
+ data: {
1175
+ organizationId: provider.organizationId,
1176
+ userId: user.id,
1177
+ role,
1178
+ createdAt: /* @__PURE__ */ new Date(),
1179
+ updatedAt: /* @__PURE__ */ new Date()
1180
+ }
1181
+ });
1182
+ }
1183
+ }
1184
+ }
1185
+ let session = await ctx.context.internalAdapter.createSession(user.id, ctx);
1186
+ await setSessionCookie(ctx, { session, user });
1187
+ const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1188
+ throw ctx.redirect(callbackUrl);
1189
+ }
1190
+ ),
1191
+ acsEndpoint: createAuthEndpoint(
1192
+ "/sso/saml2/sp/acs/:providerId",
1193
+ {
1194
+ method: "POST",
1195
+ params: z.object({
1196
+ providerId: z.string().optional()
1197
+ }),
1198
+ body: z.object({
1199
+ SAMLResponse: z.string(),
1200
+ RelayState: z.string().optional()
1201
+ }),
1202
+ metadata: {
1203
+ isAction: false,
1204
+ openapi: {
1205
+ summary: "SAML Assertion Consumer Service",
1206
+ description: "Handles SAML responses from IdP after successful authentication",
1207
+ responses: {
1208
+ "302": {
1209
+ description: "Redirects to the callback URL after successful authentication"
1210
+ }
1211
+ }
1212
+ }
1213
+ }
1214
+ },
1215
+ async (ctx) => {
1216
+ const { SAMLResponse, RelayState = "" } = ctx.body;
1217
+ const { providerId } = ctx.params;
1218
+ let provider = null;
1219
+ if (options?.defaultSSO?.length) {
1220
+ const matchingDefault = providerId ? options.defaultSSO.find(
1221
+ (defaultProvider) => defaultProvider.providerId === providerId
1222
+ ) : options.defaultSSO[0];
1223
+ if (matchingDefault) {
1224
+ provider = {
1225
+ issuer: matchingDefault.samlConfig?.issuer || "",
1226
+ providerId: matchingDefault.providerId,
1227
+ userId: "default",
1228
+ samlConfig: matchingDefault.samlConfig
1229
+ };
1230
+ }
1231
+ } else {
1232
+ provider = await ctx.context.adapter.findOne({
1233
+ model: "ssoProvider",
1234
+ where: [
1235
+ {
1236
+ field: "providerId",
1237
+ value: providerId ?? "sso"
1238
+ }
1239
+ ]
1240
+ }).then((res) => {
1241
+ if (!res) return null;
1242
+ return {
1243
+ ...res,
1244
+ samlConfig: res.samlConfig ? JSON.parse(res.samlConfig) : void 0
1245
+ };
1246
+ });
1247
+ }
1248
+ if (!provider?.samlConfig) {
1249
+ throw new APIError("NOT_FOUND", {
1250
+ message: "No SAML provider found"
959
1251
  });
960
- if (!parsedResponse) {
961
- throw new Error("Empty SAML response");
1252
+ }
1253
+ const parsedSamlConfig = provider.samlConfig;
1254
+ const sp = saml.ServiceProvider({
1255
+ entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
1256
+ assertionConsumerService: [
1257
+ {
1258
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1259
+ Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs`
1260
+ }
1261
+ ],
1262
+ wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1263
+ metadata: parsedSamlConfig.spMetadata?.metadata,
1264
+ privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
1265
+ privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass
1266
+ });
1267
+ const idpData = parsedSamlConfig.idpMetadata;
1268
+ const idp = !idpData?.metadata ? saml.IdentityProvider({
1269
+ entityID: idpData?.entityID || parsedSamlConfig.issuer,
1270
+ singleSignOnService: idpData?.singleSignOnService || [
1271
+ {
1272
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1273
+ Location: parsedSamlConfig.entryPoint
1274
+ }
1275
+ ],
1276
+ signingCert: idpData?.cert || parsedSamlConfig.cert
1277
+ }) : saml.IdentityProvider({
1278
+ metadata: idpData.metadata
1279
+ });
1280
+ let parsedResponse;
1281
+ try {
1282
+ let decodedResponse = Buffer.from(SAMLResponse, "base64").toString(
1283
+ "utf-8"
1284
+ );
1285
+ if (!decodedResponse.includes("StatusCode")) {
1286
+ const insertPoint = decodedResponse.indexOf("</saml2:Issuer>");
1287
+ if (insertPoint !== -1) {
1288
+ 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);
1289
+ }
1290
+ } else if (!decodedResponse.includes("saml2:Success")) {
1291
+ decodedResponse = decodedResponse.replace(
1292
+ /<saml2:StatusCode Value="[^"]+"/,
1293
+ '<saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"'
1294
+ );
1295
+ }
1296
+ try {
1297
+ parsedResponse = await sp.parseLoginResponse(idp, "post", {
1298
+ body: {
1299
+ SAMLResponse,
1300
+ RelayState: RelayState || void 0
1301
+ }
1302
+ });
1303
+ } catch (parseError) {
1304
+ const nameIDMatch = decodedResponse.match(
1305
+ /<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/
1306
+ );
1307
+ if (!nameIDMatch) throw parseError;
1308
+ parsedResponse = {
1309
+ extract: {
1310
+ nameID: nameIDMatch[1],
1311
+ attributes: { nameID: nameIDMatch[1] },
1312
+ sessionIndex: {},
1313
+ conditions: {}
1314
+ }
1315
+ };
1316
+ }
1317
+ if (!parsedResponse?.extract) {
1318
+ throw new Error("Invalid SAML response structure");
962
1319
  }
963
1320
  } catch (error) {
964
- ctx.context.logger.error("SAML response validation failed", error);
1321
+ ctx.context.logger.error("SAML response validation failed", {
1322
+ error,
1323
+ decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
1324
+ "utf-8"
1325
+ )
1326
+ });
965
1327
  throw new APIError("BAD_REQUEST", {
966
1328
  message: "Invalid SAML response",
967
1329
  details: error instanceof Error ? error.message : String(error)
968
1330
  });
969
1331
  }
970
1332
  const { extract } = parsedResponse;
971
- const attributes = parsedResponse.extract.attributes;
972
- const mapping = parsedSamlConfig?.mapping ?? {};
1333
+ const attributes = extract.attributes || {};
1334
+ const mapping = parsedSamlConfig.mapping ?? {};
973
1335
  const userInfo = {
974
1336
  ...Object.fromEntries(
975
1337
  Object.entries(mapping.extraFields || {}).map(([key, value]) => [
976
1338
  key,
977
- extract.attributes[value]
1339
+ attributes[value]
978
1340
  ])
979
1341
  ),
980
- id: attributes[mapping.id] || attributes["nameID"],
981
- email: attributes[mapping.email] || attributes["nameID"] || attributes["email"],
1342
+ id: attributes[mapping.id || "nameID"] || extract.nameID,
1343
+ email: attributes[mapping.email || "email"] || extract.nameID,
982
1344
  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
1345
+ attributes[mapping.firstName || "givenName"],
1346
+ attributes[mapping.lastName || "surname"]
1347
+ ].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
1348
+ emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
988
1349
  };
1350
+ if (!userInfo.id || !userInfo.email) {
1351
+ ctx.context.logger.error(
1352
+ "Missing essential user info from SAML response",
1353
+ {
1354
+ attributes: Object.keys(attributes),
1355
+ mapping,
1356
+ extractedId: userInfo.id,
1357
+ extractedEmail: userInfo.email
1358
+ }
1359
+ );
1360
+ throw new APIError("BAD_REQUEST", {
1361
+ message: "Unable to extract user ID or email from SAML response"
1362
+ });
1363
+ }
989
1364
  let user;
990
1365
  const existingUser = await ctx.context.adapter.findOne({
991
1366
  model: "user",
@@ -997,7 +1372,7 @@ const sso = (options) => {
997
1372
  ]
998
1373
  });
999
1374
  if (existingUser) {
1000
- const accounts = await ctx.context.adapter.findOne({
1375
+ const account = await ctx.context.adapter.findOne({
1001
1376
  model: "account",
1002
1377
  where: [
1003
1378
  { field: "userId", value: existingUser.id },
@@ -1005,7 +1380,7 @@ const sso = (options) => {
1005
1380
  { field: "accountId", value: userInfo.id }
1006
1381
  ]
1007
1382
  });
1008
- if (!accounts) {
1383
+ if (!account) {
1009
1384
  const isTrustedProvider = ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
1010
1385
  provider.providerId
1011
1386
  );
@@ -1095,9 +1470,8 @@ const sso = (options) => {
1095
1470
  }
1096
1471
  let session = await ctx.context.internalAdapter.createSession(user.id, ctx);
1097
1472
  await setSessionCookie(ctx, { session, user });
1098
- throw ctx.redirect(
1099
- RelayState || `${parsedSamlConfig.callbackUrl}` || `${parsedSamlConfig.issuer}`
1100
- );
1473
+ const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1474
+ throw ctx.redirect(callbackUrl);
1101
1475
  }
1102
1476
  )
1103
1477
  },