diff --git a/samples/Web/Program.cs b/samples/Web/Program.cs
index ca4b55a..2f53d26 100644
--- a/samples/Web/Program.cs
+++ b/samples/Web/Program.cs
@@ -14,6 +14,8 @@
Log.Information("Host.Main Starting up");
+Console.Title = "Web (Sample)";
+
try
{
var builder = WebApplication.CreateBuilder(args);
diff --git a/src/Duende.AccessTokenManagement.OpenIdConnect/AuthorizationServerDPoPHandler.cs b/src/Duende.AccessTokenManagement.OpenIdConnect/AuthorizationServerDPoPHandler.cs
new file mode 100644
index 0000000..cbb1781
--- /dev/null
+++ b/src/Duende.AccessTokenManagement.OpenIdConnect/AuthorizationServerDPoPHandler.cs
@@ -0,0 +1,149 @@
+// Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
+
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Duende.AccessTokenManagement.OpenIdConnect;
+
+///
+/// Delegating handler that adds behavior needed for DPoP to the backchannel
+/// http client of the OIDC authentication handler.
+///
+/// This handler has two main jobs:
+///
+/// 1. Store new nonces from successful responses from the authorization server.
+///
+/// 2. Attach proof tokens to token requests in the code flow.
+///
+/// On the authorize request, we will have sent a dpop_jkt parameter with a
+/// key thumbprint. The AS expects that we will use the corresponding key to
+/// create our proof, and we track that key in the http context. This handler
+/// retrieves that key and uses it to create proof tokens for use in the code
+/// flow.
+///
+/// Additionally, the token endpoint might respond to a token exchange
+/// request with a request to retry with a nonce that it supplies via http
+/// header. When it does, this handler retries those code exchange requests.
+///
+///
+internal class AuthorizationServerDPoPHandler : DelegatingHandler
+{
+ private readonly IDPoPProofService _dPoPProofService;
+ private readonly IDPoPNonceStore _dPoPNonceStore;
+ private readonly IHttpContextAccessor _httpContextAccessor;
+ private readonly ILogger _logger;
+
+ internal AuthorizationServerDPoPHandler(
+ IDPoPProofService dPoPProofService,
+ IDPoPNonceStore dPoPNonceStore,
+ IHttpContextAccessor httpContextAccessor,
+ ILoggerFactory loggerFactory)
+ {
+ _dPoPProofService = dPoPProofService;
+ _dPoPNonceStore = dPoPNonceStore;
+ _httpContextAccessor = httpContextAccessor;
+ // We depend on the logger factory, rather than the logger itself, since
+ // the type parameter of the logger (referencing this class) will not
+ // always be accessible.
+ _logger = loggerFactory.CreateLogger();
+ }
+
+ ///
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ var codeExchangeJwk = _httpContextAccessor.HttpContext?.GetCodeExchangeDPoPKey();
+ if (codeExchangeJwk != null)
+ {
+ await SetDPoPProofTokenForCodeExchangeAsync(request, jwk: codeExchangeJwk).ConfigureAwait(false);
+ }
+
+ var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
+
+ // The authorization server might send us a new nonce on either a success or failure
+ var dPoPNonce = response.GetDPoPNonce();
+
+ if (dPoPNonce != null)
+ {
+ // This handler contains specialized logic to create the new proof
+ // token using the proof key that was associated with a code flow
+ // using a dpop_jkt parameter on the authorize call. Other flows
+ // (such as refresh), are separately responsible for retrying with a
+ // server-issued nonce. So, we ONLY do the retry logic when we have
+ // the dpop_jkt's jwk
+ if (codeExchangeJwk != null)
+ {
+ // If the http response code indicates a bad request, we can infer
+ // that we should retry with the new nonce.
+ //
+ // The server should have also set the error: use_dpop_nonce, but
+ // there's no need to incur the cost of parsing the json and
+ // checking for that, as we would only receive the nonce http header
+ // when that error was set. Authorization servers might preemptively
+ // send a new nonce, but the spec specifically says to do that on a
+ // success (and we handle that case in the else block).
+ //
+ // TL;DR - presence of nonce and 400 response code is enough to
+ // trigger a retry during code exchange
+ if (response.StatusCode == HttpStatusCode.BadRequest)
+ {
+ _logger.LogDebug("Token request failed with DPoP nonce error. Retrying with new nonce.");
+ response.Dispose();
+ await SetDPoPProofTokenForCodeExchangeAsync(request, dPoPNonce, codeExchangeJwk).ConfigureAwait(false);
+ return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ if (response.StatusCode == HttpStatusCode.OK)
+ {
+ _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");
+
+ await _dPoPNonceStore.StoreNonceAsync(new DPoPNonceContext
+ {
+ Url = request.GetDPoPUrl(),
+ Method = request.Method.ToString(),
+ }, dPoPNonce);
+ }
+ }
+
+ return response;
+ }
+
+ ///
+ /// Creates a DPoP proof token and attaches it to a request.
+ ///
+ internal async Task SetDPoPProofTokenForCodeExchangeAsync(HttpRequestMessage request, string? dpopNonce = null, string? jwk = null)
+ {
+ if (!string.IsNullOrEmpty(jwk))
+ {
+ // remove any old headers
+ request.ClearDPoPProofToken();
+
+ // create proof
+ var proofToken = await _dPoPProofService.CreateProofTokenAsync(new DPoPProofRequest
+ {
+ Url = request.GetDPoPUrl(),
+ Method = request.Method.ToString(),
+ DPoPJsonWebKey = jwk,
+ DPoPNonce = dpopNonce,
+ });
+
+ if (proofToken != null)
+ {
+ _logger.LogDebug("Sending DPoP proof token in request to endpoint: {url}",
+ request.RequestUri?.GetLeftPart(System.UriPartial.Path));
+ request.SetDPoPProofToken(proofToken.ProofToken);
+ }
+ else
+ {
+ _logger.LogDebug("No DPoP proof token in request to endpoint: {url}",
+ request.RequestUri?.GetLeftPart(System.UriPartial.Path));
+ }
+ }
+ }
+}
diff --git a/src/Duende.AccessTokenManagement.OpenIdConnect/ConfigureOpenIdConnectClientCredentialsOptions.cs b/src/Duende.AccessTokenManagement.OpenIdConnect/ConfigureOpenIdConnectClientCredentialsOptions.cs
index e724e7a..019d94f 100644
--- a/src/Duende.AccessTokenManagement.OpenIdConnect/ConfigureOpenIdConnectClientCredentialsOptions.cs
+++ b/src/Duende.AccessTokenManagement.OpenIdConnect/ConfigureOpenIdConnectClientCredentialsOptions.cs
@@ -7,7 +7,7 @@
namespace Duende.AccessTokenManagement.OpenIdConnect;
///
-/// Named options to synthetize client credentials based on OIDC handler configuration
+/// Named options to synthesize client credentials based on OIDC handler configuration
///
public class ConfigureOpenIdConnectClientCredentialsOptions : IConfigureNamedOptions
{
diff --git a/src/Duende.AccessTokenManagement.OpenIdConnect/ConfigureOpenIdConnectOptions.cs b/src/Duende.AccessTokenManagement.OpenIdConnect/ConfigureOpenIdConnectOptions.cs
index a4419f6..9c61eec 100644
--- a/src/Duende.AccessTokenManagement.OpenIdConnect/ConfigureOpenIdConnectOptions.cs
+++ b/src/Duende.AccessTokenManagement.OpenIdConnect/ConfigureOpenIdConnectOptions.cs
@@ -6,6 +6,7 @@
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System;
using System.Net.Http;
@@ -22,6 +23,9 @@ public class ConfigureOpenIdConnectOptions : IConfigureNamedOptions _userAccessTokenManagementOptions;
+
+ private readonly ILoggerFactory _loggerFactory;
+
private readonly string? _configScheme;
private readonly string _clientName;
@@ -33,13 +37,14 @@ public ConfigureOpenIdConnectOptions(
IDPoPProofService dPoPProofService,
IHttpContextAccessor httpContextAccessor,
IOptions userAccessTokenManagementOptions,
- IAuthenticationSchemeProvider schemeProvider)
+ IAuthenticationSchemeProvider schemeProvider,
+ ILoggerFactory loggerFactory)
{
_dPoPNonceStore = dPoPNonceStore;
_dPoPProofService = dPoPProofService;
_httpContextAccessor = httpContextAccessor;
_userAccessTokenManagementOptions = userAccessTokenManagementOptions;
-
+
_configScheme = _userAccessTokenManagementOptions.Value.ChallengeScheme;
if (string.IsNullOrWhiteSpace(_configScheme))
{
@@ -55,6 +60,7 @@ public ConfigureOpenIdConnectOptions(
}
_clientName = OpenIdConnectTokenManagementDefaults.ClientCredentialsClientNamePrefix + _configScheme;
+ _loggerFactory = loggerFactory;
}
///
@@ -72,7 +78,7 @@ public void Configure(string? name, OpenIdConnectOptions options)
options.Events.OnAuthorizationCodeReceived = CreateCallback(options.Events.OnAuthorizationCodeReceived);
options.Events.OnTokenValidated = CreateCallback(options.Events.OnTokenValidated);
- options.BackchannelHttpHandler = new DPoPProofTokenHandler(_dPoPProofService, _dPoPNonceStore, _httpContextAccessor)
+ options.BackchannelHttpHandler = new AuthorizationServerDPoPHandler(_dPoPProofService, _dPoPNonceStore, _httpContextAccessor, _loggerFactory)
{
InnerHandler = options.BackchannelHttpHandler ?? new HttpClientHandler()
};
@@ -103,7 +109,10 @@ async Task Callback(RedirectContext context)
// checking for null allows for opt-out from using DPoP
if (jkt != null)
{
- // we store the proof key here to associate it with the access token returned
+ // we store the proof key here to associate it with the
+ // authorization code that will be returned. Ultimately we
+ // use this to provide proof of possession during code
+ // exchange.
context.Properties.SetProofKey(key.JsonWebKey);
// pass jkt to authorize endpoint
@@ -126,7 +135,7 @@ Task Callback(AuthorizationCodeReceivedContext context)
if (jwk != null)
{
// set it so the OIDC message handler can find it
- context.HttpContext.SetOutboundProofKey(jwk);
+ context.HttpContext.SetCodeExchangeDPoPKey(jwk);
}
return result;
diff --git a/src/Duende.AccessTokenManagement.OpenIdConnect/DPoPProofTokenHandler.cs b/src/Duende.AccessTokenManagement.OpenIdConnect/DPoPProofTokenHandler.cs
deleted file mode 100644
index ddcaa78..0000000
--- a/src/Duende.AccessTokenManagement.OpenIdConnect/DPoPProofTokenHandler.cs
+++ /dev/null
@@ -1,92 +0,0 @@
-// Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
-// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
-
-using Microsoft.AspNetCore.Authentication;
-using Microsoft.AspNetCore.Http;
-using System.Net.Http;
-using System.Threading;
-using System.Threading.Tasks;
-
-namespace Duende.AccessTokenManagement.OpenIdConnect;
-
-///
-/// Delegating handler that injects the DPoP proof token from the OIDC handler workflow
-///
-class DPoPProofTokenHandler : DelegatingHandler
-{
- private readonly IDPoPProofService _dPoPProofService;
- private readonly IDPoPNonceStore _dPoPNonceStore;
- private readonly IHttpContextAccessor _httpContextAccessor;
-
- ///
- /// ctor
- ///
- ///
- ///
- ///
- public DPoPProofTokenHandler(
- IDPoPProofService dPoPProofService,
- IDPoPNonceStore dPoPNonceStore,
- IHttpContextAccessor httpContextAccessor)
- {
- _dPoPProofService = dPoPProofService;
- _dPoPNonceStore = dPoPNonceStore;
- _httpContextAccessor = httpContextAccessor;
- }
-
- ///
- protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
- {
- await SetDPoPProofTokenAsync(request, cancellationToken).ConfigureAwait(false);
- var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
-
- var dPoPNonce = response.GetDPoPNonce();
-
- // retry if 401
- if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized && response.IsDPoPError())
- {
- response.Dispose();
-
- await SetDPoPProofTokenAsync(request, cancellationToken, dPoPNonce).ConfigureAwait(false);
- return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
- }
- else if (dPoPNonce != null)
- {
- await _dPoPNonceStore.StoreNonceAsync(new DPoPNonceContext
- {
- Url = request.GetDPoPUrl(),
- Method = request.Method.ToString(),
- }, dPoPNonce);
- }
-
- return response;
- }
-
- ///
- /// Creates a DPoP proof token and attaches it to the request.
- ///
- protected virtual async Task SetDPoPProofTokenAsync(HttpRequestMessage request, CancellationToken cancellationToken, string? dpopNonce = null)
- {
- var jwk = _httpContextAccessor.HttpContext?.GetOutboundProofKey();
-
- if (!string.IsNullOrEmpty(jwk))
- {
- // remove any old headers
- request.ClearDPoPProofToken();
-
- // create proof
- var proofToken = await _dPoPProofService.CreateProofTokenAsync(new DPoPProofRequest
- {
- Url = request.GetDPoPUrl(),
- Method = request.Method.ToString(),
- DPoPJsonWebKey = jwk,
- DPoPNonce = dpopNonce,
- });
-
- if (proofToken != null)
- {
- request.SetDPoPProofToken(proofToken.ProofToken);
- }
- }
- }
-}
diff --git a/src/Duende.AccessTokenManagement.OpenIdConnect/TokenManagementHttpContextExtensions.cs b/src/Duende.AccessTokenManagement.OpenIdConnect/TokenManagementHttpContextExtensions.cs
index c10da10..2284563 100755
--- a/src/Duende.AccessTokenManagement.OpenIdConnect/TokenManagementHttpContextExtensions.cs
+++ b/src/Duende.AccessTokenManagement.OpenIdConnect/TokenManagementHttpContextExtensions.cs
@@ -9,6 +9,7 @@
using Duende.AccessTokenManagement;
using Duende.AccessTokenManagement.OpenIdConnect;
using Microsoft.Extensions.Options;
+using Microsoft.Extensions.Logging;
namespace Microsoft.AspNetCore.Authentication;
@@ -105,11 +106,11 @@ internal static void RemoveProofKey(this AuthenticationProperties properties)
}
const string HttpContextDPoPKey = "dpop_proof_key";
- internal static void SetOutboundProofKey(this HttpContext context, string key)
+ internal static void SetCodeExchangeDPoPKey(this HttpContext context, string key)
{
context.Items[HttpContextDPoPKey] = key;
}
- internal static string? GetOutboundProofKey(this HttpContext context)
+ internal static string? GetCodeExchangeDPoPKey(this HttpContext context)
{
if (context.Items.ContainsKey(HttpContextDPoPKey))
{
diff --git a/src/Duende.AccessTokenManagement.OpenIdConnect/UserTokenEndpointService.cs b/src/Duende.AccessTokenManagement.OpenIdConnect/UserTokenEndpointService.cs
index f0173b4..0681da6 100755
--- a/src/Duende.AccessTokenManagement.OpenIdConnect/UserTokenEndpointService.cs
+++ b/src/Duende.AccessTokenManagement.OpenIdConnect/UserTokenEndpointService.cs
@@ -115,6 +115,8 @@ public async Task RefreshAccessTokenAsync(
dPoPJsonWebKey != null &&
response.DPoPNonce != null)
{
+ _logger.LogDebug("DPoP error during token refresh. Retrying with server nonce");
+
var proof = await _dPoPProofService.CreateProofTokenAsync(new DPoPProofRequest
{
Url = request.Address!,
diff --git a/src/Duende.AccessTokenManagement/DPoPExtensions.cs b/src/Duende.AccessTokenManagement/DPoPExtensions.cs
index 6090b2a..c433060 100644
--- a/src/Duende.AccessTokenManagement/DPoPExtensions.cs
+++ b/src/Duende.AccessTokenManagement/DPoPExtensions.cs
@@ -43,7 +43,7 @@ public static void SetDPoPProofToken(this HttpRequestMessage request, string? pr
}
///
- /// Reads the WWW-Authenticate response header to determine if the respone is in error due to DPoP
+ /// Reads the WWW-Authenticate response header to determine if the response is in error due to DPoP
///
public static bool IsDPoPError(this HttpResponseMessage response)
{
diff --git a/test/Tests/Framework/AppHost.cs b/test/Tests/Framework/AppHost.cs
index 5300c11..2923294 100644
--- a/test/Tests/Framework/AppHost.cs
+++ b/test/Tests/Framework/AppHost.cs
@@ -1,14 +1,15 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
+using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using System.Net;
-using Microsoft.AspNetCore.Authentication;
-using Microsoft.AspNetCore.Http;
-using RichardSzalay.MockHttp;
+using System.Web;
+using IdentityModel;
using Duende.AccessTokenManagement.OpenIdConnect;
-using Microsoft.AspNetCore.Mvc;
+using RichardSzalay.MockHttp;
namespace Duende.AccessTokenManagement.Tests;
@@ -17,18 +18,20 @@ public class AppHost : GenericHost
private readonly IdentityServerHost _identityServerHost;
private readonly ApiHost _apiHost;
private readonly string _clientId;
-
+ private readonly Action? _configureUserTokenManagementOptions;
+
public AppHost(
IdentityServerHost identityServerHost,
ApiHost apiHost,
string clientId,
- string baseAddress = "https://app")
+ string baseAddress = "https://app",
+ Action? configureUserTokenManagementOptions = default)
: base(baseAddress)
{
_identityServerHost = identityServerHost;
_apiHost = apiHost;
_clientId = clientId;
-
+ _configureUserTokenManagementOptions = configureUserTokenManagementOptions;
OnConfigureServices += ConfigureServices;
OnConfigure += Configure;
}
@@ -97,6 +100,11 @@ private void ConfigureServices(IServiceCollection services)
services.AddOpenIdConnectAccessTokenManagement(opt =>
{
opt.UseChallengeSchemeScopedTokens = true;
+
+ if (_configureUserTokenManagementOptions != null)
+ {
+ _configureUserTokenManagementOptions(opt);
+ }
});
}
@@ -145,18 +153,24 @@ await context.ChallengeAsync(new AuthenticationProperties
});
}
- public async Task LoginAsync(string sub, string? sid = null)
+ public async Task LoginAsync(string sub, string? sid = null, bool verifyDpopThumbprintSent = false)
{
await _identityServerHost.CreateIdentityServerSessionCookieAsync(sub, sid);
- return await OidcLoginAsync();
+ return await OidcLoginAsync(verifyDpopThumbprintSent);
}
- public async Task OidcLoginAsync()
+ public async Task OidcLoginAsync(bool verifyDpopThumbprintSent)
{
var response = await BrowserClient.GetAsync(Url("/login"));
response.StatusCode.ShouldBe((HttpStatusCode)302); // authorize
response.Headers.Location!.ToString().ToLowerInvariant().ShouldStartWith(_identityServerHost.Url("/connect/authorize"));
+ if (verifyDpopThumbprintSent)
+ {
+ var queryParams = HttpUtility.ParseQueryString(response.Headers.Location.Query);
+ queryParams.AllKeys.ShouldContain(OidcConstants.AuthorizeRequest.DPoPKeyThumbprint);
+ }
+
response = await _identityServerHost.BrowserClient.GetAsync(response.Headers.Location.ToString());
response.StatusCode.ShouldBe((HttpStatusCode)302); // client callback
response.Headers.Location!.ToString().ToLowerInvariant().ShouldStartWith(Url("/signin-oidc"));
diff --git a/test/Tests/Framework/IdentityServerHost.cs b/test/Tests/Framework/IdentityServerHost.cs
index c547708..f03fbd5 100644
--- a/test/Tests/Framework/IdentityServerHost.cs
+++ b/test/Tests/Framework/IdentityServerHost.cs
@@ -49,6 +49,10 @@ private void ConfigureServices(IServiceCollection services)
services.AddIdentityServer(options=>
{
options.EmitStaticAudienceClaim = true;
+
+ // Artificially low durations to force retries
+ options.DPoP.ServerClockSkew = TimeSpan.Zero;
+ options.DPoP.ProofTokenValidityDuration = TimeSpan.FromSeconds(1);
})
.AddInMemoryClients(Clients)
.AddInMemoryIdentityResources(IdentityResources)
diff --git a/test/Tests/Framework/IntegrationTestBase.cs b/test/Tests/Framework/IntegrationTestBase.cs
index 9549c7c..8ded95f 100644
--- a/test/Tests/Framework/IntegrationTestBase.cs
+++ b/test/Tests/Framework/IntegrationTestBase.cs
@@ -1,10 +1,9 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
+using Duende.AccessTokenManagement.OpenIdConnect;
using Duende.IdentityServer.Models;
-using Duende.IdentityServer.Services;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
+using IdentityModel;
using System.Security.Claims;
namespace Duende.AccessTokenManagement.Tests;
@@ -15,7 +14,7 @@ public class IntegrationTestBase
protected ApiHost ApiHost;
protected AppHost AppHost;
- public IntegrationTestBase(string clientId = "web")
+ public IntegrationTestBase(string clientId = "web", Action? configureUserTokenManagementOptions = null)
{
IdentityServerHost = new IdentityServerHost();
@@ -50,13 +49,30 @@ public IntegrationTestBase(string clientId = "web")
AccessTokenLifetime = 10
});
-
+
+ IdentityServerHost.Clients.Add(new Client
+ {
+ ClientId = "dpop",
+ ClientSecrets = { new Secret("secret".ToSha256()) },
+ AllowedGrantTypes = GrantTypes.CodeAndClientCredentials,
+ RedirectUris = { "https://app/signin-oidc" },
+ PostLogoutRedirectUris = { "https://app/signout-callback-oidc" },
+ AllowOfflineAccess = true,
+ AllowedScopes = { "openid", "profile", "scope1" },
+
+ RequireDPoP = true,
+ DPoPValidationMode = DPoPTokenExpirationValidationMode.Nonce,
+ DPoPClockSkew = TimeSpan.FromMilliseconds(10),
+
+ AccessTokenLifetime = 10
+ });
+
IdentityServerHost.InitializeAsync().Wait();
ApiHost = new ApiHost(IdentityServerHost, "scope1");
ApiHost.InitializeAsync().Wait();
- AppHost = new AppHost(IdentityServerHost, ApiHost, clientId);
+ AppHost = new AppHost(IdentityServerHost, ApiHost, clientId, configureUserTokenManagementOptions: configureUserTokenManagementOptions);
AppHost.InitializeAsync().Wait();
}
diff --git a/test/Tests/UserTokenManagementTests.cs b/test/Tests/UserTokenManagementTests.cs
index 0d797d8..971e237 100644
--- a/test/Tests/UserTokenManagementTests.cs
+++ b/test/Tests/UserTokenManagementTests.cs
@@ -216,7 +216,7 @@ public async Task Short_token_lifetime_should_trigger_refresh()
.WithFormData("refresh_token", "initial_refresh_token")
.Respond("application/json", JsonSerializer.Serialize(refreshTokenResponse));
- // short token lifetime should trigger refresh on 2st use
+ // short token lifetime should trigger refresh on 2nd use
var refreshTokenResponse2 = new
{
access_token = "refreshed2_access_token",
@@ -349,4 +349,4 @@ public async Task Resources_get_distinct_tokens()
token.RefreshToken.ShouldBe("initial_refresh_token");
token.Expiration.ShouldNotBe(DateTimeOffset.MaxValue);
}
-}
\ No newline at end of file
+}
diff --git a/test/Tests/UserTokenManagementWithDPoPTests.cs b/test/Tests/UserTokenManagementWithDPoPTests.cs
new file mode 100644
index 0000000..056f434
--- /dev/null
+++ b/test/Tests/UserTokenManagementWithDPoPTests.cs
@@ -0,0 +1,118 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Duende.AccessTokenManagement.OpenIdConnect;
+using IdentityModel;
+using Microsoft.IdentityModel.Tokens;
+using RichardSzalay.MockHttp;
+
+namespace Duende.AccessTokenManagement.Tests;
+
+public class UserTokenManagementWithDPoPTests : IntegrationTestBase
+{
+ // (An example jwk from RFC7517)
+ const string _privateJWK = "{\"kty\":\"RSA\",\"n\":\"0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw\",\"e\":\"AQAB\",\"d\":\"X4cTteJY_gn4FYPsXB8rdXix5vwsg1FLN5E3EaG6RJoVH-HLLKD9M7dx5oo7GURknchnrRweUkC7hT5fJLM0WbFAKNLWY2vv7B6NqXSzUvxT0_YSfqijwp3RTzlBaCxWp4doFk5N2o8Gy_nHNKroADIkJ46pRUohsXywbReAdYaMwFs9tv8d_cPVY3i07a3t8MN6TNwm0dSawm9v47UiCl3Sk5ZiG7xojPLu4sbg1U2jx4IBTNBznbJSzFHK66jT8bgkuqsk0GjskDJk19Z4qwjwbsnn4j2WBii3RL-Us2lGVkY8fkFzme1z0HbIkfz0Y6mqnOYtqc0X4jfcKoAC8Q\",\"p\":\"83i-7IvMGXoMXCskv73TKr8637FiO7Z27zv8oj6pbWUQyLPQBQxtPVnwD20R-60eTDmD2ujnMt5PoqMrm8RfmNhVWDtjjMmCMjOpSXicFHj7XOuVIYQyqVWlWEh6dN36GVZYk93N8Bc9vY41xy8B9RzzOGVQzXvNEvn7O0nVbfs\",\"q\":\"3dfOR9cuYq-0S-mkFLzgItgMEfFzB2q3hWehMuG0oCuqnb3vobLyumqjVZQO1dIrdwgTnCdpYzBcOfW5r370AFXjiWft_NGEiovonizhKpo9VVS78TzFgxkIdrecRezsZ-1kYd_s1qDbxtkDEgfAITAG9LUnADun4vIcb6yelxk\",\"dp\":\"G4sPXkc6Ya9y8oJW9_ILj4xuppu0lzi_H7VTkS8xj5SdX3coE0oimYwxIi2emTAue0UOa5dpgFGyBJ4c8tQ2VF402XRugKDTP8akYhFo5tAA77Qe_NmtuYZc3C3m3I24G2GvR5sSDxUyAN2zq8Lfn9EUms6rY3Ob8YeiKkTiBj0\",\"dq\":\"s9lAH9fggBsoFR8Oac2R_E2gw282rT2kGOAhvIllETE1efrA6huUUvMfBcMpn8lqeW6vzznYY5SSQF7pMdC_agI3nG8Ibp1BUb0JUiraRNqUfLhcQb_d9GF4Dh7e74WbRsobRonujTYN1xCaP6TO61jvWrX-L18txXw494Q_cgk\",\"qi\":\"GyM_p6JrXySiz1toFgKbWV-JdI3jQ4ypu9rbMWx3rQJBfmt0FoYzgUIZEVFEcOqwemRN81zoDAaa-Bk0KWNGDjJHZDdDmFhW3AN7lI-puxk_mHZGJ11rxyR8O55XLSe3SPmRfKwZI6yU24ZxvQKFYItdldUKGzO6Ia6zTKhAVRU\",\"alg\":\"RS256\",\"kid\":\"2011-04-29\"}";
+
+ public UserTokenManagementWithDPoPTests() : base("dpop", opt =>
+ {
+ opt.DPoPJsonWebKey = _privateJWK;
+ }){}
+
+ [Fact]
+ public async Task dpop_jtk_is_attached_to_authorize_requests()
+ {
+ await AppHost.InitializeAsync();
+ await AppHost.LoginAsync("alice", verifyDpopThumbprintSent: true);
+ }
+
+ [Fact]
+ public async Task dpop_token_refresh_should_succeed()
+ {
+ await AppHost.InitializeAsync();
+ await AppHost.LoginAsync("alice");
+
+ // The DPoP proof token is valid for 1 second, and that validity is checked with the server nonce.
+ // We have to wait 2 seconds to make sure our previous (from the initial login) nonce is no longer
+ // valid. Ideally we would verify that we actually retried, but in this test we aren't mocking
+ // the http client so there isn't an obvious way to do that. However, the next test
+ // (dpop_nonce_is_respected_during_code_exchange) does exactly that.
+ Thread.Sleep(2000);
+
+ // This API call should trigger a refresh, and that refresh request must use a nonce from the server (because the client is configured that way)
+ var response = await AppHost.BrowserClient.GetAsync(AppHost.Url("/user_token"));
+ var token = await response.Content.ReadFromJsonAsync();
+
+ token.ShouldNotBeNull();
+ token.IsError.ShouldBeFalse();
+ token.AccessTokenType.ShouldBe("DPoP");
+ }
+
+ [Fact]
+ public async Task dpop_nonce_is_respected_during_code_exchange()
+ {
+ var mockHttp = new MockHttpMessageHandler(BackendDefinitionBehavior.Always);
+ AppHost.IdentityServerHttpHandler = mockHttp;
+
+ // Initial login request
+ var initialTokenResponse = new
+ {
+ id_token = IdentityServerHost.CreateIdToken("1", "dpop"),
+ access_token = "initial_access_token",
+ expires_in = 10,
+ refresh_token = "initial_refresh_token",
+ };
+ mockHttp.When("/connect/token")
+ .WithFormData("grant_type", "authorization_code")
+ .Respond("application/json", JsonSerializer.Serialize(initialTokenResponse));
+
+ // First refresh token request - no nonce
+ var nonceResponse = new
+ {
+ error = "invalid_dpop_proof",
+ error_description = "Invalid 'nonce' value.",
+ };
+ var nonce = "server-provided-nonce";
+ mockHttp.Expect("/connect/token")
+ .WithFormData("grant_type", "refresh_token")
+ .Respond(HttpStatusCode.BadRequest, headers: new Dictionary
+ {
+ { OidcConstants.HttpHeaders.DPoPNonce, nonce }
+ },
+ "application/json", JsonSerializer.Serialize(nonceResponse));
+
+ // Second refresh request
+ var tokenResponse = new
+ {
+ id_token = IdentityServerHost.CreateIdToken("1", "dpop"),
+ access_token = "access_token",
+ token_type = "DPoP",
+ expires_in = 3600,
+ refresh_token = "refresh_token",
+ };
+ mockHttp.Expect("/connect/token")
+ .WithFormData("grant_type", "refresh_token")
+ .With(request =>
+ {
+ var dpopProof = request.Headers.GetValues("DPoP").SingleOrDefault();
+ var payload = dpopProof?.Split('.')[1];
+ var decodedPayload = Base64UrlEncoder.Decode(payload);
+ return decodedPayload.Contains($"\"nonce\":\"{nonce}\"");
+ })
+ .Respond("application/json", JsonSerializer.Serialize(tokenResponse));
+
+
+ await AppHost.InitializeAsync();
+ await AppHost.LoginAsync("alice");
+
+ // This API call triggers a refresh
+ var response = await AppHost.BrowserClient.GetAsync(AppHost.Url("/user_token"));
+ var token = await response.Content.ReadFromJsonAsync();
+ token.ShouldNotBeNull();
+ token.IsError.ShouldBeFalse();
+ token.AccessTokenType.ShouldBe("DPoP");
+ mockHttp.VerifyNoOutstandingExpectation();
+ }
+}
\ No newline at end of file