Skip to content

Commit 35c2d2a

Browse files
Fix stale ClientAssertion on DPoP nonce retry - AccessTokenManagement changes
Regenerate client assertions in AuthorizationServerDPoPHandler on nonce retry. Wire assertion creators into ClientCredentialsTokenClient and OIDC options. Add OpenIdConnectUserTokenEndpoint for user token assertion support. Add WebClientAssertions sample, DPoP+assertion tests, and update foss.slnx.
1 parent b59919e commit 35c2d2a

File tree

62 files changed

+40667
-39
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+40667
-39
lines changed
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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 Duende.AccessTokenManagement;
5+
using Duende.AccessTokenManagement.OpenIdConnect;
6+
using Duende.IdentityModel;
7+
using Duende.IdentityModel.Client;
8+
using Microsoft.IdentityModel.JsonWebTokens;
9+
using Microsoft.IdentityModel.Tokens;
10+
11+
namespace WebClientAssertions;
12+
13+
/// <summary>
14+
/// Creates signed client assertion JWTs (RFC 7523 / private_key_jwt) for use
15+
/// with Duende's Access Token Management library.
16+
///
17+
/// Each call produces a JWT with a fresh <c>jti</c> and <c>iat</c>, which is
18+
/// critical when DPoP nonce retries require a new assertion to avoid replay
19+
/// rejection by the authorization server.
20+
/// </summary>
21+
public class ClientAssertionService : IClientAssertionService
22+
{
23+
/// <summary>
24+
/// RSA private key that matches the public key registered at
25+
/// demo.duendesoftware.com for the JWT client authentication clients.
26+
/// In production, load from a secure store (e.g. Azure Key Vault).
27+
/// </summary>
28+
private static readonly SigningCredentials Credential = new(
29+
new JsonWebKey("""
30+
{
31+
"d":"GmiaucNIzdvsEzGjZjd43SDToy1pz-Ph-shsOUXXh-dsYNGftITGerp8bO1iryXh_zUEo8oDK3r1y4klTonQ6bLsWw4ogjLPmL3yiqsoSjJa1G2Ymh_RY_sFZLLXAcrmpbzdWIAkgkHSZTaliL6g57vA7gxvd8L4s82wgGer_JmURI0ECbaCg98JVS0Srtf9GeTRHoX4foLWKc1Vq6NHthzqRMLZe-aRBNU9IMvXNd7kCcIbHCM3GTD_8cFj135nBPP2HOgC_ZXI1txsEf-djqJj8W5vaM7ViKU28IDv1gZGH3CatoysYx6jv1XJVvb2PH8RbFKbJmeyUm3Wvo-rgQ",
32+
"dp":"YNjVBTCIwZD65WCht5ve06vnBLP_Po1NtL_4lkholmPzJ5jbLYBU8f5foNp8DVJBdFQW7wcLmx85-NC5Pl1ZeyA-Ecbw4fDraa5Z4wUKlF0LT6VV79rfOF19y8kwf6MigyrDqMLcH_CRnRGg5NfDsijlZXffINGuxg6wWzhiqqE",
33+
"dq":"LfMDQbvTFNngkZjKkN2CBh5_MBG6Yrmfy4kWA8IC2HQqID5FtreiY2MTAwoDcoINfh3S5CItpuq94tlB2t-VUv8wunhbngHiB5xUprwGAAnwJ3DL39D2m43i_3YP-UO1TgZQUAOh7Jrd4foatpatTvBtY3F1DrCrUKE5Kkn770M",
34+
"e":"AQAB",
35+
"kid":"ZzAjSnraU3bkWGnnAqLapYGpTyNfLbjbzgAPbbW2GEA",
36+
"kty":"RSA",
37+
"n":"wWwQFtSzeRjjerpEM5Rmqz_DsNaZ9S1Bw6UbZkDLowuuTCjBWUax0vBMMxdy6XjEEK4Oq9lKMvx9JzjmeJf1knoqSNrox3Ka0rnxXpNAz6sATvme8p9mTXyp0cX4lF4U2J54xa2_S9NF5QWvpXvBeC4GAJx7QaSw4zrUkrc6XyaAiFnLhQEwKJCwUw4NOqIuYvYp_IXhw-5Ti_icDlZS-282PcccnBeOcX7vc21pozibIdmZJKqXNsL1Ibx5Nkx1F1jLnekJAmdaACDjYRLL_6n3W4wUp19UvzB1lGtXcJKLLkqB6YDiZNu16OSiSprfmrRXvYmvD8m6Fnl5aetgKw",
38+
"p":"7enorp9Pm9XSHaCvQyENcvdU99WCPbnp8vc0KnY_0g9UdX4ZDH07JwKu6DQEwfmUA1qspC-e_KFWTl3x0-I2eJRnHjLOoLrTjrVSBRhBMGEH5PvtZTTThnIY2LReH-6EhceGvcsJ_MhNDUEZLykiH1OnKhmRuvSdhi8oiETqtPE",
39+
"q":"0CBLGi_kRPLqI8yfVkpBbA9zkCAshgrWWn9hsq6a7Zl2LcLaLBRUxH0q1jWnXgeJh9o5v8sYGXwhbrmuypw7kJ0uA3OgEzSsNvX5Ay3R9sNel-3Mqm8Me5OfWWvmTEBOci8RwHstdR-7b9ZT13jk-dsZI7OlV_uBja1ny9Nz9ts",
40+
"qi":"pG6J4dcUDrDndMxa-ee1yG4KjZqqyCQcmPAfqklI2LmnpRIjcK78scclvpboI3JQyg6RCEKVMwAhVtQM6cBcIO3JrHgqeYDblp5wXHjto70HVW6Z8kBruNx1AH9E8LzNvSRL-JVTFzBkJuNgzKQfD0G77tQRgJ-Ri7qu3_9o1M4"
41+
}
42+
"""),
43+
SecurityAlgorithms.RsaSha256);
44+
45+
private const string Authority = "https://demo.duendesoftware.com";
46+
47+
public Task<ClientAssertion?> GetClientAssertionAsync(
48+
ClientCredentialsClientName? clientName = null,
49+
TokenRequestParameters? parameters = null,
50+
CancellationToken ct = default)
51+
{
52+
// Determine the client_id for the assertion's issuer/subject claims.
53+
// The library calls this with different clientName values depending on context:
54+
// - scheme-based name during OIDC flows (code exchange, refresh)
55+
// - the literal client name "m2m.jwt" for the named M2M client
56+
var clientId = ResolveClientId(clientName);
57+
58+
var now = DateTime.UtcNow;
59+
var descriptor = new SecurityTokenDescriptor
60+
{
61+
Issuer = clientId,
62+
Audience = Authority,
63+
IssuedAt = now,
64+
NotBefore = now,
65+
Expires = now.AddMinutes(1),
66+
SigningCredentials = Credential,
67+
68+
Claims = new Dictionary<string, object>
69+
{
70+
{ JwtClaimTypes.JwtId, Guid.NewGuid().ToString() },
71+
{ JwtClaimTypes.Subject, clientId },
72+
},
73+
74+
AdditionalHeaderClaims = new Dictionary<string, object>
75+
{
76+
{ "typ", "client-authentication+jwt" }
77+
}
78+
};
79+
80+
var handler = new JsonWebTokenHandler();
81+
var jwt = handler.CreateToken(descriptor);
82+
83+
return Task.FromResult<ClientAssertion?>(new ClientAssertion
84+
{
85+
Type = OidcConstants.ClientAssertionTypes.JwtBearer,
86+
Value = jwt
87+
});
88+
}
89+
90+
/// <summary>
91+
/// Maps the ATM client name to the actual OAuth client_id used in the assertion.
92+
/// </summary>
93+
private static string ResolveClientId(ClientCredentialsClientName? clientName)
94+
{
95+
var name = clientName?.ToString();
96+
97+
// Default / OIDC scheme-based client → use the interactive DPoP client
98+
if (string.IsNullOrEmpty(name) || name.Contains(OpenIdConnectTokenManagementDefaults.ClientCredentialsClientNamePrefix))
99+
{
100+
return "interactive.confidential.jwt.dpop";
101+
}
102+
103+
// Named M2M client
104+
return name switch
105+
{
106+
"m2m.jwt" => "m2m.jwt",
107+
_ => name
108+
};
109+
}
110+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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.Text.Json;
5+
using Duende.AccessTokenManagement;
6+
using Duende.AccessTokenManagement.OpenIdConnect;
7+
using Duende.IdentityModel.Client;
8+
using Microsoft.AspNetCore.Authentication;
9+
using Microsoft.AspNetCore.Authorization;
10+
using Microsoft.AspNetCore.Mvc;
11+
12+
namespace WebClientAssertions.Controllers;
13+
14+
public class HomeController(IHttpClientFactory httpClientFactory, IUserTokenManager tokenManager)
15+
: Controller
16+
{
17+
[AllowAnonymous]
18+
public IActionResult Index() => View();
19+
20+
public IActionResult Secure() => View();
21+
22+
public IActionResult Logout() => SignOut("cookie", "oidc");
23+
24+
[AllowAnonymous]
25+
public IActionResult Login() => Challenge(new AuthenticationProperties { RedirectUri = "/" });
26+
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+
39+
public async Task<IActionResult> CallApiAsUserFactory()
40+
{
41+
var client = httpClientFactory.CreateClient("user_client");
42+
var response = await client.GetStringAsync("test");
43+
44+
ViewBag.Json = PrettyPrint(response);
45+
return View("CallApi");
46+
}
47+
48+
public async Task<IActionResult> CallApiAsUserFactoryTyped([FromServices] TypedUserClient client)
49+
{
50+
var response = await client.CallApi();
51+
ViewBag.Json = PrettyPrint(response);
52+
53+
return View("CallApi");
54+
}
55+
56+
[AllowAnonymous]
57+
public async Task<IActionResult> CallApiAsClientFactory()
58+
{
59+
var client = httpClientFactory.CreateClient("client");
60+
var response = await client.GetStringAsync("test");
61+
62+
ViewBag.Json = PrettyPrint(response);
63+
return View("CallApi");
64+
}
65+
66+
[AllowAnonymous]
67+
public async Task<IActionResult> CallApiAsClientFactoryTyped([FromServices] TypedClientClient client)
68+
{
69+
var response = await client.CallApi();
70+
ViewBag.Json = PrettyPrint(response);
71+
72+
return View("CallApi");
73+
}
74+
75+
private static string PrettyPrint(string json)
76+
{
77+
var doc = JsonDocument.Parse(json).RootElement;
78+
return JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true });
79+
}
80+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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 Serilog;
5+
using Serilog.Events;
6+
using Serilog.Sinks.SystemConsole.Themes;
7+
using WebClientAssertions;
8+
9+
Log.Logger = new LoggerConfiguration()
10+
.WriteTo.Console()
11+
.CreateBootstrapLogger();
12+
13+
Log.Information("Host.Main Starting up");
14+
15+
Console.Title = "WebClientAssertions (Sample)";
16+
17+
try
18+
{
19+
var builder = WebApplication.CreateBuilder(args);
20+
21+
builder.Host.UseSerilog((_, lc) => lc
22+
.WriteTo.Console(
23+
outputTemplate: "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}{NewLine}",
24+
theme: AnsiConsoleTheme.Code)
25+
.MinimumLevel.Information()
26+
.MinimumLevel.Override("Duende", LogEventLevel.Verbose)
27+
.Enrich.FromLogContext());
28+
29+
var app = builder
30+
.ConfigureServices()
31+
.ConfigurePipeline();
32+
33+
app.Run();
34+
}
35+
catch (Exception ex)
36+
{
37+
Log.Fatal(ex, "Unhandled exception");
38+
}
39+
finally
40+
{
41+
Log.Information("Shut down complete");
42+
Log.CloseAndFlush();
43+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"profiles": {
3+
"WebClientAssertions": {
4+
"commandName": "Project",
5+
"launchBrowser": true,
6+
"environmentVariables": {
7+
"ASPNETCORE_ENVIRONMENT": "Development"
8+
},
9+
"applicationUrl": "https://localhost:44305"
10+
}
11+
}
12+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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.Security.Cryptography;
5+
using System.Text.Json;
6+
using Duende.AccessTokenManagement;
7+
using Duende.AccessTokenManagement.DPoP;
8+
using Duende.AccessTokenManagement.OpenIdConnect;
9+
using Microsoft.IdentityModel.Tokens;
10+
using Serilog;
11+
using Serilog.Events;
12+
13+
namespace WebClientAssertions;
14+
15+
public static class Startup
16+
{
17+
internal static WebApplication ConfigureServices(this WebApplicationBuilder builder)
18+
{
19+
builder.Services.AddControllersWithViews();
20+
21+
builder.Services.AddAuthentication(options =>
22+
{
23+
options.DefaultScheme = "cookie";
24+
options.DefaultChallengeScheme = "oidc";
25+
})
26+
.AddCookie("cookie", options =>
27+
{
28+
options.Cookie.Name = "web-client-assertions";
29+
30+
options.Events.OnSigningOut = async e => { await e.HttpContext.RevokeRefreshTokenAsync(); };
31+
})
32+
.AddOpenIdConnect("oidc", options =>
33+
{
34+
options.Authority = "https://demo.duendesoftware.com";
35+
36+
// Interactive client with JWT client auth + DPoP nonce mode.
37+
// This client has RequireDPoP = true, DPoPValidationMode = Nonce,
38+
// and a short (75s) access token lifetime — perfect for demonstrating
39+
// assertion regeneration on DPoP nonce retries.
40+
options.ClientId = "interactive.confidential.jwt.dpop";
41+
// No ClientSecret — we use private_key_jwt via IClientAssertionService
42+
43+
options.ResponseType = "code";
44+
options.ResponseMode = "query";
45+
46+
options.Scope.Clear();
47+
options.Scope.Add("openid");
48+
options.Scope.Add("profile");
49+
options.Scope.Add("email");
50+
options.Scope.Add("offline_access");
51+
options.Scope.Add("api");
52+
53+
options.GetClaimsFromUserInfoEndpoint = true;
54+
options.SaveTokens = true;
55+
options.MapInboundClaims = false;
56+
57+
options.TokenValidationParameters = new TokenValidationParameters
58+
{
59+
NameClaimType = "name",
60+
RoleClaimType = "role"
61+
};
62+
});
63+
64+
// --- DPoP proof key (separate from client assertion signing key) ---
65+
// Generate a fresh RSA key for DPoP proof tokens. This is NOT the same
66+
// as the client assertion signing key — DPoP proves sender-constraint of
67+
// the access token, while the client assertion authenticates the client.
68+
var dpopRsaKey = new RsaSecurityKey(RSA.Create(2048));
69+
var dpopJsonWebKey = JsonWebKeyConverter.ConvertFromRSASecurityKey(dpopRsaKey);
70+
dpopJsonWebKey.Alg = "PS256";
71+
var dpopJwk = JsonSerializer.Serialize(dpopJsonWebKey);
72+
73+
builder.Services.AddOpenIdConnectAccessTokenManagement(options =>
74+
{
75+
options.DPoPJsonWebKey = DPoPProofKey.Parse(dpopJwk);
76+
});
77+
78+
// Register our client assertion service (replaces the default no-op)
79+
builder.Services.AddTransient<IClientAssertionService, ClientAssertionService>();
80+
81+
// --- Named M2M client (client credentials with JWT auth, no DPoP) ---
82+
builder.Services.AddClientCredentialsTokenManagement()
83+
.AddClient("m2m.jwt", client =>
84+
{
85+
client.TokenEndpoint = new Uri("https://demo.duendesoftware.com/connect/token");
86+
client.ClientId = ClientId.Parse("m2m.jwt");
87+
// No ClientSecret — assertion service provides credentials
88+
client.Scope = Scope.Parse("api");
89+
});
90+
91+
builder.Services.AddUserAccessTokenHttpClient("user_client",
92+
configureClient: client =>
93+
{
94+
client.BaseAddress = new Uri("https://demo.duendesoftware.com/api/dpop/");
95+
});
96+
97+
builder.Services.AddHttpClient<TypedUserClient>(client =>
98+
{
99+
client.BaseAddress = new Uri("https://demo.duendesoftware.com/api/dpop/");
100+
})
101+
.AddUserAccessTokenHandler();
102+
103+
builder.Services.AddClientCredentialsHttpClient("client",
104+
ClientCredentialsClientName.Parse("m2m.jwt"),
105+
client =>
106+
{
107+
client.BaseAddress = new Uri("https://demo.duendesoftware.com/api/");
108+
});
109+
110+
builder.Services.AddHttpClient<TypedClientClient>(client =>
111+
{
112+
client.BaseAddress = new Uri("https://demo.duendesoftware.com/api/");
113+
})
114+
.AddClientCredentialsTokenHandler(ClientCredentialsClientName.Parse("m2m.jwt"));
115+
116+
return builder.Build();
117+
}
118+
119+
internal static WebApplication ConfigurePipeline(this WebApplication app)
120+
{
121+
app.UseSerilogRequestLogging(
122+
options => options.GetLevel = (_, _, _) => LogEventLevel.Debug);
123+
124+
app.UseDeveloperExceptionPage();
125+
app.UseHttpsRedirection();
126+
app.UseStaticFiles();
127+
128+
app.UseRouting();
129+
130+
app.UseAuthentication();
131+
app.UseAuthorization();
132+
133+
app.MapDefaultControllerRoute()
134+
.RequireAuthorization();
135+
136+
return app;
137+
}
138+
}

0 commit comments

Comments
 (0)