Skip to content

Commit e8cc39c

Browse files
make sure the client assertion is also sent on par request
1 parent 35c2d2a commit e8cc39c

File tree

7 files changed

+256
-30
lines changed

7 files changed

+256
-30
lines changed

access-token-management/samples/WebClientAssertions/Controllers/HomeController.cs

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,6 @@ public class HomeController(IHttpClientFactory httpClientFactory, IUserTokenMana
2424
[AllowAnonymous]
2525
public IActionResult Login() => Challenge(new AuthenticationProperties { RedirectUri = "/" });
2626

27-
public async Task<IActionResult> CallApiAsUserManual()
28-
{
29-
var token = await tokenManager.GetAccessTokenAsync(User).GetToken();
30-
var client = httpClientFactory.CreateClient();
31-
client.SetBearerToken(token.AccessToken.ToString()!);
32-
33-
var response = await client.GetStringAsync("https://demo.duendesoftware.com/api/dpop/test");
34-
ViewBag.Json = PrettyPrint(response);
35-
36-
return View("CallApi");
37-
}
38-
3927
public async Task<IActionResult> CallApiAsUserFactory()
4028
{
4129
var client = httpClientFactory.CreateClient("user_client");

access-token-management/samples/WebClientAssertions/Views/Home/Index.cshtml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@
1212
{
1313
<h3>Call API as User (DPoP + JWT assertion)</h3>
1414

15-
<a asp-controller="Home" asp-action="CallApiAsUserManual">Manual</a>
16-
@("|")
1715
<a asp-controller="Home" asp-action="CallApiAsUserFactory">HTTP client factory</a>
1816
@("|")
1917
<a asp-controller="Home" asp-action="CallApiAsUserFactoryTyped">HTTP client factory (typed)</a>

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ public void Configure(string? name, OpenIdConnectOptions options)
5959
options.Events.OnRedirectToIdentityProvider = CreateCallback(options.Events.OnRedirectToIdentityProvider);
6060
options.Events.OnAuthorizationCodeReceived = CreateCallback(options.Events.OnAuthorizationCodeReceived);
6161
options.Events.OnTokenValidated = CreateCallback(options.Events.OnTokenValidated);
62+
options.Events.OnPushAuthorization = CreateCallback(options.Events.OnPushAuthorization);
6263

6364
options.BackchannelHttpHandler = new AuthorizationServerDPoPHandler(dPoPProofService, dPoPNonceStore, httpContextAccessor, loggerFactory)
6465
{
@@ -153,4 +154,26 @@ async Task Callback(TokenValidatedContext context)
153154

154155
return Callback;
155156
}
157+
158+
private Func<PushedAuthorizationContext, Task> CreateCallback(Func<PushedAuthorizationContext, Task> inner)
159+
{
160+
async Task Callback(PushedAuthorizationContext context)
161+
{
162+
await inner.Invoke(context);
163+
164+
// --- Client assertion ---
165+
var assertion = await clientAssertionService
166+
.GetClientAssertionAsync(ClientName, ct: context.HttpContext.RequestAborted)
167+
.ConfigureAwait(false);
168+
169+
if (assertion != null)
170+
{
171+
context.ProtocolMessage.ClientAssertionType = assertion.Type;
172+
context.ProtocolMessage.ClientAssertion = assertion.Value;
173+
context.HandleClientAuthentication();
174+
}
175+
}
176+
177+
return Callback;
178+
}
156179
}

access-token-management/test/AccessTokenManagement.Tests/Framework/AppHost.cs

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
using Microsoft.Extensions.DependencyInjection;
1414
using Microsoft.IdentityModel.Tokens;
1515
using RichardSzalay.MockHttp;
16+
using PushedAuthorizationBehavior = Microsoft.AspNetCore.Authentication.OpenIdConnect.PushedAuthorizationBehavior;
1617

1718
namespace Duende.AccessTokenManagement.Framework;
1819

@@ -21,6 +22,7 @@ public class AppHost : GenericHost
2122
public string ClientId;
2223
public string? ClientSecret;
2324
public SigningCredentials? ClientAssertionSigningCredentials { get; set; }
25+
public PushedAuthorizationBehavior PushedAuthorizationBehavior { get; set; } = PushedAuthorizationBehavior.Disable;
2426

2527
private readonly IdentityServerHost _identityServerHost;
2628
private readonly ApiHost _apiHost;
@@ -107,27 +109,13 @@ private void ConfigureServices(IServiceCollection services)
107109
}
108110

109111
options.ProtocolValidator.RequireNonce = false;
112+
options.PushedAuthorizationBehavior = PushedAuthorizationBehavior;
110113
});
111114

112115
if (ClientAssertionSigningCredentials is { } assertionCredentials)
113116
{
114117
services.AddSingleton<IClientAssertionService>(
115118
new JwtClientAssertionService(ClientId, assertionCredentials));
116-
117-
services.Configure<OpenIdConnectOptions>("oidc", opt =>
118-
{
119-
opt.Events.OnAuthorizationCodeReceived = async context =>
120-
{
121-
var svc = context.HttpContext.RequestServices
122-
.GetRequiredService<IClientAssertionService>();
123-
var assertion = await svc.GetClientAssertionAsync(
124-
ClientCredentialsClientName.Parse(ClientId))
125-
?? throw new InvalidOperationException("Client assertion is null");
126-
127-
context.TokenEndpointRequest!.ClientAssertionType = assertion.Type;
128-
context.TokenEndpointRequest.ClientAssertion = assertion.Value;
129-
};
130-
});
131119
}
132120

133121
services.AddOpenIdConnectAccessTokenManagement(opt =>

access-token-management/test/AccessTokenManagement.Tests/Framework/IdentityServerHost.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ public IdentityServerHost(WriteTestOutput writeTestOutput, string baseAddress =
2424

2525
public List<Client> Clients { get; } = [];
2626

27+
public bool EnablePar { get; set; }
28+
2729
public List<IdentityResource> IdentityResources { get; } =
2830
[
2931
new IdentityResources.OpenId(),
@@ -41,6 +43,7 @@ public IdentityServerHost(WriteTestOutput writeTestOutput, string baseAddress =
4143

4244
public List<Dictionary<string, string>> CapturedTokenRequests { get; } = [];
4345
public List<Dictionary<string, string>> CapturedRevocationRequests { get; } = [];
46+
public List<Dictionary<string, string>> CapturedParRequests { get; } = [];
4447

4548
private void ConfigureServices(IServiceCollection services)
4649
{
@@ -61,7 +64,7 @@ private void ConfigureServices(IServiceCollection services)
6164
options.DPoP.ProofTokenValidityDuration = TimeSpan.FromSeconds(1);
6265

6366
// Disable PAR (this keeps test setup simple, and we don't need to integration test PAR here - it is covered by IdentityServer itself)
64-
options.Endpoints.EnablePushedAuthorizationEndpoint = false;
67+
options.Endpoints.EnablePushedAuthorizationEndpoint = EnablePar;
6568
})
6669
.AddInMemoryClients(Clients)
6770
.AddInMemoryIdentityResources(IdentityResources)
@@ -90,6 +93,14 @@ private void Configure(IApplicationBuilder app)
9093
kvp => kvp.Value.ToString());
9194
CapturedRevocationRequests.Add(capturedData);
9295
}
96+
else if (ctx.Request.Path == "/connect/par" && ctx.Request.Method == "POST")
97+
{
98+
var form = await ctx.Request.ReadFormAsync();
99+
var capturedData = form.ToDictionary(
100+
kvp => kvp.Key,
101+
kvp => kvp.Value.ToString());
102+
CapturedParRequests.Add(capturedData);
103+
}
93104

94105
await next();
95106
});

access-token-management/test/AccessTokenManagement.Tests/Framework/IntegrationTestBase.cs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,49 @@ protected IntegrationTestBase(
9797
AccessTokenLifetime = 10
9898
});
9999

100+
IdentityServerHost.Clients.Add(new Client
101+
{
102+
ClientId = "par-assertion",
103+
ClientSecrets =
104+
{
105+
new Secret
106+
{
107+
Type = IdentityServerConstants.SecretTypes.JsonWebKey,
108+
Value = BuildPublicJwk(ClientAssertionPrivateJwk)
109+
}
110+
},
111+
AllowedGrantTypes = GrantTypes.CodeAndClientCredentials,
112+
RedirectUris = { "https://app/signin-oidc" },
113+
PostLogoutRedirectUris = { "https://app/signout-callback-oidc" },
114+
AllowOfflineAccess = true,
115+
AllowedScopes = { "openid", "profile", "scope1", "scope2" },
116+
RequirePushedAuthorization = true,
117+
AccessTokenLifetime = 10
118+
});
119+
120+
IdentityServerHost.Clients.Add(new Client
121+
{
122+
ClientId = "par-dpop-assertion",
123+
ClientSecrets =
124+
{
125+
new Secret
126+
{
127+
Type = IdentityServerConstants.SecretTypes.JsonWebKey,
128+
Value = BuildPublicJwk(ClientAssertionPrivateJwk)
129+
}
130+
},
131+
AllowedGrantTypes = GrantTypes.CodeAndClientCredentials,
132+
RedirectUris = { "https://app/signin-oidc" },
133+
PostLogoutRedirectUris = { "https://app/signout-callback-oidc" },
134+
AllowOfflineAccess = true,
135+
AllowedScopes = { "openid", "profile", "scope1", "scope2" },
136+
RequirePushedAuthorization = true,
137+
RequireDPoP = true,
138+
DPoPValidationMode = DPoPTokenExpirationValidationMode.Nonce,
139+
DPoPClockSkew = TimeSpan.FromMilliseconds(10),
140+
AccessTokenLifetime = 10
141+
});
142+
100143
ApiHost = new ApiHost(output.WriteLine, IdentityServerHost, ["scope1", "scope2"]);
101144
AppHost = new AppHost(output.WriteLine, IdentityServerHost, ApiHost, clientId, configureUserTokenManagementOptions: configureUserTokenManagementOptions);
102145
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// Copyright (c) Duende Software. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
3+
4+
using System.Net.Http.Json;
5+
using Duende.AccessTokenManagement.DPoP;
6+
using Duende.AccessTokenManagement.Framework;
7+
using Duende.IdentityModel;
8+
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
9+
using Microsoft.Extensions.DependencyInjection;
10+
using Microsoft.IdentityModel.Tokens;
11+
using JsonWebKey = Microsoft.IdentityModel.Tokens.JsonWebKey;
12+
13+
namespace Duende.AccessTokenManagement;
14+
15+
/// <summary>
16+
/// Tests that client assertions are sent on Pushed Authorization Requests (PAR).
17+
/// </summary>
18+
public sealed class PARWithClientAssertionsTests : IntegrationTestBase
19+
{
20+
private readonly CancellationToken _ct = TestContext.Current.CancellationToken;
21+
22+
public PARWithClientAssertionsTests(ITestOutputHelper output)
23+
: base(output, "par-assertion")
24+
{
25+
IdentityServerHost.EnablePar = true;
26+
AppHost.PushedAuthorizationBehavior = PushedAuthorizationBehavior.Require;
27+
AppHost.ClientAssertionSigningCredentials =
28+
new SigningCredentials(new JsonWebKey(ClientAssertionPrivateJwk), "RS256");
29+
}
30+
31+
[Fact]
32+
public async Task LoginWithPARAndClientAssertionsShouldSucceed()
33+
{
34+
await InitializeAsync();
35+
36+
await AppHost.LoginAsync("alice");
37+
38+
// Verify PAR request contained client assertion
39+
var parRequest = IdentityServerHost.CapturedParRequests
40+
.FirstOrDefault();
41+
parRequest.ShouldNotBeNull("Expected a PAR request to be captured");
42+
parRequest.ShouldContainKeyAndValue(OidcConstants.TokenRequest.ClientAssertionType,
43+
OidcConstants.ClientAssertionTypes.JwtBearer);
44+
parRequest.ShouldContainKey(OidcConstants.TokenRequest.ClientAssertion);
45+
46+
// Verify token exchange also used client assertion
47+
var codeExchangeRequest = IdentityServerHost.CapturedTokenRequests
48+
.FirstOrDefault(r => r.TryGetValue("grant_type", out var gt) && gt == "authorization_code");
49+
codeExchangeRequest.ShouldNotBeNull();
50+
codeExchangeRequest.ShouldContainKeyAndValue(OidcConstants.TokenRequest.ClientAssertionType,
51+
OidcConstants.ClientAssertionTypes.JwtBearer);
52+
codeExchangeRequest.ShouldContainKey(OidcConstants.TokenRequest.ClientAssertion);
53+
}
54+
55+
[Fact]
56+
public async Task RefreshWithPARAndClientAssertionsShouldSucceed()
57+
{
58+
await InitializeAsync();
59+
await AppHost.LoginAsync("alice");
60+
61+
// This forces a refresh (token lifetime is 10s, within RefreshBeforeExpiration window)
62+
var response = await AppHost.BrowserClient.GetAsync(AppHost.Url("/user_token"), _ct);
63+
var token = await response.Content.ReadFromJsonAsync<UserTokenModel>(_ct);
64+
token.ShouldNotBeNull();
65+
66+
// Verify refresh token request used client assertions
67+
var refreshRequests = IdentityServerHost.CapturedTokenRequests
68+
.Where(r => r.TryGetValue("grant_type", out var gt) && gt == "refresh_token")
69+
.ToList();
70+
refreshRequests.ShouldNotBeEmpty();
71+
foreach (var req in refreshRequests)
72+
{
73+
req.ShouldContainKeyAndValue(OidcConstants.TokenRequest.ClientAssertionType,
74+
OidcConstants.ClientAssertionTypes.JwtBearer);
75+
req.ShouldContainKey(OidcConstants.TokenRequest.ClientAssertion);
76+
}
77+
}
78+
}
79+
80+
/// <summary>
81+
/// Tests that both client assertions AND DPoP thumbprint are sent on PAR requests.
82+
/// </summary>
83+
public sealed class PARWithDPoPAndClientAssertionsTests : IntegrationTestBase
84+
{
85+
private readonly CancellationToken _ct = TestContext.Current.CancellationToken;
86+
87+
public PARWithDPoPAndClientAssertionsTests(ITestOutputHelper output)
88+
: base(output, "par-dpop-assertion",
89+
configureUserTokenManagementOptions: opt => { opt.DPoPJsonWebKey = DPoPProofKey.Parse(DPoPPrivateJwk); })
90+
{
91+
IdentityServerHost.EnablePar = true;
92+
AppHost.PushedAuthorizationBehavior = PushedAuthorizationBehavior.Require;
93+
AppHost.ClientAssertionSigningCredentials =
94+
new SigningCredentials(new JsonWebKey(ClientAssertionPrivateJwk), "RS256");
95+
}
96+
97+
[Fact]
98+
public async Task LoginWithPARDPoPAndClientAssertionsShouldSucceed()
99+
{
100+
await InitializeAsync();
101+
102+
await AppHost.LoginAsync("alice");
103+
104+
// Verify PAR request contained client assertion AND DPoP thumbprint
105+
var parRequest = IdentityServerHost.CapturedParRequests
106+
.FirstOrDefault();
107+
parRequest.ShouldNotBeNull("Expected a PAR request to be captured");
108+
parRequest.ShouldContainKeyAndValue(OidcConstants.TokenRequest.ClientAssertionType,
109+
OidcConstants.ClientAssertionTypes.JwtBearer);
110+
parRequest.ShouldContainKey(OidcConstants.TokenRequest.ClientAssertion);
111+
parRequest.ShouldContainKey(OidcConstants.AuthorizeRequest.DPoPKeyThumbprint);
112+
113+
// Verify we got a DPoP token back
114+
var response = await AppHost.BrowserClient.GetAsync(AppHost.Url("/user_token"), _ct);
115+
var token = await response.Content.ReadFromJsonAsync<UserTokenModel>(_ct);
116+
token.ShouldNotBeNull();
117+
token.AccessTokenType.ShouldBe("DPoP");
118+
}
119+
120+
[Fact]
121+
public async Task RefreshWithPARDPoPAndClientAssertionsShouldSucceed()
122+
{
123+
// Use always-null nonce store so the server always issues a nonce challenge,
124+
// guaranteeing the DPoP nonce retry deterministically (no wall-clock dependency).
125+
AppHost.OnConfigureServices += services =>
126+
services.AddSingleton<IDPoPNonceStore>(new TestDPoPNonceStore());
127+
128+
await InitializeAsync();
129+
await AppHost.LoginAsync("alice");
130+
131+
// This forces a refresh (token lifetime is 10s, within RefreshBeforeExpiration window)
132+
var response = await AppHost.BrowserClient.GetAsync(AppHost.Url("/user_token"), _ct);
133+
var token = await response.Content.ReadFromJsonAsync<UserTokenModel>(_ct);
134+
token.ShouldNotBeNull();
135+
token.AccessTokenType.ShouldBe("DPoP");
136+
137+
// Verify refresh token requests used client assertions
138+
var refreshRequests = IdentityServerHost.CapturedTokenRequests
139+
.Where(r => r.TryGetValue("grant_type", out var gt) && gt == "refresh_token")
140+
.ToList();
141+
refreshRequests.ShouldNotBeEmpty();
142+
foreach (var req in refreshRequests)
143+
{
144+
req.ShouldContainKeyAndValue(OidcConstants.TokenRequest.ClientAssertionType,
145+
OidcConstants.ClientAssertionTypes.JwtBearer);
146+
req.ShouldContainKey(OidcConstants.TokenRequest.ClientAssertion);
147+
}
148+
149+
// Nonce retry must have occurred
150+
refreshRequests.Count.ShouldBe(2, "Expected exactly 2 refresh requests (initial + DPoP nonce retry)");
151+
152+
var assertions = refreshRequests
153+
.Select(r => r[OidcConstants.TokenRequest.ClientAssertion])
154+
.ToList();
155+
assertions.Distinct().Count().ShouldBe(assertions.Count,
156+
"Client assertions must be unique across DPoP nonce retries during refresh");
157+
}
158+
159+
private const string DPoPPrivateJwk =
160+
"""
161+
{
162+
"kty":"RSA",
163+
"n":"0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw",
164+
"e":"AQAB",
165+
"d":"X4cTteJY_gn4FYPsXB8rdXix5vwsg1FLN5E3EaG6RJoVH-HLLKD9M7dx5oo7GURknchnrRweUkC7hT5fJLM0WbFAKNLWY2vv7B6NqXSzUvxT0_YSfqijwp3RTzlBaCxWp4doFk5N2o8Gy_nHNKroADIkJ46pRUohsXywbReAdYaMwFs9tv8d_cPVY3i07a3t8MN6TNwm0dSawm9v47UiCl3Sk5ZiG7xojPLu4sbg1U2jx4IBTNBznbJSzFHK66jT8bgkuqsk0GjskDJk19Z4qwjwbsnn4j2WBii3RL-Us2lGVkY8fkFzme1z0HbIkfz0Y6mqnOYtqc0X4jfcKoAC8Q",
166+
"p":"83i-7IvMGXoMXCskv73TKr8637FiO7Z27zv8oj6pbWUQyLPQBQxtPVnwD20R-60eTDmD2ujnMt5PoqMrm8RfmNhVWDtjjMmCMjOpSXicFHj7XOuVIYQyqVWlWEh6dN36GVZYk93N8Bc9vY41xy8B9RzzOGVQzXvNEvn7O0nVbfs",
167+
"q":"3dfOR9cuYq-0S-mkFLzgItgMEfFzB2q3hWehMuG0oCuqnb3vobLyumqjVZQO1dIrdwgTnCdpYzBcOfW5r370AFXjiWft_NGEiovonizhKpo9VVS78TzFgxkIdrecRezsZ-1kYd_s1qDbxtkDEgfAITAG9LUnADun4vIcb6yelxk",
168+
"dp":"G4sPXkc6Ya9y8oJW9_ILj4xuppu0lzi_H7VTkS8xj5SdX3coE0oimYwxIi2emTAue0UOa5dpgFGyBJ4c8tQ2VF402XRugKDTP8akYhFo5tAA77Qe_NmtuYZc3C3m3I24G2GvR5sSDxUyAN2zq8Lfn9EUms6rY3Ob8YeiKkTiBj0",
169+
"dq":"s9lAH9fggBsoFR8Oac2R_E2gw282rT2kGOAhvIllETE1efrA6huUUvMfBcMpn8lqeW6vzznYY5SSQF7pMdC_agI3nG8Ibp1BUb0JUiraRNqUfLhcQb_d9GF4Dh7e74WbRsobRonujTYN1xCaP6TO61jvWrX-L18txXw494Q_cgk",
170+
"qi":"GyM_p6JrXySiz1toFgKbWV-JdI3jQ4ypu9rbMWx3rQJBfmt0FoYzgUIZEVFEcOqwemRN81zoDAaa-Bk0KWNGDjJHZDdDmFhW3AN7lI-puxk_mHZGJ11rxyR8O55XLSe3SPmRfKwZI6yU24ZxvQKFYItdldUKGzO6Ia6zTKhAVRU",
171+
"alg":"RS256",
172+
"kid":"2011-04-29"
173+
}
174+
""";
175+
}

0 commit comments

Comments
 (0)