Skip to content

Commit d233a68

Browse files
Introduce the capability to refresh client assertions on retry in OidcClient.
Wire client assertion creator into OIDC code exchange flow. Update DPoP ProofTokenMessageHandler to regenerate assertions on retry. Add ClientAssertionService to NetCoreConsoleClient sample. Expand DPoP test coverage for client assertion scenarios. wip cleanup
1 parent a79814a commit d233a68

File tree

9 files changed

+462
-113
lines changed

9 files changed

+462
-113
lines changed

Directory.Packages.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
1111
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.11.0" />
1212
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
13-
<PackageVersion Include="Duende.IdentityModel" Version="8.0.1" />
13+
<PackageVersion Include="Duende.IdentityModel" Version="8.1.0" />
1414
<PackageVersion Include="Duende.IdentityServer" Version="7.4.2" />
1515
<PackageVersion Include="MartinCostello.Logging.XUnit.v3" Version="0.7.0" />
1616
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="$(FrameworkVersion)" />
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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 Duende.IdentityModel;
6+
using Duende.IdentityModel.Client;
7+
using Microsoft.IdentityModel.JsonWebTokens;
8+
using Microsoft.IdentityModel.Tokens;
9+
10+
namespace ConsoleClientWithBrowser;
11+
12+
/// <summary>
13+
/// Creates signed client assertion JWTs (RFC 7523 / private_key_jwt).
14+
/// Each call to <see cref="CreateAssertionAsync"/> produces a JWT with a fresh
15+
/// <c>jti</c> and <c>iat</c>, which is critical when retries (e.g. DPoP nonce
16+
/// challenges) require a new assertion to avoid replay rejection.
17+
/// </summary>
18+
public class ClientAssertionService
19+
{
20+
private readonly string _clientId;
21+
private readonly string _audience;
22+
private readonly SigningCredentials _signingCredentials;
23+
24+
public ClientAssertionService(string clientId, string audience, SigningCredentials signingCredentials)
25+
{
26+
_clientId = clientId ?? throw new ArgumentNullException(nameof(clientId));
27+
_audience = audience ?? throw new ArgumentNullException(nameof(audience));
28+
_signingCredentials = signingCredentials ?? throw new ArgumentNullException(nameof(signingCredentials));
29+
}
30+
31+
/// <summary>
32+
/// Creates a fresh <see cref="ClientAssertion"/> with a unique <c>jti</c>.
33+
/// </summary>
34+
public Task<ClientAssertion> CreateAssertionAsync()
35+
{
36+
var now = DateTime.UtcNow;
37+
38+
var descriptor = new SecurityTokenDescriptor
39+
{
40+
Issuer = _clientId,
41+
Audience = _audience,
42+
IssuedAt = now,
43+
NotBefore = now,
44+
Expires = now.AddMinutes(1),
45+
SigningCredentials = _signingCredentials,
46+
AdditionalHeaderClaims = new Dictionary<string, object>
47+
{
48+
{ "typ", "client-authentication+jwt" }
49+
},
50+
Claims = new Dictionary<string, object>
51+
{
52+
{ JwtClaimTypes.JwtId, Guid.NewGuid().ToString() },
53+
{ JwtClaimTypes.Subject, _clientId },
54+
}
55+
};
56+
57+
var handler = new JsonWebTokenHandler();
58+
var jwt = handler.CreateToken(descriptor);
59+
60+
return Task.FromResult(new ClientAssertion
61+
{
62+
Type = OidcConstants.ClientAssertionTypes.JwtBearer,
63+
Value = jwt
64+
});
65+
}
66+
67+
/// <summary>
68+
/// Creates a new RSA signing credential suitable for client assertion signing.
69+
/// </summary>
70+
public static SigningCredentials CreateSigningCredentials()
71+
{
72+
var rsa = RSA.Create(2048);
73+
var key = new RsaSecurityKey(rsa);
74+
return new SigningCredentials(key, SecurityAlgorithms.RsaSha256);
75+
}
76+
}

identity-model-oidc-client/samples/NetCoreConsoleClient/src/NetCoreConsoleClient/NetCoreConsoleClient.csproj

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@
44
<TargetFramework>net10.0</TargetFramework>
55
<AssemblyName>NetCoreConsoleClient</AssemblyName>
66
<OutputType>Exe</OutputType>
7+
<ImplicitUsings>true</ImplicitUsings>
8+
<Nullable>enable</Nullable>
79
</PropertyGroup>
810

911
<ItemGroup>
1012
<FrameworkReference Include="Microsoft.AspNetCore.App"></FrameworkReference>
11-
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
13+
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.15.0" />
1214
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
1315
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
14-
<PackageReference Include="Duende.IdentityModel.OidcClient" Version="6.0.0" />
16+
<ProjectReference Include="../../../../src/IdentityModel.OidcClient.Extensions/IdentityModel.OidcClient.Extensions.csproj" />
1517
</ItemGroup>
1618

1719
</Project>
Lines changed: 144 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -1,134 +1,174 @@
1-
using Duende.IdentityModel.Client;
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+
//
5+
// This sample demonstrates using OidcClient with DPoP and a ClientAssertionFactory
6+
// so that DPoP nonce retries automatically regenerate the client_assertion JWT.
7+
//
8+
9+
using System.Security.Cryptography;
10+
using System.Text.Json;
11+
using Duende.IdentityModel.Client;
212
using Duende.IdentityModel.OidcClient;
3-
using Newtonsoft.Json.Linq;
13+
using Duende.IdentityModel.OidcClient.DPoP;
14+
using Microsoft.IdentityModel.Tokens;
415
using Serilog;
516

6-
namespace ConsoleClientWithBrowser
17+
namespace ConsoleClientWithBrowser;
18+
19+
public class Program
720
{
8-
public class Program
21+
static string _authority = "https://demo.duendesoftware.com";
22+
static string _api = "https://demo.duendesoftware.com/api/test";
23+
24+
static HttpClient _apiClient = new HttpClient { BaseAddress = new Uri(_api) };
25+
26+
public static void Main(string[] args) => MainAsync().GetAwaiter().GetResult();
27+
28+
public static async Task MainAsync()
929
{
10-
static string _authority = "https://demo.duendesoftware.com";
11-
static string _api = "https://demo.duendesoftware.com/api/test";
30+
Console.WriteLine("+-----------------------------------------+");
31+
Console.WriteLine("| Sign in with OIDC + DPoP + Assertion |");
32+
Console.WriteLine("+-----------------------------------------+");
33+
Console.WriteLine("");
34+
Console.WriteLine("Press any key to sign in...");
35+
Console.ReadKey();
36+
37+
await Login();
38+
}
1239

13-
static HttpClient _apiClient = new HttpClient { BaseAddress = new Uri(_api) };
40+
private static async Task Login()
41+
{
42+
// create a redirect URI using an available port on the loopback address.
43+
var browser = new SystemBrowser();
44+
string redirectUri = string.Format($"http://127.0.0.1:{browser.Port}");
1445

15-
public static void Main(string[] args) => MainAsync().GetAwaiter().GetResult();
46+
// Create a DPoP proof key
47+
var dpopKey = CreateDPoPProofKey();
1648

17-
public static async Task MainAsync()
18-
{
19-
Console.WriteLine("+-----------------------+");
20-
Console.WriteLine("| Sign in with OIDC |");
21-
Console.WriteLine("+-----------------------+");
22-
Console.WriteLine("");
23-
Console.WriteLine("Press any key to sign in...");
24-
Console.ReadKey();
25-
26-
await Login();
27-
}
49+
// Create a client assertion signing key and service
50+
var signingCredentials = ClientAssertionService.CreateSigningCredentials();
2851

29-
private static async Task Login()
52+
var options = new OidcClientOptions
3053
{
31-
// create a redirect URI using an available port on the loopback address.
32-
// requires the OP to allow random ports on 127.0.0.1 - otherwise set a static port
33-
var browser = new SystemBrowser();
34-
string redirectUri = string.Format($"http://127.0.0.1:{browser.Port}");
54+
Authority = _authority,
55+
ClientId = "interactive.public",
56+
RedirectUri = redirectUri,
57+
Scope = "openid profile api",
58+
FilterClaims = false,
59+
Browser = browser,
60+
};
61+
62+
// Wire up DPoP
63+
options.ConfigureDPoP(dpopKey);
64+
65+
// Wire up the client assertion factory.
66+
// The factory produces a fresh JWT (with unique jti) on each invocation,
67+
// which ensures DPoP nonce retries don't replay a stale assertion.
68+
var assertionService = new ClientAssertionService(
69+
clientId: options.ClientId,
70+
audience: _authority,
71+
signingCredentials: signingCredentials);
72+
options.GetClientAssertionAsync = assertionService.CreateAssertionAsync;
73+
74+
var serilog = new LoggerConfiguration()
75+
.MinimumLevel.Error()
76+
.Enrich.FromLogContext()
77+
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message}{NewLine}{Exception}{NewLine}")
78+
.CreateLogger();
79+
80+
options.LoggerFactory.AddSerilog(serilog);
81+
82+
var oidcClient = new OidcClient(options);
83+
var result = await oidcClient.LoginAsync(new LoginRequest());
84+
85+
ShowResult(result);
86+
await NextSteps(result, oidcClient);
87+
}
3588

36-
var options = new OidcClientOptions
37-
{
38-
Authority = _authority,
39-
ClientId = "interactive.public",
40-
RedirectUri = redirectUri,
41-
Scope = "openid profile api",
42-
FilterClaims = false,
43-
Browser = browser,
44-
};
45-
46-
var serilog = new LoggerConfiguration()
47-
.MinimumLevel.Error()
48-
.Enrich.FromLogContext()
49-
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message}{NewLine}{Exception}{NewLine}")
50-
.CreateLogger();
51-
52-
options.LoggerFactory.AddSerilog(serilog);
53-
54-
var oidcClient = new OidcClient(options);
55-
var result = await oidcClient.LoginAsync(new LoginRequest());
56-
57-
ShowResult(result);
58-
await NextSteps(result, oidcClient);
89+
/// <summary>
90+
/// Creates a DPoP proof key (RSA, serialized as JWK JSON).
91+
/// In production, persist this key so the same key is used across restarts.
92+
/// </summary>
93+
private static string CreateDPoPProofKey()
94+
{
95+
using var rsa = RSA.Create(2048);
96+
var key = new RsaSecurityKey(rsa);
97+
var jwk = JsonWebKeyConverter.ConvertFromRSASecurityKey(key);
98+
jwk.Alg = SecurityAlgorithms.RsaSha256;
99+
return JsonSerializer.Serialize(jwk);
100+
}
101+
102+
private static void ShowResult(LoginResult result)
103+
{
104+
if (result.IsError)
105+
{
106+
Console.WriteLine("\n\nError:\n{0}", result.Error);
107+
return;
59108
}
60109

61-
private static void ShowResult(LoginResult result)
110+
Console.WriteLine("\n\nClaims:");
111+
foreach (var claim in result.User.Claims)
62112
{
63-
if (result.IsError)
64-
{
65-
Console.WriteLine("\n\nError:\n{0}", result.Error);
66-
return;
67-
}
113+
Console.WriteLine("{0}: {1}", claim.Type, claim.Value);
114+
}
68115

69-
Console.WriteLine("\n\nClaims:");
70-
foreach (var claim in result.User.Claims)
71-
{
72-
Console.WriteLine("{0}: {1}", claim.Type, claim.Value);
73-
}
116+
Console.WriteLine($"\nidentity token: {result.IdentityToken}");
117+
Console.WriteLine($"access token: {result.AccessToken}");
118+
Console.WriteLine($"refresh token: {result?.RefreshToken ?? "none"}");
119+
}
74120

75-
Console.WriteLine($"\nidentity token: {result.IdentityToken}");
76-
Console.WriteLine($"access token: {result.AccessToken}");
77-
Console.WriteLine($"refresh token: {result?.RefreshToken ?? "none"}");
78-
}
121+
private static async Task NextSteps(LoginResult result, OidcClient oidcClient)
122+
{
123+
var currentAccessToken = result.AccessToken;
124+
var currentRefreshToken = result.RefreshToken;
125+
126+
var menu = " x...exit c...call api ";
127+
if (currentRefreshToken != null) menu += "r...refresh token ";
79128

80-
private static async Task NextSteps(LoginResult result, OidcClient oidcClient)
129+
while (true)
81130
{
82-
var currentAccessToken = result.AccessToken;
83-
var currentRefreshToken = result.RefreshToken;
131+
Console.WriteLine("\n\n");
84132

85-
var menu = " x...exit c...call api ";
86-
if (currentRefreshToken != null) menu += "r...refresh token ";
133+
Console.Write(menu);
134+
var key = Console.ReadKey();
87135

88-
while (true)
136+
if (key.Key == ConsoleKey.X) return;
137+
if (key.Key == ConsoleKey.C) await CallApi(currentAccessToken);
138+
if (key.Key == ConsoleKey.R)
89139
{
90-
Console.WriteLine("\n\n");
91-
92-
Console.Write(menu);
93-
var key = Console.ReadKey();
94-
95-
if (key.Key == ConsoleKey.X) return;
96-
if (key.Key == ConsoleKey.C) await CallApi(currentAccessToken);
97-
if (key.Key == ConsoleKey.R)
140+
var refreshResult = await oidcClient.RefreshTokenAsync(currentRefreshToken);
141+
if (refreshResult.IsError)
98142
{
99-
var refreshResult = await oidcClient.RefreshTokenAsync(currentRefreshToken);
100-
if (refreshResult.IsError)
101-
{
102-
Console.WriteLine($"Error: {refreshResult.Error}");
103-
}
104-
else
105-
{
106-
currentRefreshToken = refreshResult.RefreshToken;
107-
currentAccessToken = refreshResult.AccessToken;
108-
109-
Console.WriteLine("\n\n");
110-
Console.WriteLine($"access token: {refreshResult.AccessToken}");
111-
Console.WriteLine($"refresh token: {refreshResult?.RefreshToken ?? "none"}");
112-
}
143+
Console.WriteLine($"Error: {refreshResult.Error}");
144+
}
145+
else
146+
{
147+
currentRefreshToken = refreshResult.RefreshToken;
148+
currentAccessToken = refreshResult.AccessToken;
149+
150+
Console.WriteLine("\n\n");
151+
Console.WriteLine($"access token: {refreshResult.AccessToken}");
152+
Console.WriteLine($"refresh token: {refreshResult?.RefreshToken ?? "none"}");
113153
}
114154
}
115155
}
156+
}
116157

117-
private static async Task CallApi(string currentAccessToken)
118-
{
119-
_apiClient.SetBearerToken(currentAccessToken);
120-
var response = await _apiClient.GetAsync("");
158+
private static async Task CallApi(string currentAccessToken)
159+
{
160+
_apiClient.SetBearerToken(currentAccessToken);
161+
var response = await _apiClient.GetAsync("");
121162

122-
if (response.IsSuccessStatusCode)
123-
{
124-
var json = JArray.Parse(await response.Content.ReadAsStringAsync());
125-
Console.WriteLine("\n\n");
126-
Console.WriteLine(json);
127-
}
128-
else
129-
{
130-
Console.WriteLine($"Error: {response.ReasonPhrase}");
131-
}
163+
if (response.IsSuccessStatusCode)
164+
{
165+
var json = await response.Content.ReadAsStringAsync();
166+
Console.WriteLine("\n\n");
167+
Console.WriteLine(json);
168+
}
169+
else
170+
{
171+
Console.WriteLine($"Error: {response.ReasonPhrase}");
132172
}
133173
}
134174
}

0 commit comments

Comments
 (0)