1
0
mirror of https://github.com/bitwarden/server.git synced 2024-12-26 17:37:36 +01:00

SSO - Added custom scopes and claim types for OIDC (#1133)

* SSO - Added custom scopes and claim types for OIDC

* Removed redundant field labels

* Added acr_values to OIDC config + request
This commit is contained in:
Chad Scharf 2021-02-10 12:00:12 -05:00 committed by GitHub
parent 9f42357705
commit 6cc317c4ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 181 additions and 51 deletions

View File

@ -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,
};
}

View File

@ -64,7 +64,7 @@
<h2>@i18nService.T("OpenIdConnectConfig")</h2>
<div class="row">
<div class="col-7 form-group">
<label asp-for="Data.CallbackPath">@i18nService.T("CallbackPath")</label>
<label asp-for="Data.CallbackPath"></label>
<div class="input-group">
<input asp-for="Data.CallbackPath" class="form-control" readonly>
<div class="input-group-append">
@ -79,7 +79,7 @@
</div>
<div class="row">
<div class="col-7 form-group">
<label asp-for="Data.SignedOutCallbackPath">@i18nService.T("SignedOutCallbackPath")</label>
<label asp-for="Data.SignedOutCallbackPath"></label>
<div class="input-group">
<input asp-for="Data.SignedOutCallbackPath" class="form-control" readonly>
<div class="input-group-append">
@ -94,34 +94,34 @@
</div>
<div class="row">
<div class="col-7 form-group">
<label asp-for="Data.Authority">@i18nService.T("Authority")</label>
<label asp-for="Data.Authority"></label>
<input asp-for="Data.Authority" class="form-control">
<span asp-validation-for="Data.Authority" class="text-danger"></span>
</div>
</div>
<div class="row">
<div class="col-7 form-group">
<label asp-for="Data.ClientId">@i18nService.T("ClientId")</label>
<label asp-for="Data.ClientId"></label>
<input asp-for="Data.ClientId" class="form-control">
<span asp-validation-for="Data.ClientId" class="text-danger"></span>
</div>
</div>
<div class="row">
<div class="col-7 form-group">
<label asp-for="Data.ClientSecret">@i18nService.T("ClientSecret")</label>
<label asp-for="Data.ClientSecret"></label>
<input asp-for="Data.ClientSecret" class="form-control">
<span asp-validation-for="Data.ClientSecret" class="text-danger"></span>
</div>
</div>
<div class="row">
<div class="col-7 form-group">
<label asp-for="Data.MetadataAddress">@i18nService.T("MetadataAddress")</label>
<label asp-for="Data.MetadataAddress"></label>
<input asp-for="Data.MetadataAddress" class="form-control">
</div>
</div>
<div class="row">
<div class="col-7 form-group">
<label asp-for="Data.RedirectBehavior">@i18nService.T("RedirectBehavior")</label>
<label asp-for="Data.RedirectBehavior"></label>
<select asp-for="Data.RedirectBehavior" asp-items="Model.RedirectBehaviors"
class="form-control"></select>
</div>
@ -134,6 +134,36 @@
</div>
</div>
</div>
<div class="row">
<div class="col-7 form-group">
<label asp-for="Data.AdditionalScopes"></label>
<input asp-for="Data.AdditionalScopes" class="form-control">
</div>
</div>
<div class="row">
<div class="col-7 form-group">
<label asp-for="Data.AdditionalUserIdClaimTypes"></label>
<input asp-for="Data.AdditionalUserIdClaimTypes" class="form-control">
</div>
</div>
<div class="row">
<div class="col-7 form-group">
<label asp-for="Data.AdditionalEmailClaimTypes"></label>
<input asp-for="Data.AdditionalEmailClaimTypes" class="form-control">
</div>
</div>
<div class="row">
<div class="col-7 form-group">
<label asp-for="Data.AdditionalNameClaimTypes"></label>
<input asp-for="Data.AdditionalNameClaimTypes" class="form-control">
</div>
</div>
<div class="row">
<div class="col-7 form-group">
<label asp-for="Data.AcrValues"></label>
<input asp-for="Data.AcrValues" class="form-control">
</div>
</div>
</div>
</div>
@ -143,7 +173,7 @@
<h2>@i18nService.T("SamlSpConfig")</h2>
<div class="row">
<div class="col-7 form-group">
<label asp-for="Data.SpEntityId">@i18nService.T("SpEntityId")</label>
<label asp-for="Data.SpEntityId"></label>
<div class="input-group">
<input asp-for="Data.SpEntityId" class="form-control" readonly>
<div class="input-group-append">
@ -158,7 +188,7 @@
</div>
<div class="row">
<div class="col-7 form-group">
<label asp-for="Data.SpMetadataUrl">@i18nService.T("SpMetadataUrl")</label>
<label asp-for="Data.SpMetadataUrl"></label>
<div class="input-group">
<input asp-for="Data.SpMetadataUrl" class="form-control" readonly>
<div class="input-group-append">
@ -182,7 +212,7 @@
</div>
<div class="row">
<div class="col-7 form-group">
<label asp-for="Data.SpAcsUrl">@i18nService.T("SpAcsUrl")</label>
<label asp-for="Data.SpAcsUrl"></label>
<div class="input-group">
<input asp-for="Data.SpAcsUrl" class="form-control" readonly>
<div class="input-group-append">
@ -199,28 +229,28 @@
</div>
<div class="row">
<div class="col-7 form-group">
<label asp-for="Data.SpNameIdFormat">@i18nService.T("NameIdFormat")</label>
<label asp-for="Data.SpNameIdFormat"></label>
<select asp-for="Data.SpNameIdFormat" asp-items="Model.SpNameIdFormats"
class="form-control"></select>
</div>
</div>
<div class="row">
<div class="col-7 form-group">
<label asp-for="Data.SpOutboundSigningAlgorithm">@i18nService.T("OutboundSigningAlgorithm")</label>
<label asp-for="Data.SpOutboundSigningAlgorithm"></label>
<select asp-for="Data.SpOutboundSigningAlgorithm" asp-items="Model.SigningAlgorithms"
class="form-control"></select>
</div>
</div>
<div class="row">
<div class="col-7 form-group">
<label asp-for="Data.SpSigningBehavior">@i18nService.T("SigningBehavior")</label>
<label asp-for="Data.SpSigningBehavior"></label>
<select asp-for="Data.SpSigningBehavior" asp-items="Model.SigningBehaviors"
class="form-control"></select>
</div>
</div>
<div class="row">
<div class="col-7 form-group">
<label asp-for="Data.SpMinIncomingSigningAlgorithm">@i18nService.T("MinIncomingSigningAlgorithm")</label>
<label asp-for="Data.SpMinIncomingSigningAlgorithm"></label>
<select asp-for="Data.SpMinIncomingSigningAlgorithm" asp-items="Model.SigningAlgorithms"
class="form-control"></select>
</div>
@ -245,7 +275,7 @@
<div class="row">
<div class="col-7 form-group">
<label asp-for="Data.IdpEntityId">@i18nService.T("EntityId")</label>
<label asp-for="Data.IdpEntityId"></label>
<input asp-for="Data.IdpEntityId" class="form-control">
<span asp-validation-for="Data.IdpEntityId" class="text-danger"></span>
</div>
@ -258,14 +288,14 @@
</div>
<div class="row">
<div class="col-7 form-group">
<label asp-for="Data.IdpSingleSignOnServiceUrl">@i18nService.T("SingleSignOnServiceUrl")</label>
<label asp-for="Data.IdpSingleSignOnServiceUrl"></label>
<input asp-for="Data.IdpSingleSignOnServiceUrl" class="form-control">
<span asp-validation-for="Data.IdpSingleSignOnServiceUrl" class="text-danger"></span>
</div>
</div>
<div class="row">
<div class="col-7 form-group">
<label asp-for="Data.IdpSingleLogoutServiceUrl">@i18nService.T("SingleLogoutServiceUrl")</label>
<label asp-for="Data.IdpSingleLogoutServiceUrl"></label>
<input asp-for="Data.IdpSingleLogoutServiceUrl" class="form-control">
</div>
</div>
@ -278,7 +308,7 @@
</div>
<div class="row">
<div class="col-7 form-group">
<label asp-for="Data.IdpX509PublicCert">@i18nService.T("X509PublicCert")</label>
<label asp-for="Data.IdpX509PublicCert"></label>
<textarea asp-for="Data.IdpX509PublicCert" class="form-control form-control-sm text-monospace" rows="6"></textarea>
<span asp-validation-for="Data.IdpX509PublicCert" class="text-danger"></span>
</div>

View File

@ -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<Claim> claims)>
private async Task<(User user, string provider, string providerUserId, IEnumerable<Claim> 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<SsoConfigurationData>(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<User> AutoProvisionUserAsync(string provider, string providerUserId,
IEnumerable<Claim> claims, string userIdentifier)
IEnumerable<Claim> 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<Claim> claims)
private string GetEmailAddress(IEnumerable<Claim> claims, IEnumerable<string> 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<Claim> claims)
private string GetName(IEnumerable<Claim> claims, IEnumerable<string> 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;

View File

@ -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);
}

View File

@ -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<string> GetAdditionalScopes() => AdditionalScopes?
.Split(',')?
.Where(c => !string.IsNullOrWhiteSpace(c))?
.Select(c => c.Trim()) ??
Array.Empty<string>();
public IEnumerable<string> GetAdditionalUserIdClaimTypes() => AdditionalUserIdClaimTypes?
.Split(',')?
.Where(c => !string.IsNullOrWhiteSpace(c))?
.Select(c => c.Trim()) ??
Array.Empty<string>();
public IEnumerable<string> GetAdditionalEmailClaimTypes() => AdditionalEmailClaimTypes?
.Split(',')?
.Where(c => !string.IsNullOrWhiteSpace(c))?
.Select(c => c.Trim()) ??
Array.Empty<string>();
public IEnumerable<string> GetAdditionalNameClaimTypes() => AdditionalNameClaimTypes?
.Split(',')?
.Where(c => !string.IsNullOrWhiteSpace(c))?
.Select(c => c.Trim()) ??
Array.Empty<string>();
private string BuildSsoUrl(string relativePath, string ssoUri)
{
if (string.IsNullOrWhiteSpace(ssoUri) ||

View File

@ -604,4 +604,20 @@
<data name="PersonalOwnershipCheckboxDesc" xml:space="preserve">
<value>Disable personal ownership for organization users</value>
</data>
<data name="AdditionalScopes" xml:space="preserve">
<value>Additional/Custom Scopes (comma delimited)</value>
</data>
<data name="AdditionalUserIdClaimTypes" xml:space="preserve">
<value>Additional/Custom User ID Claim Types (comma delimited)</value>
</data>
<data name="AdditionalEmailClaimTypes" xml:space="preserve">
<value>Additional/Custom Email Claim Types (comma delimited)</value>
</data>
<data name="AdditionalNameClaimTypes" xml:space="preserve">
<value>Additional/Custom Name Claim Types (comma delimited)</value>
</data>
<data name="AcrValues" xml:space="preserve">
<value>Requested Authentication Context Class Reference values (acr_values)</value>
<comment>'acr_values' is an explicit OIDC param, see https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest. It should not be translated.</comment>
</data>
</root>

View File

@ -810,5 +810,15 @@ namespace Bit.Core.Utilities
return System.Text.Json.JsonSerializer.Deserialize<T>(jsonData, options);
}
public static ICollection<T> AddIfNotExists<T>(this ICollection<T> list, T item)
{
if (list.Contains(item))
{
return list;
}
list.Add(item);
return list;
}
}
}