@@ -303,6 +303,69 @@ public async Task dpop_nonce_retry_during_code_exchange_should_use_fresh_client_
303303 "Client assertion must be regenerated on DPoP nonce retry during code exchange" ) ;
304304 }
305305
306+ [ Fact ]
307+ public async Task dpop_nonce_retry_during_code_exchange_should_pass_client_name_to_assertion_service ( )
308+ {
309+ var mockHttp = new MockHttpMessageHandler ( BackendDefinitionBehavior . Always ) ;
310+ AppHost . IdentityServerHttpHandler = mockHttp ;
311+ AppHost . ClientSecret = null ;
312+
313+ // Register an assertion service that captures the clientName on each call
314+ var capturedClientNames = new List < ClientCredentialsClientName ? > ( ) ;
315+ AppHost . OnConfigureServices += services =>
316+ {
317+ services . AddSingleton < IClientAssertionService > (
318+ new ClientNameCapturingAssertionService ( capturedClientNames ) ) ;
319+ } ;
320+
321+ // First code-exchange request — DPoP nonce error
322+ var nonceResponse = new
323+ {
324+ error = "use_dpop_nonce" ,
325+ error_description = "Invalid 'nonce' value." ,
326+ } ;
327+ var nonce = "server-code-nonce" ;
328+ mockHttp . Expect ( "/connect/token" )
329+ . WithFormData ( "grant_type" , "authorization_code" )
330+ . Respond ( HttpStatusCode . BadRequest , headers : new Dictionary < string , string >
331+ {
332+ { OidcConstants . HttpHeaders . DPoPNonce , nonce }
333+ } ,
334+ "application/json" , JsonSerializer . Serialize ( nonceResponse ) ) ;
335+
336+ // Second code-exchange request (nonce retry) — succeeds
337+ var tokenResponse = new
338+ {
339+ id_token = IdentityServerHost . CreateIdToken ( "1" , "dpop" ) ,
340+ access_token = "initial_access_token" ,
341+ token_type = "DPoP" ,
342+ expires_in = 3600 ,
343+ refresh_token = "initial_refresh_token" ,
344+ } ;
345+ mockHttp . Expect ( "/connect/token" )
346+ . WithFormData ( "grant_type" , "authorization_code" )
347+ . Respond ( "application/json" , JsonSerializer . Serialize ( tokenResponse ) ) ;
348+
349+ await InitializeAsync ( ) ;
350+ await AppHost . LoginAsync ( "alice" ) ;
351+
352+ mockHttp . VerifyNoOutstandingExpectation ( ) ;
353+
354+ // The assertion service should have been called at least twice:
355+ // once for the initial code exchange (via ConfigureOpenIdConnectOptions),
356+ // and once for the retry (via AuthorizationServerDPoPHandler.RefreshClientAssertionAsync).
357+ capturedClientNames . Count . ShouldBeGreaterThanOrEqualTo ( 2 ,
358+ "Expected at least 2 assertion calls (initial code exchange + nonce retry)" ) ;
359+
360+ // ALL calls should have received the scheme-derived client name, not null
361+ var expectedPrefix = OpenIdConnect . OpenIdConnectTokenManagementDefaults . ClientCredentialsClientNamePrefix ;
362+ foreach ( var name in capturedClientNames )
363+ {
364+ name . ShouldNotBeNull ( "clientName must not be null — the OIDC scheme name should be forwarded" ) ;
365+ name . Value . ToString ( ) . ShouldStartWith ( expectedPrefix ) ;
366+ }
367+ }
368+
306369 // A client assertion service that returns a new assertion value on each call.
307370 // Matches any client name (for integration tests where scheme-derived names vary).
308371 private class CountingClientAssertionService ( Func < string > valueFactory ) : IClientAssertionService
@@ -316,4 +379,23 @@ private class CountingClientAssertionService(Func<string> valueFactory) : IClien
316379 Value = valueFactory ( )
317380 } ) ;
318381 }
382+
383+ // A client assertion service that captures the clientName parameter on each call.
384+ private class ClientNameCapturingAssertionService ( List < ClientCredentialsClientName ? > capturedNames ) : IClientAssertionService
385+ {
386+ private int _callCount ;
387+
388+ public Task < ClientAssertion ? > GetClientAssertionAsync (
389+ ClientCredentialsClientName ? clientName = null ,
390+ TokenRequestParameters ? parameters = null ,
391+ CancellationToken ct = default )
392+ {
393+ capturedNames . Add ( clientName ) ;
394+ return Task . FromResult < ClientAssertion ? > ( new ClientAssertion
395+ {
396+ Type = OidcConstants . ClientAssertionTypes . JwtBearer ,
397+ Value = $ "assertion_{ Interlocked . Increment ( ref _callCount ) } "
398+ } ) ;
399+ }
400+ }
319401}
0 commit comments