Notat · 10.08.2021
DPoP #2: Implementing DPoP in Duende IdentityServer
In this second post of the series of three posts I will write about my endeavors to implement the DPoP draft from the IETF OAuth WG.
In my first post i tried to give a high level description of how the mechanism works.
Possible abstract for this post: Not so super programming skills vs IETF OAuth draft, part 1.
Duende IdentityServer
I wanted to test the mechanism. So, my first task was to implement DPoP in an Authorization Server.
Building an AS from scratch was definitely out of scope, so I decided to use the Duende IdentityServer as a starting point. Duende IdentityServer is an implementation of OpenID Connect. It should be considered as more of a core framework than a complete product. Now, I might not be totally unpartial, but believe me when I say that the framework is easy to work with - just plainly damned well written - and it has an appetite for extensions.
Keeping it simple
The specification describes the following:
To request an access token that is bound to a public key using DPoP, the client MUST provide a valid DPoP proof JWT in a “DPoP” header when making an access token request to the authorization server’s token endpoint. This is applicable for all access token requests regardless of grant type (including, for example, the common “authorization_code” and “refresh_token” grant types but also extension grants such as the JWT authorization grant [RFC7523]).
I interpreted this part quite literally, and aimed for the simplest possible solution:
- only testing client_credentials grant/flow
- no refresh token support
- not caring if I break existing flows (e.g. mTLS)
The token endpoint
I started by extending Duende IdentityServer with a DPoP validator class, and added it to the token endpoint.
var dpopResult = await _dpopValidator.ValidateAsync(context);
if (dpopResult.IsError)
{
return Error("invalid_dpop_proof"); //TODO: conform to spec
}
The thumbprint of the public key in the DPoP proof
I want Duende IdentityServer to include the thumbprint of the public key in the DPoP proof in accordance with the specification. The goal is to enable the API to verify that the jwk in the DPoP proof that it receives from its client is the same that was used when the client requested the Access Token.
if (dpopResult != null)
{
if (dpopResult.ValidatedDpopProof.ThumbprintBase64Url != null)
{
requestResult.ValidatedRequest.DPoPThumbprint = dpopResult.ValidatedDpopProof.ThumbprintBase64Url;
}
}
The DPoP validator
My DPoP validator interface has one method that needs to be implemented. This stuff is a job for people that actually know what they’re doing (e.g. Dominick and Brock) - in other words: my validator is not in any way complete..
public async Task<DpopValidationResult> ValidateAsync(HttpContext context)
{
_proof = new ValidatedDpopProof(context);
if (_proof.DpopHeader == null)
return null;
//2. check "typ"
if (!_proof.JoseHeader.ContainsKey("typ"))
{
return Invalid("DPoP proof does not contain \"typ\" claim", null, null);
}
if (_proof.JoseHeader["typ"].GetString() != "dpop+jwt")
{
return Invalid("DPoP proof is not correct type");
}
//3. check algorithm
if (!_proof.JoseHeader.ContainsKey("alg"))
{
return Invalid("DPoP proof is not well formed", "JOSE header does not contain \"alg\" claim", null);
}
var algorithms = new string[] { "RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512" };
var joseAlg = _proof.JoseHeader["alg"].GetString();
if (joseAlg == "none")
return Invalid("DPoP jose header cannot contain \"none\" as value");
if (!Array.Exists(algorithms, alg => alg == joseAlg))
{
return Invalid("DPoP jose header contains invalid algorithm", null, null);
}
if (!_proof.Payload.ContainsKey("htu"))
{
return Invalid("DPoP proof is not well formed", "The \"htu\" claim is not present", null);
}
if (_proof.Payload["htu"].GetString() != "https://dpopidentityserver.azurewebsites.net/connect/token")
{
return Invalid("Invalid htu value");
}
if (!_proof.Payload.ContainsKey("htm"))
{
return Invalid("DPoP proof is not well formed", "The \"htm\" claim is not present", null);
}
if (_proof.Payload["htm"].GetString().ToLower() != context.Request.Method.ToLower())
{
return Invalid("Invalid htm value");
}
//4. Validate jwt signature using key in jose header
var jwtHandler = new JwtSecurityTokenHandler();
var validationParameters = new TokenValidationParameters
{
IssuerSigningKey = _proof.Jwk,
ValidateIssuerSigningKey = true,
RequireAudience = false,
ValidateIssuer = false,
ValidateAudience = false,
RequireExpirationTime = false
};
jwtHandler.ValidateToken(_proof.DpopHeader, validationParameters, out var validatedToken);
if (validatedToken == null)
return Invalid("DPoP Error", "DPoP validation failed", null);
return new DpopValidationResult(_proof, null, null);
}
Binding the DPoP proof to the access token
The DPoP spec describes how the access token should be bound to the key material contained in the DPoP proof. I felt lucky when I discovered that Microsoft has added some convenience methods that makes the job of creating the thumbprint in accordance with the spec really easy:
var jwk = new Microsoft.IdentityModel.Tokens.JsonWebKey(JoseHeader["jwk"].GetRawText());
Thumbprint = jwk.ComputeJwkThumbprint();
Now that the token validation result contains the DPoP proof thumbprint, the last step is to include the claim in the Access Token. I added the following line to the TokenResponseGenerator class:
if (!request.DPoPThumbprint.IsNullOrEmpty())
{
tokenRequest.Jkt = request.DPoPThumbprint;
}
And the following code to the DefaultTokenService implementation:
else if (request.Jkt.IsPresent())
{
token.Confirmation = "{\"jkt\" : \"" + request.Jkt + "\"}";
}
The last steps involved adding the "token_type": "dpop" to the token response, but this was such an ugly hack that I am too embarrassed to show anyone..
Conclusion
I think I spent around a total of 6-7 hours in Duende IdentityServer to implement the features that I needed for my POC. Most of this time was spent on plumbing, frenetically trying to remember .net, c# and VS. The DPoP specific details were actually really easy to implement.
I think the specification is quite easy to follow, and Duende IdentityServer was easy to extend. At the same time I think that writing a production ready implementation will take a lot more skills than I possess, and more time - which is why I’ll wait for the professionals to implement it correctly.
In my next post I’ll show you how my attempts at writing a client that uses my DPoP implementation in Duende IdentityServer went..
Spoiler alert! It turns out that javascript does not love me..