Skip to content

Commit 0b2b573

Browse files
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>
1 parent 63f836a commit 0b2b573

14 files changed

+698
-294
lines changed

src/Identity/Identity.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,8 @@
1212
<ProjectReference Include="..\Core\Core.csproj" />
1313
</ItemGroup>
1414

15+
<ItemGroup>
16+
<InternalsVisibleTo Include="Identity.Test" />
17+
</ItemGroup>
18+
1519
</Project>
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
using Bit.Core.IdentityServer;
2+
using Bit.Core.Platform.Installations;
3+
using Duende.IdentityServer.Models;
4+
using IdentityModel;
5+
6+
namespace Bit.Identity.IdentityServer.ClientProviders;
7+
8+
internal class InstallationClientProvider : IClientProvider
9+
{
10+
private readonly IInstallationRepository _installationRepository;
11+
12+
public InstallationClientProvider(IInstallationRepository installationRepository)
13+
{
14+
_installationRepository = installationRepository;
15+
}
16+
17+
public async Task<Client> GetAsync(string identifier)
18+
{
19+
if (!Guid.TryParse(identifier, out var installationId))
20+
{
21+
return null;
22+
}
23+
24+
var installation = await _installationRepository.GetByIdAsync(installationId);
25+
26+
if (installation == null)
27+
{
28+
return null;
29+
}
30+
31+
return new Client
32+
{
33+
ClientId = $"installation.{installation.Id}",
34+
RequireClientSecret = true,
35+
ClientSecrets = { new Secret(installation.Key.Sha256()) },
36+
AllowedScopes = new[]
37+
{
38+
ApiScopes.ApiPush,
39+
ApiScopes.ApiLicensing,
40+
ApiScopes.ApiInstallation,
41+
},
42+
AllowedGrantTypes = GrantTypes.ClientCredentials,
43+
AccessTokenLifetime = 3600 * 24,
44+
Enabled = installation.Enabled,
45+
Claims = new List<ClientClaim>
46+
{
47+
new(JwtClaimTypes.Subject, installation.Id.ToString()),
48+
},
49+
};
50+
}
51+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
#nullable enable
2+
3+
using System.Diagnostics;
4+
using Bit.Core.IdentityServer;
5+
using Bit.Core.Settings;
6+
using Duende.IdentityServer.Models;
7+
using IdentityModel;
8+
9+
namespace Bit.Identity.IdentityServer.ClientProviders;
10+
11+
internal class InternalClientProvider : IClientProvider
12+
{
13+
private readonly GlobalSettings _globalSettings;
14+
15+
public InternalClientProvider(GlobalSettings globalSettings)
16+
{
17+
// This class should not have been registered when it's not self hosted
18+
Debug.Assert(globalSettings.SelfHosted);
19+
20+
_globalSettings = globalSettings;
21+
}
22+
23+
public Task<Client?> GetAsync(string identifier)
24+
{
25+
return Task.FromResult<Client?>(new Client
26+
{
27+
ClientId = $"internal.{identifier}",
28+
RequireClientSecret = true,
29+
ClientSecrets = { new Secret(_globalSettings.InternalIdentityKey.Sha256()) },
30+
AllowedScopes = [ApiScopes.Internal],
31+
AllowedGrantTypes = GrantTypes.ClientCredentials,
32+
AccessTokenLifetime = 3600 * 24,
33+
Enabled = true,
34+
Claims =
35+
[
36+
new(JwtClaimTypes.Subject, identifier),
37+
],
38+
});
39+
}
40+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
using Bit.Core.Enums;
2+
using Bit.Core.Identity;
3+
using Bit.Core.IdentityServer;
4+
using Bit.Core.Repositories;
5+
using Duende.IdentityServer.Models;
6+
using IdentityModel;
7+
8+
namespace Bit.Identity.IdentityServer.ClientProviders;
9+
10+
internal class OrganizationClientProvider : IClientProvider
11+
{
12+
private readonly IOrganizationRepository _organizationRepository;
13+
private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository;
14+
15+
public OrganizationClientProvider(
16+
IOrganizationRepository organizationRepository,
17+
IOrganizationApiKeyRepository organizationApiKeyRepository
18+
)
19+
{
20+
_organizationRepository = organizationRepository;
21+
_organizationApiKeyRepository = organizationApiKeyRepository;
22+
}
23+
24+
public async Task<Client> GetAsync(string identifier)
25+
{
26+
if (!Guid.TryParse(identifier, out var organizationId))
27+
{
28+
return null;
29+
}
30+
31+
var organization = await _organizationRepository.GetByIdAsync(organizationId);
32+
33+
if (organization == null)
34+
{
35+
return null;
36+
}
37+
38+
var orgApiKey = (await _organizationApiKeyRepository
39+
.GetManyByOrganizationIdTypeAsync(organization.Id, OrganizationApiKeyType.Default))
40+
.First();
41+
42+
return new Client
43+
{
44+
ClientId = $"organization.{organization.Id}",
45+
RequireClientSecret = true,
46+
ClientSecrets = [new Secret(orgApiKey.ApiKey.Sha256())],
47+
AllowedScopes = [ApiScopes.ApiOrganization],
48+
AllowedGrantTypes = GrantTypes.ClientCredentials,
49+
AccessTokenLifetime = 3600 * 1,
50+
Enabled = organization.Enabled && organization.UseApi,
51+
Claims =
52+
[
53+
new(JwtClaimTypes.Subject, organization.Id.ToString()),
54+
new(Claims.Type, IdentityClientType.Organization.ToString())
55+
],
56+
};
57+
}
58+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
using Bit.Core.Identity;
2+
using Bit.Core.Repositories;
3+
using Bit.Core.SecretsManager.Models.Data;
4+
using Bit.Core.SecretsManager.Repositories;
5+
using Duende.IdentityServer.Models;
6+
using IdentityModel;
7+
8+
namespace Bit.Identity.IdentityServer.ClientProviders;
9+
10+
internal class SecretsManagerApiKeyProvider : IClientProvider
11+
{
12+
public const string ApiKeyPrefix = "apikey";
13+
14+
private readonly IApiKeyRepository _apiKeyRepository;
15+
private readonly IOrganizationRepository _organizationRepository;
16+
17+
public SecretsManagerApiKeyProvider(IApiKeyRepository apiKeyRepository, IOrganizationRepository organizationRepository)
18+
{
19+
_apiKeyRepository = apiKeyRepository;
20+
_organizationRepository = organizationRepository;
21+
}
22+
23+
public async Task<Client> GetAsync(string identifier)
24+
{
25+
if (!Guid.TryParse(identifier, out var apiKeyId))
26+
{
27+
return null;
28+
}
29+
30+
var apiKey = await _apiKeyRepository.GetDetailsByIdAsync(apiKeyId);
31+
32+
if (apiKey == null || apiKey.ExpireAt <= DateTime.UtcNow)
33+
{
34+
return null;
35+
}
36+
37+
switch (apiKey)
38+
{
39+
case ServiceAccountApiKeyDetails key:
40+
var org = await _organizationRepository.GetByIdAsync(key.ServiceAccountOrganizationId);
41+
if (!org.UseSecretsManager || !org.Enabled)
42+
{
43+
return null;
44+
}
45+
break;
46+
}
47+
48+
var client = new Client
49+
{
50+
ClientId = identifier,
51+
RequireClientSecret = true,
52+
ClientSecrets = { new Secret(apiKey.ClientSecretHash) },
53+
AllowedScopes = apiKey.GetScopes(),
54+
AllowedGrantTypes = GrantTypes.ClientCredentials,
55+
AccessTokenLifetime = 3600 * 1,
56+
ClientClaimsPrefix = null,
57+
Properties = new Dictionary<string, string> {
58+
{"encryptedPayload", apiKey.EncryptedPayload},
59+
},
60+
Claims = new List<ClientClaim>
61+
{
62+
new(JwtClaimTypes.Subject, apiKey.ServiceAccountId.ToString()),
63+
new(Claims.Type, IdentityClientType.ServiceAccount.ToString()),
64+
},
65+
};
66+
67+
switch (apiKey)
68+
{
69+
case ServiceAccountApiKeyDetails key:
70+
client.Claims.Add(new ClientClaim(Claims.Organization, key.ServiceAccountOrganizationId.ToString()));
71+
break;
72+
}
73+
74+
return client;
75+
}
76+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
#nullable enable
2+
3+
using System.Collections.ObjectModel;
4+
using System.Security.Claims;
5+
using Bit.Core.AdminConsole.Repositories;
6+
using Bit.Core.Context;
7+
using Bit.Core.Identity;
8+
using Bit.Core.Repositories;
9+
using Bit.Core.Services;
10+
using Bit.Core.Utilities;
11+
using Duende.IdentityServer.Models;
12+
using IdentityModel;
13+
14+
namespace Bit.Identity.IdentityServer.ClientProviders;
15+
16+
public class UserClientProvider : IClientProvider
17+
{
18+
private readonly IUserRepository _userRepository;
19+
private readonly ICurrentContext _currentContext;
20+
private readonly ILicensingService _licensingService;
21+
private readonly IOrganizationUserRepository _organizationUserRepository;
22+
private readonly IProviderUserRepository _providerUserRepository;
23+
24+
public UserClientProvider(
25+
IUserRepository userRepository,
26+
ICurrentContext currentContext,
27+
ILicensingService licensingService,
28+
IOrganizationUserRepository organizationUserRepository,
29+
IProviderUserRepository providerUserRepository)
30+
{
31+
_userRepository = userRepository;
32+
_currentContext = currentContext;
33+
_licensingService = licensingService;
34+
_organizationUserRepository = organizationUserRepository;
35+
_providerUserRepository = providerUserRepository;
36+
}
37+
38+
public async Task<Client?> GetAsync(string identifier)
39+
{
40+
if (!Guid.TryParse(identifier, out var userId))
41+
{
42+
return null;
43+
}
44+
45+
var user = await _userRepository.GetByIdAsync(userId);
46+
if (user == null)
47+
{
48+
return null;
49+
}
50+
51+
var claims = new Collection<ClientClaim>
52+
{
53+
new(JwtClaimTypes.Subject, user.Id.ToString()),
54+
new(JwtClaimTypes.AuthenticationMethod, "Application", "external"),
55+
new(Claims.Type, IdentityClientType.User.ToString()),
56+
};
57+
var orgs = await _currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id);
58+
var providers = await _currentContext.ProviderMembershipAsync(_providerUserRepository, user.Id);
59+
var isPremium = await _licensingService.ValidateUserPremiumAsync(user);
60+
foreach (var claim in CoreHelpers.BuildIdentityClaims(user, orgs, providers, isPremium))
61+
{
62+
var upperValue = claim.Value.ToUpperInvariant();
63+
var isBool = upperValue is "TRUE" or "FALSE";
64+
claims.Add(isBool
65+
? new ClientClaim(claim.Key, claim.Value, ClaimValueTypes.Boolean)
66+
: new ClientClaim(claim.Key, claim.Value)
67+
);
68+
}
69+
70+
return new Client
71+
{
72+
ClientId = $"user.{userId}",
73+
RequireClientSecret = true,
74+
ClientSecrets = { new Secret(user.ApiKey.Sha256()) },
75+
AllowedScopes = new[] { "api" },
76+
AllowedGrantTypes = GrantTypes.ClientCredentials,
77+
AccessTokenLifetime = 3600 * 1,
78+
ClientClaimsPrefix = null,
79+
Claims = claims,
80+
};
81+
}
82+
}

0 commit comments

Comments
 (0)