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