|
| 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 Duende.AccessTokenManagement; |
| 5 | +using Duende.AccessTokenManagement.OpenIdConnect; |
| 6 | +using Duende.IdentityModel; |
| 7 | +using Duende.IdentityModel.Client; |
| 8 | +using Microsoft.IdentityModel.JsonWebTokens; |
| 9 | +using Microsoft.IdentityModel.Tokens; |
| 10 | + |
| 11 | +namespace WebClientAssertions; |
| 12 | + |
| 13 | +/// <summary> |
| 14 | +/// Creates signed client assertion JWTs (RFC 7523 / private_key_jwt) for use |
| 15 | +/// with Duende's Access Token Management library. |
| 16 | +/// |
| 17 | +/// Each call produces a JWT with a fresh <c>jti</c> and <c>iat</c>, which is |
| 18 | +/// critical when DPoP nonce retries require a new assertion to avoid replay |
| 19 | +/// rejection by the authorization server. |
| 20 | +/// </summary> |
| 21 | +public class ClientAssertionService : IClientAssertionService |
| 22 | +{ |
| 23 | + /// <summary> |
| 24 | + /// RSA private key that matches the public key registered at |
| 25 | + /// demo.duendesoftware.com for the JWT client authentication clients. |
| 26 | + /// In production, load from a secure store (e.g. Azure Key Vault). |
| 27 | + /// </summary> |
| 28 | + private static readonly SigningCredentials Credential = new( |
| 29 | + new JsonWebKey(""" |
| 30 | + { |
| 31 | + "d":"GmiaucNIzdvsEzGjZjd43SDToy1pz-Ph-shsOUXXh-dsYNGftITGerp8bO1iryXh_zUEo8oDK3r1y4klTonQ6bLsWw4ogjLPmL3yiqsoSjJa1G2Ymh_RY_sFZLLXAcrmpbzdWIAkgkHSZTaliL6g57vA7gxvd8L4s82wgGer_JmURI0ECbaCg98JVS0Srtf9GeTRHoX4foLWKc1Vq6NHthzqRMLZe-aRBNU9IMvXNd7kCcIbHCM3GTD_8cFj135nBPP2HOgC_ZXI1txsEf-djqJj8W5vaM7ViKU28IDv1gZGH3CatoysYx6jv1XJVvb2PH8RbFKbJmeyUm3Wvo-rgQ", |
| 32 | + "dp":"YNjVBTCIwZD65WCht5ve06vnBLP_Po1NtL_4lkholmPzJ5jbLYBU8f5foNp8DVJBdFQW7wcLmx85-NC5Pl1ZeyA-Ecbw4fDraa5Z4wUKlF0LT6VV79rfOF19y8kwf6MigyrDqMLcH_CRnRGg5NfDsijlZXffINGuxg6wWzhiqqE", |
| 33 | + "dq":"LfMDQbvTFNngkZjKkN2CBh5_MBG6Yrmfy4kWA8IC2HQqID5FtreiY2MTAwoDcoINfh3S5CItpuq94tlB2t-VUv8wunhbngHiB5xUprwGAAnwJ3DL39D2m43i_3YP-UO1TgZQUAOh7Jrd4foatpatTvBtY3F1DrCrUKE5Kkn770M", |
| 34 | + "e":"AQAB", |
| 35 | + "kid":"ZzAjSnraU3bkWGnnAqLapYGpTyNfLbjbzgAPbbW2GEA", |
| 36 | + "kty":"RSA", |
| 37 | + "n":"wWwQFtSzeRjjerpEM5Rmqz_DsNaZ9S1Bw6UbZkDLowuuTCjBWUax0vBMMxdy6XjEEK4Oq9lKMvx9JzjmeJf1knoqSNrox3Ka0rnxXpNAz6sATvme8p9mTXyp0cX4lF4U2J54xa2_S9NF5QWvpXvBeC4GAJx7QaSw4zrUkrc6XyaAiFnLhQEwKJCwUw4NOqIuYvYp_IXhw-5Ti_icDlZS-282PcccnBeOcX7vc21pozibIdmZJKqXNsL1Ibx5Nkx1F1jLnekJAmdaACDjYRLL_6n3W4wUp19UvzB1lGtXcJKLLkqB6YDiZNu16OSiSprfmrRXvYmvD8m6Fnl5aetgKw", |
| 38 | + "p":"7enorp9Pm9XSHaCvQyENcvdU99WCPbnp8vc0KnY_0g9UdX4ZDH07JwKu6DQEwfmUA1qspC-e_KFWTl3x0-I2eJRnHjLOoLrTjrVSBRhBMGEH5PvtZTTThnIY2LReH-6EhceGvcsJ_MhNDUEZLykiH1OnKhmRuvSdhi8oiETqtPE", |
| 39 | + "q":"0CBLGi_kRPLqI8yfVkpBbA9zkCAshgrWWn9hsq6a7Zl2LcLaLBRUxH0q1jWnXgeJh9o5v8sYGXwhbrmuypw7kJ0uA3OgEzSsNvX5Ay3R9sNel-3Mqm8Me5OfWWvmTEBOci8RwHstdR-7b9ZT13jk-dsZI7OlV_uBja1ny9Nz9ts", |
| 40 | + "qi":"pG6J4dcUDrDndMxa-ee1yG4KjZqqyCQcmPAfqklI2LmnpRIjcK78scclvpboI3JQyg6RCEKVMwAhVtQM6cBcIO3JrHgqeYDblp5wXHjto70HVW6Z8kBruNx1AH9E8LzNvSRL-JVTFzBkJuNgzKQfD0G77tQRgJ-Ri7qu3_9o1M4" |
| 41 | + } |
| 42 | + """), |
| 43 | + SecurityAlgorithms.RsaSha256); |
| 44 | + |
| 45 | + private const string Authority = "https://demo.duendesoftware.com"; |
| 46 | + |
| 47 | + public Task<ClientAssertion?> GetClientAssertionAsync( |
| 48 | + ClientCredentialsClientName? clientName = null, |
| 49 | + TokenRequestParameters? parameters = null, |
| 50 | + CancellationToken ct = default) |
| 51 | + { |
| 52 | + // Determine the client_id for the assertion's issuer/subject claims. |
| 53 | + // The library calls this with different clientName values depending on context: |
| 54 | + // - scheme-based name during OIDC flows (code exchange, refresh) |
| 55 | + // - the literal client name "m2m.jwt" for the named M2M client |
| 56 | + var clientId = ResolveClientId(clientName); |
| 57 | + |
| 58 | + var now = DateTime.UtcNow; |
| 59 | + var descriptor = new SecurityTokenDescriptor |
| 60 | + { |
| 61 | + Issuer = clientId, |
| 62 | + Audience = Authority, |
| 63 | + IssuedAt = now, |
| 64 | + NotBefore = now, |
| 65 | + Expires = now.AddMinutes(1), |
| 66 | + SigningCredentials = Credential, |
| 67 | + |
| 68 | + Claims = new Dictionary<string, object> |
| 69 | + { |
| 70 | + { JwtClaimTypes.JwtId, Guid.NewGuid().ToString() }, |
| 71 | + { JwtClaimTypes.Subject, clientId }, |
| 72 | + }, |
| 73 | + |
| 74 | + AdditionalHeaderClaims = new Dictionary<string, object> |
| 75 | + { |
| 76 | + { "typ", "client-authentication+jwt" } |
| 77 | + } |
| 78 | + }; |
| 79 | + |
| 80 | + var handler = new JsonWebTokenHandler(); |
| 81 | + var jwt = handler.CreateToken(descriptor); |
| 82 | + |
| 83 | + return Task.FromResult<ClientAssertion?>(new ClientAssertion |
| 84 | + { |
| 85 | + Type = OidcConstants.ClientAssertionTypes.JwtBearer, |
| 86 | + Value = jwt |
| 87 | + }); |
| 88 | + } |
| 89 | + |
| 90 | + /// <summary> |
| 91 | + /// Maps the ATM client name to the actual OAuth client_id used in the assertion. |
| 92 | + /// </summary> |
| 93 | + private static string ResolveClientId(ClientCredentialsClientName? clientName) |
| 94 | + { |
| 95 | + var name = clientName?.ToString(); |
| 96 | + |
| 97 | + // Default / OIDC scheme-based client → use the interactive DPoP client |
| 98 | + if (string.IsNullOrEmpty(name) || name.Contains(OpenIdConnectTokenManagementDefaults.ClientCredentialsClientNamePrefix)) |
| 99 | + { |
| 100 | + return "interactive.confidential.jwt.dpop"; |
| 101 | + } |
| 102 | + |
| 103 | + // Named M2M client |
| 104 | + return name switch |
| 105 | + { |
| 106 | + "m2m.jwt" => "m2m.jwt", |
| 107 | + _ => name |
| 108 | + }; |
| 109 | + } |
| 110 | +} |
0 commit comments