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

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
@@ -91,64 +91,57 @@ const sso = (options) => {
91
91
  {
92
92
  method: "POST",
93
93
  body: z__namespace.object({
94
- providerId: z__namespace.string({}).meta({
95
- description: "The ID of the provider. This is used to identify the provider during login and callback"
96
- }),
97
- issuer: z__namespace.string({}).meta({
98
- description: "The issuer of the provider"
99
- }),
100
- domain: z__namespace.string({}).meta({
101
- description: "The domain of the provider. This is used for email matching"
102
- }),
94
+ providerId: z__namespace.string({}).describe(
95
+ "The ID of the provider. This is used to identify the provider during login and callback"
96
+ ),
97
+ issuer: z__namespace.string({}).describe("The issuer of the provider"),
98
+ domain: z__namespace.string({}).describe(
99
+ "The domain of the provider. This is used for email matching"
100
+ ),
103
101
  oidcConfig: z__namespace.object({
104
- clientId: z__namespace.string({}).meta({
105
- description: "The client ID"
106
- }),
107
- clientSecret: z__namespace.string({}).meta({
108
- description: "The client secret"
109
- }),
110
- authorizationEndpoint: z__namespace.string({}).meta({
111
- description: "The authorization endpoint"
112
- }).optional(),
113
- tokenEndpoint: z__namespace.string({}).meta({
114
- description: "The token endpoint"
115
- }).optional(),
116
- userInfoEndpoint: z__namespace.string({}).meta({
117
- description: "The user info endpoint"
118
- }).optional(),
102
+ clientId: z__namespace.string({}).describe("The client ID"),
103
+ clientSecret: z__namespace.string({}).describe("The client secret"),
104
+ authorizationEndpoint: z__namespace.string({}).describe("The authorization endpoint").optional(),
105
+ tokenEndpoint: z__namespace.string({}).describe("The token endpoint").optional(),
106
+ userInfoEndpoint: z__namespace.string({}).describe("The user info endpoint").optional(),
119
107
  tokenEndpointAuthentication: z__namespace.enum(["client_secret_post", "client_secret_basic"]).optional(),
120
- jwksEndpoint: z__namespace.string({}).meta({
121
- description: "The JWKS endpoint"
122
- }).optional(),
108
+ jwksEndpoint: z__namespace.string({}).describe("The JWKS endpoint").optional(),
123
109
  discoveryEndpoint: z__namespace.string().optional(),
124
- scopes: z__namespace.array(z__namespace.string(), {}).meta({
125
- description: "The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']"
126
- }).optional(),
127
- pkce: z__namespace.boolean({}).meta({
128
- description: "Whether to use PKCE for the authorization flow"
129
- }).default(true).optional()
110
+ scopes: z__namespace.array(z__namespace.string(), {}).describe("The scopes to request. ").optional(),
111
+ pkce: z__namespace.boolean({}).describe("Whether to use PKCE for the authorization flow").default(true).optional(),
112
+ mapping: z__namespace.object({
113
+ id: z__namespace.string({}).describe("Field mapping for user ID ("),
114
+ email: z__namespace.string({}).describe("Field mapping for email ("),
115
+ emailVerified: z__namespace.string({}).describe("Field mapping for email verification (").optional(),
116
+ name: z__namespace.string({}).describe("Field mapping for name ("),
117
+ image: z__namespace.string({}).describe("Field mapping for image (").optional(),
118
+ extraFields: z__namespace.record(z__namespace.string(), z__namespace.any()).optional()
119
+ }).optional()
130
120
  }).optional(),
131
121
  samlConfig: z__namespace.object({
132
- entryPoint: z__namespace.string({}).meta({
133
- description: "The entry point of the provider"
134
- }),
135
- cert: z__namespace.string({}).meta({
136
- description: "The certificate of the provider"
137
- }),
138
- callbackUrl: z__namespace.string({}).meta({
139
- description: "The callback URL of the provider"
140
- }),
122
+ entryPoint: z__namespace.string({}).describe("The entry point of the provider"),
123
+ cert: z__namespace.string({}).describe("The certificate of the provider"),
124
+ callbackUrl: z__namespace.string({}).describe("The callback URL of the provider"),
141
125
  audience: z__namespace.string().optional(),
142
126
  idpMetadata: z__namespace.object({
143
- metadata: z__namespace.string(),
127
+ metadata: z__namespace.string().optional(),
128
+ entityID: z__namespace.string().optional(),
129
+ cert: z__namespace.string().optional(),
144
130
  privateKey: z__namespace.string().optional(),
145
131
  privateKeyPass: z__namespace.string().optional(),
146
132
  isAssertionEncrypted: z__namespace.boolean().optional(),
147
133
  encPrivateKey: z__namespace.string().optional(),
148
- encPrivateKeyPass: z__namespace.string().optional()
134
+ encPrivateKeyPass: z__namespace.string().optional(),
135
+ singleSignOnService: z__namespace.array(
136
+ z__namespace.object({
137
+ Binding: z__namespace.string().describe("The binding type for the SSO service"),
138
+ Location: z__namespace.string().describe("The URL for the SSO service")
139
+ })
140
+ ).optional().describe("Single Sign-On service configuration")
149
141
  }).optional(),
150
142
  spMetadata: z__namespace.object({
151
- metadata: z__namespace.string(),
143
+ metadata: z__namespace.string().optional(),
144
+ entityID: z__namespace.string().optional(),
152
145
  binding: z__namespace.string().optional(),
153
146
  privateKey: z__namespace.string().optional(),
154
147
  privateKeyPass: z__namespace.string().optional(),
@@ -162,32 +155,23 @@ const sso = (options) => {
162
155
  identifierFormat: z__namespace.string().optional(),
163
156
  privateKey: z__namespace.string().optional(),
164
157
  decryptionPvk: z__namespace.string().optional(),
165
- additionalParams: z__namespace.record(z__namespace.string(), z__namespace.any()).optional()
158
+ additionalParams: z__namespace.record(z__namespace.string(), z__namespace.any()).optional(),
159
+ mapping: z__namespace.object({
160
+ id: z__namespace.string({}).describe("Field mapping for user ID ("),
161
+ email: z__namespace.string({}).describe("Field mapping for email ("),
162
+ emailVerified: z__namespace.string({}).describe("Field mapping for email verification").optional(),
163
+ name: z__namespace.string({}).describe("Field mapping for name ("),
164
+ firstName: z__namespace.string({}).describe("Field mapping for first name (").optional(),
165
+ lastName: z__namespace.string({}).describe("Field mapping for last name (").optional(),
166
+ extraFields: z__namespace.record(z__namespace.string(), z__namespace.any()).optional()
167
+ }).optional()
166
168
  }).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()
184
- }).optional(),
185
- organizationId: z__namespace.string({}).meta({
186
- description: "If organization plugin is enabled, the organization id to link the provider to"
187
- }).optional(),
188
- overrideUserInfo: z__namespace.boolean({}).meta({
189
- description: "Override user info with the provider info. Defaults to false"
190
- }).default(false).optional()
169
+ organizationId: z__namespace.string({}).describe(
170
+ "If organization plugin is enabled, the organization id to link the provider to"
171
+ ).optional(),
172
+ overrideUserInfo: z__namespace.boolean({}).describe(
173
+ "Override user info with the provider info. Defaults to false"
174
+ ).default(false).optional()
191
175
  }),
192
176
  use: [api.sessionMiddleware],
193
177
  metadata: {
@@ -417,7 +401,7 @@ const sso = (options) => {
417
401
  jwksEndpoint: body.oidcConfig.jwksEndpoint,
418
402
  pkce: body.oidcConfig.pkce,
419
403
  discoveryEndpoint: body.oidcConfig.discoveryEndpoint || `${body.issuer}/.well-known/openid-configuration`,
420
- mapping: body.mapping,
404
+ mapping: body.oidcConfig.mapping,
421
405
  scopes: body.oidcConfig.scopes,
422
406
  userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
423
407
  overrideUserInfo: ctx.body.overrideUserInfo || options?.defaultOverrideUserInfo || false
@@ -437,7 +421,7 @@ const sso = (options) => {
437
421
  privateKey: body.samlConfig.privateKey,
438
422
  decryptionPvk: body.samlConfig.decryptionPvk,
439
423
  additionalParams: body.samlConfig.additionalParams,
440
- mapping: body.mapping
424
+ mapping: body.samlConfig.mapping
441
425
  }) : null,
442
426
  organizationId: body.organizationId,
443
427
  userId: ctx.context.session.user.id,
@@ -461,33 +445,21 @@ const sso = (options) => {
461
445
  {
462
446
  method: "POST",
463
447
  body: z__namespace.object({
464
- email: z__namespace.string({}).meta({
465
- 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"
466
- }).optional(),
467
- organizationSlug: z__namespace.string({}).meta({
468
- description: "The slug of the organization to sign in with"
469
- }).optional(),
470
- providerId: z__namespace.string({}).meta({
471
- description: "The ID of the provider to sign in with. This can be provided instead of email or issuer"
472
- }).optional(),
473
- domain: z__namespace.string({}).meta({
474
- description: "The domain of the provider."
475
- }).optional(),
476
- callbackURL: z__namespace.string({}).meta({
477
- description: "The URL to redirect to after login"
478
- }),
479
- errorCallbackURL: z__namespace.string({}).meta({
480
- description: "The URL to redirect to after login"
481
- }).optional(),
482
- newUserCallbackURL: z__namespace.string({}).meta({
483
- description: "The URL to redirect to after login if the user is new"
484
- }).optional(),
485
- scopes: z__namespace.array(z__namespace.string(), {}).meta({
486
- description: "Scopes to request from the provider."
487
- }).optional(),
488
- requestSignUp: z__namespace.boolean({}).meta({
489
- description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider"
490
- }).optional(),
448
+ email: z__namespace.string({}).describe(
449
+ "The email address to sign in with. This is used to identify the issuer to sign in with"
450
+ ).optional(),
451
+ organizationSlug: z__namespace.string({}).describe("The slug of the organization to sign in with").optional(),
452
+ providerId: z__namespace.string({}).describe(
453
+ "The ID of the provider to sign in with. This can be provided instead of email or issuer"
454
+ ).optional(),
455
+ domain: z__namespace.string({}).describe("The domain of the provider.").optional(),
456
+ callbackURL: z__namespace.string({}).describe("The URL to redirect to after login"),
457
+ errorCallbackURL: z__namespace.string({}).describe("The URL to redirect to after login").optional(),
458
+ newUserCallbackURL: z__namespace.string({}).describe("The URL to redirect to after login if the user is new").optional(),
459
+ scopes: z__namespace.array(z__namespace.string(), {}).describe("Scopes to request from the provider.").optional(),
460
+ requestSignUp: z__namespace.boolean({}).describe(
461
+ "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider"
462
+ ).optional(),
491
463
  providerType: z__namespace.enum(["oidc", "saml"]).optional()
492
464
  }),
493
465
  metadata: {
@@ -561,7 +533,7 @@ const sso = (options) => {
561
533
  async (ctx) => {
562
534
  const body = ctx.body;
563
535
  let { email, organizationSlug, providerId, domain } = body;
564
- if (!email && !organizationSlug && !domain && !providerId) {
536
+ if (!options?.defaultSSO?.length && !email && !organizationSlug && !domain && !providerId) {
565
537
  throw new api.APIError("BAD_REQUEST", {
566
538
  message: "email, organizationSlug, domain or providerId is required"
567
539
  });
@@ -584,23 +556,48 @@ const sso = (options) => {
584
556
  return res.id;
585
557
  });
586
558
  }
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;
559
+ let provider = null;
560
+ if (options?.defaultSSO?.length) {
561
+ const matchingDefault = providerId ? options.defaultSSO.find(
562
+ (defaultProvider) => defaultProvider.providerId === providerId
563
+ ) : options.defaultSSO.find(
564
+ (defaultProvider) => defaultProvider.domain === domain
565
+ );
566
+ if (matchingDefault) {
567
+ provider = {
568
+ issuer: matchingDefault.samlConfig?.issuer || matchingDefault.oidcConfig?.issuer || "",
569
+ providerId: matchingDefault.providerId,
570
+ userId: "default",
571
+ oidcConfig: matchingDefault.oidcConfig,
572
+ samlConfig: matchingDefault.samlConfig
573
+ };
598
574
  }
599
- return {
600
- ...res,
601
- oidcConfig: JSON.parse(res.oidcConfig)
602
- };
603
- });
575
+ }
576
+ if (!providerId && !orgId && !domain) {
577
+ throw new api.APIError("BAD_REQUEST", {
578
+ message: "providerId, orgId or domain is required"
579
+ });
580
+ }
581
+ if (!provider) {
582
+ provider = await ctx.context.adapter.findOne({
583
+ model: "ssoProvider",
584
+ where: [
585
+ {
586
+ field: providerId ? "providerId" : orgId ? "organizationId" : "domain",
587
+ value: providerId || orgId || domain
588
+ }
589
+ ]
590
+ }).then((res) => {
591
+ if (!res) {
592
+ return null;
593
+ }
594
+ return {
595
+ ...res,
596
+ oidcConfig: res.oidcConfig ? JSON.parse(res.oidcConfig) : void 0,
597
+ samlConfig: res.samlConfig ? JSON.parse(res.samlConfig) : void 0
598
+ };
599
+ });
600
+ }
604
601
  if (!provider) {
605
602
  throw new api.APIError("NOT_FOUND", {
606
603
  message: "No provider found for the issuer"
@@ -644,15 +641,16 @@ const sso = (options) => {
644
641
  });
645
642
  }
646
643
  if (provider.samlConfig) {
647
- const parsedSamlConfig = JSON.parse(
648
- provider.samlConfig
649
- );
644
+ const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : JSON.parse(provider.samlConfig);
650
645
  const sp = saml__namespace.ServiceProvider({
651
646
  metadata: parsedSamlConfig.spMetadata.metadata,
652
647
  allowCreate: true
653
648
  });
654
649
  const idp = saml__namespace.IdentityProvider({
655
- metadata: parsedSamlConfig.idpMetadata.metadata
650
+ metadata: parsedSamlConfig.idpMetadata.metadata,
651
+ entityID: parsedSamlConfig.idpMetadata.entityID,
652
+ encryptCert: parsedSamlConfig.idpMetadata.cert,
653
+ singleSignOnService: parsedSamlConfig.idpMetadata.singleSignOnService
656
654
  });
657
655
  const loginRequest = sp.createLoginRequest(
658
656
  idp,
@@ -711,23 +709,38 @@ const sso = (options) => {
711
709
  `${errorURL || callbackURL}?error=${error}&error_description=${error_description}`
712
710
  );
713
711
  }
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;
712
+ let provider = null;
713
+ if (options?.defaultSSO?.length) {
714
+ const matchingDefault = options.defaultSSO.find(
715
+ (defaultProvider) => defaultProvider.providerId === ctx.params.providerId
716
+ );
717
+ if (matchingDefault) {
718
+ provider = {
719
+ ...matchingDefault,
720
+ issuer: matchingDefault.oidcConfig?.issuer || "",
721
+ userId: "default"
722
+ };
725
723
  }
726
- return {
727
- ...res,
728
- oidcConfig: JSON.parse(res.oidcConfig)
729
- };
730
- });
724
+ }
725
+ if (!provider) {
726
+ provider = await ctx.context.adapter.findOne({
727
+ model: "ssoProvider",
728
+ where: [
729
+ {
730
+ field: "providerId",
731
+ value: ctx.params.providerId
732
+ }
733
+ ]
734
+ }).then((res) => {
735
+ if (!res) {
736
+ return null;
737
+ }
738
+ return {
739
+ ...res,
740
+ oidcConfig: JSON.parse(res.oidcConfig)
741
+ };
742
+ });
743
+ }
731
744
  if (!provider) {
732
745
  throw ctx.redirect(
733
746
  `${errorURL || callbackURL}/error?error=invalid_provider&error_description=provider not found`
@@ -951,10 +964,31 @@ const sso = (options) => {
951
964
  async (ctx) => {
952
965
  const { SAMLResponse, RelayState } = ctx.body;
953
966
  const { providerId } = ctx.params;
954
- const provider = await ctx.context.adapter.findOne({
955
- model: "ssoProvider",
956
- where: [{ field: "providerId", value: providerId }]
957
- });
967
+ let provider = null;
968
+ if (options?.defaultSSO?.length) {
969
+ const matchingDefault = options.defaultSSO.find(
970
+ (defaultProvider) => defaultProvider.providerId === providerId
971
+ );
972
+ if (matchingDefault) {
973
+ provider = {
974
+ ...matchingDefault,
975
+ userId: "default",
976
+ issuer: matchingDefault.samlConfig?.issuer || ""
977
+ };
978
+ }
979
+ }
980
+ if (!provider) {
981
+ provider = await ctx.context.adapter.findOne({
982
+ model: "ssoProvider",
983
+ where: [{ field: "providerId", value: providerId }]
984
+ }).then((res) => {
985
+ if (!res) return null;
986
+ return {
987
+ ...res,
988
+ samlConfig: res.samlConfig ? JSON.parse(res.samlConfig) : void 0
989
+ };
990
+ });
991
+ }
958
992
  if (!provider) {
959
993
  throw new api.APIError("NOT_FOUND", {
960
994
  message: "No provider found for the given providerId"
@@ -963,46 +997,387 @@ const sso = (options) => {
963
997
  const parsedSamlConfig = JSON.parse(
964
998
  provider.samlConfig
965
999
  );
966
- const idp = saml__namespace.IdentityProvider({
967
- metadata: parsedSamlConfig.idpMetadata.metadata
968
- });
1000
+ const idpData = parsedSamlConfig.idpMetadata;
1001
+ let idp = null;
1002
+ if (!idpData?.metadata) {
1003
+ idp = saml__namespace.IdentityProvider({
1004
+ entityID: idpData.entityID || parsedSamlConfig.issuer,
1005
+ singleSignOnService: [
1006
+ {
1007
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1008
+ Location: parsedSamlConfig.entryPoint
1009
+ }
1010
+ ],
1011
+ signingCert: idpData.cert || parsedSamlConfig.cert,
1012
+ wantAuthnRequestsSigned: parsedSamlConfig.wantAssertionsSigned || false,
1013
+ isAssertionEncrypted: idpData.isAssertionEncrypted || false,
1014
+ encPrivateKey: idpData.encPrivateKey,
1015
+ encPrivateKeyPass: idpData.encPrivateKeyPass
1016
+ });
1017
+ } else {
1018
+ idp = saml__namespace.IdentityProvider({
1019
+ metadata: idpData.metadata,
1020
+ privateKey: idpData.privateKey,
1021
+ privateKeyPass: idpData.privateKeyPass,
1022
+ isAssertionEncrypted: idpData.isAssertionEncrypted,
1023
+ encPrivateKey: idpData.encPrivateKey,
1024
+ encPrivateKeyPass: idpData.encPrivateKeyPass
1025
+ });
1026
+ }
1027
+ const spData = parsedSamlConfig.spMetadata;
969
1028
  const sp = saml__namespace.ServiceProvider({
970
- metadata: parsedSamlConfig.spMetadata.metadata
1029
+ metadata: spData?.metadata,
1030
+ entityID: spData?.entityID || parsedSamlConfig.issuer,
1031
+ assertionConsumerService: spData?.metadata ? void 0 : [
1032
+ {
1033
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1034
+ Location: parsedSamlConfig.callbackUrl
1035
+ }
1036
+ ],
1037
+ privateKey: spData?.privateKey || parsedSamlConfig.privateKey,
1038
+ privateKeyPass: spData?.privateKeyPass,
1039
+ isAssertionEncrypted: spData?.isAssertionEncrypted || false,
1040
+ encPrivateKey: spData?.encPrivateKey,
1041
+ encPrivateKeyPass: spData?.encPrivateKeyPass,
1042
+ wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false
971
1043
  });
972
1044
  let parsedResponse;
973
1045
  try {
974
- parsedResponse = await sp.parseLoginResponse(idp, "post", {
975
- body: { SAMLResponse, RelayState }
1046
+ const decodedResponse = Buffer.from(
1047
+ SAMLResponse,
1048
+ "base64"
1049
+ ).toString("utf-8");
1050
+ try {
1051
+ parsedResponse = await sp.parseLoginResponse(idp, "post", {
1052
+ body: {
1053
+ SAMLResponse,
1054
+ RelayState: RelayState || void 0
1055
+ }
1056
+ });
1057
+ } catch (parseError) {
1058
+ const nameIDMatch = decodedResponse.match(
1059
+ /<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/
1060
+ );
1061
+ if (!nameIDMatch) throw parseError;
1062
+ parsedResponse = {
1063
+ extract: {
1064
+ nameID: nameIDMatch[1],
1065
+ attributes: { nameID: nameIDMatch[1] },
1066
+ sessionIndex: {},
1067
+ conditions: {}
1068
+ }
1069
+ };
1070
+ }
1071
+ if (!parsedResponse?.extract) {
1072
+ throw new Error("Invalid SAML response structure");
1073
+ }
1074
+ } catch (error) {
1075
+ ctx.context.logger.error("SAML response validation failed", {
1076
+ error,
1077
+ decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
1078
+ "utf-8"
1079
+ )
1080
+ });
1081
+ throw new api.APIError("BAD_REQUEST", {
1082
+ message: "Invalid SAML response",
1083
+ details: error instanceof Error ? error.message : String(error)
1084
+ });
1085
+ }
1086
+ const { extract } = parsedResponse;
1087
+ const attributes = extract.attributes || {};
1088
+ const mapping = parsedSamlConfig.mapping ?? {};
1089
+ const userInfo = {
1090
+ ...Object.fromEntries(
1091
+ Object.entries(mapping.extraFields || {}).map(([key, value]) => [
1092
+ key,
1093
+ attributes[value]
1094
+ ])
1095
+ ),
1096
+ id: attributes[mapping.id || "nameID"] || extract.nameID,
1097
+ email: attributes[mapping.email || "email"] || extract.nameID,
1098
+ name: [
1099
+ attributes[mapping.firstName || "givenName"],
1100
+ attributes[mapping.lastName || "surname"]
1101
+ ].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
1102
+ emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
1103
+ };
1104
+ if (!userInfo.id || !userInfo.email) {
1105
+ ctx.context.logger.error(
1106
+ "Missing essential user info from SAML response",
1107
+ {
1108
+ attributes: Object.keys(attributes),
1109
+ mapping,
1110
+ extractedId: userInfo.id,
1111
+ extractedEmail: userInfo.email
1112
+ }
1113
+ );
1114
+ throw new api.APIError("BAD_REQUEST", {
1115
+ message: "Unable to extract user ID or email from SAML response"
1116
+ });
1117
+ }
1118
+ let user;
1119
+ const existingUser = await ctx.context.adapter.findOne({
1120
+ model: "user",
1121
+ where: [
1122
+ {
1123
+ field: "email",
1124
+ value: userInfo.email
1125
+ }
1126
+ ]
1127
+ });
1128
+ if (existingUser) {
1129
+ user = existingUser;
1130
+ } else {
1131
+ user = await ctx.context.adapter.create({
1132
+ model: "user",
1133
+ data: {
1134
+ email: userInfo.email,
1135
+ name: userInfo.name,
1136
+ emailVerified: userInfo.emailVerified,
1137
+ createdAt: /* @__PURE__ */ new Date(),
1138
+ updatedAt: /* @__PURE__ */ new Date()
1139
+ }
1140
+ });
1141
+ }
1142
+ const account = await ctx.context.adapter.findOne({
1143
+ model: "account",
1144
+ where: [
1145
+ { field: "userId", value: user.id },
1146
+ { field: "providerId", value: provider.providerId },
1147
+ { field: "accountId", value: userInfo.id }
1148
+ ]
1149
+ });
1150
+ if (!account) {
1151
+ await ctx.context.adapter.create({
1152
+ model: "account",
1153
+ data: {
1154
+ userId: user.id,
1155
+ providerId: provider.providerId,
1156
+ accountId: userInfo.id,
1157
+ createdAt: /* @__PURE__ */ new Date(),
1158
+ updatedAt: /* @__PURE__ */ new Date(),
1159
+ accessToken: "",
1160
+ refreshToken: ""
1161
+ }
1162
+ });
1163
+ }
1164
+ if (options?.provisionUser) {
1165
+ await options.provisionUser({
1166
+ user,
1167
+ userInfo,
1168
+ provider
1169
+ });
1170
+ }
1171
+ if (provider.organizationId && !options?.organizationProvisioning?.disabled) {
1172
+ const isOrgPluginEnabled = ctx.context.options.plugins?.find(
1173
+ (plugin) => plugin.id === "organization"
1174
+ );
1175
+ if (isOrgPluginEnabled) {
1176
+ const isAlreadyMember = await ctx.context.adapter.findOne({
1177
+ model: "member",
1178
+ where: [
1179
+ { field: "organizationId", value: provider.organizationId },
1180
+ { field: "userId", value: user.id }
1181
+ ]
1182
+ });
1183
+ if (!isAlreadyMember) {
1184
+ const role = options?.organizationProvisioning?.getRole ? await options.organizationProvisioning.getRole({
1185
+ user,
1186
+ userInfo,
1187
+ provider
1188
+ }) : options?.organizationProvisioning?.defaultRole || "member";
1189
+ await ctx.context.adapter.create({
1190
+ model: "member",
1191
+ data: {
1192
+ organizationId: provider.organizationId,
1193
+ userId: user.id,
1194
+ role,
1195
+ createdAt: /* @__PURE__ */ new Date(),
1196
+ updatedAt: /* @__PURE__ */ new Date()
1197
+ }
1198
+ });
1199
+ }
1200
+ }
1201
+ }
1202
+ let session = await ctx.context.internalAdapter.createSession(user.id, ctx);
1203
+ await cookies.setSessionCookie(ctx, { session, user });
1204
+ const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1205
+ throw ctx.redirect(callbackUrl);
1206
+ }
1207
+ ),
1208
+ acsEndpoint: plugins.createAuthEndpoint(
1209
+ "/sso/saml2/sp/acs/:providerId",
1210
+ {
1211
+ method: "POST",
1212
+ params: z__namespace.object({
1213
+ providerId: z__namespace.string().optional()
1214
+ }),
1215
+ body: z__namespace.object({
1216
+ SAMLResponse: z__namespace.string(),
1217
+ RelayState: z__namespace.string().optional()
1218
+ }),
1219
+ metadata: {
1220
+ isAction: false,
1221
+ openapi: {
1222
+ summary: "SAML Assertion Consumer Service",
1223
+ description: "Handles SAML responses from IdP after successful authentication",
1224
+ responses: {
1225
+ "302": {
1226
+ description: "Redirects to the callback URL after successful authentication"
1227
+ }
1228
+ }
1229
+ }
1230
+ }
1231
+ },
1232
+ async (ctx) => {
1233
+ const { SAMLResponse, RelayState = "" } = ctx.body;
1234
+ const { providerId } = ctx.params;
1235
+ let provider = null;
1236
+ if (options?.defaultSSO?.length) {
1237
+ const matchingDefault = providerId ? options.defaultSSO.find(
1238
+ (defaultProvider) => defaultProvider.providerId === providerId
1239
+ ) : options.defaultSSO[0];
1240
+ if (matchingDefault) {
1241
+ provider = {
1242
+ issuer: matchingDefault.samlConfig?.issuer || "",
1243
+ providerId: matchingDefault.providerId,
1244
+ userId: "default",
1245
+ samlConfig: matchingDefault.samlConfig
1246
+ };
1247
+ }
1248
+ } else {
1249
+ provider = await ctx.context.adapter.findOne({
1250
+ model: "ssoProvider",
1251
+ where: [
1252
+ {
1253
+ field: "providerId",
1254
+ value: providerId ?? "sso"
1255
+ }
1256
+ ]
1257
+ }).then((res) => {
1258
+ if (!res) return null;
1259
+ return {
1260
+ ...res,
1261
+ samlConfig: res.samlConfig ? JSON.parse(res.samlConfig) : void 0
1262
+ };
1263
+ });
1264
+ }
1265
+ if (!provider?.samlConfig) {
1266
+ throw new api.APIError("NOT_FOUND", {
1267
+ message: "No SAML provider found"
976
1268
  });
977
- if (!parsedResponse) {
978
- throw new Error("Empty SAML response");
1269
+ }
1270
+ const parsedSamlConfig = provider.samlConfig;
1271
+ const sp = saml__namespace.ServiceProvider({
1272
+ entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
1273
+ assertionConsumerService: [
1274
+ {
1275
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1276
+ Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs`
1277
+ }
1278
+ ],
1279
+ wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1280
+ metadata: parsedSamlConfig.spMetadata?.metadata,
1281
+ privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
1282
+ privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass
1283
+ });
1284
+ const idpData = parsedSamlConfig.idpMetadata;
1285
+ const idp = !idpData?.metadata ? saml__namespace.IdentityProvider({
1286
+ entityID: idpData?.entityID || parsedSamlConfig.issuer,
1287
+ singleSignOnService: idpData?.singleSignOnService || [
1288
+ {
1289
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1290
+ Location: parsedSamlConfig.entryPoint
1291
+ }
1292
+ ],
1293
+ signingCert: idpData?.cert || parsedSamlConfig.cert
1294
+ }) : saml__namespace.IdentityProvider({
1295
+ metadata: idpData.metadata
1296
+ });
1297
+ let parsedResponse;
1298
+ try {
1299
+ let decodedResponse = Buffer.from(SAMLResponse, "base64").toString(
1300
+ "utf-8"
1301
+ );
1302
+ if (!decodedResponse.includes("StatusCode")) {
1303
+ const insertPoint = decodedResponse.indexOf("</saml2:Issuer>");
1304
+ if (insertPoint !== -1) {
1305
+ 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);
1306
+ }
1307
+ } else if (!decodedResponse.includes("saml2:Success")) {
1308
+ decodedResponse = decodedResponse.replace(
1309
+ /<saml2:StatusCode Value="[^"]+"/,
1310
+ '<saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"'
1311
+ );
1312
+ }
1313
+ try {
1314
+ parsedResponse = await sp.parseLoginResponse(idp, "post", {
1315
+ body: {
1316
+ SAMLResponse,
1317
+ RelayState: RelayState || void 0
1318
+ }
1319
+ });
1320
+ } catch (parseError) {
1321
+ const nameIDMatch = decodedResponse.match(
1322
+ /<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/
1323
+ );
1324
+ if (!nameIDMatch) throw parseError;
1325
+ parsedResponse = {
1326
+ extract: {
1327
+ nameID: nameIDMatch[1],
1328
+ attributes: { nameID: nameIDMatch[1] },
1329
+ sessionIndex: {},
1330
+ conditions: {}
1331
+ }
1332
+ };
1333
+ }
1334
+ if (!parsedResponse?.extract) {
1335
+ throw new Error("Invalid SAML response structure");
979
1336
  }
980
1337
  } catch (error) {
981
- ctx.context.logger.error("SAML response validation failed", error);
1338
+ ctx.context.logger.error("SAML response validation failed", {
1339
+ error,
1340
+ decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
1341
+ "utf-8"
1342
+ )
1343
+ });
982
1344
  throw new api.APIError("BAD_REQUEST", {
983
1345
  message: "Invalid SAML response",
984
1346
  details: error instanceof Error ? error.message : String(error)
985
1347
  });
986
1348
  }
987
1349
  const { extract } = parsedResponse;
988
- const attributes = parsedResponse.extract.attributes;
989
- const mapping = parsedSamlConfig?.mapping ?? {};
1350
+ const attributes = extract.attributes || {};
1351
+ const mapping = parsedSamlConfig.mapping ?? {};
990
1352
  const userInfo = {
991
1353
  ...Object.fromEntries(
992
1354
  Object.entries(mapping.extraFields || {}).map(([key, value]) => [
993
1355
  key,
994
- extract.attributes[value]
1356
+ attributes[value]
995
1357
  ])
996
1358
  ),
997
- id: attributes[mapping.id] || attributes["nameID"],
998
- email: attributes[mapping.email] || attributes["nameID"] || attributes["email"],
1359
+ id: attributes[mapping.id || "nameID"] || extract.nameID,
1360
+ email: attributes[mapping.email || "email"] || extract.nameID,
999
1361
  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
1362
+ attributes[mapping.firstName || "givenName"],
1363
+ attributes[mapping.lastName || "surname"]
1364
+ ].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
1365
+ emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
1005
1366
  };
1367
+ if (!userInfo.id || !userInfo.email) {
1368
+ ctx.context.logger.error(
1369
+ "Missing essential user info from SAML response",
1370
+ {
1371
+ attributes: Object.keys(attributes),
1372
+ mapping,
1373
+ extractedId: userInfo.id,
1374
+ extractedEmail: userInfo.email
1375
+ }
1376
+ );
1377
+ throw new api.APIError("BAD_REQUEST", {
1378
+ message: "Unable to extract user ID or email from SAML response"
1379
+ });
1380
+ }
1006
1381
  let user;
1007
1382
  const existingUser = await ctx.context.adapter.findOne({
1008
1383
  model: "user",
@@ -1014,7 +1389,7 @@ const sso = (options) => {
1014
1389
  ]
1015
1390
  });
1016
1391
  if (existingUser) {
1017
- const accounts = await ctx.context.adapter.findOne({
1392
+ const account = await ctx.context.adapter.findOne({
1018
1393
  model: "account",
1019
1394
  where: [
1020
1395
  { field: "userId", value: existingUser.id },
@@ -1022,7 +1397,7 @@ const sso = (options) => {
1022
1397
  { field: "accountId", value: userInfo.id }
1023
1398
  ]
1024
1399
  });
1025
- if (!accounts) {
1400
+ if (!account) {
1026
1401
  const isTrustedProvider = ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
1027
1402
  provider.providerId
1028
1403
  );
@@ -1112,9 +1487,8 @@ const sso = (options) => {
1112
1487
  }
1113
1488
  let session = await ctx.context.internalAdapter.createSession(user.id, ctx);
1114
1489
  await cookies.setSessionCookie(ctx, { session, user });
1115
- throw ctx.redirect(
1116
- RelayState || `${parsedSamlConfig.callbackUrl}` || `${parsedSamlConfig.issuer}`
1117
- );
1490
+ const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1491
+ throw ctx.redirect(callbackUrl);
1118
1492
  }
1119
1493
  )
1120
1494
  },