This blog looks at implementing client assertions for the client credentials flow using OAuth 2.0 Demonstration of Proof-of-Possession (DPoP). The client credentials flow is an OAuth 2.0 authorization grant type used for machine-to-machine authentication. DPoP further strengthens the security by ensuring that the client possesses a specific key at the time of the request, forcing token binding.
Code: https://github.com/damienbod/OAuthClientAssertions
Blogs in this series
- Implement client assertions with client credentials flow using OAuth DPoP
- Implement client assertions for OAuth client credential flows in ASP.NET Core
- Using client assertions in OpenID Connect and ASP.NET Core
NOTE: The code in the blog and the linked repository was created using the samples from IdentityServer.
Setup
Three different applications are used in this setup, an API which uses the DPoP access token, an OAuth client application implemented as a console app and an OAuth server, implemented using ASP.NET Core and Duende IdentityServer. The OAuth client credentials flow is used to acquire the access token and the signed JWT is used to authenticate the client request. A second RSA Key is used for the DPoP implementation and created on the fly for the token requests.

OAuth Server using Duende
Duende IdentityServer supports DPoP really good. The Enterprise license is required to use the DPoP feature. The client credentials flow just needs the RequireDPoP property set to true and DPoP is supported.
new Client
{
ClientId = "mobile-dpop-client",
ClientName = "Mobile dpop client",
RequireDPoP = true,
AllowedGrantTypes = GrantTypes.ClientCredentials,
ClientSecrets =
[
new Secret
{
// X509 cert base64-encoded
Type = IdentityServerConstants.SecretTypes.X509CertificateBase64,
Value = Convert.ToBase64String(rsaCertificate.GetRawCertData())
}
],
AllowedScopes = { "scope-dpop" }
}
Client assertions required middleware which is not added in the default setup.
idsvrBuilder.AddJwtBearerClientAuthentication();
OAuth client credentials client requesting DPoP AT
(Note: code taken from the Duende samples.)
The Duende.AccessTokenManagement Nuget package is used to support client assertions and DPoP token usage in the client application. This is integrated into a named HttpClient factory. The support for client assertions and DPoP used this HttpClient is added using the AddClientCredentialsHttpClient extension.
services.AddDistributedMemoryCache();
services.AddScoped<IClientAssertionService, ClientAssertionService>();
// https://docs.duendesoftware.com/foss/accesstokenmanagement/advanced/client_assertions/
services.AddClientCredentialsTokenManagement()
.AddClient("mobile-dpop-client", client =>
{
client.TokenEndpoint = "https://localhost:5001/connect/token";
client.ClientId = "mobile-dpop-client";
// Using client assertion
//client.ClientSecret = "905e4892-7610-44cb-a122-6209b38c882f";
client.Scope = "scope-dpop";
client.DPoPJsonWebKey = CreateDPoPKey();
});
services.AddClientCredentialsHttpClient("mobile-dpop-client", "mobile-dpop-client", client =>
{
client.BaseAddress = new Uri("https://localhost:5005/");
});
The DPoP is created for each instance.
private static string CreateDPoPKey()
{
var key = new RsaSecurityKey(RSA.Create(2048));
var jwk = JsonWebKeyConverter.ConvertFromRSASecurityKey(key);
jwk.Alg = "PS256";
var jwkJson = JsonSerializer.Serialize(jwk);
return jwkJson;
}
The IClientAssertionService interface is used to add the client assertion to the client credentials client using the Duende client Nuget package. This works the same as in the previous blog.
public class ClientAssertionService : IClientAssertionService
{
private readonly IOptionsSnapshot<ClientCredentialsClient> _options;
public ClientAssertionService(IOptionsSnapshot<ClientCredentialsClient> options)
{
_options = options;
}
public Task<ClientAssertion?> GetClientAssertionAsync(
string? clientName = null, TokenRequestParameters? parameters = null)
{
if (clientName == "mobile-dpop-client")
{
// client assertion
var privatePem = File.ReadAllText(Path.Combine("", "rsa256-private.pem"));
var publicPem = File.ReadAllText(Path.Combine("", "rsa256-public.pem"));
var rsaCertificate = X509Certificate2.CreateFromPem(publicPem, privatePem);
var signingCredentials = new SigningCredentials(new X509SecurityKey(rsaCertificate), "RS256");
var options = _options.Get(clientName);
var descriptor = new SecurityTokenDescriptor
{
Issuer = options.ClientId,
Audience = options.TokenEndpoint,
Expires = DateTime.UtcNow.AddMinutes(1),
SigningCredentials = signingCredentials,
Claims = new Dictionary<string, object>
{
{ JwtClaimTypes.JwtId, Guid.NewGuid().ToString() },
{ JwtClaimTypes.Subject, options.ClientId! },
{ JwtClaimTypes.IssuedAt, DateTime.UtcNow.ToEpochTime() }
}
};
var handler = new JsonWebTokenHandler();
var jwt = handler.CreateToken(descriptor);
return Task.FromResult<ClientAssertion?>(new ClientAssertion
{
Type = OidcConstants.ClientAssertionTypes.JwtBearer,
Value = jwt
});
}
return Task.FromResult<ClientAssertion?>(null);
}
}
The services can be used like any other HttpClient named client.
var client = _clientFactory.CreateClient("mobile-dpop-client");
var response = await client.GetAsync("api/values", stoppingToken);
Notes
Using DPoP and client assertions work well together in this setup and different keys are used for the different OAuth flows. A lot of logic is solved using the Duende Nuget packages. Using DPoP and token binding for the API increases the security and should be used whenever possible. If using a web application with a user, a delegated OpenID Connect flow would be the better solution.
Links
https://datatracker.ietf.org/doc/html/rfc9449
https://docs.duendesoftware.com/identityserver/v7/tokens/authentication/jwt/
https://docs.duendesoftware.com/identityserver/v7/tokens/authentication/jwt/
https://docs.duendesoftware.com/foss/accesstokenmanagement/advanced/client_assertions/
https://www.scottbrady.io/oauth/removing-shared-secrets-for-oauth-client-authentication
https://github.com/DuendeSoftware/products/tree/main/aspnetcore-authentication-jwtbearer