Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// 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.AccessTokenManagement;
using Duende.AccessTokenManagement.OpenIdConnect;
using Duende.IdentityModel;
using Duende.IdentityModel.Client;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;

namespace WebClientAssertions;

/// <summary>
/// Creates signed client assertion JWTs (RFC 7523 / private_key_jwt) for use
/// with Duende's Access Token Management library.
///
/// Each call produces a JWT with a fresh <c>jti</c> and <c>iat</c>, which is
/// critical when DPoP nonce retries require a new assertion to avoid replay
/// rejection by the authorization server.
/// </summary>
public class ClientAssertionService : IClientAssertionService
{
/// <summary>
/// RSA private key that matches the public key registered at
/// demo.duendesoftware.com for the JWT client authentication clients.
/// In production, load from a secure store (e.g. Azure Key Vault).
/// </summary>
private static readonly SigningCredentials Credential = new(
new JsonWebKey("""
{
"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"
}
"""),
SecurityAlgorithms.RsaSha256);

private const string Authority = "https://demo.duendesoftware.com";

public Task<ClientAssertion?> GetClientAssertionAsync(
ClientCredentialsClientName? clientName = null,
TokenRequestParameters? parameters = null,
CancellationToken ct = default)
{
// Determine the client_id for the assertion's issuer/subject claims.
// The library calls this with different clientName values depending on context:
// - scheme-based name during OIDC flows (code exchange, refresh)
// - the literal client name "m2m.jwt" for the named M2M client
var clientId = ResolveClientId(clientName);

var now = DateTime.UtcNow;
var descriptor = new SecurityTokenDescriptor
{
Issuer = clientId,
Audience = Authority,
IssuedAt = now,
NotBefore = now,
Expires = now.AddMinutes(1),
SigningCredentials = Credential,

Claims = new Dictionary<string, object>
{
{ JwtClaimTypes.JwtId, Guid.NewGuid().ToString() },
{ JwtClaimTypes.Subject, clientId },
},

AdditionalHeaderClaims = new Dictionary<string, object>
{
{ "typ", "client-authentication+jwt" }
}
};

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

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

/// <summary>
/// Maps the ATM client name to the actual OAuth client_id used in the assertion.
/// </summary>
private static string ResolveClientId(ClientCredentialsClientName? clientName)
{
var name = clientName?.ToString();

// Default / OIDC scheme-based client → use the interactive DPoP client
if (string.IsNullOrEmpty(name) || name.Contains(OpenIdConnectTokenManagementDefaults.ClientCredentialsClientNamePrefix))
{
return "interactive.confidential.jwt.dpop";
}

// Named M2M client
return name switch
{
"m2m.jwt" => "m2m.jwt",
_ => name
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// 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 System.Text.Json;
using Duende.AccessTokenManagement;
using Duende.AccessTokenManagement.OpenIdConnect;
using Duende.IdentityModel.Client;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace WebClientAssertions.Controllers;

public class HomeController(IHttpClientFactory httpClientFactory, IUserTokenManager tokenManager)
: Controller
{
[AllowAnonymous]
public IActionResult Index() => View();

public IActionResult Secure() => View();

public IActionResult Logout() => SignOut("cookie", "oidc");

[AllowAnonymous]
public IActionResult Login() => Challenge(new AuthenticationProperties { RedirectUri = "/" });

public async Task<IActionResult> CallApiAsUserManual()
{
var token = await tokenManager.GetAccessTokenAsync(User).GetToken();
var client = httpClientFactory.CreateClient();
client.SetBearerToken(token.AccessToken.ToString()!);

var response = await client.GetStringAsync("https://demo.duendesoftware.com/api/dpop/test");
ViewBag.Json = PrettyPrint(response);

return View("CallApi");
}

public async Task<IActionResult> CallApiAsUserFactory()
{
var client = httpClientFactory.CreateClient("user_client");
var response = await client.GetStringAsync("test");

ViewBag.Json = PrettyPrint(response);
return View("CallApi");
}

public async Task<IActionResult> CallApiAsUserFactoryTyped([FromServices] TypedUserClient client)
{
var response = await client.CallApi();
ViewBag.Json = PrettyPrint(response);

return View("CallApi");
}

[AllowAnonymous]
public async Task<IActionResult> CallApiAsClientFactory()
{
var client = httpClientFactory.CreateClient("client");
var response = await client.GetStringAsync("test");

ViewBag.Json = PrettyPrint(response);
return View("CallApi");
}

[AllowAnonymous]
public async Task<IActionResult> CallApiAsClientFactoryTyped([FromServices] TypedClientClient client)
{
var response = await client.CallApi();
ViewBag.Json = PrettyPrint(response);

return View("CallApi");
}

private static string PrettyPrint(string json)
{
var doc = JsonDocument.Parse(json).RootElement;
return JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true });
}
}
43 changes: 43 additions & 0 deletions access-token-management/samples/WebClientAssertions/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// 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 Serilog;
using Serilog.Events;
using Serilog.Sinks.SystemConsole.Themes;
using WebClientAssertions;

Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger();

Log.Information("Host.Main Starting up");

Console.Title = "WebClientAssertions (Sample)";

try
{
var builder = WebApplication.CreateBuilder(args);

builder.Host.UseSerilog((_, lc) => lc
.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}{NewLine}",
theme: AnsiConsoleTheme.Code)
.MinimumLevel.Information()
.MinimumLevel.Override("Duende", LogEventLevel.Verbose)
.Enrich.FromLogContext());

var app = builder
.ConfigureServices()
.ConfigurePipeline();

app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Unhandled exception");
}
finally
{
Log.Information("Shut down complete");
Log.CloseAndFlush();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"profiles": {
"WebClientAssertions": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:44305"
}
}
}
138 changes: 138 additions & 0 deletions access-token-management/samples/WebClientAssertions/Startup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// 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 System.Security.Cryptography;
using System.Text.Json;
using Duende.AccessTokenManagement;
using Duende.AccessTokenManagement.DPoP;
using Duende.AccessTokenManagement.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using Serilog;
using Serilog.Events;

namespace WebClientAssertions;

public static class Startup
{
internal static WebApplication ConfigureServices(this WebApplicationBuilder builder)
{
builder.Services.AddControllersWithViews();

builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = "cookie";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("cookie", options =>
{
options.Cookie.Name = "web-client-assertions";

options.Events.OnSigningOut = async e => { await e.HttpContext.RevokeRefreshTokenAsync(); };
})
.AddOpenIdConnect("oidc", options =>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get invalid_client when I run this on my machine. I thought we were updating demo to fix that, but it looks like that's been merged already.

{
options.Authority = "https://demo.duendesoftware.com";

// Interactive client with JWT client auth + DPoP nonce mode.
// This client has RequireDPoP = true, DPoPValidationMode = Nonce,
// and a short (75s) access token lifetime — perfect for demonstrating
// assertion regeneration on DPoP nonce retries.
options.ClientId = "interactive.confidential.jwt.dpop";
// No ClientSecret — we use private_key_jwt via IClientAssertionService

options.ResponseType = "code";
options.ResponseMode = "query";

options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
options.Scope.Add("offline_access");
options.Scope.Add("api");

options.GetClaimsFromUserInfoEndpoint = true;
options.SaveTokens = true;
options.MapInboundClaims = false;

options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "name",
RoleClaimType = "role"
};
});

// --- DPoP proof key (separate from client assertion signing key) ---
// Generate a fresh RSA key for DPoP proof tokens. This is NOT the same
// as the client assertion signing key — DPoP proves sender-constraint of
// the access token, while the client assertion authenticates the client.
var dpopRsaKey = new RsaSecurityKey(RSA.Create(2048));
var dpopJsonWebKey = JsonWebKeyConverter.ConvertFromRSASecurityKey(dpopRsaKey);
dpopJsonWebKey.Alg = "PS256";
var dpopJwk = JsonSerializer.Serialize(dpopJsonWebKey);

builder.Services.AddOpenIdConnectAccessTokenManagement(options =>
{
options.DPoPJsonWebKey = DPoPProofKey.Parse(dpopJwk);
});

// Register our client assertion service (replaces the default no-op)
builder.Services.AddTransient<IClientAssertionService, ClientAssertionService>();

// --- Named M2M client (client credentials with JWT auth, no DPoP) ---
builder.Services.AddClientCredentialsTokenManagement()
.AddClient("m2m.jwt", client =>
{
client.TokenEndpoint = new Uri("https://demo.duendesoftware.com/connect/token");
client.ClientId = ClientId.Parse("m2m.jwt");
// No ClientSecret — assertion service provides credentials
client.Scope = Scope.Parse("api");
});

builder.Services.AddUserAccessTokenHttpClient("user_client",
configureClient: client =>
{
client.BaseAddress = new Uri("https://demo.duendesoftware.com/api/dpop/");
});

builder.Services.AddHttpClient<TypedUserClient>(client =>
{
client.BaseAddress = new Uri("https://demo.duendesoftware.com/api/dpop/");
})
.AddUserAccessTokenHandler();

builder.Services.AddClientCredentialsHttpClient("client",
ClientCredentialsClientName.Parse("m2m.jwt"),
client =>
{
client.BaseAddress = new Uri("https://demo.duendesoftware.com/api/");
});

builder.Services.AddHttpClient<TypedClientClient>(client =>
{
client.BaseAddress = new Uri("https://demo.duendesoftware.com/api/");
})
.AddClientCredentialsTokenHandler(ClientCredentialsClientName.Parse("m2m.jwt"));

return builder.Build();
}

internal static WebApplication ConfigurePipeline(this WebApplication app)
{
app.UseSerilogRequestLogging(
options => options.GetLevel = (_, _, _) => LogEventLevel.Debug);

app.UseDeveloperExceptionPage();
app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();

app.MapDefaultControllerRoute()
.RequireAuthorization();

return app;
}
}
Loading
Loading