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

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