Skip to content

Commit 7e67a05

Browse files
Fix stale ClientAssertion on DPoP nonce retry and missing assertion in 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
1 parent 1c74e3d commit 7e67a05

File tree

10 files changed

+694
-25
lines changed

10 files changed

+694
-25
lines changed

access-token-management/src/AccessTokenManagement.OpenIdConnect/Internal/AuthorizationServerDPoPHandler.cs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using Duende.AccessTokenManagement.Internal;
77

88
using Microsoft.AspNetCore.Http;
9+
using Microsoft.AspNetCore.WebUtilities;
910
using Microsoft.Extensions.Logging;
1011

1112
namespace Duende.AccessTokenManagement.OpenIdConnect.Internal;
@@ -35,7 +36,9 @@ internal class AuthorizationServerDPoPHandler(
3536
IDPoPProofService dPoPProofService,
3637
IDPoPNonceStore dPoPNonceStore,
3738
IHttpContextAccessor httpContextAccessor,
38-
ILoggerFactory loggerFactory) : DelegatingHandler
39+
ILoggerFactory loggerFactory,
40+
IClientAssertionService? clientAssertionService = null,
41+
ClientCredentialsClientName? clientName = null) : DelegatingHandler
3942
{
4043
// We depend on the logger factory, rather than the logger itself, since
4144
// the type parameter of the logger (referencing this class) will not
@@ -87,6 +90,26 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
8790
_logger.DPoPErrorDuringTokenRefreshWillRetryWithServerNonce(LogLevel.Debug, response.GetDPoPError());
8891
response.Dispose();
8992
await SetDPoPProofTokenForCodeExchangeAsync(request, dPoPNonce, codeExchangeJwk).ConfigureAwait(false);
93+
94+
// Regenerate the client assertion to ensure a fresh jti on retry.
95+
if (clientAssertionService != null && clientName != null)
96+
{
97+
var freshAssertion = await clientAssertionService
98+
.GetClientAssertionAsync(clientName.Value, ct: ct)
99+
.ConfigureAwait(false);
100+
101+
if (freshAssertion != null && request.Content != null)
102+
{
103+
var bodyString = await request.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
104+
var formData = QueryHelpers.ParseQuery(bodyString);
105+
formData[Duende.IdentityModel.OidcConstants.TokenRequest.ClientAssertionType] = freshAssertion.Type;
106+
formData[Duende.IdentityModel.OidcConstants.TokenRequest.ClientAssertion] = freshAssertion.Value;
107+
request.Content = new FormUrlEncodedContent(
108+
formData.SelectMany(kvp => kvp.Value.Select(v =>
109+
new KeyValuePair<string, string>(kvp.Key, v ?? string.Empty))));
110+
}
111+
}
112+
90113
return await base.SendAsync(request, ct).ConfigureAwait(false);
91114
}
92115
}

access-token-management/src/AccessTokenManagement.OpenIdConnect/Internal/ConfigureOpenIdConnectOptions.cs

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ internal class ConfigureOpenIdConnectOptions(
2222
IHttpContextAccessor httpContextAccessor,
2323
IOptions<UserTokenManagementOptions> userAccessTokenManagementOptions,
2424
IAuthenticationSchemeProvider schemeProvider,
25+
IClientAssertionService clientAssertionService,
2526
ILoggerFactory loggerFactory) : IConfigureNamedOptions<OpenIdConnectOptions>
2627
{
2728
private readonly Scheme _configScheme = GetConfigScheme(userAccessTokenManagementOptions.Value, schemeProvider);
@@ -59,7 +60,7 @@ public void Configure(string? name, OpenIdConnectOptions options)
5960
options.Events.OnAuthorizationCodeReceived = CreateCallback(options.Events.OnAuthorizationCodeReceived);
6061
options.Events.OnTokenValidated = CreateCallback(options.Events.OnTokenValidated);
6162

62-
options.BackchannelHttpHandler = new AuthorizationServerDPoPHandler(dPoPProofService, dPoPNonceStore, httpContextAccessor, loggerFactory)
63+
options.BackchannelHttpHandler = new AuthorizationServerDPoPHandler(dPoPProofService, dPoPNonceStore, httpContextAccessor, loggerFactory, clientAssertionService, ClientName)
6364
{
6465
InnerHandler = options.BackchannelHttpHandler ?? new HttpClientHandler()
6566
};
@@ -104,9 +105,9 @@ async Task Callback(RedirectContext context)
104105

105106
private Func<AuthorizationCodeReceivedContext, Task> CreateCallback(Func<AuthorizationCodeReceivedContext, Task> inner)
106107
{
107-
Task Callback(AuthorizationCodeReceivedContext context)
108+
async Task Callback(AuthorizationCodeReceivedContext context)
108109
{
109-
var result = inner.Invoke(context);
110+
await inner.Invoke(context);
110111

111112
// get key from storage
112113
var jwk = context.Properties?.GetProofKey();
@@ -116,17 +117,26 @@ Task Callback(AuthorizationCodeReceivedContext context)
116117
context.HttpContext.SetCodeExchangeDPoPKey(jwk.Value);
117118
}
118119

119-
return result;
120+
// Automatically send client assertion during code exchange if a service is registered
121+
var assertion = await clientAssertionService
122+
.GetClientAssertionAsync(ClientName, ct: context.HttpContext.RequestAborted)
123+
.ConfigureAwait(false);
124+
125+
if (assertion != null)
126+
{
127+
context.TokenEndpointRequest!.ClientAssertionType = assertion.Type;
128+
context.TokenEndpointRequest.ClientAssertion = assertion.Value;
129+
}
120130
}
121131

122132
return Callback;
123133
}
124134

125135
private Func<TokenValidatedContext, Task> CreateCallback(Func<TokenValidatedContext, Task> inner)
126136
{
127-
Task Callback(TokenValidatedContext context)
137+
async Task Callback(TokenValidatedContext context)
128138
{
129-
var result = inner.Invoke(context);
139+
await inner.Invoke(context);
130140

131141
// TODO: we don't have a good approach for this right now, since the IUserTokenStore
132142
// just assumes that the session management has been populated with all the token values
@@ -139,8 +149,6 @@ Task Callback(TokenValidatedContext context)
139149
// // and defer to the host and/or IUserTokenStore implementation to decide where the key is kept
140150
// //context.Properties!.RemoveProofKey();
141151
//}
142-
143-
return result;
144152
}
145153

146154
return Callback;

access-token-management/src/AccessTokenManagement.OpenIdConnect/Internal/OpenIdConnectUserTokenEndpoint.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,24 @@ public async Task<TokenResult<UserToken>> RefreshAccessTokenAsync(
120120

121121
request.DPoPProofToken = proof;
122122

123+
// Regenerate the client assertion to ensure a fresh jti on retry.
124+
// Only apply when using the service-based assertion path (not the static Assertion parameter).
125+
if (parameters.Assertion == null)
126+
{
127+
var freshAssertion = await clientAssertionService
128+
.GetClientAssertionAsync(
129+
clientName: oidc.Scheme.ToClientName(),
130+
parameters,
131+
ct)
132+
.ConfigureAwait(false);
133+
134+
if (freshAssertion != null)
135+
{
136+
request.ClientAssertion = freshAssertion;
137+
request.ClientCredentialStyle = ClientCredentialStyle.PostBody;
138+
}
139+
}
140+
123141
if (request.DPoPProofToken != null)
124142
{
125143
metrics.DPoPNonceErrorRetry(ClientId.Parse(request.ClientId), response.Error);

access-token-management/src/AccessTokenManagement/Internal/ClientCredentialsTokenClient.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,19 @@ public virtual async Task<TokenResult<ClientCredentialsToken>> RequestAccessToke
127127
dPoPNonce: DPoPNonce.Parse(response.DPoPNonce),
128128
ct: ct);
129129

130+
// Regenerate the client assertion to ensure a fresh jti on retry.
131+
// Only apply when using the service-based assertion path (not the static Assertion parameter).
132+
if (parameters.Assertion == null)
133+
{
134+
var freshAssertion = await clientAssertionService.GetClientAssertionAsync(clientName, parameters, ct: ct)
135+
.ConfigureAwait(false);
136+
if (freshAssertion != null)
137+
{
138+
request.ClientAssertion = freshAssertion;
139+
request.ClientCredentialStyle = ClientCredentialStyle.PostBody;
140+
}
141+
}
142+
130143
if (request.DPoPProofToken != null)
131144
{
132145
response = await httpClient.RequestClientCredentialsTokenAsync(request, ct).ConfigureAwait(false);

access-token-management/test/AccessTokenManagement.Tests/ClientTokenManagementTests.cs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,4 +602,94 @@ public async Task Cache_auto_tuning_should_persist_across_transient_manager_inst
602602
secondRequestExpiration.ShouldBe(expectedExpiration,
603603
"Second request should use the auto-tuned cache duration learned from the first request");
604604
}
605+
606+
[Fact]
607+
public async Task dpop_nonce_retry_should_use_fresh_client_assertion()
608+
{
609+
// Arrange: assertion service that returns incrementing assertion values
610+
var callCount = 0;
611+
var capturedAssertions = new List<string>();
612+
_services.AddTransient<IClientAssertionService>(_ =>
613+
new CountingClientAssertionService("test", () => $"assertion_{Interlocked.Increment(ref callCount)}"));
614+
615+
var proof = new TestDPoPProofService { ProofToken = "proof_token", AppendNonce = true };
616+
_services.AddSingleton<IDPoPProofService>(proof);
617+
618+
_services.AddClientCredentialsTokenManagement()
619+
.AddClient("test", client => Some.ClientCredentialsClient(
620+
toConfigure: client,
621+
jsonWebKey: The.JsonWebKey));
622+
623+
// First request: returns DPoP nonce error
624+
_mockHttp.Expect(The.TokenEndpoint.ToString())
625+
.With(m =>
626+
{
627+
var content = m.Content!.ReadAsStringAsync().Result;
628+
var pairs = System.Web.HttpUtility.ParseQueryString(content);
629+
var assertion = pairs["client_assertion"];
630+
if (assertion != null) capturedAssertions.Add(assertion);
631+
return true;
632+
})
633+
.Respond(HttpStatusCode.BadRequest,
634+
[new KeyValuePair<string, string>("DPoP-Nonce", "some_nonce")],
635+
"application/json",
636+
JsonSerializer.Serialize(new { error = "use_dpop_nonce" }));
637+
638+
// Retry request: should succeed
639+
_mockHttp.Expect(The.TokenEndpoint.ToString())
640+
.With(m =>
641+
{
642+
var content = m.Content!.ReadAsStringAsync().Result;
643+
var pairs = System.Web.HttpUtility.ParseQueryString(content);
644+
var assertion = pairs["client_assertion"];
645+
if (assertion != null) capturedAssertions.Add(assertion);
646+
return true;
647+
})
648+
.Respond(_ => Some.TokenHttpResponse());
649+
650+
_services.AddHttpClient(ClientCredentialsTokenManagementDefaults.BackChannelHttpClientName)
651+
.ConfigurePrimaryHttpMessageHandler(() => _mockHttp);
652+
653+
var provider = _services.BuildServiceProvider();
654+
var sut = provider.GetRequiredService<IClientCredentialsTokenManager>();
655+
656+
// Act
657+
var token = await sut.GetAccessTokenAsync(ClientCredentialsClientName.Parse("test"), ct: _ct).GetToken();
658+
659+
// Assert
660+
_mockHttp.VerifyNoOutstandingExpectation();
661+
token.ShouldBeEquivalentTo(Some.ClientCredentialsToken() with
662+
{
663+
DPoPJsonWebKey = The.JsonWebKey
664+
});
665+
666+
// CRITICAL ASSERTION: The two requests should have different client assertions
667+
capturedAssertions.Count.ShouldBe(2, "Expected two token requests (initial + nonce retry)");
668+
capturedAssertions[0].ShouldNotBe(capturedAssertions[1],
669+
"Client assertion must be regenerated on DPoP nonce retry, not reused");
670+
}
671+
672+
/// <summary>
673+
/// A client assertion service that returns a new assertion value on each call,
674+
/// useful for proving that assertions are (or are not) being refreshed.
675+
/// </summary>
676+
private class CountingClientAssertionService(string name, Func<string> valueFactory) : IClientAssertionService
677+
{
678+
public Task<ClientAssertion?> GetClientAssertionAsync(
679+
ClientCredentialsClientName? clientName = null,
680+
TokenRequestParameters? parameters = null,
681+
CancellationToken ct = default)
682+
{
683+
if (clientName == name)
684+
{
685+
return Task.FromResult<ClientAssertion?>(new ClientAssertion
686+
{
687+
Type = OidcConstants.ClientAssertionTypes.JwtBearer,
688+
Value = valueFactory()
689+
});
690+
}
691+
692+
return Task.FromResult<ClientAssertion?>(null);
693+
}
694+
}
605695
}

access-token-management/test/AccessTokenManagement.Tests/UserTokenManagementTests.cs

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -511,26 +511,16 @@ public async Task Can_request_user_token_using_client_assertions()
511511
var mockHttp = new MockHttpMessageHandler();
512512
AppHost.IdentityServerHttpHandler = mockHttp;
513513
AppHost.ClientSecret = null;
514+
515+
// Use the scheme-derived client name — ConfigureOpenIdConnectOptions calls
516+
// IClientAssertionService.GetClientAssertionAsync with this name during code exchange.
517+
var schemeClientName = OpenIdConnectTokenManagementDefaults.ClientCredentialsClientNamePrefix + "oidc";
514518
AppHost.OnConfigureServices += services =>
515519
{
516520
services.AddSingleton<IClientAssertionService>(
517-
new TestClientAssertionService("test", "service_type", "service_value"));
518-
services.PostConfigure<OpenIdConnectOptions>("oidc", options =>
519-
{
520-
options.Events.OnAuthorizationCodeReceived = async context =>
521-
{
522-
var clientAssertionService =
523-
context.HttpContext.RequestServices.GetRequiredService<IClientAssertionService>();
524-
var assertion =
525-
await clientAssertionService.GetClientAssertionAsync(
526-
ClientCredentialsClientName.Parse("test")) ??
527-
throw new InvalidOperationException("Client assertion is null");
528-
529-
context.TokenEndpointRequest!.ClientAssertionType = assertion.Type;
530-
context.TokenEndpointRequest.ClientAssertion = assertion.Value;
531-
};
532-
});
521+
new TestClientAssertionService(schemeClientName, "service_type", "service_value"));
533522
};
523+
534524
var expectedRequestFormData = new Dictionary<string, string>
535525
{
536526
{ OidcConstants.TokenRequest.ClientAssertionType, "service_type" },
@@ -559,6 +549,53 @@ await clientAssertionService.GetClientAssertionAsync(
559549
token.AccessTokenType.ShouldBe("clientAssertionsWork");
560550
}
561551

552+
[Fact]
553+
public async Task Code_exchange_sends_client_assertion_automatically_when_service_registered()
554+
{
555+
// This test verifies that when IClientAssertionService is registered, the assertion
556+
// is automatically sent during OIDC code exchange WITHOUT any manual PostConfigure workaround.
557+
// This is the fix for GitHub issue #325 / #1392.
558+
var mockHttp = new MockHttpMessageHandler();
559+
AppHost.IdentityServerHttpHandler = mockHttp;
560+
AppHost.ClientSecret = null;
561+
562+
// Use the scheme-derived client name that ConfigureOpenIdConnectOptions uses internally
563+
var schemeClientName = OpenIdConnectTokenManagementDefaults.ClientCredentialsClientNamePrefix + "oidc";
564+
AppHost.OnConfigureServices += services =>
565+
{
566+
services.AddSingleton<IClientAssertionService>(
567+
new TestClientAssertionService(schemeClientName, "service_type", "auto_assertion_value"));
568+
};
569+
570+
var expectedRequestFormData = new Dictionary<string, string>
571+
{
572+
{ OidcConstants.TokenRequest.ClientAssertionType, "service_type" },
573+
{ OidcConstants.TokenRequest.ClientAssertion, "auto_assertion_value" },
574+
};
575+
var initialTokenResponse = new
576+
{
577+
id_token = IdentityServerHost.CreateIdToken("1", "web"),
578+
access_token = "initial_access_token",
579+
token_type = "assertionAutoSent",
580+
expires_in = 3600,
581+
refresh_token = "initial_refresh_token",
582+
};
583+
584+
// The code exchange request should include the client assertion automatically
585+
mockHttp.When("/connect/token")
586+
.WithFormData(expectedRequestFormData)
587+
.Respond("application/json", JsonSerializer.Serialize(initialTokenResponse));
588+
589+
await InitializeAsync();
590+
await AppHost.LoginAsync("alice");
591+
592+
var response = await AppHost.BrowserClient.GetAsync(AppHost.Url("/user_token"), _ct);
593+
var token = await response.Content.ReadFromJsonAsync<UserTokenModel>(_ct);
594+
595+
token.ShouldNotBeNull();
596+
token.AccessTokenType.ShouldBe("assertionAutoSent");
597+
}
598+
562599
[Fact]
563600
public async Task Refresh_token_request_should_include_additional_parameters()
564601
{

0 commit comments

Comments
 (0)