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:
parent
63f836a73a
commit
0b2b573bd3
@ -12,4 +12,8 @@
|
|||||||
<ProjectReference Include="..\Core\Core.csproj" />
|
<ProjectReference Include="..\Core\Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="Identity.Test" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
@ -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()),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -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),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -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())
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -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()),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
75
src/Identity/IdentityServer/DynamicClientStore.cs
Normal file
75
src/Identity/IdentityServer/DynamicClientStore.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
28
src/Identity/IdentityServer/ServiceCollectionExtensions.cs
Normal file
28
src/Identity/IdentityServer/ServiceCollectionExtensions.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -3,6 +3,7 @@ using Bit.Core.IdentityServer;
|
|||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Bit.Identity.IdentityServer;
|
using Bit.Identity.IdentityServer;
|
||||||
|
using Bit.Identity.IdentityServer.ClientProviders;
|
||||||
using Bit.Identity.IdentityServer.RequestValidators;
|
using Bit.Identity.IdentityServer.RequestValidators;
|
||||||
using Bit.SharedWeb.Utilities;
|
using Bit.SharedWeb.Utilities;
|
||||||
using Duende.IdentityServer.ResponseHandling;
|
using Duende.IdentityServer.ResponseHandling;
|
||||||
@ -48,14 +49,29 @@ public static class ServiceCollectionExtensions
|
|||||||
.AddInMemoryCaching()
|
.AddInMemoryCaching()
|
||||||
.AddInMemoryApiResources(ApiResources.GetApiResources())
|
.AddInMemoryApiResources(ApiResources.GetApiResources())
|
||||||
.AddInMemoryApiScopes(ApiScopes.GetApiScopes())
|
.AddInMemoryApiScopes(ApiScopes.GetApiScopes())
|
||||||
.AddClientStoreCache<ClientStore>()
|
.AddClientStoreCache<DynamicClientStore>()
|
||||||
.AddCustomTokenRequestValidator<CustomTokenRequestValidator>()
|
.AddCustomTokenRequestValidator<CustomTokenRequestValidator>()
|
||||||
.AddProfileService<ProfileService>()
|
.AddProfileService<ProfileService>()
|
||||||
.AddResourceOwnerValidator<ResourceOwnerPasswordValidator>()
|
.AddResourceOwnerValidator<ResourceOwnerPasswordValidator>()
|
||||||
.AddClientStore<ClientStore>()
|
.AddClientStore<DynamicClientStore>()
|
||||||
.AddIdentityServerCertificate(env, globalSettings)
|
.AddIdentityServerCertificate(env, globalSettings)
|
||||||
.AddExtensionGrantValidator<WebAuthnGrantValidator>();
|
.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))
|
if (CoreHelpers.SettingHasValue(globalSettings.IdentityServer.CosmosConnectionString))
|
||||||
{
|
{
|
||||||
services.AddSingleton<IPersistedGrantStore>(sp =>
|
services.AddSingleton<IPersistedGrantStore>(sp =>
|
||||||
|
@ -9,7 +9,6 @@ using Bit.Core.Enums;
|
|||||||
using Bit.Core.Platform.Installations;
|
using Bit.Core.Platform.Installations;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Test.Auth.AutoFixture;
|
using Bit.Core.Test.Auth.AutoFixture;
|
||||||
using Bit.Identity.IdentityServer;
|
|
||||||
using Bit.IntegrationTestCommon.Factories;
|
using Bit.IntegrationTestCommon.Factories;
|
||||||
using Bit.Test.Common.AutoFixture.Attributes;
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
using Bit.Test.Common.Helpers;
|
using Bit.Test.Common.Helpers;
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
148
test/Identity.Test/IdentityServer/DynamicClientStoreTests.cs
Normal file
148
test/Identity.Test/IdentityServer/DynamicClientStoreTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user