IdentityServer client authentication with public/private keys instead of shared secrets

Figured this out thanks to the unit tests in IdentityServer4!

When using public/private authentication, client_secret is not used. Rather, a client_assertion is used, which is a JWT token.

Here is sample code for the token request. client.pfx is the certificate bundle generated from the steps above in the question.

var now = DateTime.UtcNow;
var clientId = "abc";
var tokenEndpoint = "http://localhost:5000/connect/token";

var cert = new X509Certificate2("client.pfx", "1234");

// create client_assertion JWT token
var token = new JwtSecurityToken(
    clientId,
    tokenEndpoint,
    new List<Claim>
    {
        new Claim("jti", Guid.NewGuid().ToString()),
        new Claim(JwtClaimTypes.Subject, clientId),
        new Claim(JwtClaimTypes.IssuedAt, now.ToEpochTime().ToString(), ClaimValueTypes.Integer64)
    },
    now,
    now.AddMinutes(1),
    new SigningCredentials(
        new X509SecurityKey(cert),
        SecurityAlgorithms.RsaSha256
    )
);

var tokenHandler = new JwtSecurityTokenHandler();
var tokenString = tokenHandler.WriteToken(token);


// token request - note there's no client_secret but a client_assertion which contains the token above
var requestBody = new FormUrlEncodedContent(new Dictionary<string, string>
{
    {"client_id", clientId},
    {"client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"},
    {"client_assertion", tokenString},
    {"grant_type", "client_credentials"},
    {"scope", "api1 api2"}
});


var client = new HttpClient();
var response = await client.PostAsync(tokenEndpoint, requestBody);
var tokenRespone = new TokenResponse(await response.Content.ReadAsStringAsync());

I think it has to be a signed JWT. Check out the PrivateKeyJwtSecretValidator class in the IDS4 codebase:

https://github.com/IdentityServer/IdentityServer4/blob/2.1.3/src/IdentityServer4/Validation/PrivateKeyJwtSecretValidator.cs