Fix stale ClientAssertion on DPoP nonce retry and missing assertion in OIDC code exchange#329
Fix stale ClientAssertion on DPoP nonce retry and missing assertion in OIDC code exchange#329Erwinvandervalk wants to merge 8 commits intomainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This pull request fixes two critical issues with client assertions in DPoP-enabled OAuth/OIDC flows:
-
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. -
Missing assertions in OIDC code exchange: The OIDC authorization code exchange flow previously required manual
PostConfigureworkarounds to inject client assertions. This PR automatically injects them whenIClientAssertionServiceis registered.
Changes:
- Client assertion regeneration added to three DPoP nonce retry paths: client credentials, refresh token, and authorization code exchange
IClientAssertionServiceinjection added to OIDC code exchange flow viaConfigureOpenIdConnectOptions- Fixed async/await bugs in OIDC event callbacks (inner callbacks were not awaited)
- Added
ClientAssertionFactoryproperty toProofTokenMessageHandlerfor 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.
...management/src/AccessTokenManagement.OpenIdConnect/Internal/ConfigureOpenIdConnectOptions.cs
Outdated
Show resolved
Hide resolved
…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
ed4a2c7 to
cb31f00
Compare
josephdecock
left a comment
There was a problem hiding this comment.
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", |
There was a problem hiding this comment.
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", |
There was a problem hiding this comment.
Again, audience should be the issuer identifier.
| var signingCredentials = ClientAssertionService.CreateSigningCredentials(); | ||
|
|
||
| // 2. Create the assertion service. | ||
| var assertionService = new ClientAssertionService(ClientId, TokenEndpoint, signingCredentials); |
| /// Constructor | ||
| /// </summary> | ||
| public ProofTokenMessageHandler(IDPoPProofTokenFactory dPoPProofTokenFactory, HttpMessageHandler innerHandler) | ||
| : this(dPoPProofTokenFactory, innerHandler, NullLogger<ProofTokenMessageHandler>.Instance) |
There was a problem hiding this comment.
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?
No description provided.