1
0
mirror of https://github.com/bitwarden/server.git synced 2025-07-02 00:22:50 -05:00

Add DynamicClientStore (#5670)

* Add DynamicClientStore

* Formatting

* Fix Debug assertion

* Make Identity internals visible to its unit tests

* Add installation client provider tests

* Add internal client provider tests

* Add DynamicClientStore tests

* Fix namespaces after merge

* Format

* Add docs and remove TODO comments

* Use preferred prefix for API keys

---------

Co-authored-by: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com>
This commit is contained in:
Justin Baur
2025-05-30 12:58:54 -04:00
committed by GitHub
parent 63f836a73a
commit 0b2b573bd3
14 changed files with 698 additions and 294 deletions

View File

@ -12,4 +12,8 @@
<ProjectReference Include="..\Core\Core.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Identity.Test" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,51 @@
using Bit.Core.IdentityServer;
using Bit.Core.Platform.Installations;
using Duende.IdentityServer.Models;
using IdentityModel;
namespace Bit.Identity.IdentityServer.ClientProviders;
internal class InstallationClientProvider : IClientProvider
{
private readonly IInstallationRepository _installationRepository;
public InstallationClientProvider(IInstallationRepository installationRepository)
{
_installationRepository = installationRepository;
}
public async Task<Client> GetAsync(string identifier)
{
if (!Guid.TryParse(identifier, out var installationId))
{
return null;
}
var installation = await _installationRepository.GetByIdAsync(installationId);
if (installation == null)
{
return null;
}
return new Client
{
ClientId = $"installation.{installation.Id}",
RequireClientSecret = true,
ClientSecrets = { new Secret(installation.Key.Sha256()) },
AllowedScopes = new[]
{
ApiScopes.ApiPush,
ApiScopes.ApiLicensing,
ApiScopes.ApiInstallation,
},
AllowedGrantTypes = GrantTypes.ClientCredentials,
AccessTokenLifetime = 3600 * 24,
Enabled = installation.Enabled,
Claims = new List<ClientClaim>
{
new(JwtClaimTypes.Subject, installation.Id.ToString()),
},
};
}
}

View File

@ -0,0 +1,40 @@
#nullable enable
using System.Diagnostics;
using Bit.Core.IdentityServer;
using Bit.Core.Settings;
using Duende.IdentityServer.Models;
using IdentityModel;
namespace Bit.Identity.IdentityServer.ClientProviders;
internal class InternalClientProvider : IClientProvider
{
private readonly GlobalSettings _globalSettings;
public InternalClientProvider(GlobalSettings globalSettings)
{
// This class should not have been registered when it's not self hosted
Debug.Assert(globalSettings.SelfHosted);
_globalSettings = globalSettings;
}
public Task<Client?> GetAsync(string identifier)
{
return Task.FromResult<Client?>(new Client
{
ClientId = $"internal.{identifier}",
RequireClientSecret = true,
ClientSecrets = { new Secret(_globalSettings.InternalIdentityKey.Sha256()) },
AllowedScopes = [ApiScopes.Internal],
AllowedGrantTypes = GrantTypes.ClientCredentials,
AccessTokenLifetime = 3600 * 24,
Enabled = true,
Claims =
[
new(JwtClaimTypes.Subject, identifier),
],
});
}
}

View File

@ -0,0 +1,58 @@
using Bit.Core.Enums;
using Bit.Core.Identity;
using Bit.Core.IdentityServer;
using Bit.Core.Repositories;
using Duende.IdentityServer.Models;
using IdentityModel;
namespace Bit.Identity.IdentityServer.ClientProviders;
internal class OrganizationClientProvider : IClientProvider
{
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository;
public OrganizationClientProvider(
IOrganizationRepository organizationRepository,
IOrganizationApiKeyRepository organizationApiKeyRepository
)
{
_organizationRepository = organizationRepository;
_organizationApiKeyRepository = organizationApiKeyRepository;
}
public async Task<Client> GetAsync(string identifier)
{
if (!Guid.TryParse(identifier, out var organizationId))
{
return null;
}
var organization = await _organizationRepository.GetByIdAsync(organizationId);
if (organization == null)
{
return null;
}
var orgApiKey = (await _organizationApiKeyRepository
.GetManyByOrganizationIdTypeAsync(organization.Id, OrganizationApiKeyType.Default))
.First();
return new Client
{
ClientId = $"organization.{organization.Id}",
RequireClientSecret = true,
ClientSecrets = [new Secret(orgApiKey.ApiKey.Sha256())],
AllowedScopes = [ApiScopes.ApiOrganization],
AllowedGrantTypes = GrantTypes.ClientCredentials,
AccessTokenLifetime = 3600 * 1,
Enabled = organization.Enabled && organization.UseApi,
Claims =
[
new(JwtClaimTypes.Subject, organization.Id.ToString()),
new(Claims.Type, IdentityClientType.Organization.ToString())
],
};
}
}

View File

@ -0,0 +1,76 @@
using Bit.Core.Identity;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Models.Data;
using Bit.Core.SecretsManager.Repositories;
using Duende.IdentityServer.Models;
using IdentityModel;
namespace Bit.Identity.IdentityServer.ClientProviders;
internal class SecretsManagerApiKeyProvider : IClientProvider
{
public const string ApiKeyPrefix = "apikey";
private readonly IApiKeyRepository _apiKeyRepository;
private readonly IOrganizationRepository _organizationRepository;
public SecretsManagerApiKeyProvider(IApiKeyRepository apiKeyRepository, IOrganizationRepository organizationRepository)
{
_apiKeyRepository = apiKeyRepository;
_organizationRepository = organizationRepository;
}
public async Task<Client> GetAsync(string identifier)
{
if (!Guid.TryParse(identifier, out var apiKeyId))
{
return null;
}
var apiKey = await _apiKeyRepository.GetDetailsByIdAsync(apiKeyId);
if (apiKey == null || apiKey.ExpireAt <= DateTime.UtcNow)
{
return null;
}
switch (apiKey)
{
case ServiceAccountApiKeyDetails key:
var org = await _organizationRepository.GetByIdAsync(key.ServiceAccountOrganizationId);
if (!org.UseSecretsManager || !org.Enabled)
{
return null;
}
break;
}
var client = new Client
{
ClientId = identifier,
RequireClientSecret = true,
ClientSecrets = { new Secret(apiKey.ClientSecretHash) },
AllowedScopes = apiKey.GetScopes(),
AllowedGrantTypes = GrantTypes.ClientCredentials,
AccessTokenLifetime = 3600 * 1,
ClientClaimsPrefix = null,
Properties = new Dictionary<string, string> {
{"encryptedPayload", apiKey.EncryptedPayload},
},
Claims = new List<ClientClaim>
{
new(JwtClaimTypes.Subject, apiKey.ServiceAccountId.ToString()),
new(Claims.Type, IdentityClientType.ServiceAccount.ToString()),
},
};
switch (apiKey)
{
case ServiceAccountApiKeyDetails key:
client.Claims.Add(new ClientClaim(Claims.Organization, key.ServiceAccountOrganizationId.ToString()));
break;
}
return client;
}
}

View File

@ -0,0 +1,82 @@
#nullable enable
using System.Collections.ObjectModel;
using System.Security.Claims;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Context;
using Bit.Core.Identity;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Duende.IdentityServer.Models;
using IdentityModel;
namespace Bit.Identity.IdentityServer.ClientProviders;
public class UserClientProvider : IClientProvider
{
private readonly IUserRepository _userRepository;
private readonly ICurrentContext _currentContext;
private readonly ILicensingService _licensingService;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IProviderUserRepository _providerUserRepository;
public UserClientProvider(
IUserRepository userRepository,
ICurrentContext currentContext,
ILicensingService licensingService,
IOrganizationUserRepository organizationUserRepository,
IProviderUserRepository providerUserRepository)
{
_userRepository = userRepository;
_currentContext = currentContext;
_licensingService = licensingService;
_organizationUserRepository = organizationUserRepository;
_providerUserRepository = providerUserRepository;
}
public async Task<Client?> GetAsync(string identifier)
{
if (!Guid.TryParse(identifier, out var userId))
{
return null;
}
var user = await _userRepository.GetByIdAsync(userId);
if (user == null)
{
return null;
}
var claims = new Collection<ClientClaim>
{
new(JwtClaimTypes.Subject, user.Id.ToString()),
new(JwtClaimTypes.AuthenticationMethod, "Application", "external"),
new(Claims.Type, IdentityClientType.User.ToString()),
};
var orgs = await _currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id);
var providers = await _currentContext.ProviderMembershipAsync(_providerUserRepository, user.Id);
var isPremium = await _licensingService.ValidateUserPremiumAsync(user);
foreach (var claim in CoreHelpers.BuildIdentityClaims(user, orgs, providers, isPremium))
{
var upperValue = claim.Value.ToUpperInvariant();
var isBool = upperValue is "TRUE" or "FALSE";
claims.Add(isBool
? new ClientClaim(claim.Key, claim.Value, ClaimValueTypes.Boolean)
: new ClientClaim(claim.Key, claim.Value)
);
}
return new Client
{
ClientId = $"user.{userId}",
RequireClientSecret = true,
ClientSecrets = { new Secret(user.ApiKey.Sha256()) },
AllowedScopes = new[] { "api" },
AllowedGrantTypes = GrantTypes.ClientCredentials,
AccessTokenLifetime = 3600 * 1,
ClientClaimsPrefix = null,
Claims = claims,
};
}
}

View File

@ -1,291 +0,0 @@
using System.Collections.ObjectModel;
using System.Security.Claims;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Identity;
using Bit.Core.IdentityServer;
using Bit.Core.Platform.Installations;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Models.Data;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Stores;
using IdentityModel;
namespace Bit.Identity.IdentityServer;
public class ClientStore : IClientStore
{
private readonly IInstallationRepository _installationRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IUserRepository _userRepository;
private readonly GlobalSettings _globalSettings;
private readonly StaticClientStore _staticClientStore;
private readonly ILicensingService _licensingService;
private readonly ICurrentContext _currentContext;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IProviderUserRepository _providerUserRepository;
private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository;
private readonly IApiKeyRepository _apiKeyRepository;
public ClientStore(
IInstallationRepository installationRepository,
IOrganizationRepository organizationRepository,
IUserRepository userRepository,
GlobalSettings globalSettings,
StaticClientStore staticClientStore,
ILicensingService licensingService,
ICurrentContext currentContext,
IOrganizationUserRepository organizationUserRepository,
IProviderUserRepository providerUserRepository,
IOrganizationApiKeyRepository organizationApiKeyRepository,
IApiKeyRepository apiKeyRepository)
{
_installationRepository = installationRepository;
_organizationRepository = organizationRepository;
_userRepository = userRepository;
_globalSettings = globalSettings;
_staticClientStore = staticClientStore;
_licensingService = licensingService;
_currentContext = currentContext;
_organizationUserRepository = organizationUserRepository;
_providerUserRepository = providerUserRepository;
_organizationApiKeyRepository = organizationApiKeyRepository;
_apiKeyRepository = apiKeyRepository;
}
public async Task<Client> FindClientByIdAsync(string clientId)
{
if (!_globalSettings.SelfHosted && clientId.StartsWith("installation."))
{
return await CreateInstallationClientAsync(clientId);
}
if (_globalSettings.SelfHosted && clientId.StartsWith("internal.") &&
CoreHelpers.SettingHasValue(_globalSettings.InternalIdentityKey))
{
return CreateInternalClient(clientId);
}
if (clientId.StartsWith("organization."))
{
return await CreateOrganizationClientAsync(clientId);
}
if (clientId.StartsWith("user."))
{
return await CreateUserClientAsync(clientId);
}
if (_staticClientStore.ApiClients.TryGetValue(clientId, out var client))
{
return client;
}
return await CreateApiKeyClientAsync(clientId);
}
private async Task<Client> CreateApiKeyClientAsync(string clientId)
{
if (!Guid.TryParse(clientId, out var guid))
{
return null;
}
var apiKey = await _apiKeyRepository.GetDetailsByIdAsync(guid);
if (apiKey == null || apiKey.ExpireAt <= DateTime.Now)
{
return null;
}
switch (apiKey)
{
case ServiceAccountApiKeyDetails key:
var org = await _organizationRepository.GetByIdAsync(key.ServiceAccountOrganizationId);
if (!org.UseSecretsManager || !org.Enabled)
{
return null;
}
break;
}
var client = new Client
{
ClientId = clientId,
RequireClientSecret = true,
ClientSecrets = { new Secret(apiKey.ClientSecretHash) },
AllowedScopes = apiKey.GetScopes(),
AllowedGrantTypes = GrantTypes.ClientCredentials,
AccessTokenLifetime = 3600 * 1,
ClientClaimsPrefix = null,
Properties = new Dictionary<string, string> {
{"encryptedPayload", apiKey.EncryptedPayload},
},
Claims = new List<ClientClaim>
{
new(JwtClaimTypes.Subject, apiKey.ServiceAccountId.ToString()),
new(Claims.Type, IdentityClientType.ServiceAccount.ToString()),
},
};
switch (apiKey)
{
case ServiceAccountApiKeyDetails key:
client.Claims.Add(new ClientClaim(Claims.Organization, key.ServiceAccountOrganizationId.ToString()));
break;
}
return client;
}
private async Task<Client> CreateUserClientAsync(string clientId)
{
var idParts = clientId.Split('.');
if (idParts.Length <= 1 || !Guid.TryParse(idParts[1], out var id))
{
return null;
}
var user = await _userRepository.GetByIdAsync(id);
if (user == null)
{
return null;
}
var claims = new Collection<ClientClaim>
{
new(JwtClaimTypes.Subject, user.Id.ToString()),
new(JwtClaimTypes.AuthenticationMethod, "Application", "external"),
new(Claims.Type, IdentityClientType.User.ToString()),
};
var orgs = await _currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id);
var providers = await _currentContext.ProviderMembershipAsync(_providerUserRepository, user.Id);
var isPremium = await _licensingService.ValidateUserPremiumAsync(user);
foreach (var claim in CoreHelpers.BuildIdentityClaims(user, orgs, providers, isPremium))
{
var upperValue = claim.Value.ToUpperInvariant();
var isBool = upperValue is "TRUE" or "FALSE";
claims.Add(isBool
? new ClientClaim(claim.Key, claim.Value, ClaimValueTypes.Boolean)
: new ClientClaim(claim.Key, claim.Value)
);
}
return new Client
{
ClientId = clientId,
RequireClientSecret = true,
ClientSecrets = { new Secret(user.ApiKey.Sha256()) },
AllowedScopes = new[] { "api" },
AllowedGrantTypes = GrantTypes.ClientCredentials,
AccessTokenLifetime = 3600 * 1,
ClientClaimsPrefix = null,
Claims = claims,
};
}
private async Task<Client> CreateOrganizationClientAsync(string clientId)
{
var idParts = clientId.Split('.');
if (idParts.Length <= 1 || !Guid.TryParse(idParts[1], out var id))
{
return null;
}
var org = await _organizationRepository.GetByIdAsync(id);
if (org == null)
{
return null;
}
var orgApiKey = (await _organizationApiKeyRepository
.GetManyByOrganizationIdTypeAsync(org.Id, OrganizationApiKeyType.Default))
.First();
return new Client
{
ClientId = $"organization.{org.Id}",
RequireClientSecret = true,
ClientSecrets = { new Secret(orgApiKey.ApiKey.Sha256()) },
AllowedScopes = new[] { ApiScopes.ApiOrganization },
AllowedGrantTypes = GrantTypes.ClientCredentials,
AccessTokenLifetime = 3600 * 1,
Enabled = org.Enabled && org.UseApi,
Claims = new List<ClientClaim>
{
new(JwtClaimTypes.Subject, org.Id.ToString()),
new(Claims.Type, IdentityClientType.Organization.ToString()),
},
};
}
private Client CreateInternalClient(string clientId)
{
var idParts = clientId.Split('.');
if (idParts.Length <= 1)
{
return null;
}
var id = idParts[1];
if (string.IsNullOrWhiteSpace(id))
{
return null;
}
return new Client
{
ClientId = $"internal.{id}",
RequireClientSecret = true,
ClientSecrets = { new Secret(_globalSettings.InternalIdentityKey.Sha256()) },
AllowedScopes = new[] { ApiScopes.Internal },
AllowedGrantTypes = GrantTypes.ClientCredentials,
AccessTokenLifetime = 3600 * 24,
Enabled = true,
Claims = new List<ClientClaim>
{
new(JwtClaimTypes.Subject, id),
},
};
}
private async Task<Client> CreateInstallationClientAsync(string clientId)
{
var idParts = clientId.Split('.');
if (idParts.Length <= 1 || !Guid.TryParse(idParts[1], out Guid id))
{
return null;
}
var installation = await _installationRepository.GetByIdAsync(id);
if (installation == null)
{
return null;
}
return new Client
{
ClientId = $"installation.{installation.Id}",
RequireClientSecret = true,
ClientSecrets = { new Secret(installation.Key.Sha256()) },
AllowedScopes = new[]
{
ApiScopes.ApiPush,
ApiScopes.ApiLicensing,
ApiScopes.ApiInstallation,
},
AllowedGrantTypes = GrantTypes.ClientCredentials,
AccessTokenLifetime = 3600 * 24,
Enabled = installation.Enabled,
Claims = new List<ClientClaim>
{
new(JwtClaimTypes.Subject, installation.Id.ToString()),
},
};
}
}

View File

@ -0,0 +1,75 @@
#nullable enable
using Bit.Identity.IdentityServer.ClientProviders;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Stores;
namespace Bit.Identity.IdentityServer;
public interface IClientProvider
{
Task<Client?> GetAsync(string identifier);
}
internal class DynamicClientStore : IClientStore
{
private readonly IServiceProvider _serviceProvider;
private readonly IClientProvider _apiKeyClientProvider;
private readonly StaticClientStore _staticClientStore;
public DynamicClientStore(
IServiceProvider serviceProvider,
[FromKeyedServices(SecretsManagerApiKeyProvider.ApiKeyPrefix)] IClientProvider apiKeyClientProvider,
StaticClientStore staticClientStore
)
{
_serviceProvider = serviceProvider;
_apiKeyClientProvider = apiKeyClientProvider;
_staticClientStore = staticClientStore;
}
public Task<Client?> FindClientByIdAsync(string clientId)
{
var clientIdSpan = clientId.AsSpan();
var firstPeriod = clientIdSpan.IndexOf('.');
if (firstPeriod == -1)
{
// No splitter, attempt but don't fail for a static client
if (_staticClientStore.ApiClients.TryGetValue(clientId, out var client))
{
return Task.FromResult<Client?>(client);
}
}
else
{
// Increment past the period
var identifierName = clientIdSpan[..firstPeriod++];
var identifier = clientIdSpan[firstPeriod..];
// The identifier is required to be non-empty
if (identifier.IsEmpty || identifier.IsWhiteSpace())
{
return Task.FromResult<Client?>(null);
}
// Once identifierName is proven valid, materialize the string
var clientBuilder = _serviceProvider.GetKeyedService<IClientProvider>(identifierName.ToString());
if (clientBuilder == null)
{
// No client registered by this identifier
return Task.FromResult<Client?>(null);
}
return clientBuilder.GetAsync(identifier.ToString());
}
// It could be an ApiKey, give them the full thing to try,
// this is a special case for legacy reasons, no other client should
// be allowed without a prefixing identifier.
return _apiKeyClientProvider.GetAsync(clientId);
}
}

View File

@ -0,0 +1,28 @@
using Bit.Identity.IdentityServer;
namespace Microsoft.Extensions.DependencyInjection;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers a custom <see cref="IClientProvider"/> for the given identifier to be called when a client id with
/// the identifier is attempting authentication.
/// </summary>
/// <typeparam name="T">Your custom implementation of <see cref="IClientProvider"/>.</typeparam>
/// <param name="services">The service collection to add services to.</param>
/// <param name="identifier">
/// The identifier to be used to invoke your client provider if a <c>client_id</c> is prefixed with your identifier
/// then your <see cref="IClientProvider"/> implementation will be invoked with the data after the seperating <c>.</c>.
/// </param>
/// <returns>The <see cref="IServiceCollection"/> for additional chaining.</returns>
public static IServiceCollection AddClientProvider<T>(this IServiceCollection services, string identifier)
where T : class, IClientProvider
{
ArgumentNullException.ThrowIfNull(services);
ArgumentException.ThrowIfNullOrWhiteSpace(identifier);
services.AddKeyedTransient<IClientProvider, T>(identifier);
return services;
}
}

View File

@ -3,6 +3,7 @@ using Bit.Core.IdentityServer;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.Identity.IdentityServer;
using Bit.Identity.IdentityServer.ClientProviders;
using Bit.Identity.IdentityServer.RequestValidators;
using Bit.SharedWeb.Utilities;
using Duende.IdentityServer.ResponseHandling;
@ -48,14 +49,29 @@ public static class ServiceCollectionExtensions
.AddInMemoryCaching()
.AddInMemoryApiResources(ApiResources.GetApiResources())
.AddInMemoryApiScopes(ApiScopes.GetApiScopes())
.AddClientStoreCache<ClientStore>()
.AddClientStoreCache<DynamicClientStore>()
.AddCustomTokenRequestValidator<CustomTokenRequestValidator>()
.AddProfileService<ProfileService>()
.AddResourceOwnerValidator<ResourceOwnerPasswordValidator>()
.AddClientStore<ClientStore>()
.AddClientStore<DynamicClientStore>()
.AddIdentityServerCertificate(env, globalSettings)
.AddExtensionGrantValidator<WebAuthnGrantValidator>();
if (!globalSettings.SelfHosted)
{
// Only cloud instances should be able to handle installations
services.AddClientProvider<InstallationClientProvider>("installation");
}
if (globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.InternalIdentityKey))
{
services.AddClientProvider<InternalClientProvider>("internal");
}
services.AddClientProvider<UserClientProvider>("user");
services.AddClientProvider<OrganizationClientProvider>("organization");
services.AddClientProvider<SecretsManagerApiKeyProvider>(SecretsManagerApiKeyProvider.ApiKeyPrefix);
if (CoreHelpers.SettingHasValue(globalSettings.IdentityServer.CosmosConnectionString))
{
services.AddSingleton<IPersistedGrantStore>(sp =>