Skip to content

Fix stale ClientAssertion on DPoP nonce retry and missing assertion in OIDC code exchange#329

Open
Erwinvandervalk wants to merge 8 commits intomainfrom
fix/dpop-stale-client-assertion-1392
Open

Fix stale ClientAssertion on DPoP nonce retry and missing assertion in OIDC code exchange#329
Erwinvandervalk wants to merge 8 commits intomainfrom
fix/dpop-stale-client-assertion-1392

Conversation

@Erwinvandervalk
Copy link
Contributor

@Erwinvandervalk Erwinvandervalk commented Feb 27, 2026

No description provided.

Copilot AI review requested due to automatic review settings February 27, 2026 13:31
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This pull request fixes two critical issues with client assertions in DPoP-enabled OAuth/OIDC flows:

  1. Stale client assertions on DPoP nonce retry: When authorization servers require unique jti (JWT ID) claims in client assertions, retrying a failed request with the same assertion causes rejection. This PR regenerates client assertions on DPoP nonce retries across all token request paths.

  2. Missing assertions in OIDC code exchange: The OIDC authorization code exchange flow previously required manual PostConfigure workarounds to inject client assertions. This PR automatically injects them when IClientAssertionService is registered.

Changes:

  • Client assertion regeneration added to three DPoP nonce retry paths: client credentials, refresh token, and authorization code exchange
  • IClientAssertionService injection added to OIDC code exchange flow via ConfigureOpenIdConnectOptions
  • Fixed async/await bugs in OIDC event callbacks (inner callbacks were not awaited)
  • Added ClientAssertionFactory property to ProofTokenMessageHandler for OidcClient Extensions library
  • 8 new tests verify the fixes work correctly and maintain backward compatibility

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
ClientCredentialsTokenClient.cs Regenerates client assertions on DPoP nonce retry for client credentials flow
OpenIdConnectUserTokenEndpoint.cs Regenerates client assertions on DPoP nonce retry for refresh token flow
ConfigureOpenIdConnectOptions.cs Injects IClientAssertionService, fixes async bugs, adds assertion to code exchange
AuthorizationServerDPoPHandler.cs Accepts optional assertion service, rebuilds form body with fresh assertion on retry
ProofTokenMessageHandler.cs Adds ClientAssertionFactory property, implements form body rebuilding on retry
OidcClientExtensions.cs Wires up assertion factory when GetClientAssertionAsync is configured
DPoPTests.cs Unit tests for ProofTokenMessageHandler assertion factory behavior
UserTokenManagementWithDPoPTests.cs Integration tests for refresh and code exchange assertion regeneration
UserTokenManagementTests.cs Updated test to remove PostConfigure workaround, adds automatic injection test
ClientTokenManagementTests.cs Tests client credentials assertion regeneration on DPoP nonce retry

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@Erwinvandervalk Erwinvandervalk marked this pull request as draft February 27, 2026 13:37
…n OIDC code exchange

When DPoP nonce negotiation triggers a token request retry, regenerate
the client assertion (private_key_jwt) so servers enforcing unique jti
claims accept the retry. Also inject IClientAssertionService into the
OIDC code-exchange flow so assertions are sent automatically without
requiring manual PostConfigure workarounds.

Five fix sites across two libraries:

1. ClientCredentialsTokenClient — re-invoke GetClientAssertionAsync
   before DPoP nonce retry (access-token-management)
2. OpenIdConnectUserTokenEndpoint — same pattern for refresh path
3. ConfigureOpenIdConnectOptions — inject IClientAssertionService, fix
   async bugs in CreateCallback, add assertion to OnAuthorizationCodeReceived
4. AuthorizationServerDPoPHandler — accept optional IClientAssertionService,
   rebuild form body with fresh assertion on nonce retry
5. ProofTokenMessageHandler — add optional ClientAssertionFactory callback,
   rebuild form body on nonce retry (identity-model-oidc-client)

OidcClientExtensions.ConfigureDPoP() wires GetClientAssertionAsync to the
token-endpoint handler's ClientAssertionFactory automatically.

Backward compatible: NoOpClientAssertionService returns null, all new
code paths null-check and skip. Static parameters.Assertion path is
intentionally left unchanged.

Closes #1392, closes #1393
Refactor client assertion regeneration to use a factory pattern that
produces fresh JWTs (with unique jti) on each DPoP nonce retry attempt,
preventing servers from rejecting replayed assertions.

identity-model:
- Add ProtocolRequestOptions.ClientAssertionFactory on HttpRequestMessage.Options
- Add ClientAssertionFactory property to ProtocolRequest, wired into Clone<T>()
- Invoke factory in RequestTokenAsync and PushAuthorizationAsync
- Add ClientAssertions sample project

identity-model-oidc-client:
- Rewrite ProofTokenMessageHandler to read factory from request.Options
- Remove handler-level ClientAssertionFactory property and WireClientAssertionFactory
- Wire ClientAssertionFactory in AuthorizeClient, OidcClient, ResponseProcessor
- Update NetCoreConsoleClient sample for DPoP + assertions
- Switch to ProjectReference for cross-project visibility

access-token-management:
- Refactor AuthorizationServerDPoPHandler to resolve IClientAssertionService
  from DI (HttpContext.RequestServices) instead of constructor injection
- Add integration test framework: JwtClientAssertionService, dpop-assertion
  client, JWT bearer auth, AppHost assertion wiring
- Add DPoPWithClientAssertionsTests with full IdentityServer integration
  tests for code exchange, refresh token, and client credentials flows
@Erwinvandervalk Erwinvandervalk force-pushed the fix/dpop-stale-client-assertion-1392 branch from ed4a2c7 to cb31f00 Compare March 3, 2026 07:16
@Erwinvandervalk Erwinvandervalk marked this pull request as ready for review March 3, 2026 10:24
Copy link
Member

@josephdecock josephdecock left a comment

Choose a reason for hiding this comment

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

Strongly recommend that we get a sample client running end to end for access token management.

// which ensures DPoP nonce retries don't replay a stale assertion.
var assertionService = new ClientAssertionService(
options.ClientId,
$"{_authority}/connect/token",
Copy link
Member

Choose a reason for hiding this comment

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

The audience must be the issuer identifier, not the token endpoint, as per RFC 7523 bis. In this case, the issuer identifier is "https://demo.duendesoftware.com".

var descriptor = new SecurityTokenDescriptor
{
Issuer = clientId,
Audience = "https://identityserver/connect/token",
Copy link
Member

Choose a reason for hiding this comment

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

Again, audience should be the issuer identifier.

var signingCredentials = ClientAssertionService.CreateSigningCredentials();

// 2. Create the assertion service.
var assertionService = new ClientAssertionService(ClientId, TokenEndpoint, signingCredentials);
Copy link
Member

Choose a reason for hiding this comment

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

Again, use issuer.

/// Constructor
/// </summary>
public ProofTokenMessageHandler(IDPoPProofTokenFactory dPoPProofTokenFactory, HttpMessageHandler innerHandler)
: this(dPoPProofTokenFactory, innerHandler, NullLogger<ProofTokenMessageHandler>.Instance)
Copy link
Member

Choose a reason for hiding this comment

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

NullLogger here feels bad. If we are going for avoiding a breaking change, isn't there something better we can pass to the base ctor that will actually log something?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants