Skip to content

Commit 98330c2

Browse files
allow openid connect login with par & client assertions to work
1 parent c89c216 commit 98330c2

File tree

5 files changed

+236
-17
lines changed

5 files changed

+236
-17
lines changed

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

Lines changed: 37 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,40 @@ 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+
// --- DPoP thumbprint ---
165+
var dPoPKeyStore = context.HttpContext.RequestServices.GetRequiredService<IDPoPKeyStore>();
166+
var key = await dPoPKeyStore.GetKeyAsync(ClientName);
167+
if (key != null)
168+
{
169+
var jkt = dPoPProofService.GetProofKeyThumbprint(key.Value);
170+
if (jkt != null)
171+
{
172+
context.Properties.SetProofKey(key.Value);
173+
context.ProtocolMessage.Parameters[OidcConstants.AuthorizeRequest.DPoPKeyThumbprint] =
174+
jkt.ToString();
175+
}
176+
}
177+
178+
// --- Client assertion ---
179+
var assertion = await clientAssertionService
180+
.GetClientAssertionAsync(ClientName, ct: context.HttpContext.RequestAborted)
181+
.ConfigureAwait(false);
182+
183+
if (assertion != null)
184+
{
185+
context.ProtocolMessage.ClientAssertionType = assertion.Type;
186+
context.ProtocolMessage.ClientAssertion = assertion.Value;
187+
context.HandleClientAuthentication();
188+
}
189+
}
190+
191+
return Callback;
192+
}
156193
}

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

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public class AppHost : GenericHost
2121
public string ClientId;
2222
public string? ClientSecret;
2323
public SigningCredentials? ClientAssertionSigningCredentials { get; set; }
24+
public PushedAuthorizationBehavior PushedAuthorizationBehavior { get; set; } = PushedAuthorizationBehavior.Disable;
2425

2526
private readonly IdentityServerHost _identityServerHost;
2627
private readonly ApiHost _apiHost;
@@ -107,27 +108,13 @@ private void ConfigureServices(IServiceCollection services)
107108
}
108109

109110
options.ProtocolValidator.RequireNonce = false;
111+
options.PushedAuthorizationBehavior = PushedAuthorizationBehavior;
110112
});
111113

112114
if (ClientAssertionSigningCredentials is { } assertionCredentials)
113115
{
114116
services.AddSingleton<IClientAssertionService>(
115117
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-
});
131118
}
132119

133120
services.AddOpenIdConnectAccessTokenManagement(opt =>

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,11 @@ public IdentityServerHost(WriteTestOutput writeTestOutput, string baseAddress =
3939
new("urn:api2")
4040
];
4141

42+
public bool EnablePar { get; set; }
43+
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
{
@@ -60,8 +63,8 @@ private void ConfigureServices(IServiceCollection services)
6063
options.DPoP.ServerClockSkew = TimeSpan.Zero;
6164
options.DPoP.ProofTokenValidityDuration = TimeSpan.FromSeconds(1);
6265

63-
// 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;
66+
// Disable PAR by default (this keeps test setup simple). Tests that need PAR set EnablePar = true.
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: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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+
public sealed class PARWithClientAssertionsTests : IntegrationTestBase
16+
{
17+
private readonly CancellationToken _ct = TestContext.Current.CancellationToken;
18+
19+
public PARWithClientAssertionsTests(ITestOutputHelper output)
20+
: base(output, "par-assertion")
21+
{
22+
IdentityServerHost.EnablePar = true;
23+
AppHost.PushedAuthorizationBehavior = PushedAuthorizationBehavior.Require;
24+
AppHost.ClientAssertionSigningCredentials =
25+
new SigningCredentials(new JsonWebKey(ClientAssertionPrivateJwk), "RS256");
26+
}
27+
28+
[Fact]
29+
public async Task LoginWithPARAndClientAssertionsShouldSucceed()
30+
{
31+
await InitializeAsync();
32+
await AppHost.LoginAsync("alice");
33+
34+
// Verify the PAR request contained client assertion
35+
var parRequest = IdentityServerHost.CapturedParRequests.ShouldHaveSingleItem();
36+
parRequest.ShouldContainKeyAndValue(OidcConstants.TokenRequest.ClientAssertionType,
37+
OidcConstants.ClientAssertionTypes.JwtBearer);
38+
parRequest.ShouldContainKey(OidcConstants.TokenRequest.ClientAssertion);
39+
40+
// Verify code exchange also used client assertion
41+
var codeExchangeRequest = IdentityServerHost.CapturedTokenRequests
42+
.FirstOrDefault(r => r.TryGetValue("grant_type", out var gt) && gt == "authorization_code");
43+
codeExchangeRequest.ShouldNotBeNull();
44+
codeExchangeRequest.ShouldContainKeyAndValue(OidcConstants.TokenRequest.ClientAssertionType,
45+
OidcConstants.ClientAssertionTypes.JwtBearer);
46+
codeExchangeRequest.ShouldContainKey(OidcConstants.TokenRequest.ClientAssertion);
47+
}
48+
49+
[Fact]
50+
public async Task PARRequestShouldNotContainDPoPJktWhenDPoPNotConfigured()
51+
{
52+
await InitializeAsync();
53+
await AppHost.LoginAsync("alice");
54+
55+
var parRequest = IdentityServerHost.CapturedParRequests.ShouldHaveSingleItem();
56+
parRequest.ShouldNotContainKey(OidcConstants.AuthorizeRequest.DPoPKeyThumbprint);
57+
}
58+
}
59+
60+
public sealed class PARWithDPoPAndClientAssertionsTests : IntegrationTestBase
61+
{
62+
private readonly CancellationToken _ct = TestContext.Current.CancellationToken;
63+
64+
public PARWithDPoPAndClientAssertionsTests(ITestOutputHelper output)
65+
: base(output, "par-dpop-assertion",
66+
configureUserTokenManagementOptions: opt => { opt.DPoPJsonWebKey = DPoPProofKey.Parse(DPoPPrivateJwk); })
67+
{
68+
IdentityServerHost.EnablePar = true;
69+
AppHost.PushedAuthorizationBehavior = PushedAuthorizationBehavior.Require;
70+
AppHost.ClientAssertionSigningCredentials =
71+
new SigningCredentials(new JsonWebKey(ClientAssertionPrivateJwk), "RS256");
72+
}
73+
74+
[Fact]
75+
public async Task LoginWithPARAndDPoPAndClientAssertionsShouldSucceed()
76+
{
77+
await InitializeAsync();
78+
await AppHost.LoginAsync("alice");
79+
80+
// Verify PAR request has client assertion AND dpop_jkt
81+
var parRequest = IdentityServerHost.CapturedParRequests.ShouldHaveSingleItem();
82+
parRequest.ShouldContainKeyAndValue(OidcConstants.TokenRequest.ClientAssertionType,
83+
OidcConstants.ClientAssertionTypes.JwtBearer);
84+
parRequest.ShouldContainKey(OidcConstants.TokenRequest.ClientAssertion);
85+
parRequest.ShouldContainKey(OidcConstants.AuthorizeRequest.DPoPKeyThumbprint);
86+
parRequest[OidcConstants.AuthorizeRequest.DPoPKeyThumbprint].ShouldNotBeNullOrEmpty();
87+
88+
// Verify we got a DPoP token back
89+
var response = await AppHost.BrowserClient.GetAsync(AppHost.Url("/user_token"), _ct);
90+
var token = await response.Content.ReadFromJsonAsync<UserTokenModel>(_ct);
91+
token.ShouldNotBeNull();
92+
token.AccessTokenType.ShouldBe("DPoP");
93+
}
94+
95+
[Fact]
96+
public async Task RefreshWithPARAndDPoPAndClientAssertionsShouldSucceed()
97+
{
98+
// Use always-null nonce store so the server always issues a nonce challenge,
99+
// guaranteeing the DPoP nonce retry deterministically (no wall-clock dependency).
100+
AppHost.OnConfigureServices += services =>
101+
services.AddSingleton<IDPoPNonceStore>(new TestDPoPNonceStore());
102+
103+
await InitializeAsync();
104+
await AppHost.LoginAsync("alice");
105+
106+
// This forces a refresh (token lifetime is 10s, within RefreshBeforeExpiration window)
107+
var response = await AppHost.BrowserClient.GetAsync(AppHost.Url("/user_token"), _ct);
108+
var token = await response.Content.ReadFromJsonAsync<UserTokenModel>(_ct);
109+
token.ShouldNotBeNull();
110+
token.AccessTokenType.ShouldBe("DPoP");
111+
112+
// Verify refresh token requests used client assertions
113+
var refreshRequests = IdentityServerHost.CapturedTokenRequests
114+
.Where(r => r.TryGetValue("grant_type", out var gt) && gt == "refresh_token")
115+
.ToList();
116+
refreshRequests.ShouldNotBeEmpty();
117+
foreach (var req in refreshRequests)
118+
{
119+
req.ShouldContainKeyAndValue(OidcConstants.TokenRequest.ClientAssertionType,
120+
OidcConstants.ClientAssertionTypes.JwtBearer);
121+
req.ShouldContainKey(OidcConstants.TokenRequest.ClientAssertion);
122+
}
123+
}
124+
125+
private const string DPoPPrivateJwk =
126+
"""
127+
{
128+
"kty":"RSA",
129+
"n":"0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw",
130+
"e":"AQAB",
131+
"d":"X4cTteJY_gn4FYPsXB8rdXix5vwsg1FLN5E3EaG6RJoVH-HLLKD9M7dx5oo7GURknchnrRweUkC7hT5fJLM0WbFAKNLWY2vv7B6NqXSzUvxT0_YSfqijwp3RTzlBaCxWp4doFk5N2o8Gy_nHNKroADIkJ46pRUohsXywbReAdYaMwFs9tv8d_cPVY3i07a3t8MN6TNwm0dSawm9v47UiCl3Sk5ZiG7xojPLu4sbg1U2jx4IBTNBznbJSzFHK66jT8bgkuqsk0GjskDJk19Z4qwjwbsnn4j2WBii3RL-Us2lGVkY8fkFzme1z0HbIkfz0Y6mqnOYtqc0X4jfcKoAC8Q",
132+
"p":"83i-7IvMGXoMXCskv73TKr8637FiO7Z27zv8oj6pbWUQyLPQBQxtPVnwD20R-60eTDmD2ujnMt5PoqMrm8RfmNhVWDtjjMmCMjOpSXicFHj7XOuVIYQyqVWlWEh6dN36GVZYk93N8Bc9vY41xy8B9RzzOGVQzXvNEvn7O0nVbfs",
133+
"q":"3dfOR9cuYq-0S-mkFLzgItgMEfFzB2q3hWehMuG0oCuqnb3vobLyumqjVZQO1dIrdwgTnCdpYzBcOfW5r370AFXjiWft_NGEiovonizhKpo9VVS78TzFgxkIdrecRezsZ-1kYd_s1qDbxtkDEgfAITAG9LUnADun4vIcb6yelxk",
134+
"dp":"G4sPXkc6Ya9y8oJW9_ILj4xuppu0lzi_H7VTkS8xj5SdX3coE0oimYwxIi2emTAue0UOa5dpgFGyBJ4c8tQ2VF402XRugKDTP8akYhFo5tAA77Qe_NmtuYZc3C3m3I24G2GvR5sSDxUyAN2zq8Lfn9EUms6rY3Ob8YeiKkTiBj0",
135+
"dq":"s9lAH9fggBsoFR8Oac2R_E2gw282rT2kGOAhvIllETE1efrA6huUUvMfBcMpn8lqeW6vzznYY5SSQF7pMdC_agI3nG8Ibp1BUb0JUiraRNqUfLhcQb_d9GF4Dh7e74WbRsobRonujTYN1xCaP6TO61jvWrX-L18txXw494Q_cgk",
136+
"qi":"GyM_p6JrXySiz1toFgKbWV-JdI3jQ4ypu9rbMWx3rQJBfmt0FoYzgUIZEVFEcOqwemRN81zoDAaa-Bk0KWNGDjJHZDdDmFhW3AN7lI-puxk_mHZGJ11rxyR8O55XLSe3SPmRfKwZI6yU24ZxvQKFYItdldUKGzO6Ia6zTKhAVRU",
137+
"alg":"RS256",
138+
"kid":"2011-04-29"
139+
}
140+
""";
141+
}

0 commit comments

Comments
 (0)