|
| 1 | +// Copyright (c) Duende Software. All rights reserved. |
| 2 | +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. |
| 3 | + |
| 4 | +using System.Net.Http.Json; |
| 5 | +using Duende.AccessTokenManagement.DPoP; |
| 6 | +using Duende.AccessTokenManagement.Framework; |
| 7 | +using Duende.IdentityModel; |
| 8 | +using Microsoft.AspNetCore.Authentication.OpenIdConnect; |
| 9 | +using Microsoft.Extensions.DependencyInjection; |
| 10 | +using Microsoft.IdentityModel.Tokens; |
| 11 | +using JsonWebKey = Microsoft.IdentityModel.Tokens.JsonWebKey; |
| 12 | + |
| 13 | +namespace Duende.AccessTokenManagement; |
| 14 | + |
| 15 | +/// <summary> |
| 16 | +/// Tests that client assertions are sent on Pushed Authorization Requests (PAR). |
| 17 | +/// </summary> |
| 18 | +public sealed class PARWithClientAssertionsTests : IntegrationTestBase |
| 19 | +{ |
| 20 | + private readonly CancellationToken _ct = TestContext.Current.CancellationToken; |
| 21 | + |
| 22 | + public PARWithClientAssertionsTests(ITestOutputHelper output) |
| 23 | + : base(output, "par-assertion") |
| 24 | + { |
| 25 | + IdentityServerHost.EnablePar = true; |
| 26 | + AppHost.PushedAuthorizationBehavior = PushedAuthorizationBehavior.Require; |
| 27 | + AppHost.ClientAssertionSigningCredentials = |
| 28 | + new SigningCredentials(new JsonWebKey(ClientAssertionPrivateJwk), "RS256"); |
| 29 | + } |
| 30 | + |
| 31 | + [Fact] |
| 32 | + public async Task LoginWithPARAndClientAssertionsShouldSucceed() |
| 33 | + { |
| 34 | + await InitializeAsync(); |
| 35 | + |
| 36 | + await AppHost.LoginAsync("alice"); |
| 37 | + |
| 38 | + // Verify PAR request contained client assertion |
| 39 | + var parRequest = IdentityServerHost.CapturedParRequests |
| 40 | + .FirstOrDefault(); |
| 41 | + parRequest.ShouldNotBeNull("Expected a PAR request to be captured"); |
| 42 | + parRequest.ShouldContainKeyAndValue(OidcConstants.TokenRequest.ClientAssertionType, |
| 43 | + OidcConstants.ClientAssertionTypes.JwtBearer); |
| 44 | + parRequest.ShouldContainKey(OidcConstants.TokenRequest.ClientAssertion); |
| 45 | + |
| 46 | + // Verify token exchange also used client assertion |
| 47 | + var codeExchangeRequest = IdentityServerHost.CapturedTokenRequests |
| 48 | + .FirstOrDefault(r => r.TryGetValue("grant_type", out var gt) && gt == "authorization_code"); |
| 49 | + codeExchangeRequest.ShouldNotBeNull(); |
| 50 | + codeExchangeRequest.ShouldContainKeyAndValue(OidcConstants.TokenRequest.ClientAssertionType, |
| 51 | + OidcConstants.ClientAssertionTypes.JwtBearer); |
| 52 | + codeExchangeRequest.ShouldContainKey(OidcConstants.TokenRequest.ClientAssertion); |
| 53 | + } |
| 54 | + |
| 55 | + [Fact] |
| 56 | + public async Task RefreshWithPARAndClientAssertionsShouldSucceed() |
| 57 | + { |
| 58 | + await InitializeAsync(); |
| 59 | + await AppHost.LoginAsync("alice"); |
| 60 | + |
| 61 | + // This forces a refresh (token lifetime is 10s, within RefreshBeforeExpiration window) |
| 62 | + var response = await AppHost.BrowserClient.GetAsync(AppHost.Url("/user_token"), _ct); |
| 63 | + var token = await response.Content.ReadFromJsonAsync<UserTokenModel>(_ct); |
| 64 | + token.ShouldNotBeNull(); |
| 65 | + |
| 66 | + // Verify refresh token request used client assertions |
| 67 | + var refreshRequests = IdentityServerHost.CapturedTokenRequests |
| 68 | + .Where(r => r.TryGetValue("grant_type", out var gt) && gt == "refresh_token") |
| 69 | + .ToList(); |
| 70 | + refreshRequests.ShouldNotBeEmpty(); |
| 71 | + foreach (var req in refreshRequests) |
| 72 | + { |
| 73 | + req.ShouldContainKeyAndValue(OidcConstants.TokenRequest.ClientAssertionType, |
| 74 | + OidcConstants.ClientAssertionTypes.JwtBearer); |
| 75 | + req.ShouldContainKey(OidcConstants.TokenRequest.ClientAssertion); |
| 76 | + } |
| 77 | + } |
| 78 | +} |
| 79 | + |
| 80 | +/// <summary> |
| 81 | +/// Tests that both client assertions AND DPoP thumbprint are sent on PAR requests. |
| 82 | +/// </summary> |
| 83 | +public sealed class PARWithDPoPAndClientAssertionsTests : IntegrationTestBase |
| 84 | +{ |
| 85 | + private readonly CancellationToken _ct = TestContext.Current.CancellationToken; |
| 86 | + |
| 87 | + public PARWithDPoPAndClientAssertionsTests(ITestOutputHelper output) |
| 88 | + : base(output, "par-dpop-assertion", |
| 89 | + configureUserTokenManagementOptions: opt => { opt.DPoPJsonWebKey = DPoPProofKey.Parse(DPoPPrivateJwk); }) |
| 90 | + { |
| 91 | + IdentityServerHost.EnablePar = true; |
| 92 | + AppHost.PushedAuthorizationBehavior = PushedAuthorizationBehavior.Require; |
| 93 | + AppHost.ClientAssertionSigningCredentials = |
| 94 | + new SigningCredentials(new JsonWebKey(ClientAssertionPrivateJwk), "RS256"); |
| 95 | + } |
| 96 | + |
| 97 | + [Fact] |
| 98 | + public async Task LoginWithPARDPoPAndClientAssertionsShouldSucceed() |
| 99 | + { |
| 100 | + await InitializeAsync(); |
| 101 | + |
| 102 | + await AppHost.LoginAsync("alice"); |
| 103 | + |
| 104 | + // Verify PAR request contained client assertion AND DPoP thumbprint |
| 105 | + var parRequest = IdentityServerHost.CapturedParRequests |
| 106 | + .FirstOrDefault(); |
| 107 | + parRequest.ShouldNotBeNull("Expected a PAR request to be captured"); |
| 108 | + parRequest.ShouldContainKeyAndValue(OidcConstants.TokenRequest.ClientAssertionType, |
| 109 | + OidcConstants.ClientAssertionTypes.JwtBearer); |
| 110 | + parRequest.ShouldContainKey(OidcConstants.TokenRequest.ClientAssertion); |
| 111 | + parRequest.ShouldContainKey(OidcConstants.AuthorizeRequest.DPoPKeyThumbprint); |
| 112 | + |
| 113 | + // Verify we got a DPoP token back |
| 114 | + var response = await AppHost.BrowserClient.GetAsync(AppHost.Url("/user_token"), _ct); |
| 115 | + var token = await response.Content.ReadFromJsonAsync<UserTokenModel>(_ct); |
| 116 | + token.ShouldNotBeNull(); |
| 117 | + token.AccessTokenType.ShouldBe("DPoP"); |
| 118 | + } |
| 119 | + |
| 120 | + [Fact] |
| 121 | + public async Task RefreshWithPARDPoPAndClientAssertionsShouldSucceed() |
| 122 | + { |
| 123 | + // Use always-null nonce store so the server always issues a nonce challenge, |
| 124 | + // guaranteeing the DPoP nonce retry deterministically (no wall-clock dependency). |
| 125 | + AppHost.OnConfigureServices += services => |
| 126 | + services.AddSingleton<IDPoPNonceStore>(new TestDPoPNonceStore()); |
| 127 | + |
| 128 | + await InitializeAsync(); |
| 129 | + await AppHost.LoginAsync("alice"); |
| 130 | + |
| 131 | + // This forces a refresh (token lifetime is 10s, within RefreshBeforeExpiration window) |
| 132 | + var response = await AppHost.BrowserClient.GetAsync(AppHost.Url("/user_token"), _ct); |
| 133 | + var token = await response.Content.ReadFromJsonAsync<UserTokenModel>(_ct); |
| 134 | + token.ShouldNotBeNull(); |
| 135 | + token.AccessTokenType.ShouldBe("DPoP"); |
| 136 | + |
| 137 | + // Verify refresh token requests used client assertions |
| 138 | + var refreshRequests = IdentityServerHost.CapturedTokenRequests |
| 139 | + .Where(r => r.TryGetValue("grant_type", out var gt) && gt == "refresh_token") |
| 140 | + .ToList(); |
| 141 | + refreshRequests.ShouldNotBeEmpty(); |
| 142 | + foreach (var req in refreshRequests) |
| 143 | + { |
| 144 | + req.ShouldContainKeyAndValue(OidcConstants.TokenRequest.ClientAssertionType, |
| 145 | + OidcConstants.ClientAssertionTypes.JwtBearer); |
| 146 | + req.ShouldContainKey(OidcConstants.TokenRequest.ClientAssertion); |
| 147 | + } |
| 148 | + |
| 149 | + // Nonce retry must have occurred |
| 150 | + refreshRequests.Count.ShouldBe(2, "Expected exactly 2 refresh requests (initial + DPoP nonce retry)"); |
| 151 | + |
| 152 | + var assertions = refreshRequests |
| 153 | + .Select(r => r[OidcConstants.TokenRequest.ClientAssertion]) |
| 154 | + .ToList(); |
| 155 | + assertions.Distinct().Count().ShouldBe(assertions.Count, |
| 156 | + "Client assertions must be unique across DPoP nonce retries during refresh"); |
| 157 | + } |
| 158 | + |
| 159 | + private const string DPoPPrivateJwk = |
| 160 | + """ |
| 161 | + { |
| 162 | + "kty":"RSA", |
| 163 | + "n":"0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", |
| 164 | + "e":"AQAB", |
| 165 | + "d":"X4cTteJY_gn4FYPsXB8rdXix5vwsg1FLN5E3EaG6RJoVH-HLLKD9M7dx5oo7GURknchnrRweUkC7hT5fJLM0WbFAKNLWY2vv7B6NqXSzUvxT0_YSfqijwp3RTzlBaCxWp4doFk5N2o8Gy_nHNKroADIkJ46pRUohsXywbReAdYaMwFs9tv8d_cPVY3i07a3t8MN6TNwm0dSawm9v47UiCl3Sk5ZiG7xojPLu4sbg1U2jx4IBTNBznbJSzFHK66jT8bgkuqsk0GjskDJk19Z4qwjwbsnn4j2WBii3RL-Us2lGVkY8fkFzme1z0HbIkfz0Y6mqnOYtqc0X4jfcKoAC8Q", |
| 166 | + "p":"83i-7IvMGXoMXCskv73TKr8637FiO7Z27zv8oj6pbWUQyLPQBQxtPVnwD20R-60eTDmD2ujnMt5PoqMrm8RfmNhVWDtjjMmCMjOpSXicFHj7XOuVIYQyqVWlWEh6dN36GVZYk93N8Bc9vY41xy8B9RzzOGVQzXvNEvn7O0nVbfs", |
| 167 | + "q":"3dfOR9cuYq-0S-mkFLzgItgMEfFzB2q3hWehMuG0oCuqnb3vobLyumqjVZQO1dIrdwgTnCdpYzBcOfW5r370AFXjiWft_NGEiovonizhKpo9VVS78TzFgxkIdrecRezsZ-1kYd_s1qDbxtkDEgfAITAG9LUnADun4vIcb6yelxk", |
| 168 | + "dp":"G4sPXkc6Ya9y8oJW9_ILj4xuppu0lzi_H7VTkS8xj5SdX3coE0oimYwxIi2emTAue0UOa5dpgFGyBJ4c8tQ2VF402XRugKDTP8akYhFo5tAA77Qe_NmtuYZc3C3m3I24G2GvR5sSDxUyAN2zq8Lfn9EUms6rY3Ob8YeiKkTiBj0", |
| 169 | + "dq":"s9lAH9fggBsoFR8Oac2R_E2gw282rT2kGOAhvIllETE1efrA6huUUvMfBcMpn8lqeW6vzznYY5SSQF7pMdC_agI3nG8Ibp1BUb0JUiraRNqUfLhcQb_d9GF4Dh7e74WbRsobRonujTYN1xCaP6TO61jvWrX-L18txXw494Q_cgk", |
| 170 | + "qi":"GyM_p6JrXySiz1toFgKbWV-JdI3jQ4ypu9rbMWx3rQJBfmt0FoYzgUIZEVFEcOqwemRN81zoDAaa-Bk0KWNGDjJHZDdDmFhW3AN7lI-puxk_mHZGJ11rxyR8O55XLSe3SPmRfKwZI6yU24ZxvQKFYItdldUKGzO6Ia6zTKhAVRU", |
| 171 | + "alg":"RS256", |
| 172 | + "kid":"2011-04-29" |
| 173 | + } |
| 174 | + """; |
| 175 | +} |
0 commit comments