Skip to content

Allow ClientAssertions with PAR#340

Closed
Erwinvandervalk wants to merge 2 commits intofix/dpop-stale-client-assertion-1392from
fix/dpop-and-jwt-atm
Closed

Allow ClientAssertions with PAR#340
Erwinvandervalk wants to merge 2 commits intofix/dpop-stale-client-assertion-1392from
fix/dpop-and-jwt-atm

Conversation

@Erwinvandervalk
Copy link
Copy Markdown
Contributor

@Erwinvandervalk Erwinvandervalk commented Mar 13, 2026

Problem statement

When the authorization server supports Pushed Authorization Requests (PAR), the ASP.NET Core OIDC handler makes a backchannel POST to the PAR endpoint before redirecting the user. ConfigureOpenIdConnectOptions does not wrap the OnPushAuthorization event, so:

  1. The JWT client assertion is not included in the PAR request body. The IClientAssertionService is never called for PAR requests — only for code exchange (OnAuthorizationCodeReceived).
  2. The dpop_jkt parameter is not included in the PAR request. It's currently only added during OnRedirectToIdentityProvider, but when PAR is active the authorize parameters are sent in the backchannel PAR request, not the front-channel redirect.

This means clients using private_key_jwt authentication cannot use PAR — the authorization server rejects the unauthenticated PAR request.

Per RFC 9126 §2, the PAR endpoint requires client authentication using the same method as the token endpoint:

The client is authenticated in the same way as at the token endpoint.

The WebJarJwt sample works around this with an explicit opt-out:

// Disable PAR because it is incompatible with currently wired up OidcEvents
options.PushedAuthorizationBehavior = PushedAuthorizationBehavior.Disable;

Solution

Add a handler for options.Events.OnPushAuthorization and calculate both a dpop proof and add the jwt.

@Erwinvandervalk Erwinvandervalk changed the base branch from main to fix/dpop-stale-client-assertion-1392 March 13, 2026 09:37
@Erwinvandervalk Erwinvandervalk self-assigned this Mar 13, 2026
@Erwinvandervalk Erwinvandervalk added area/foss/atm Issues related to Access Token Management impact/non-breaking The fix or change will not be a breaking one labels Mar 13, 2026
Comment on lines +51 to +57
var proof = await _dPoPProofService.CreateProofTokenAsync(new DPoPProofRequest
{
Url = url,
Method = HttpMethod.Get,
DPoPProofKey = key,
AccessToken = token.AccessToken,
});
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't access token management create this proof? I think there's an http handler designed for interaction with the resource server.

Comment on lines +164 to +176
// --- DPoP thumbprint ---
var dPoPKeyStore = context.HttpContext.RequestServices.GetRequiredService<IDPoPKeyStore>();
var key = await dPoPKeyStore.GetKeyAsync(ClientName);
if (key != null)
{
var jkt = dPoPProofService.GetProofKeyThumbprint(key.Value);
if (jkt != null)
{
context.Properties.SetProofKey(key.Value);
context.ProtocolMessage.Parameters[OidcConstants.AuthorizeRequest.DPoPKeyThumbprint] =
jkt.ToString();
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to add this. We should double check, but the backchannel handler already sets the DPoP header value.

DPoP defines two ways to bind the authorization code to the proof key. If you're not using PAR, you have to use the dpop_jkt query string parameter. Probably this is so because custom headers on a redirect can be annoying. So, the spec gives you this easy way to say "here's the thumbprint of my public key".

But, if you are using PAR, the backchannel pushed authorization request can easily set headers, including the DPoP header. That header is actually a stronger binding because it conveys both the public key and proof of possession of the private key.

The spec also points out that doing it this way means that the client can just always include the proof on all backchannel requests, and needs less special case logic.

For your reference, from RFC 9449:

When Pushed Authorization Requests (PARs) [RFC9126] are used in conjunction with DPoP, there are two ways in which the DPoP key can be communicated in the PAR request:

The dpop_jkt parameter can be used as described in Section 10 to bind the issued authorization code to a specific key. In this case, dpop_jkt MUST be included alongside other authorization request parameters in the POST body of the PAR request.

Alternatively, the DPoP header can be added to the PAR request. In this case, the authorization server MUST check the provided DPoP proof JWT as defined in Section 4.3. It MUST further behave as if the contained public key's thumbprint was provided using dpop_jkt, i.e., reject the subsequent token request unless a DPoP proof for the same key is provided. This can help to simplify the implementation of the client, as it can "blindly" attach the DPoP header to all requests to the authorization server regardless of the type of request. Additionally, it provides a stronger binding, as the DPoP header contains a proof of possession of the private key.

@Erwinvandervalk Erwinvandervalk deleted the fix/dpop-and-jwt-atm branch March 19, 2026 06:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/foss/atm Issues related to Access Token Management impact/non-breaking The fix or change will not be a breaking one

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants