|
| 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 System.Text.Json; |
| 6 | +using Duende.AccessTokenManagement.DPoP; |
| 7 | +using Duende.AccessTokenManagement.Framework; |
| 8 | +using Duende.IdentityModel; |
| 9 | +using Duende.IdentityModel.Client; |
| 10 | +using Duende.IdentityServer; |
| 11 | +using Duende.IdentityServer.Models; |
| 12 | +using Microsoft.AspNetCore.Authentication.OpenIdConnect; |
| 13 | +using Microsoft.Extensions.DependencyInjection; |
| 14 | +using Microsoft.IdentityModel.JsonWebTokens; |
| 15 | +using Microsoft.IdentityModel.Tokens; |
| 16 | +using JsonWebKey = Microsoft.IdentityModel.Tokens.JsonWebKey; |
| 17 | + |
| 18 | +namespace Duende.AccessTokenManagement; |
| 19 | + |
| 20 | +public sealed class DPoPWithClientAssertionsTests : IntegrationTestBase |
| 21 | +{ |
| 22 | + private readonly CancellationToken _ct = TestContext.Current.CancellationToken; |
| 23 | + |
| 24 | + public DPoPWithClientAssertionsTests(ITestOutputHelper output) |
| 25 | + : base(output, "dpop", |
| 26 | + configureUserTokenManagementOptions: opt => { opt.DPoPJsonWebKey = DPoPProofKey.Parse(DPoPPrivateJwk); }, |
| 27 | + configureServicesBeforeAtm: services => |
| 28 | + { |
| 29 | + services.AddSingleton<IClientAssertionService>( |
| 30 | + new JwtClientAssertionService(ClientAssertionCredentials)); |
| 31 | + |
| 32 | + services.Configure<OpenIdConnectOptions>("oidc", opt => |
| 33 | + { |
| 34 | + opt.Events.OnAuthorizationCodeReceived = async context => |
| 35 | + { |
| 36 | + var svc = context.HttpContext.RequestServices |
| 37 | + .GetRequiredService<IClientAssertionService>(); |
| 38 | + var assertion = await svc.GetClientAssertionAsync( |
| 39 | + ClientCredentialsClientName.Parse("dpop")) |
| 40 | + ?? throw new InvalidOperationException("Client assertion is null"); |
| 41 | + |
| 42 | + context.TokenEndpointRequest!.ClientAssertionType = assertion.Type; |
| 43 | + context.TokenEndpointRequest.ClientAssertion = assertion.Value; |
| 44 | + }; |
| 45 | + }); |
| 46 | + }) |
| 47 | + { |
| 48 | + AppHost.ClientSecret = null; |
| 49 | + |
| 50 | + var dpopClient = IdentityServerHost.Clients.Single(c => c.ClientId == "dpop"); |
| 51 | + dpopClient.ClientSecrets.Clear(); |
| 52 | + dpopClient.ClientSecrets.Add(new Secret |
| 53 | + { |
| 54 | + Type = IdentityServerConstants.SecretTypes.JsonWebKey, |
| 55 | + Value = BuildPublicJwk(ClientAssertionPrivateJwk) |
| 56 | + }); |
| 57 | + } |
| 58 | + |
| 59 | + [Fact] |
| 60 | + public async Task LoginWithDPoPAndClientAssertionsShouldSucceed() |
| 61 | + { |
| 62 | + await InitializeAsync(); |
| 63 | + |
| 64 | + await AppHost.LoginAsync("alice"); |
| 65 | + |
| 66 | + var codeExchangeRequest = IdentityServerHost.CapturedTokenRequests |
| 67 | + .FirstOrDefault(r => r.TryGetValue("grant_type", out var gt) && gt == "authorization_code"); |
| 68 | + codeExchangeRequest.ShouldNotBeNull(); |
| 69 | + codeExchangeRequest.ShouldContainKeyAndValue(OidcConstants.TokenRequest.ClientAssertionType, |
| 70 | + OidcConstants.ClientAssertionTypes.JwtBearer); |
| 71 | + codeExchangeRequest.ShouldContainKey(OidcConstants.TokenRequest.ClientAssertion); |
| 72 | + |
| 73 | + // Verify we got a DPoP token back (confirms the DPoP proof was also accepted) |
| 74 | + var response = await AppHost.BrowserClient.GetAsync(AppHost.Url("/user_token"), _ct); |
| 75 | + var token = await response.Content.ReadFromJsonAsync<UserTokenModel>(_ct); |
| 76 | + token.ShouldNotBeNull(); |
| 77 | + token.AccessTokenType.ShouldBe("DPoP"); |
| 78 | + } |
| 79 | + |
| 80 | + private const string ClientAssertionPrivateJwk = |
| 81 | + """ |
| 82 | + { |
| 83 | + "d":"GmiaucNIzdvsEzGjZjd43SDToy1pz-Ph-shsOUXXh-dsYNGftITGerp8bO1iryXh_zUEo8oDK3r1y4klTonQ6bLsWw4ogjLPmL3yiqsoSjJa1G2Ymh_RY_sFZLLXAcrmpbzdWIAkgkHSZTaliL6g57vA7gxvd8L4s82wgGer_JmURI0ECbaCg98JVS0Srtf9GeTRHoX4foLWKc1Vq6NHthzqRMLZe-aRBNU9IMvXNd7kCcIbHCM3GTD_8cFj135nBPP2HOgC_ZXI1txsEf-djqJj8W5vaM7ViKU28IDv1gZGH3CatoysYx6jv1XJVvb2PH8RbFKbJmeyUm3Wvo-rgQ", |
| 84 | + "dp":"YNjVBTCIwZD65WCht5ve06vnBLP_Po1NtL_4lkholmPzJ5jbLYBU8f5foNp8DVJBdFQW7wcLmx85-NC5Pl1ZeyA-Ecbw4fDraa5Z4wUKlF0LT6VV79rfOF19y8kwf6MigyrDqMLcH_CRnRGg5NfDsijlZXffINGuxg6wWzhiqqE", |
| 85 | + "dq":"LfMDQbvTFNngkZjKkN2CBh5_MBG6Yrmfy4kWA8IC2HQqID5FtreiY2MTAwoDcoINfh3S5CItpuq94tlB2t-VUv8wunhbngHiB5xUprwGAAnwJ3DL39D2m43i_3YP-UO1TgZQUAOh7Jrd4foatpatTvBtY3F1DrCrUKE5Kkn770M", |
| 86 | + "e":"AQAB", |
| 87 | + "kid":"ZzAjSnraU3bkWGnnAqLapYGpTyNfLbjbzgAPbbW2GEA", |
| 88 | + "kty":"RSA", |
| 89 | + "n":"wWwQFtSzeRjjerpEM5Rmqz_DsNaZ9S1Bw6UbZkDLowuuTCjBWUax0vBMMxdy6XjEEK4Oq9lKMvx9JzjmeJf1knoqSNrox3Ka0rnxXpNAz6sATvme8p9mTXyp0cX4lF4U2J54xa2_S9NF5QWvpXvBeC4GAJx7QaSw4zrUkrc6XyaAiFnLhQEwKJCwUw4NOqIuYvYp_IXhw-5Ti_icDlZS-282PcccnBeOcX7vc21pozibIdmZJKqXNsL1Ibx5Nkx1F1jLnekJAmdaACDjYRLL_6n3W4wUp19UvzB1lGtXcJKLLkqB6YDiZNu16OSiSprfmrRXvYmvD8m6Fnl5aetgKw", |
| 90 | + "p":"7enorp9Pm9XSHaCvQyENcvdU99WCPbnp8vc0KnY_0g9UdX4ZDH07JwKu6DQEwfmUA1qspC-e_KFWTl3x0-I2eJRnHjLOoLrTjrVSBRhBMGEH5PvtZTTThnIY2LReH-6EhceGvcsJ_MhNDUEZLykiH1OnKhmRuvSdhi8oiETqtPE", |
| 91 | + "q":"0CBLGi_kRPLqI8yfVkpBbA9zkCAshgrWWn9hsq6a7Zl2LcLaLBRUxH0q1jWnXgeJh9o5v8sYGXwhbrmuypw7kJ0uA3OgEzSsNvX5Ay3R9sNel-3Mqm8Me5OfWWvmTEBOci8RwHstdR-7b9ZT13jk-dsZI7OlV_uBja1ny9Nz9ts", |
| 92 | + "qi":"pG6J4dcUDrDndMxa-ee1yG4KjZqqyCQcmPAfqklI2LmnpRIjcK78scclvpboI3JQyg6RCEKVMwAhVtQM6cBcIO3JrHgqeYDblp5wXHjto70HVW6Z8kBruNx1AH9E8LzNvSRL-JVTFzBkJuNgzKQfD0G77tQRgJ-Ri7qu3_9o1M4" |
| 93 | + } |
| 94 | + """; |
| 95 | + |
| 96 | + private const string DPoPPrivateJwk = |
| 97 | + """ |
| 98 | + { |
| 99 | + "kty":"RSA", |
| 100 | + "n":"0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", |
| 101 | + "e":"AQAB", |
| 102 | + "d":"X4cTteJY_gn4FYPsXB8rdXix5vwsg1FLN5E3EaG6RJoVH-HLLKD9M7dx5oo7GURknchnrRweUkC7hT5fJLM0WbFAKNLWY2vv7B6NqXSzUvxT0_YSfqijwp3RTzlBaCxWp4doFk5N2o8Gy_nHNKroADIkJ46pRUohsXywbReAdYaMwFs9tv8d_cPVY3i07a3t8MN6TNwm0dSawm9v47UiCl3Sk5ZiG7xojPLu4sbg1U2jx4IBTNBznbJSzFHK66jT8bgkuqsk0GjskDJk19Z4qwjwbsnn4j2WBii3RL-Us2lGVkY8fkFzme1z0HbIkfz0Y6mqnOYtqc0X4jfcKoAC8Q", |
| 103 | + "p":"83i-7IvMGXoMXCskv73TKr8637FiO7Z27zv8oj6pbWUQyLPQBQxtPVnwD20R-60eTDmD2ujnMt5PoqMrm8RfmNhVWDtjjMmCMjOpSXicFHj7XOuVIYQyqVWlWEh6dN36GVZYk93N8Bc9vY41xy8B9RzzOGVQzXvNEvn7O0nVbfs", |
| 104 | + "q":"3dfOR9cuYq-0S-mkFLzgItgMEfFzB2q3hWehMuG0oCuqnb3vobLyumqjVZQO1dIrdwgTnCdpYzBcOfW5r370AFXjiWft_NGEiovonizhKpo9VVS78TzFgxkIdrecRezsZ-1kYd_s1qDbxtkDEgfAITAG9LUnADun4vIcb6yelxk", |
| 105 | + "dp":"G4sPXkc6Ya9y8oJW9_ILj4xuppu0lzi_H7VTkS8xj5SdX3coE0oimYwxIi2emTAue0UOa5dpgFGyBJ4c8tQ2VF402XRugKDTP8akYhFo5tAA77Qe_NmtuYZc3C3m3I24G2GvR5sSDxUyAN2zq8Lfn9EUms6rY3Ob8YeiKkTiBj0", |
| 106 | + "dq":"s9lAH9fggBsoFR8Oac2R_E2gw282rT2kGOAhvIllETE1efrA6huUUvMfBcMpn8lqeW6vzznYY5SSQF7pMdC_agI3nG8Ibp1BUb0JUiraRNqUfLhcQb_d9GF4Dh7e74WbRsobRonujTYN1xCaP6TO61jvWrX-L18txXw494Q_cgk", |
| 107 | + "qi":"GyM_p6JrXySiz1toFgKbWV-JdI3jQ4ypu9rbMWx3rQJBfmt0FoYzgUIZEVFEcOqwemRN81zoDAaa-Bk0KWNGDjJHZDdDmFhW3AN7lI-puxk_mHZGJ11rxyR8O55XLSe3SPmRfKwZI6yU24ZxvQKFYItdldUKGzO6Ia6zTKhAVRU", |
| 108 | + "alg":"RS256", |
| 109 | + "kid":"2011-04-29" |
| 110 | + } |
| 111 | + """; |
| 112 | + |
| 113 | + private static readonly SigningCredentials ClientAssertionCredentials = |
| 114 | + new(new JsonWebKey(ClientAssertionPrivateJwk), "RS256"); |
| 115 | + |
| 116 | + |
| 117 | + private static string BuildPublicJwk(string privateJwk) |
| 118 | + { |
| 119 | + var fullKey = new JsonWebKey(privateJwk); |
| 120 | + var publicKey = new JsonWebKey |
| 121 | + { |
| 122 | + Kty = fullKey.Kty, |
| 123 | + N = fullKey.N, |
| 124 | + E = fullKey.E, |
| 125 | + Kid = fullKey.Kid, |
| 126 | + Alg = "RS256" |
| 127 | + }; |
| 128 | + return JsonSerializer.Serialize(publicKey); |
| 129 | + } |
| 130 | +} |
| 131 | + |
| 132 | +internal sealed class JwtClientAssertionService(SigningCredentials credentials) : IClientAssertionService |
| 133 | +{ |
| 134 | + public Task<ClientAssertion?> GetClientAssertionAsync( |
| 135 | + ClientCredentialsClientName? clientName = null, |
| 136 | + TokenRequestParameters? parameters = null, |
| 137 | + CancellationToken ct = default) |
| 138 | + { |
| 139 | + var descriptor = new SecurityTokenDescriptor |
| 140 | + { |
| 141 | + Issuer = "dpop", |
| 142 | + Audience = "https://identityserver/connect/token", |
| 143 | + Expires = DateTime.UtcNow.AddMinutes(1), |
| 144 | + SigningCredentials = credentials, |
| 145 | + Claims = new Dictionary<string, object> |
| 146 | + { |
| 147 | + { JwtClaimTypes.JwtId, Guid.NewGuid().ToString() }, |
| 148 | + { JwtClaimTypes.Subject, "dpop" }, |
| 149 | + { JwtClaimTypes.IssuedAt, DateTimeOffset.UtcNow.ToUnixTimeSeconds() } |
| 150 | + } |
| 151 | + }; |
| 152 | + |
| 153 | + var jwt = new JsonWebTokenHandler().CreateToken(descriptor); |
| 154 | + |
| 155 | + return Task.FromResult<ClientAssertion?>(new ClientAssertion |
| 156 | + { |
| 157 | + Type = OidcConstants.ClientAssertionTypes.JwtBearer, |
| 158 | + Value = jwt |
| 159 | + }); |
| 160 | + } |
| 161 | +} |
0 commit comments