diff --git a/bitwarden_license/src/Portal/Models/SsoConfigDataViewModel.cs b/bitwarden_license/src/Portal/Models/SsoConfigDataViewModel.cs index 5ecf5cebc..e90321445 100644 --- a/bitwarden_license/src/Portal/Models/SsoConfigDataViewModel.cs +++ b/bitwarden_license/src/Portal/Models/SsoConfigDataViewModel.cs @@ -49,6 +49,11 @@ namespace Bit.Portal.Models SpWantAssertionsSigned = configurationData.SpWantAssertionsSigned; SpValidateCertificates = configurationData.SpValidateCertificates; SpMinIncomingSigningAlgorithm = configurationData.SpMinIncomingSigningAlgorithm ?? SamlSigningAlgorithms.Sha256; + AdditionalScopes = configurationData.AdditionalScopes; + AdditionalUserIdClaimTypes = configurationData.AdditionalUserIdClaimTypes; + AdditionalEmailClaimTypes = configurationData.AdditionalEmailClaimTypes; + AdditionalNameClaimTypes = configurationData.AdditionalNameClaimTypes; + AcrValues = configurationData.AcrValues; } [Required] @@ -72,6 +77,16 @@ namespace Bit.Portal.Models public OpenIdConnectRedirectBehavior RedirectBehavior { get; set; } [Display(Name = "GetClaimsFromUserInfoEndpoint")] public bool GetClaimsFromUserInfoEndpoint { get; set; } + [Display(Name = "AdditionalScopes")] + public string AdditionalScopes { get; set; } + [Display(Name = "AdditionalUserIdClaimTypes")] + public string AdditionalUserIdClaimTypes { get; set; } + [Display(Name = "AdditionalEmailClaimTypes")] + public string AdditionalEmailClaimTypes { get; set; } + [Display(Name = "AdditionalNameClaimTypes")] + public string AdditionalNameClaimTypes { get; set; } + [Display(Name = "AcrValues")] + public string AcrValues { get; set; } // SAML2 SP [Display(Name = "SpEntityId")] @@ -218,6 +233,11 @@ namespace Bit.Portal.Models SpWantAssertionsSigned = SpWantAssertionsSigned, SpValidateCertificates = SpValidateCertificates, SpMinIncomingSigningAlgorithm = SpMinIncomingSigningAlgorithm, + AdditionalScopes = AdditionalScopes, + AdditionalUserIdClaimTypes = AdditionalUserIdClaimTypes, + AdditionalEmailClaimTypes = AdditionalEmailClaimTypes, + AdditionalNameClaimTypes = AdditionalNameClaimTypes, + AcrValues = AcrValues, }; } diff --git a/bitwarden_license/src/Portal/Views/Sso/Index.cshtml b/bitwarden_license/src/Portal/Views/Sso/Index.cshtml index 0f2c0eaa1..813606782 100644 --- a/bitwarden_license/src/Portal/Views/Sso/Index.cshtml +++ b/bitwarden_license/src/Portal/Views/Sso/Index.cshtml @@ -64,7 +64,7 @@

@i18nService.T("OpenIdConnectConfig")

- +
@@ -79,7 +79,7 @@
- +
@@ -94,34 +94,34 @@
- +
- +
- +
- +
- +
@@ -134,6 +134,36 @@
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
@@ -143,7 +173,7 @@

@i18nService.T("SamlSpConfig")

- +
@@ -158,7 +188,7 @@
- +
@@ -182,7 +212,7 @@
- +
@@ -199,28 +229,28 @@
- +
- +
- +
- +
@@ -245,7 +275,7 @@
- +
@@ -258,14 +288,14 @@
- +
- +
@@ -278,7 +308,7 @@
- +
diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index a627773f4..a2cfeefee 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -23,6 +23,8 @@ using System.Threading.Tasks; using Bit.Core.Models; using Bit.Core.Models.Api; using Bit.Core.Utilities; +using System.Text.Json; +using Bit.Core.Models.Data; namespace Bit.Sso.Controllers { @@ -204,7 +206,7 @@ namespace Bit.Sso.Controllers { // Read external identity from the temporary cookie var result = await HttpContext.AuthenticateAsync( - Core.AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme); + AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme); if (result?.Succeeded != true) { throw new Exception(_i18nService.T("ExternalAuthenticationError")); @@ -215,7 +217,7 @@ namespace Bit.Sso.Controllers _logger.LogDebug("External claims: {@claims}", externalClaims); // Lookup our user and external provider info - var (user, provider, providerUserId, claims) = await FindUserFromExternalProviderAsync(result); + var (user, provider, providerUserId, claims, ssoConfigData) = await FindUserFromExternalProviderAsync(result); if (user == null) { // This might be where you might initiate a custom workflow for user registration @@ -223,7 +225,7 @@ namespace Bit.Sso.Controllers // simply auto-provisions new external user var userIdentifier = result.Properties.Items.Keys.Contains("user_identifier") ? result.Properties.Items["user_identifier"] : null; - user = await AutoProvisionUserAsync(provider, providerUserId, claims, userIdentifier); + user = await AutoProvisionUserAsync(provider, providerUserId, claims, userIdentifier, ssoConfigData); } if (user != null) @@ -305,9 +307,23 @@ namespace Bit.Sso.Controllers } } - private async Task<(User user, string provider, string providerUserId, IEnumerable claims)> + private async Task<(User user, string provider, string providerUserId, IEnumerable claims, SsoConfigurationData config)> FindUserFromExternalProviderAsync(AuthenticateResult result) { + var provider = result.Properties.Items["scheme"]; + var orgId = new Guid(provider); + var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId); + if (ssoConfig == null || !ssoConfig.Enabled) + { + throw new Exception(_i18nService.T("OrganizationOrSsoConfigNotFound")); + } + + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + var ssoConfigData = JsonSerializer.Deserialize(ssoConfig.Data, options); + var externalUser = result.Principal; // Ensure the NameIdentifier used is not a transient name ID, if so, we need a different attribute @@ -320,7 +336,9 @@ namespace Bit.Sso.Controllers // Try to determine the unique id of the external user (issued by the provider) // the most common claim type for that are the sub claim and the NameIdentifier // depending on the external provider, some other claim type might be used - var userIdClaim = externalUser.FindFirst(JwtClaimTypes.Subject) ?? + var customUserIdClaimTypes = ssoConfigData.GetAdditionalUserIdClaimTypes(); + var userIdClaim = externalUser.FindFirst(c => customUserIdClaimTypes.Contains(c.Type)) ?? + externalUser.FindFirst(JwtClaimTypes.Subject) ?? externalUser.FindFirst(nameIdIsNotTransient) ?? // Some SAML providers may use the `uid` attribute for this // where a transient NameID has been sent in the subject @@ -333,26 +351,19 @@ namespace Bit.Sso.Controllers var claims = externalUser.Claims.ToList(); claims.Remove(userIdClaim); - var provider = result.Properties.Items["scheme"]; + // find external user var providerUserId = userIdClaim.Value; - // find external user - var orgId = new Guid(provider); - var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId); - if (ssoConfig == null || !ssoConfig.Enabled) - { - throw new Exception(_i18nService.T("OrganizationOrSsoConfigNotFound")); - } var user = await _userRepository.GetBySsoUserAsync(providerUserId, orgId); - return (user, provider, providerUserId, claims); + return (user, provider, providerUserId, claims, ssoConfigData); } private async Task AutoProvisionUserAsync(string provider, string providerUserId, - IEnumerable claims, string userIdentifier) + IEnumerable claims, string userIdentifier, SsoConfigurationData config) { - var name = GetName(claims); - var email = GetEmailAddress(claims); + var name = GetName(claims, config.GetAdditionalNameClaimTypes()); + var email = GetEmailAddress(claims, config.GetAdditionalEmailClaimTypes()); if (string.IsNullOrWhiteSpace(email) && providerUserId.Contains("@")) { email = providerUserId; @@ -498,12 +509,13 @@ namespace Bit.Sso.Controllers return user; } - private string GetEmailAddress(IEnumerable claims) + private string GetEmailAddress(IEnumerable claims, IEnumerable additionalClaimTypes) { var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value) && c.Value.Contains("@")); - var email = filteredClaims.GetFirstMatch(JwtClaimTypes.Email, ClaimTypes.Email, - SamlClaimTypes.Email, "mail", "emailaddress"); + var email = filteredClaims.GetFirstMatch(additionalClaimTypes.ToArray()) ?? + filteredClaims.GetFirstMatch(JwtClaimTypes.Email, ClaimTypes.Email, + SamlClaimTypes.Email, "mail", "emailaddress"); if (!string.IsNullOrWhiteSpace(email)) { return email; @@ -519,12 +531,13 @@ namespace Bit.Sso.Controllers return null; } - private string GetName(IEnumerable claims) + private string GetName(IEnumerable claims, IEnumerable additionalClaimTypes) { var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value)); - var name = filteredClaims.GetFirstMatch(JwtClaimTypes.Name, ClaimTypes.Name, - SamlClaimTypes.DisplayName, SamlClaimTypes.CommonName, "displayname", "cn"); + var name = filteredClaims.GetFirstMatch(additionalClaimTypes.ToArray()) ?? + filteredClaims.GetFirstMatch(JwtClaimTypes.Name, ClaimTypes.Name, + SamlClaimTypes.DisplayName, SamlClaimTypes.CommonName, "displayname", "cn"); if (!string.IsNullOrWhiteSpace(name)) { return name; diff --git a/bitwarden_license/src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs b/bitwarden_license/src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs index ded86fde6..22d69b8ac 100644 --- a/bitwarden_license/src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs +++ b/bitwarden_license/src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs @@ -10,6 +10,7 @@ using Bit.Core.Models.Data; using Bit.Core.Models.Table; using Bit.Core.Repositories; using Bit.Core.Sso; +using Bit.Core.Utilities; using Bit.Sso.Models; using Bit.Sso.Utilities; using IdentityModel; @@ -324,21 +325,30 @@ namespace Bit.Core.Business.Sso AuthenticationMethod = config.RedirectBehavior, GetClaimsFromUserInfoEndpoint = config.GetClaimsFromUserInfoEndpoint, }; - if (!oidcOptions.Scope.Contains(OpenIdConnectScopes.OpenId)) + oidcOptions.Scope + .AddIfNotExists(OpenIdConnectScopes.OpenId) + .AddIfNotExists(OpenIdConnectScopes.Email) + .AddIfNotExists(OpenIdConnectScopes.Profile); + foreach (var scope in config.GetAdditionalScopes()) { - oidcOptions.Scope.Add(OpenIdConnectScopes.OpenId); - } - if (!oidcOptions.Scope.Contains(OpenIdConnectScopes.Email)) - { - oidcOptions.Scope.Add(OpenIdConnectScopes.Email); - } - if (!oidcOptions.Scope.Contains(OpenIdConnectScopes.Profile)) - { - oidcOptions.Scope.Add(OpenIdConnectScopes.Profile); + oidcOptions.Scope.AddIfNotExists(scope); } oidcOptions.StateDataFormat = new DistributedCacheStateDataFormatter(_httpContextAccessor, name); + // see: https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest (acr_values) + if (!string.IsNullOrWhiteSpace(config.AcrValues)) + { + oidcOptions.Events = new OpenIdConnectEvents + { + OnRedirectToIdentityProvider = ctx => + { + ctx.ProtocolMessage.AcrValues = config.AcrValues; + return Task.CompletedTask; + } + }; + } + return new DynamicAuthenticationScheme(name, name, typeof(OpenIdConnectHandler), oidcOptions, SsoType.OpenIdConnect); } diff --git a/src/Core/Models/Data/SsoConfigurationData.cs b/src/Core/Models/Data/SsoConfigurationData.cs index e3d6675d5..d7c357a49 100644 --- a/src/Core/Models/Data/SsoConfigurationData.cs +++ b/src/Core/Models/Data/SsoConfigurationData.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using Bit.Core.Enums; using Bit.Core.Sso; using Microsoft.AspNetCore.Authentication.OpenIdConnect; @@ -20,6 +22,11 @@ namespace Bit.Core.Models.Data public string MetadataAddress { get; set; } public OpenIdConnectRedirectBehavior RedirectBehavior { get; set; } = OpenIdConnectRedirectBehavior.FormPost; public bool GetClaimsFromUserInfoEndpoint { get; set; } + public string AdditionalScopes { get; set; } + public string AdditionalUserIdClaimTypes { get; set; } + public string AdditionalEmailClaimTypes { get; set; } + public string AdditionalNameClaimTypes { get; set; } + public string AcrValues { get; set; } // SAML2 IDP public string IdpEntityId { get; set; } @@ -67,6 +74,30 @@ namespace Bit.Core.Models.Data return BuildSaml2ModulePath(ssoUri, scheme); } + public IEnumerable GetAdditionalScopes() => AdditionalScopes? + .Split(',')? + .Where(c => !string.IsNullOrWhiteSpace(c))? + .Select(c => c.Trim()) ?? + Array.Empty(); + + public IEnumerable GetAdditionalUserIdClaimTypes() => AdditionalUserIdClaimTypes? + .Split(',')? + .Where(c => !string.IsNullOrWhiteSpace(c))? + .Select(c => c.Trim()) ?? + Array.Empty(); + + public IEnumerable GetAdditionalEmailClaimTypes() => AdditionalEmailClaimTypes? + .Split(',')? + .Where(c => !string.IsNullOrWhiteSpace(c))? + .Select(c => c.Trim()) ?? + Array.Empty(); + + public IEnumerable GetAdditionalNameClaimTypes() => AdditionalNameClaimTypes? + .Split(',')? + .Where(c => !string.IsNullOrWhiteSpace(c))? + .Select(c => c.Trim()) ?? + Array.Empty(); + private string BuildSsoUrl(string relativePath, string ssoUri) { if (string.IsNullOrWhiteSpace(ssoUri) || diff --git a/src/Core/Resources/SharedResources.en.resx b/src/Core/Resources/SharedResources.en.resx index 7d89d89a3..478f37d03 100644 --- a/src/Core/Resources/SharedResources.en.resx +++ b/src/Core/Resources/SharedResources.en.resx @@ -604,4 +604,20 @@ Disable personal ownership for organization users + + Additional/Custom Scopes (comma delimited) + + + Additional/Custom User ID Claim Types (comma delimited) + + + Additional/Custom Email Claim Types (comma delimited) + + + Additional/Custom Name Claim Types (comma delimited) + + + Requested Authentication Context Class Reference values (acr_values) + 'acr_values' is an explicit OIDC param, see https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest. It should not be translated. + diff --git a/src/Core/Utilities/CoreHelpers.cs b/src/Core/Utilities/CoreHelpers.cs index e2ab4c0b4..fdedf118e 100644 --- a/src/Core/Utilities/CoreHelpers.cs +++ b/src/Core/Utilities/CoreHelpers.cs @@ -810,5 +810,15 @@ namespace Bit.Core.Utilities return System.Text.Json.JsonSerializer.Deserialize(jsonData, options); } + + public static ICollection AddIfNotExists(this ICollection list, T item) + { + if (list.Contains(item)) + { + return list; + } + list.Add(item); + return list; + } } }