Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions foss.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
<File Path="identity-model/README.md" />
</Folder>
<Folder Name="/identity-model/samples/">
<Project Path="identity-model/samples/ClientAssertions/ClientAssertions.csproj" />
<Project Path="identity-model/samples/HttpClientFactory/HttpClientFactory.csproj" />
</Folder>
<Folder Name="/identity-model/src/">
Expand Down
5 changes: 4 additions & 1 deletion identity-model/identity-model.slnf
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@
"solution": {
"path": "..\\foss.slnx",
"projects": [
"identity-model\\samples\\ClientAssertions\\ClientAssertions.csproj",
"identity-model\\samples\\HttpClientFactory\\HttpClientFactory.csproj",
"identity-model\\src\\IdentityModel\\IdentityModel.csproj",
"identity-model\\src\\TrimmableAnalysis\\TrimmableAnalysis.csproj",
"identity-model\\test\\IdentityModel.Tests\\IdentityModel.Tests.csproj"
"identity-model\\test\\IdentityModel.Tests\\IdentityModel.Tests.csproj",
"identity-model-oidc-client\\src\\IdentityModel.OidcClient.Extensions\\IdentityModel.OidcClient.Extensions.csproj",
"identity-model-oidc-client\\src\\IdentityModel.OidcClient\\IdentityModel.OidcClient.csproj"
]
}
}
93 changes: 93 additions & 0 deletions identity-model/samples/ClientAssertions/ClientAssertionService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Copyright (c) Duende Software. All rights reserved.
// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.

using Duende.IdentityModel;
using Duende.IdentityModel.Client;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;

namespace ClientAssertions;

/// <summary>
/// Creates signed client assertion JWTs (RFC 7523 / private_key_jwt).
/// Each call to <see cref="CreateAssertionAsync"/> produces a JWT with a fresh
/// <c>jti</c> and <c>iat</c>, which is critical when retries (e.g. DPoP nonce
/// challenges) require a new assertion to avoid replay rejection.
/// </summary>
public class ClientAssertionService
{
private readonly string _clientId;
private readonly string _audience;
private readonly SigningCredentials _signingCredentials;

public ClientAssertionService(string clientId, string audience, SigningCredentials signingCredentials)
{
_clientId = clientId ?? throw new ArgumentNullException(nameof(clientId));
_audience = audience ?? throw new ArgumentNullException(nameof(audience));
_signingCredentials = signingCredentials ?? throw new ArgumentNullException(nameof(signingCredentials));
}

/// <summary>
/// Creates a fresh <see cref="ClientAssertion"/> with a unique <c>jti</c>.
/// </summary>
public Task<ClientAssertion> CreateAssertionAsync()
{
var now = DateTime.UtcNow;

var descriptor = new SecurityTokenDescriptor
{
Issuer = _clientId,
Audience = _audience,
IssuedAt = now,
NotBefore = now,
Expires = now.AddMinutes(1),
SigningCredentials = _signingCredentials,
AdditionalHeaderClaims = new Dictionary<string, object>
{
{ "typ", "client-authentication+jwt" }
},
Claims = new Dictionary<string, object>
{
{ JwtClaimTypes.JwtId, Guid.NewGuid().ToString() },
{ JwtClaimTypes.Subject, _clientId },
}
};

var handler = new JsonWebTokenHandler();
var jwt = handler.CreateToken(descriptor);

return Task.FromResult(new ClientAssertion
{
Type = OidcConstants.ClientAssertionTypes.JwtBearer,
Value = jwt
});
}

/// <summary>
/// Creates signing credentials from the RSA key pair that matches the public
/// key registered for the "m2m.jwt" client at demo.duendesoftware.com.
/// In production, load the private key from a secure store (e.g. Azure Key Vault).
/// </summary>
public static SigningCredentials CreateSigningCredentials()
{
// This JWK contains the private key that corresponds to the public key
// registered at the demo IdentityServer for the "m2m.jwt" client.
var jwk = """
{
"d":"GmiaucNIzdvsEzGjZjd43SDToy1pz-Ph-shsOUXXh-dsYNGftITGerp8bO1iryXh_zUEo8oDK3r1y4klTonQ6bLsWw4ogjLPmL3yiqsoSjJa1G2Ymh_RY_sFZLLXAcrmpbzdWIAkgkHSZTaliL6g57vA7gxvd8L4s82wgGer_JmURI0ECbaCg98JVS0Srtf9GeTRHoX4foLWKc1Vq6NHthzqRMLZe-aRBNU9IMvXNd7kCcIbHCM3GTD_8cFj135nBPP2HOgC_ZXI1txsEf-djqJj8W5vaM7ViKU28IDv1gZGH3CatoysYx6jv1XJVvb2PH8RbFKbJmeyUm3Wvo-rgQ",
"dp":"YNjVBTCIwZD65WCht5ve06vnBLP_Po1NtL_4lkholmPzJ5jbLYBU8f5foNp8DVJBdFQW7wcLmx85-NC5Pl1ZeyA-Ecbw4fDraa5Z4wUKlF0LT6VV79rfOF19y8kwf6MigyrDqMLcH_CRnRGg5NfDsijlZXffINGuxg6wWzhiqqE",
"dq":"LfMDQbvTFNngkZjKkN2CBh5_MBG6Yrmfy4kWA8IC2HQqID5FtreiY2MTAwoDcoINfh3S5CItpuq94tlB2t-VUv8wunhbngHiB5xUprwGAAnwJ3DL39D2m43i_3YP-UO1TgZQUAOh7Jrd4foatpatTvBtY3F1DrCrUKE5Kkn770M",
"e":"AQAB",
"kid":"ZzAjSnraU3bkWGnnAqLapYGpTyNfLbjbzgAPbbW2GEA",
"kty":"RSA",
"n":"wWwQFtSzeRjjerpEM5Rmqz_DsNaZ9S1Bw6UbZkDLowuuTCjBWUax0vBMMxdy6XjEEK4Oq9lKMvx9JzjmeJf1knoqSNrox3Ka0rnxXpNAz6sATvme8p9mTXyp0cX4lF4U2J54xa2_S9NF5QWvpXvBeC4GAJx7QaSw4zrUkrc6XyaAiFnLhQEwKJCwUw4NOqIuYvYp_IXhw-5Ti_icDlZS-282PcccnBeOcX7vc21pozibIdmZJKqXNsL1Ibx5Nkx1F1jLnekJAmdaACDjYRLL_6n3W4wUp19UvzB1lGtXcJKLLkqB6YDiZNu16OSiSprfmrRXvYmvD8m6Fnl5aetgKw",
"p":"7enorp9Pm9XSHaCvQyENcvdU99WCPbnp8vc0KnY_0g9UdX4ZDH07JwKu6DQEwfmUA1qspC-e_KFWTl3x0-I2eJRnHjLOoLrTjrVSBRhBMGEH5PvtZTTThnIY2LReH-6EhceGvcsJ_MhNDUEZLykiH1OnKhmRuvSdhi8oiETqtPE",
"q":"0CBLGi_kRPLqI8yfVkpBbA9zkCAshgrWWn9hsq6a7Zl2LcLaLBRUxH0q1jWnXgeJh9o5v8sYGXwhbrmuypw7kJ0uA3OgEzSsNvX5Ay3R9sNel-3Mqm8Me5OfWWvmTEBOci8RwHstdR-7b9ZT13jk-dsZI7OlV_uBja1ny9Nz9ts",
"qi":"pG6J4dcUDrDndMxa-ee1yG4KjZqqyCQcmPAfqklI2LmnpRIjcK78scclvpboI3JQyg6RCEKVMwAhVtQM6cBcIO3JrHgqeYDblp5wXHjto70HVW6Z8kBruNx1AH9E8LzNvSRL-JVTFzBkJuNgzKQfD0G77tQRgJ-Ri7qu3_9o1M4"
}
""";

var key = new JsonWebKey(jwk);
return new SigningCredentials(key, SecurityAlgorithms.RsaSha256);
}
}
15 changes: 15 additions & 0 deletions identity-model/samples/ClientAssertions/ClientAssertions.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<ImplicitUsings>true</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.15.0" />
<ProjectReference Include="..\..\src\IdentityModel\IdentityModel.csproj" />
</ItemGroup>

</Project>
92 changes: 92 additions & 0 deletions identity-model/samples/ClientAssertions/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Copyright (c) Duende Software. All rights reserved.
// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.

//
// This sample demonstrates how to use a ClientAssertionFactory so that token
// requests (including DPoP nonce retries) automatically produce a fresh
// client_assertion JWT with a unique jti on each attempt.
//
// Background
// ----------
// When a token request carries a client_assertion (RFC 7521 / private_key_jwt),
// the server may reject retries that reuse the same assertion because the jti
// has already been seen. Setting ClientAssertionFactory on the ProtocolRequest
// ensures that:
// 1. The initial request gets a freshly-minted assertion.
// 2. The factory is stored on HttpRequestMessage.Options so that downstream
// handlers (e.g. DPoP proof-token handlers) can invoke it on retries.
//

using Duende.IdentityModel.Client;

namespace ClientAssertions;

public class Program
{
private const string Authority = "https://demo.duendesoftware.com";
private const string TokenEndpoint = $"{Authority}/connect/token";
private const string ClientId = "m2m.jwt";
private const string Scope = "api";

public static async Task Main()
{
Console.WriteLine("+------------------------------------------+");
Console.WriteLine("| Client Assertions Sample |");
Console.WriteLine("+------------------------------------------+");
Console.WriteLine();

// 1. Create a signing key for client_assertion JWTs.
// In production this would be the key registered with the identity
// provider for private_key_jwt authentication.
var signingCredentials = ClientAssertionService.CreateSigningCredentials();

// 2. Create the assertion service.
var assertionService = new ClientAssertionService(ClientId, Authority, signingCredentials);

// 3. Build the HTTP client. In a real DPoP scenario you would wrap this
// with a ProofTokenMessageHandler, but this sample focuses on the
// client assertion factory pattern alone.
var client = new HttpClient();

// 4. Build the token request with a ClientAssertionFactory.
// The factory is called once for the initial request and stored on
// HttpRequestMessage.Options so that retry handlers can call it again.
var tokenRequest = new ClientCredentialsTokenRequest
{
Address = TokenEndpoint,
ClientId = ClientId,
ClientCredentialStyle = ClientCredentialStyle.PostBody,
Scope = Scope,

// The key feature: a factory that produces a fresh assertion each time.
ClientAssertionFactory = assertionService.CreateAssertionAsync,
};

Console.WriteLine("Requesting token with client_assertion...");
Console.WriteLine($" Endpoint : {TokenEndpoint}");
Console.WriteLine($" ClientId : {ClientId}");
Console.WriteLine();

// 5. Send the token request.
var response = await client.RequestClientCredentialsTokenAsync(tokenRequest);

// 6. Display the result.
if (response.IsError)
{
Console.WriteLine($"Error: {response.Error}");
Console.WriteLine($"Description: {response.ErrorDescription}");

if (response.HttpStatusCode != 0)
{
Console.WriteLine($"HTTP {(int)response.HttpStatusCode}");
}
}
else
{
Console.WriteLine("Success!");
Console.WriteLine($" Token type : {response.TokenType}");
Console.WriteLine($" Expires in : {response.ExpiresIn}s");
Console.WriteLine($" Access token : {response.AccessToken}");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ public static Task<PushedAuthorizationResponse> PushAuthorizationAsync(this Http

internal static async Task<PushedAuthorizationResponse> PushAuthorizationAsync(this HttpMessageInvoker client, ProtocolRequest request, CancellationToken cancellationToken = default)
{
// If a factory is set, invoke it to get a fresh assertion for this attempt and
// store it on Options so that DPoP retry handlers can invoke it again on each
// subsequent attempt. The factory always takes precedence over a fixed ClientAssertion value.
if (request.ClientAssertionFactory != null)
{
request.ClientAssertion = await request.ClientAssertionFactory().ConfigureAwait();
request.Options.Set(ProtocolRequestOptions.ClientAssertionFactory, request.ClientAssertionFactory);
}

request.Prepare();
request.Method = HttpMethod.Post;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,15 @@ public static async Task<TokenResponse> RequestTokenRawAsync(this HttpMessageInv

internal static async Task<TokenResponse> RequestTokenAsync(this HttpMessageInvoker client, ProtocolRequest request, CancellationToken cancellationToken = default)
{
// If a factory is set, invoke it to get a fresh assertion for this attempt and
// store it on Options so that DPoP retry handlers can invoke it again on each
// subsequent attempt. The factory always takes precedence over a fixed ClientAssertion value.
if (request.ClientAssertionFactory != null)
{
request.ClientAssertion = await request.ClientAssertionFactory().ConfigureAwait();
request.Options.Set(ProtocolRequestOptions.ClientAssertionFactory, request.ClientAssertionFactory);
}

request.Prepare();
request.Method = HttpMethod.Post;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@

namespace Duende.IdentityModel.Client;

/// <summary>
/// Well-known <see cref="HttpRequestOptionsKey{TValue}"/> keys used by Duende IdentityModel
/// when passing data through handler chains.
/// </summary>
public static class ProtocolRequestOptions
{
/// <summary>
/// The key used to store a <see cref="ProtocolRequest.ClientAssertionFactory"/> on
/// <see cref="HttpRequestMessage.Options"/>. DPoP retry handlers read this key to obtain
/// a fresh <see cref="ClientAssertion"/> for each attempt.
/// </summary>
public static readonly HttpRequestOptionsKey<Func<Task<ClientAssertion>>?> ClientAssertionFactory =
new HttpRequestOptionsKey<Func<Task<ClientAssertion>>?>("Duende.IdentityModel.ClientAssertionFactory");
}

/// <summary>
/// Models a base OAuth/OIDC request with client credentials
/// </summary>
Expand Down Expand Up @@ -73,6 +88,14 @@ public ProtocolRequest()
/// </summary>
public string? DPoPProofToken { get; set; }

/// <summary>
/// Gets or sets a factory function that creates a fresh <see cref="ClientAssertion"/> on demand.
/// When set, this factory is stored on <see cref="HttpRequestMessage.Options"/> so that DPoP retry
/// handlers can invoke it to obtain a new assertion (with a fresh <c>jti</c> and <c>iat</c>) on
/// each attempt, avoiding client-assertion replay rejected by servers that enforce uniqueness.
/// </summary>
public Func<Task<ClientAssertion>>? ClientAssertionFactory { get; set; }

/// <summary>
/// Gets or sets additional protocol parameters.
/// </summary>
Expand Down Expand Up @@ -101,6 +124,7 @@ public T Clone<T>()
Address = Address,
AuthorizationHeaderStyle = AuthorizationHeaderStyle,
ClientAssertion = ClientAssertion,
ClientAssertionFactory = ClientAssertionFactory,
ClientCredentialStyle = ClientCredentialStyle,
ClientId = ClientId,
ClientSecret = ClientSecret,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,4 +213,65 @@ public async Task Pushed_authorization_with_request_uri_should_fail()
var exception = await act.ShouldThrowAsync<ArgumentException>();
exception.ParamName.ShouldBe("request_uri");
}

[Fact]
public async Task ClientAssertionFactoryShouldBeInvokedAndResultSentInBody()
{
var document = File.ReadAllText(FileName.Create("success_par_response.json"));
var handler = new NetworkHandler(document, HttpStatusCode.OK);
var client = new HttpClient(handler);

var factoryCallCount = 0;

var response = await client.PushAuthorizationAsync(new PushedAuthorizationRequest
{
Address = Endpoint,
ClientId = "client",
ResponseType = "code",
ClientCredentialStyle = ClientCredentialStyle.PostBody,
ClientAssertionFactory = () =>
{
factoryCallCount++;
return Task.FromResult(new ClientAssertion
{
Type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
Value = "fresh-jwt-value"
});
}
}, _ct);

factoryCallCount.ShouldBe(1);

var fields = QueryHelpers.ParseQuery(handler.Body);
fields["client_assertion_type"].First().ShouldBe("urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
fields["client_assertion"].First().ShouldBe("fresh-jwt-value");
}

[Fact]
public async Task ClientAssertionFactoryShouldBeStoredOnRequestOptions()
{
var document = File.ReadAllText(FileName.Create("success_par_response.json"));
var handler = new NetworkHandler(document, HttpStatusCode.OK);
var client = new HttpClient(handler);

Func<Task<ClientAssertion>> factory = () => Task.FromResult(new ClientAssertion
{
Type = "type",
Value = "value"
});

await client.PushAuthorizationAsync(new PushedAuthorizationRequest
{
Address = Endpoint,
ClientId = "client",
ResponseType = "code",
ClientCredentialStyle = ClientCredentialStyle.PostBody,
ClientAssertionFactory = factory
}, _ct);

handler.Request.Options
.TryGetValue(ProtocolRequestOptions.ClientAssertionFactory, out var storedFactory)
.ShouldBeTrue();
storedFactory.ShouldBe(factory);
}
}
Loading
Loading