1
0
mirror of https://github.com/bitwarden/server.git synced 2025-06-05 18:50:35 -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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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 =>

View File

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

View File

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

View File

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

View File

@ -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<DynamicClientStore> _sutCreator;
public DynamicClientStoreTests()
{
_services = new ServiceCollection();
_apiKeyProvider = Substitute.For<IClientProvider>();
_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<FakeClientProvider>("my-provider");
var sut = _sutCreator();
var client = await sut.FindClientByIdAsync("blah.something");
Assert.Null(client);
await _apiKeyProvider
.Received(0)
.GetAsync(Arg.Any<string>());
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task FindClientByIdAsync_SplitName_HasService_ReturnsValueFromService(bool returnNull)
{
var fakeProvider = Substitute.For<IClientProvider>();
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<string>());
}
[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<IClientProvider>();
}
public IClientProvider Fake { get; }
public Task<Client?> GetAsync(string identifier)
{
return Fake.GetAsync(identifier);
}
}
}