diff --git a/src/Identity/Identity.csproj b/src/Identity/Identity.csproj
index cb506d86e9..bf5ab82166 100644
--- a/src/Identity/Identity.csproj
+++ b/src/Identity/Identity.csproj
@@ -12,4 +12,8 @@
+
+
+
+
diff --git a/src/Identity/IdentityServer/ClientProviders/InstallationClientProvider.cs b/src/Identity/IdentityServer/ClientProviders/InstallationClientProvider.cs
new file mode 100644
index 0000000000..a7e2754f00
--- /dev/null
+++ b/src/Identity/IdentityServer/ClientProviders/InstallationClientProvider.cs
@@ -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 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
+ {
+ new(JwtClaimTypes.Subject, installation.Id.ToString()),
+ },
+ };
+ }
+}
diff --git a/src/Identity/IdentityServer/ClientProviders/InternalClientProvider.cs b/src/Identity/IdentityServer/ClientProviders/InternalClientProvider.cs
new file mode 100644
index 0000000000..6d7fdc3459
--- /dev/null
+++ b/src/Identity/IdentityServer/ClientProviders/InternalClientProvider.cs
@@ -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 GetAsync(string identifier)
+ {
+ return Task.FromResult(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),
+ ],
+ });
+ }
+}
diff --git a/src/Identity/IdentityServer/ClientProviders/OrganizationClientProvider.cs b/src/Identity/IdentityServer/ClientProviders/OrganizationClientProvider.cs
new file mode 100644
index 0000000000..76842a9e54
--- /dev/null
+++ b/src/Identity/IdentityServer/ClientProviders/OrganizationClientProvider.cs
@@ -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 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())
+ ],
+ };
+ }
+}
diff --git a/src/Identity/IdentityServer/ClientProviders/SecretsManagerApiKeyProvider.cs b/src/Identity/IdentityServer/ClientProviders/SecretsManagerApiKeyProvider.cs
new file mode 100644
index 0000000000..dec5f8dc64
--- /dev/null
+++ b/src/Identity/IdentityServer/ClientProviders/SecretsManagerApiKeyProvider.cs
@@ -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 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 {
+ {"encryptedPayload", apiKey.EncryptedPayload},
+ },
+ Claims = new List
+ {
+ 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;
+ }
+}
diff --git a/src/Identity/IdentityServer/ClientProviders/UserClientProvider.cs b/src/Identity/IdentityServer/ClientProviders/UserClientProvider.cs
new file mode 100644
index 0000000000..82abfa3536
--- /dev/null
+++ b/src/Identity/IdentityServer/ClientProviders/UserClientProvider.cs
@@ -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 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
+ {
+ 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,
+ };
+ }
+}
diff --git a/src/Identity/IdentityServer/ClientStore.cs b/src/Identity/IdentityServer/ClientStore.cs
deleted file mode 100644
index c204e364ce..0000000000
--- a/src/Identity/IdentityServer/ClientStore.cs
+++ /dev/null
@@ -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 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 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 {
- {"encryptedPayload", apiKey.EncryptedPayload},
- },
- Claims = new List
- {
- 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 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
- {
- 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 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
- {
- 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
- {
- new(JwtClaimTypes.Subject, id),
- },
- };
- }
-
- private async Task 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
- {
- new(JwtClaimTypes.Subject, installation.Id.ToString()),
- },
- };
- }
-}
diff --git a/src/Identity/IdentityServer/DynamicClientStore.cs b/src/Identity/IdentityServer/DynamicClientStore.cs
new file mode 100644
index 0000000000..9d7764bf42
--- /dev/null
+++ b/src/Identity/IdentityServer/DynamicClientStore.cs
@@ -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 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 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);
+ }
+ }
+ 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(null);
+ }
+
+ // Once identifierName is proven valid, materialize the string
+ var clientBuilder = _serviceProvider.GetKeyedService(identifierName.ToString());
+
+ if (clientBuilder == null)
+ {
+ // No client registered by this identifier
+ return Task.FromResult(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);
+ }
+}
diff --git a/src/Identity/IdentityServer/ServiceCollectionExtensions.cs b/src/Identity/IdentityServer/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000000..2402ebb7f9
--- /dev/null
+++ b/src/Identity/IdentityServer/ServiceCollectionExtensions.cs
@@ -0,0 +1,28 @@
+using Bit.Identity.IdentityServer;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// Registers a custom for the given identifier to be called when a client id with
+ /// the identifier is attempting authentication.
+ ///
+ /// Your custom implementation of .
+ /// The service collection to add services to.
+ ///
+ /// The identifier to be used to invoke your client provider if a client_id is prefixed with your identifier
+ /// then your implementation will be invoked with the data after the seperating ..
+ ///
+ /// The for additional chaining.
+ public static IServiceCollection AddClientProvider(this IServiceCollection services, string identifier)
+ where T : class, IClientProvider
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentException.ThrowIfNullOrWhiteSpace(identifier);
+
+ services.AddKeyedTransient(identifier);
+
+ return services;
+ }
+}
diff --git a/src/Identity/Utilities/ServiceCollectionExtensions.cs b/src/Identity/Utilities/ServiceCollectionExtensions.cs
index bf90b1aa24..1476a5ec76 100644
--- a/src/Identity/Utilities/ServiceCollectionExtensions.cs
+++ b/src/Identity/Utilities/ServiceCollectionExtensions.cs
@@ -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()
+ .AddClientStoreCache()
.AddCustomTokenRequestValidator()
.AddProfileService()
.AddResourceOwnerValidator()
- .AddClientStore()
+ .AddClientStore()
.AddIdentityServerCertificate(env, globalSettings)
.AddExtensionGrantValidator();
+ if (!globalSettings.SelfHosted)
+ {
+ // Only cloud instances should be able to handle installations
+ services.AddClientProvider("installation");
+ }
+
+ if (globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.InternalIdentityKey))
+ {
+ services.AddClientProvider("internal");
+ }
+
+ services.AddClientProvider("user");
+ services.AddClientProvider("organization");
+ services.AddClientProvider(SecretsManagerApiKeyProvider.ApiKeyPrefix);
+
if (CoreHelpers.SettingHasValue(globalSettings.IdentityServer.CosmosConnectionString))
{
services.AddSingleton(sp =>
diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs
index 6a9e1796dc..80f2b5e20b 100644
--- a/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs
+++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs
@@ -9,7 +9,6 @@ using Bit.Core.Enums;
using Bit.Core.Platform.Installations;
using Bit.Core.Repositories;
using Bit.Core.Test.Auth.AutoFixture;
-using Bit.Identity.IdentityServer;
using Bit.IntegrationTestCommon.Factories;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers;
diff --git a/test/Identity.Test/IdentityServer/ClientProviders/InstallationClientProviderTests.cs b/test/Identity.Test/IdentityServer/ClientProviders/InstallationClientProviderTests.cs
new file mode 100644
index 0000000000..136ff507d2
--- /dev/null
+++ b/test/Identity.Test/IdentityServer/ClientProviders/InstallationClientProviderTests.cs
@@ -0,0 +1,75 @@
+using Bit.Core.IdentityServer;
+using Bit.Core.Platform.Installations;
+using Bit.Identity.IdentityServer.ClientProviders;
+using IdentityModel;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Identity.Test.IdentityServer.ClientProviders;
+
+public class InstallationClientProviderTests
+{
+
+ private readonly IInstallationRepository _installationRepository;
+ private readonly InstallationClientProvider _sut;
+
+ public InstallationClientProviderTests()
+ {
+ _installationRepository = Substitute.For();
+
+ _sut = new InstallationClientProvider(_installationRepository);
+ }
+
+ [Fact]
+ public async Task GetAsync_NonGuidIdentifier_ReturnsNull()
+ {
+ var installationClient = await _sut.GetAsync("non-guid");
+
+ Assert.Null(installationClient);
+ }
+
+ [Fact]
+ public async Task GetAsync_NonExistingInstallationGuid_ReturnsNull()
+ {
+ var installationClient = await _sut.GetAsync(Guid.NewGuid().ToString());
+
+ Assert.Null(installationClient);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task GetAsync_ExistingClient_ReturnsClientRespectingEnabledStatus(bool enabled)
+ {
+ var installationId = Guid.NewGuid();
+
+ _installationRepository
+ .GetByIdAsync(installationId)
+ .Returns(new Installation
+ {
+ Id = installationId,
+ Key = "some-key",
+ Email = "some-email",
+ Enabled = enabled,
+ });
+
+ var installationClient = await _sut.GetAsync(installationId.ToString());
+
+ Assert.NotNull(installationClient);
+ Assert.Equal($"installation.{installationId}", installationClient.ClientId);
+ Assert.True(installationClient.RequireClientSecret);
+ // The usage of this secret is tested in integration tests
+ Assert.Single(installationClient.ClientSecrets);
+ Assert.Collection(
+ installationClient.AllowedScopes,
+ s => Assert.Equal(ApiScopes.ApiPush, s),
+ s => Assert.Equal(ApiScopes.ApiLicensing, s),
+ s => Assert.Equal(ApiScopes.ApiInstallation, s)
+ );
+ Assert.Equal(enabled, installationClient.Enabled);
+ Assert.Equal(TimeSpan.FromDays(1).TotalSeconds, installationClient.AccessTokenLifetime);
+ var claim = Assert.Single(installationClient.Claims);
+ Assert.Equal(JwtClaimTypes.Subject, claim.Type);
+ Assert.Equal(installationId.ToString(), claim.Value);
+ }
+}
diff --git a/test/Identity.Test/IdentityServer/ClientProviders/InternalClientProviderTests.cs b/test/Identity.Test/IdentityServer/ClientProviders/InternalClientProviderTests.cs
new file mode 100644
index 0000000000..23da4b570a
--- /dev/null
+++ b/test/Identity.Test/IdentityServer/ClientProviders/InternalClientProviderTests.cs
@@ -0,0 +1,43 @@
+using Bit.Core.IdentityServer;
+using Bit.Core.Settings;
+using Bit.Identity.IdentityServer.ClientProviders;
+using IdentityModel;
+using Xunit;
+
+namespace Bit.Identity.Test.IdentityServer.ClientProviders;
+
+public class InternalClientProviderTests
+{
+ private readonly GlobalSettings _globalSettings;
+
+ private readonly InternalClientProvider _sut;
+
+ public InternalClientProviderTests()
+ {
+ _globalSettings = new GlobalSettings
+ {
+ SelfHosted = true,
+ };
+ _sut = new InternalClientProvider(_globalSettings);
+ }
+
+ [Fact]
+ public async Task GetAsync_ReturnsInternalClient()
+ {
+ var internalClient = await _sut.GetAsync("blah");
+
+ Assert.NotNull(internalClient);
+ Assert.Equal($"internal.blah", internalClient.ClientId);
+ Assert.True(internalClient.RequireClientSecret);
+ var secret = Assert.Single(internalClient.ClientSecrets);
+ Assert.NotNull(secret);
+ Assert.NotNull(secret.Value);
+ var scope = Assert.Single(internalClient.AllowedScopes);
+ Assert.Equal(ApiScopes.Internal, scope);
+ Assert.Equal(TimeSpan.FromDays(1).TotalSeconds, internalClient.AccessTokenLifetime);
+ Assert.True(internalClient.Enabled);
+ var claim = Assert.Single(internalClient.Claims);
+ Assert.Equal(JwtClaimTypes.Subject, claim.Type);
+ Assert.Equal("blah", claim.Value);
+ }
+}
diff --git a/test/Identity.Test/IdentityServer/DynamicClientStoreTests.cs b/test/Identity.Test/IdentityServer/DynamicClientStoreTests.cs
new file mode 100644
index 0000000000..aa4154c7a0
--- /dev/null
+++ b/test/Identity.Test/IdentityServer/DynamicClientStoreTests.cs
@@ -0,0 +1,148 @@
+using Bit.Identity.IdentityServer;
+using Duende.IdentityServer.Models;
+using Microsoft.Extensions.DependencyInjection;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Identity.Test.IdentityServer;
+
+public class DynamicClientStoreTests
+{
+ private readonly IServiceCollection _services;
+ private readonly IClientProvider _apiKeyProvider;
+
+ private readonly Func _sutCreator;
+
+ public DynamicClientStoreTests()
+ {
+ _services = new ServiceCollection();
+ _apiKeyProvider = Substitute.For();
+
+ _sutCreator = () => new DynamicClientStore(
+ _services.BuildServiceProvider(),
+ _apiKeyProvider,
+ new StaticClientStore(new Core.Settings.GlobalSettings())
+ );
+ }
+
+ [Theory]
+ [InlineData("mobile")]
+ [InlineData("web")]
+ [InlineData("browser")]
+ [InlineData("desktop")]
+ [InlineData("cli")]
+ [InlineData("connector")]
+ public async Task FindClientByIdAsync_StaticClients_Works(string staticClientId)
+ {
+ var sut = _sutCreator();
+
+ var client = await sut.FindClientByIdAsync(staticClientId);
+
+ Assert.NotNull(client);
+ Assert.Equal(staticClientId, client.ClientId);
+ }
+
+ [Fact]
+ public async Task FindClientByIdAsync_SplitName_NoService_ReturnsNull()
+ {
+ _services.AddClientProvider("my-provider");
+
+ var sut = _sutCreator();
+
+ var client = await sut.FindClientByIdAsync("blah.something");
+
+ Assert.Null(client);
+
+ await _apiKeyProvider
+ .Received(0)
+ .GetAsync(Arg.Any());
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task FindClientByIdAsync_SplitName_HasService_ReturnsValueFromService(bool returnNull)
+ {
+ var fakeProvider = Substitute.For();
+
+ fakeProvider
+ .GetAsync("something")
+ .Returns(returnNull ? null : new Client { ClientId = "fake" });
+
+ _services.AddKeyedSingleton("my-provider", fakeProvider);
+
+ var sut = _sutCreator();
+
+ var client = await sut.FindClientByIdAsync("my-provider.something");
+
+ if (returnNull)
+ {
+ Assert.Null(client);
+ }
+ else
+ {
+ Assert.NotNull(client);
+ }
+
+ await fakeProvider
+ .Received(1)
+ .GetAsync("something");
+
+ await _apiKeyProvider
+ .Received(0)
+ .GetAsync(Arg.Any());
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task FindClientByIdAsync_RandomString_NotSplit_TriesApiKey(bool returnsNull)
+ {
+ _apiKeyProvider
+ .GetAsync("random-string")
+ .Returns(returnsNull ? null : new Client { ClientId = "test" });
+
+ var sut = _sutCreator();
+
+ var client = await sut.FindClientByIdAsync("random-string");
+
+ if (returnsNull)
+ {
+ Assert.Null(client);
+ }
+ else
+ {
+ Assert.NotNull(client);
+ }
+
+ await _apiKeyProvider
+ .Received(1)
+ .GetAsync("random-string");
+ }
+
+ [Theory]
+ [InlineData("id.")]
+ [InlineData("id. ")]
+ public async Task FindClientByIdAsync_InvalidIdentifierValue_ReturnsNull(string clientId)
+ {
+ var sut = _sutCreator();
+
+ var client = await sut.FindClientByIdAsync(clientId);
+ Assert.Null(client);
+ }
+
+ private class FakeClientProvider : IClientProvider
+ {
+ public FakeClientProvider()
+ {
+ Fake = Substitute.For();
+ }
+
+ public IClientProvider Fake { get; }
+
+ public Task GetAsync(string identifier)
+ {
+ return Fake.GetAsync(identifier);
+ }
+ }
+}