Removing Shared Secrets for OAuth Client Authentication

Scott Brady
Scott Brady
OAuth

Passwords suck. We all complain about them and constantly look for alternatives or add multiple factors to secure our user authentication. So why do many of us still use passwords to authenticate our OAuth clients? After all, a client ID and client secret is just a username and password with a different name.

One of the easiest ways to remove the use of shared secrets for client authentication is to replace them with public-key cryptography by using JWT Bearer Token for Client Authentication defined in RFC 7523 and again detailed in the core OpenID Connect specification as the private_key_jwt client authentication method.

This flow makes use of signed JSON Web Tokens (JWTs) to simplify public-key cryptography, allowing us to use well known and established libraries to simplify our implementation.

There is a similar specification that uses SAML assertions for client authentication (RFC 7522), but this is a topic for another day.

So, let’s take a look at how JWT Bearer client authentication works, and then see how we can use it with the popular OAuth and OpenID Connect framework IdentityServer4.

JWT Bearer Tokens for Client Authentication

JWT Bearer Tokens can be used for client authentication anywhere client authentication takes place (typically the token endpoint) and for any flow or grant type.

It simply changes a request to look something like this (using either the post body or “OAuth style” basic authentication):

POST /token HTTP/1.1
Host: as.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=n0esc3NRze7LTCu7iYzS6a5acc3f0ogp4
&client_id=web_app
&client_secret=my_super_secret_password

To the slightly longer:

POST /token HTTP/1.1
Host: as.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=n0esc3NRze7LTCu7iYzS6a5acc3f0ogp4
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=eyJhbGciOiJSUzI1NiIsImtpZCI6IjIyIn0.
eyJpc3Mi[...omitted for brevity...].
cC4hiUPo[...omitted for brevity...]

Here we are introducing replacing client_id and client_secret with client_assertion_type and client_assertion, where the type is urn:ietf:params:oauth:client-assertion-type:jwt-bearer and the assertion itself is our JWT bearer token. This JWT is generated and signed by the client application, and it is the client application that holds the private key.

So, instead of comparing the client_secret against a value stored in the database, the authorization server must now instead validate a signed JWT.

There are few rules to validating this JWT:

  1. The issuer (iss) and subject (sub) must be the client_id of the OAuth client application
  2. The audience (aud) must identify the authorization server or a specific endpoint on the authorization server, such as the token endpoint
  3. It must have an expiry (exp), and it must still be valid

Other typical JWT claims can also be used, such as not before (nbf), issued at (iat), and JWT ID (jti) which, if present, must be validated.

If the token is invalid, the authorization returns the typical error response for that endpoint, using the error type of invalid_client.

Implementing JWT Bearer Token for Client Authentication in IdentityServer4 and ASP.NET Core

IdentityServer4 has existing support for JWT bearer client authentication, so let’s see how to configure it within IdentityServer4 and then use it with an ASP.NET Core application.

Enabling JWT Client Authenicaton within IdentityServer4

I’m going to start by using the is4inmem template available on GitHub, giving us a full demo instance of IdentityServer4 with a UI and user authentication.

By default, the two implementations we need, JwtBearerClientAssertionSecretParser and PrivateKeyJwtSecretValidator are not registered by the core AddIdentityServer registration. So, to start we’ll need to update our registration to look something like the following:

services.AddIdentityServer()
    // existing registrations
    .AddJwtBearerClientAuthentication();

Now that we have the required functionality enabled, we need to configure a client application that has a client secret that is the public key that will be used to verify the signature of any incoming JWTs. This public key must be added with a secret type of IdentityServerConstants.SecretTypes.X509CertificateBase64.

For this example, I’m going to allow the client application to use both the hybrid flow and the client credentials grant type.

new Client
{
    ClientId = "client_using_jwt",
    ClientName = "Client App using JWT Bearer Token for Client Authentication",

ClientSecrets = { new Secret { // Type must be "X509CertificateBase64" Type = IdentityServerConstants.SecretTypes.X509CertificateBase64,
// base64 value of the public key Value = Convert.ToBase64String(new X509Certificate2("./idsrv3test.cer").GetRawCertData()) } },
AllowedGrantTypes = GrantTypes.HybridAndClientCredentials, RedirectUris = {"http://localhost:5001/signin-oidc"}, AllowedScopes = {"openid", "profile", "api1"} }

In my example I’ve used the idsrv3test private/public key pairs that have been floating around for years because I am lazy. I hope it’s obvious that you should create your own for production implementations.

When adding a client to the IdentityServer4 templates, make sure you add it to where clients are being read from (e.g. from code, or from a JSON file).

Creating the JWT

Within a client application, we now need to create and sign our JWT. This can be done using the Microsoft JWT library. This JWT will be serialized with the compact serialization format, and use the meet the criteria we’ve already discussed:

public static class TokenGenerator
{
    public static string CreateClientAuthJwt()
    {
        // set exp to 5 minutes
        var tokenHandler = new JwtSecurityTokenHandler { TokenLifetimeInMinutes = 5 };

var securityToken = tokenHandler.CreateJwtSecurityToken( // iss must be the client_id of our application issuer: "client_using_jwt", // aud must be the identity provider (token endpoint) audience: "http://localhost:5000/connect/token", // sub must be the client_id of our application subject: new ClaimsIdentity( new List<Claim> { new Claim("sub", "client_using_jwt") }), // sign with the private key (using RS256 for IdentityServer) signingCredentials: new SigningCredentials( new X509SecurityKey(new X509Certificate2("./idsrv3test.pfx", "idsrv3test")), "RS256") );
return tokenHandler.WriteToken(securityToken); } }

In IdentityServer4, the audience must be the token endpoint of your IdentityServer.

Using Client Credentials

We can now use the IdentityModel library to make a client credentials request to our IdentityServer instance, using the client_assertion and client_assertion_type parameters:

var client = new HttpClient();
var response = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest
{
    Address = "http://localhost:5000/connect/token",
    GrantType = OidcConstants.GrantTypes.ClientCredentials,
    Scope = "api1",

ClientAssertion = new ClientAssertion { Type = OidcConstants.ClientAssertionTypes.JwtBearer, Value = TokenGenerator.CreateClientAuthJwt() } });

Using the Microsoft OpenID Connect Middleware

From what I can tell, the Microsoft OpenID Connect authentication middleware only allows us to use a shared secret for client credentials, and the only way I’ve been able to see for changing this is to use the OIDC events.

Luckily, the only time this middleware currently calls the token endpoint is when using the authorization_code grant type (swapping authorization codes from tokens). This means we only need to modify the OnAuthorizationCodeReceived event.

services.AddAuthentication()
    // other authentication registrations
    .AddOpenIdConnect(options => 
    {
        // other configuration
        options.Events.OnAuthorizationCodeReceived = context =>
        {
            context.TokenEndpointRequest.ClientAssertionType = OidcConstants.ClientAssertionTypes.JwtBearer;
            context.TokenEndpointRequest.ClientAssertion = TokenGenerator.CreateClientAuthJwt();

return Task.CompletedTask; }; });

Here we again generate a new token for each request and modify the TokenEndpointRequest to include our client assertion.

And that’s all there is to it!