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
Expand Up @@ -3,8 +3,8 @@

using System.Text.Json;
using Duende.AccessTokenManagement;
using Duende.AccessTokenManagement.DPoP;
using Duende.AccessTokenManagement.OpenIdConnect;
using Duende.IdentityModel.Client;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
Expand All @@ -15,11 +15,13 @@ public class HomeController : Controller
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IUserTokenManager _tokenManager;
private readonly IDPoPProofService _dPoPProofService;

public HomeController(IHttpClientFactory httpClientFactory, IUserTokenManager tokenManager)
public HomeController(IHttpClientFactory httpClientFactory, IUserTokenManager tokenManager, IDPoPProofService dPoPProofService)
{
_httpClientFactory = httpClientFactory;
_tokenManager = tokenManager;
_dPoPProofService = dPoPProofService;
}

[AllowAnonymous]
Expand All @@ -39,11 +41,33 @@ public HomeController(IHttpClientFactory httpClientFactory, IUserTokenManager to
public async Task<IActionResult> CallApiAsUserManual()
{
var token = await _tokenManager.GetAccessTokenAsync(User).GetToken();

var url = new Uri("https://demo.duendesoftware.com/api/dpop/test");
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new ("DPoP", token.AccessToken.ToString());

if (token.DPoPJsonWebKey is { } key)
{
var proof = await _dPoPProofService.CreateProofTokenAsync(new DPoPProofRequest
{
Url = url,
Method = HttpMethod.Get,
DPoPProofKey = key,
AccessToken = token.AccessToken,
});
Comment on lines +51 to +57
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Shouldn't access token management create this proof? I think there's an http handler designed for interaction with the resource server.


if (proof is not null)
{
request.SetDPoPProofToken(proof.Value);
}
}

var client = _httpClientFactory.CreateClient();
client.SetBearerToken(token.AccessToken.ToString()!);
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();

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

return View("CallApi");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,15 @@ internal static WebApplication ConfigureServices(this WebApplicationBuilder buil
// 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) ---
// --- Named M2M client (client credentials with JWT auth + 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");
client.DPoPJsonWebKey = DPoPProofKey.Parse(dpopJwk);
});

// --- HTTP Clients ---
Expand All @@ -111,17 +112,17 @@ internal static WebApplication ConfigureServices(this WebApplicationBuilder buil
})
.AddUserAccessTokenHandler();

// Client access token clients (M2M with JWT assertion, no DPoP)
// Client access token clients (M2M with JWT assertion + DPoP)
builder.Services.AddClientCredentialsHttpClient("client",
ClientCredentialsClientName.Parse("m2m.jwt"),
client =>
{
client.BaseAddress = new Uri("https://demo.duendesoftware.com/api/");
client.BaseAddress = new Uri("https://demo.duendesoftware.com/api/dpop/");
});

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ public void Configure(string? name, OpenIdConnectOptions options)
options.Events.OnRedirectToIdentityProvider = CreateCallback(options.Events.OnRedirectToIdentityProvider);
options.Events.OnAuthorizationCodeReceived = CreateCallback(options.Events.OnAuthorizationCodeReceived);
options.Events.OnTokenValidated = CreateCallback(options.Events.OnTokenValidated);
options.Events.OnPushAuthorization = CreateCallback(options.Events.OnPushAuthorization);

options.BackchannelHttpHandler = new AuthorizationServerDPoPHandler(dPoPProofService, dPoPNonceStore, httpContextAccessor, loggerFactory)
{
Expand Down Expand Up @@ -153,4 +154,40 @@ async Task Callback(TokenValidatedContext context)

return Callback;
}

private Func<PushedAuthorizationContext, Task> CreateCallback(Func<PushedAuthorizationContext, Task> inner)
{
async Task Callback(PushedAuthorizationContext context)
{
await inner.Invoke(context);

// --- DPoP thumbprint ---
var dPoPKeyStore = context.HttpContext.RequestServices.GetRequiredService<IDPoPKeyStore>();
var key = await dPoPKeyStore.GetKeyAsync(ClientName);
if (key != null)
{
var jkt = dPoPProofService.GetProofKeyThumbprint(key.Value);
if (jkt != null)
{
context.Properties.SetProofKey(key.Value);
context.ProtocolMessage.Parameters[OidcConstants.AuthorizeRequest.DPoPKeyThumbprint] =
jkt.ToString();
}
}
Comment on lines +164 to +176
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think we need to add this. We should double check, but the backchannel handler already sets the DPoP header value.

DPoP defines two ways to bind the authorization code to the proof key. If you're not using PAR, you have to use the dpop_jkt query string parameter. Probably this is so because custom headers on a redirect can be annoying. So, the spec gives you this easy way to say "here's the thumbprint of my public key".

But, if you are using PAR, the backchannel pushed authorization request can easily set headers, including the DPoP header. That header is actually a stronger binding because it conveys both the public key and proof of possession of the private key.

The spec also points out that doing it this way means that the client can just always include the proof on all backchannel requests, and needs less special case logic.

For your reference, from RFC 9449:

When Pushed Authorization Requests (PARs) [RFC9126] are used in conjunction with DPoP, there are two ways in which the DPoP key can be communicated in the PAR request:

The dpop_jkt parameter can be used as described in Section 10 to bind the issued authorization code to a specific key. In this case, dpop_jkt MUST be included alongside other authorization request parameters in the POST body of the PAR request.

Alternatively, the DPoP header can be added to the PAR request. In this case, the authorization server MUST check the provided DPoP proof JWT as defined in Section 4.3. It MUST further behave as if the contained public key's thumbprint was provided using dpop_jkt, i.e., reject the subsequent token request unless a DPoP proof for the same key is provided. This can help to simplify the implementation of the client, as it can "blindly" attach the DPoP header to all requests to the authorization server regardless of the type of request. Additionally, it provides a stronger binding, as the DPoP header contains a proof of possession of the private key.


// --- Client assertion ---
var assertion = await clientAssertionService
.GetClientAssertionAsync(ClientName, ct: context.HttpContext.RequestAborted)
.ConfigureAwait(false);

if (assertion != null)
{
context.ProtocolMessage.ClientAssertionType = assertion.Type;
context.ProtocolMessage.ClientAssertion = assertion.Value;
context.HandleClientAuthentication();
}
}

return Callback;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public class AppHost : GenericHost
public string ClientId;
public string? ClientSecret;
public SigningCredentials? ClientAssertionSigningCredentials { get; set; }
public PushedAuthorizationBehavior PushedAuthorizationBehavior { get; set; } = PushedAuthorizationBehavior.Disable;

private readonly IdentityServerHost _identityServerHost;
private readonly ApiHost _apiHost;
Expand Down Expand Up @@ -107,27 +108,13 @@ private void ConfigureServices(IServiceCollection services)
}

options.ProtocolValidator.RequireNonce = false;
options.PushedAuthorizationBehavior = PushedAuthorizationBehavior;
});

if (ClientAssertionSigningCredentials is { } assertionCredentials)
{
services.AddSingleton<IClientAssertionService>(
new JwtClientAssertionService(ClientId, assertionCredentials));

services.Configure<OpenIdConnectOptions>("oidc", opt =>
{
opt.Events.OnAuthorizationCodeReceived = async context =>
{
var svc = context.HttpContext.RequestServices
.GetRequiredService<IClientAssertionService>();
var assertion = await svc.GetClientAssertionAsync(
ClientCredentialsClientName.Parse(ClientId))
?? throw new InvalidOperationException("Client assertion is null");

context.TokenEndpointRequest!.ClientAssertionType = assertion.Type;
context.TokenEndpointRequest.ClientAssertion = assertion.Value;
};
});
}

services.AddOpenIdConnectAccessTokenManagement(opt =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,11 @@ public IdentityServerHost(WriteTestOutput writeTestOutput, string baseAddress =
new("urn:api2")
];

public bool EnablePar { get; set; }

public List<Dictionary<string, string>> CapturedTokenRequests { get; } = [];
public List<Dictionary<string, string>> CapturedRevocationRequests { get; } = [];
public List<Dictionary<string, string>> CapturedParRequests { get; } = [];

private void ConfigureServices(IServiceCollection services)
{
Expand All @@ -60,8 +63,8 @@ private void ConfigureServices(IServiceCollection services)
options.DPoP.ServerClockSkew = TimeSpan.Zero;
options.DPoP.ProofTokenValidityDuration = TimeSpan.FromSeconds(1);

// Disable PAR (this keeps test setup simple, and we don't need to integration test PAR here - it is covered by IdentityServer itself)
options.Endpoints.EnablePushedAuthorizationEndpoint = false;
// Disable PAR by default (this keeps test setup simple). Tests that need PAR set EnablePar = true.
options.Endpoints.EnablePushedAuthorizationEndpoint = EnablePar;
})
.AddInMemoryClients(Clients)
.AddInMemoryIdentityResources(IdentityResources)
Expand Down Expand Up @@ -90,6 +93,14 @@ private void Configure(IApplicationBuilder app)
kvp => kvp.Value.ToString());
CapturedRevocationRequests.Add(capturedData);
}
else if (ctx.Request.Path == "/connect/par" && ctx.Request.Method == "POST")
{
var form = await ctx.Request.ReadFormAsync();
var capturedData = form.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value.ToString());
CapturedParRequests.Add(capturedData);
}

await next();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,49 @@ protected IntegrationTestBase(
AccessTokenLifetime = 10
});

IdentityServerHost.Clients.Add(new Client
{
ClientId = "par-assertion",
ClientSecrets =
{
new Secret
{
Type = IdentityServerConstants.SecretTypes.JsonWebKey,
Value = BuildPublicJwk(ClientAssertionPrivateJwk)
}
},
AllowedGrantTypes = GrantTypes.CodeAndClientCredentials,
RedirectUris = { "https://app/signin-oidc" },
PostLogoutRedirectUris = { "https://app/signout-callback-oidc" },
AllowOfflineAccess = true,
AllowedScopes = { "openid", "profile", "scope1", "scope2" },
RequirePushedAuthorization = true,
AccessTokenLifetime = 10
});

IdentityServerHost.Clients.Add(new Client
{
ClientId = "par-dpop-assertion",
ClientSecrets =
{
new Secret
{
Type = IdentityServerConstants.SecretTypes.JsonWebKey,
Value = BuildPublicJwk(ClientAssertionPrivateJwk)
}
},
AllowedGrantTypes = GrantTypes.CodeAndClientCredentials,
RedirectUris = { "https://app/signin-oidc" },
PostLogoutRedirectUris = { "https://app/signout-callback-oidc" },
AllowOfflineAccess = true,
AllowedScopes = { "openid", "profile", "scope1", "scope2" },
RequirePushedAuthorization = true,
RequireDPoP = true,
DPoPValidationMode = DPoPTokenExpirationValidationMode.Nonce,
DPoPClockSkew = TimeSpan.FromMilliseconds(10),
AccessTokenLifetime = 10
});

ApiHost = new ApiHost(output.WriteLine, IdentityServerHost, ["scope1", "scope2"]);
AppHost = new AppHost(output.WriteLine, IdentityServerHost, ApiHost, clientId, configureUserTokenManagementOptions: configureUserTokenManagementOptions);
}
Expand Down
Loading
Loading