Skip to content
This repository was archived by the owner on Nov 19, 2024. It is now read-only.

Commit db3ac68

Browse files
authored
Merge pull request #79 from DuendeSoftware/joe/dpop-nonce-from-auth-server
Fix handling of dpop nonce sent during token exchange
2 parents d8187f9 + ad95329 commit db3ac68

13 files changed

+342
-119
lines changed

samples/Web/Program.cs

+2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

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

17+
Console.Title = "Web (Sample)";
18+
1719
try
1820
{
1921
var builder = WebApplication.CreateBuilder(args);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
3+
4+
using Microsoft.AspNetCore.Authentication;
5+
using Microsoft.AspNetCore.Http;
6+
using Microsoft.Extensions.Logging;
7+
using System.Net;
8+
using System.Net.Http;
9+
using System.Threading;
10+
using System.Threading.Tasks;
11+
12+
namespace Duende.AccessTokenManagement.OpenIdConnect;
13+
14+
/// <summary>
15+
/// Delegating handler that adds behavior needed for DPoP to the backchannel
16+
/// http client of the OIDC authentication handler.
17+
///
18+
/// This handler has two main jobs:
19+
///
20+
/// 1. Store new nonces from successful responses from the authorization server.
21+
///
22+
/// 2. Attach proof tokens to token requests in the code flow.
23+
///
24+
/// On the authorize request, we will have sent a dpop_jkt parameter with a
25+
/// key thumbprint. The AS expects that we will use the corresponding key to
26+
/// create our proof, and we track that key in the http context. This handler
27+
/// retrieves that key and uses it to create proof tokens for use in the code
28+
/// flow.
29+
///
30+
/// Additionally, the token endpoint might respond to a token exchange
31+
/// request with a request to retry with a nonce that it supplies via http
32+
/// header. When it does, this handler retries those code exchange requests.
33+
///
34+
/// </summary>
35+
internal class AuthorizationServerDPoPHandler : DelegatingHandler
36+
{
37+
private readonly IDPoPProofService _dPoPProofService;
38+
private readonly IDPoPNonceStore _dPoPNonceStore;
39+
private readonly IHttpContextAccessor _httpContextAccessor;
40+
private readonly ILogger<AuthorizationServerDPoPHandler> _logger;
41+
42+
internal AuthorizationServerDPoPHandler(
43+
IDPoPProofService dPoPProofService,
44+
IDPoPNonceStore dPoPNonceStore,
45+
IHttpContextAccessor httpContextAccessor,
46+
ILoggerFactory loggerFactory)
47+
{
48+
_dPoPProofService = dPoPProofService;
49+
_dPoPNonceStore = dPoPNonceStore;
50+
_httpContextAccessor = httpContextAccessor;
51+
// We depend on the logger factory, rather than the logger itself, since
52+
// the type parameter of the logger (referencing this class) will not
53+
// always be accessible.
54+
_logger = loggerFactory.CreateLogger<AuthorizationServerDPoPHandler>();
55+
}
56+
57+
/// <inheritdoc/>
58+
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
59+
{
60+
var codeExchangeJwk = _httpContextAccessor.HttpContext?.GetCodeExchangeDPoPKey();
61+
if (codeExchangeJwk != null)
62+
{
63+
await SetDPoPProofTokenForCodeExchangeAsync(request, jwk: codeExchangeJwk).ConfigureAwait(false);
64+
}
65+
66+
var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
67+
68+
// The authorization server might send us a new nonce on either a success or failure
69+
var dPoPNonce = response.GetDPoPNonce();
70+
71+
if (dPoPNonce != null)
72+
{
73+
// This handler contains specialized logic to create the new proof
74+
// token using the proof key that was associated with a code flow
75+
// using a dpop_jkt parameter on the authorize call. Other flows
76+
// (such as refresh), are separately responsible for retrying with a
77+
// server-issued nonce. So, we ONLY do the retry logic when we have
78+
// the dpop_jkt's jwk
79+
if (codeExchangeJwk != null)
80+
{
81+
// If the http response code indicates a bad request, we can infer
82+
// that we should retry with the new nonce.
83+
//
84+
// The server should have also set the error: use_dpop_nonce, but
85+
// there's no need to incur the cost of parsing the json and
86+
// checking for that, as we would only receive the nonce http header
87+
// when that error was set. Authorization servers might preemptively
88+
// send a new nonce, but the spec specifically says to do that on a
89+
// success (and we handle that case in the else block).
90+
//
91+
// TL;DR - presence of nonce and 400 response code is enough to
92+
// trigger a retry during code exchange
93+
if (response.StatusCode == HttpStatusCode.BadRequest)
94+
{
95+
_logger.LogDebug("Token request failed with DPoP nonce error. Retrying with new nonce.");
96+
response.Dispose();
97+
await SetDPoPProofTokenForCodeExchangeAsync(request, dPoPNonce, codeExchangeJwk).ConfigureAwait(false);
98+
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
99+
}
100+
}
101+
102+
if (response.StatusCode == HttpStatusCode.OK)
103+
{
104+
_logger.LogDebug("The authorization server has supplied a new nonce on a successful response, which will be stored and used in future requests to the authorization server");
105+
106+
await _dPoPNonceStore.StoreNonceAsync(new DPoPNonceContext
107+
{
108+
Url = request.GetDPoPUrl(),
109+
Method = request.Method.ToString(),
110+
}, dPoPNonce);
111+
}
112+
}
113+
114+
return response;
115+
}
116+
117+
/// <summary>
118+
/// Creates a DPoP proof token and attaches it to a request.
119+
/// </summary>
120+
internal async Task SetDPoPProofTokenForCodeExchangeAsync(HttpRequestMessage request, string? dpopNonce = null, string? jwk = null)
121+
{
122+
if (!string.IsNullOrEmpty(jwk))
123+
{
124+
// remove any old headers
125+
request.ClearDPoPProofToken();
126+
127+
// create proof
128+
var proofToken = await _dPoPProofService.CreateProofTokenAsync(new DPoPProofRequest
129+
{
130+
Url = request.GetDPoPUrl(),
131+
Method = request.Method.ToString(),
132+
DPoPJsonWebKey = jwk,
133+
DPoPNonce = dpopNonce,
134+
});
135+
136+
if (proofToken != null)
137+
{
138+
_logger.LogDebug("Sending DPoP proof token in request to endpoint: {url}",
139+
request.RequestUri?.GetLeftPart(System.UriPartial.Path));
140+
request.SetDPoPProofToken(proofToken.ProofToken);
141+
}
142+
else
143+
{
144+
_logger.LogDebug("No DPoP proof token in request to endpoint: {url}",
145+
request.RequestUri?.GetLeftPart(System.UriPartial.Path));
146+
}
147+
}
148+
}
149+
}

src/Duende.AccessTokenManagement.OpenIdConnect/ConfigureOpenIdConnectClientCredentialsOptions.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
namespace Duende.AccessTokenManagement.OpenIdConnect;
88

99
/// <summary>
10-
/// Named options to synthetize client credentials based on OIDC handler configuration
10+
/// Named options to synthesize client credentials based on OIDC handler configuration
1111
/// </summary>
1212
public class ConfigureOpenIdConnectClientCredentialsOptions : IConfigureNamedOptions<ClientCredentialsClient>
1313
{

src/Duende.AccessTokenManagement.OpenIdConnect/ConfigureOpenIdConnectOptions.cs

+14-5
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
77
using Microsoft.AspNetCore.Http;
88
using Microsoft.Extensions.DependencyInjection;
9+
using Microsoft.Extensions.Logging;
910
using Microsoft.Extensions.Options;
1011
using System;
1112
using System.Net.Http;
@@ -22,6 +23,9 @@ public class ConfigureOpenIdConnectOptions : IConfigureNamedOptions<OpenIdConnec
2223
private readonly IDPoPProofService _dPoPProofService;
2324
private readonly IHttpContextAccessor _httpContextAccessor;
2425
private readonly IOptions<UserTokenManagementOptions> _userAccessTokenManagementOptions;
26+
27+
private readonly ILoggerFactory _loggerFactory;
28+
2529
private readonly string? _configScheme;
2630
private readonly string _clientName;
2731

@@ -33,13 +37,14 @@ public ConfigureOpenIdConnectOptions(
3337
IDPoPProofService dPoPProofService,
3438
IHttpContextAccessor httpContextAccessor,
3539
IOptions<UserTokenManagementOptions> userAccessTokenManagementOptions,
36-
IAuthenticationSchemeProvider schemeProvider)
40+
IAuthenticationSchemeProvider schemeProvider,
41+
ILoggerFactory loggerFactory)
3742
{
3843
_dPoPNonceStore = dPoPNonceStore;
3944
_dPoPProofService = dPoPProofService;
4045
_httpContextAccessor = httpContextAccessor;
4146
_userAccessTokenManagementOptions = userAccessTokenManagementOptions;
42-
47+
4348
_configScheme = _userAccessTokenManagementOptions.Value.ChallengeScheme;
4449
if (string.IsNullOrWhiteSpace(_configScheme))
4550
{
@@ -55,6 +60,7 @@ public ConfigureOpenIdConnectOptions(
5560
}
5661

5762
_clientName = OpenIdConnectTokenManagementDefaults.ClientCredentialsClientNamePrefix + _configScheme;
63+
_loggerFactory = loggerFactory;
5864
}
5965

6066
/// <inheritdoc/>
@@ -72,7 +78,7 @@ public void Configure(string? name, OpenIdConnectOptions options)
7278
options.Events.OnAuthorizationCodeReceived = CreateCallback(options.Events.OnAuthorizationCodeReceived);
7379
options.Events.OnTokenValidated = CreateCallback(options.Events.OnTokenValidated);
7480

75-
options.BackchannelHttpHandler = new DPoPProofTokenHandler(_dPoPProofService, _dPoPNonceStore, _httpContextAccessor)
81+
options.BackchannelHttpHandler = new AuthorizationServerDPoPHandler(_dPoPProofService, _dPoPNonceStore, _httpContextAccessor, _loggerFactory)
7682
{
7783
InnerHandler = options.BackchannelHttpHandler ?? new HttpClientHandler()
7884
};
@@ -103,7 +109,10 @@ async Task Callback(RedirectContext context)
103109
// checking for null allows for opt-out from using DPoP
104110
if (jkt != null)
105111
{
106-
// we store the proof key here to associate it with the access token returned
112+
// we store the proof key here to associate it with the
113+
// authorization code that will be returned. Ultimately we
114+
// use this to provide proof of possession during code
115+
// exchange.
107116
context.Properties.SetProofKey(key.JsonWebKey);
108117

109118
// pass jkt to authorize endpoint
@@ -126,7 +135,7 @@ Task Callback(AuthorizationCodeReceivedContext context)
126135
if (jwk != null)
127136
{
128137
// set it so the OIDC message handler can find it
129-
context.HttpContext.SetOutboundProofKey(jwk);
138+
context.HttpContext.SetCodeExchangeDPoPKey(jwk);
130139
}
131140

132141
return result;

src/Duende.AccessTokenManagement.OpenIdConnect/DPoPProofTokenHandler.cs

-92
This file was deleted.

src/Duende.AccessTokenManagement.OpenIdConnect/TokenManagementHttpContextExtensions.cs

+3-2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using Duende.AccessTokenManagement;
1010
using Duende.AccessTokenManagement.OpenIdConnect;
1111
using Microsoft.Extensions.Options;
12+
using Microsoft.Extensions.Logging;
1213

1314
namespace Microsoft.AspNetCore.Authentication;
1415

@@ -105,11 +106,11 @@ internal static void RemoveProofKey(this AuthenticationProperties properties)
105106
}
106107

107108
const string HttpContextDPoPKey = "dpop_proof_key";
108-
internal static void SetOutboundProofKey(this HttpContext context, string key)
109+
internal static void SetCodeExchangeDPoPKey(this HttpContext context, string key)
109110
{
110111
context.Items[HttpContextDPoPKey] = key;
111112
}
112-
internal static string? GetOutboundProofKey(this HttpContext context)
113+
internal static string? GetCodeExchangeDPoPKey(this HttpContext context)
113114
{
114115
if (context.Items.ContainsKey(HttpContextDPoPKey))
115116
{

src/Duende.AccessTokenManagement.OpenIdConnect/UserTokenEndpointService.cs

+2
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ public async Task<UserToken> RefreshAccessTokenAsync(
115115
dPoPJsonWebKey != null &&
116116
response.DPoPNonce != null)
117117
{
118+
_logger.LogDebug("DPoP error during token refresh. Retrying with server nonce");
119+
118120
var proof = await _dPoPProofService.CreateProofTokenAsync(new DPoPProofRequest
119121
{
120122
Url = request.Address!,

src/Duende.AccessTokenManagement/DPoPExtensions.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public static void SetDPoPProofToken(this HttpRequestMessage request, string? pr
4343
}
4444

4545
/// <summary>
46-
/// Reads the WWW-Authenticate response header to determine if the respone is in error due to DPoP
46+
/// Reads the WWW-Authenticate response header to determine if the response is in error due to DPoP
4747
/// </summary>
4848
public static bool IsDPoPError(this HttpResponseMessage response)
4949
{

0 commit comments

Comments
 (0)