diff --git a/foss.slnx b/foss.slnx index 2805c9c0..99a9b06a 100644 --- a/foss.slnx +++ b/foss.slnx @@ -50,6 +50,7 @@ + diff --git a/identity-model/identity-model.slnf b/identity-model/identity-model.slnf index 06ae3981..75986307 100644 --- a/identity-model/identity-model.slnf +++ b/identity-model/identity-model.slnf @@ -2,10 +2,13 @@ "solution": { "path": "..\\foss.slnx", "projects": [ + "identity-model\\samples\\ClientAssertions\\ClientAssertions.csproj", "identity-model\\samples\\HttpClientFactory\\HttpClientFactory.csproj", "identity-model\\src\\IdentityModel\\IdentityModel.csproj", "identity-model\\src\\TrimmableAnalysis\\TrimmableAnalysis.csproj", - "identity-model\\test\\IdentityModel.Tests\\IdentityModel.Tests.csproj" + "identity-model\\test\\IdentityModel.Tests\\IdentityModel.Tests.csproj", + "identity-model-oidc-client\\src\\IdentityModel.OidcClient.Extensions\\IdentityModel.OidcClient.Extensions.csproj", + "identity-model-oidc-client\\src\\IdentityModel.OidcClient\\IdentityModel.OidcClient.csproj" ] } } \ No newline at end of file diff --git a/identity-model/samples/ClientAssertions/ClientAssertionService.cs b/identity-model/samples/ClientAssertions/ClientAssertionService.cs new file mode 100644 index 00000000..c5c6fa57 --- /dev/null +++ b/identity-model/samples/ClientAssertions/ClientAssertionService.cs @@ -0,0 +1,93 @@ +// Copyright (c) Duende Software. All rights reserved. +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using Duende.IdentityModel; +using Duende.IdentityModel.Client; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace ClientAssertions; + +/// +/// Creates signed client assertion JWTs (RFC 7523 / private_key_jwt). +/// Each call to produces a JWT with a fresh +/// jti and iat, which is critical when retries (e.g. DPoP nonce +/// challenges) require a new assertion to avoid replay rejection. +/// +public class ClientAssertionService +{ + private readonly string _clientId; + private readonly string _audience; + private readonly SigningCredentials _signingCredentials; + + public ClientAssertionService(string clientId, string audience, SigningCredentials signingCredentials) + { + _clientId = clientId ?? throw new ArgumentNullException(nameof(clientId)); + _audience = audience ?? throw new ArgumentNullException(nameof(audience)); + _signingCredentials = signingCredentials ?? throw new ArgumentNullException(nameof(signingCredentials)); + } + + /// + /// Creates a fresh with a unique jti. + /// + public Task CreateAssertionAsync() + { + var now = DateTime.UtcNow; + + var descriptor = new SecurityTokenDescriptor + { + Issuer = _clientId, + Audience = _audience, + IssuedAt = now, + NotBefore = now, + Expires = now.AddMinutes(1), + SigningCredentials = _signingCredentials, + AdditionalHeaderClaims = new Dictionary + { + { "typ", "client-authentication+jwt" } + }, + Claims = new Dictionary + { + { JwtClaimTypes.JwtId, Guid.NewGuid().ToString() }, + { JwtClaimTypes.Subject, _clientId }, + } + }; + + var handler = new JsonWebTokenHandler(); + var jwt = handler.CreateToken(descriptor); + + return Task.FromResult(new ClientAssertion + { + Type = OidcConstants.ClientAssertionTypes.JwtBearer, + Value = jwt + }); + } + + /// + /// Creates signing credentials from the RSA key pair that matches the public + /// key registered for the "m2m.jwt" client at demo.duendesoftware.com. + /// In production, load the private key from a secure store (e.g. Azure Key Vault). + /// + public static SigningCredentials CreateSigningCredentials() + { + // This JWK contains the private key that corresponds to the public key + // registered at the demo IdentityServer for the "m2m.jwt" client. + var jwk = """ + { + "d":"GmiaucNIzdvsEzGjZjd43SDToy1pz-Ph-shsOUXXh-dsYNGftITGerp8bO1iryXh_zUEo8oDK3r1y4klTonQ6bLsWw4ogjLPmL3yiqsoSjJa1G2Ymh_RY_sFZLLXAcrmpbzdWIAkgkHSZTaliL6g57vA7gxvd8L4s82wgGer_JmURI0ECbaCg98JVS0Srtf9GeTRHoX4foLWKc1Vq6NHthzqRMLZe-aRBNU9IMvXNd7kCcIbHCM3GTD_8cFj135nBPP2HOgC_ZXI1txsEf-djqJj8W5vaM7ViKU28IDv1gZGH3CatoysYx6jv1XJVvb2PH8RbFKbJmeyUm3Wvo-rgQ", + "dp":"YNjVBTCIwZD65WCht5ve06vnBLP_Po1NtL_4lkholmPzJ5jbLYBU8f5foNp8DVJBdFQW7wcLmx85-NC5Pl1ZeyA-Ecbw4fDraa5Z4wUKlF0LT6VV79rfOF19y8kwf6MigyrDqMLcH_CRnRGg5NfDsijlZXffINGuxg6wWzhiqqE", + "dq":"LfMDQbvTFNngkZjKkN2CBh5_MBG6Yrmfy4kWA8IC2HQqID5FtreiY2MTAwoDcoINfh3S5CItpuq94tlB2t-VUv8wunhbngHiB5xUprwGAAnwJ3DL39D2m43i_3YP-UO1TgZQUAOh7Jrd4foatpatTvBtY3F1DrCrUKE5Kkn770M", + "e":"AQAB", + "kid":"ZzAjSnraU3bkWGnnAqLapYGpTyNfLbjbzgAPbbW2GEA", + "kty":"RSA", + "n":"wWwQFtSzeRjjerpEM5Rmqz_DsNaZ9S1Bw6UbZkDLowuuTCjBWUax0vBMMxdy6XjEEK4Oq9lKMvx9JzjmeJf1knoqSNrox3Ka0rnxXpNAz6sATvme8p9mTXyp0cX4lF4U2J54xa2_S9NF5QWvpXvBeC4GAJx7QaSw4zrUkrc6XyaAiFnLhQEwKJCwUw4NOqIuYvYp_IXhw-5Ti_icDlZS-282PcccnBeOcX7vc21pozibIdmZJKqXNsL1Ibx5Nkx1F1jLnekJAmdaACDjYRLL_6n3W4wUp19UvzB1lGtXcJKLLkqB6YDiZNu16OSiSprfmrRXvYmvD8m6Fnl5aetgKw", + "p":"7enorp9Pm9XSHaCvQyENcvdU99WCPbnp8vc0KnY_0g9UdX4ZDH07JwKu6DQEwfmUA1qspC-e_KFWTl3x0-I2eJRnHjLOoLrTjrVSBRhBMGEH5PvtZTTThnIY2LReH-6EhceGvcsJ_MhNDUEZLykiH1OnKhmRuvSdhi8oiETqtPE", + "q":"0CBLGi_kRPLqI8yfVkpBbA9zkCAshgrWWn9hsq6a7Zl2LcLaLBRUxH0q1jWnXgeJh9o5v8sYGXwhbrmuypw7kJ0uA3OgEzSsNvX5Ay3R9sNel-3Mqm8Me5OfWWvmTEBOci8RwHstdR-7b9ZT13jk-dsZI7OlV_uBja1ny9Nz9ts", + "qi":"pG6J4dcUDrDndMxa-ee1yG4KjZqqyCQcmPAfqklI2LmnpRIjcK78scclvpboI3JQyg6RCEKVMwAhVtQM6cBcIO3JrHgqeYDblp5wXHjto70HVW6Z8kBruNx1AH9E8LzNvSRL-JVTFzBkJuNgzKQfD0G77tQRgJ-Ri7qu3_9o1M4" + } + """; + + var key = new JsonWebKey(jwk); + return new SigningCredentials(key, SecurityAlgorithms.RsaSha256); + } +} diff --git a/identity-model/samples/ClientAssertions/ClientAssertions.csproj b/identity-model/samples/ClientAssertions/ClientAssertions.csproj new file mode 100644 index 00000000..7962b279 --- /dev/null +++ b/identity-model/samples/ClientAssertions/ClientAssertions.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + Exe + true + enable + + + + + + + + diff --git a/identity-model/samples/ClientAssertions/Program.cs b/identity-model/samples/ClientAssertions/Program.cs new file mode 100644 index 00000000..2aba5fe6 --- /dev/null +++ b/identity-model/samples/ClientAssertions/Program.cs @@ -0,0 +1,92 @@ +// Copyright (c) Duende Software. All rights reserved. +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +// +// This sample demonstrates how to use a ClientAssertionFactory so that token +// requests (including DPoP nonce retries) automatically produce a fresh +// client_assertion JWT with a unique jti on each attempt. +// +// Background +// ---------- +// When a token request carries a client_assertion (RFC 7521 / private_key_jwt), +// the server may reject retries that reuse the same assertion because the jti +// has already been seen. Setting ClientAssertionFactory on the ProtocolRequest +// ensures that: +// 1. The initial request gets a freshly-minted assertion. +// 2. The factory is stored on HttpRequestMessage.Options so that downstream +// handlers (e.g. DPoP proof-token handlers) can invoke it on retries. +// + +using Duende.IdentityModel.Client; + +namespace ClientAssertions; + +public class Program +{ + private const string Authority = "https://demo.duendesoftware.com"; + private const string TokenEndpoint = $"{Authority}/connect/token"; + private const string ClientId = "m2m.jwt"; + private const string Scope = "api"; + + public static async Task Main() + { + Console.WriteLine("+------------------------------------------+"); + Console.WriteLine("| Client Assertions Sample |"); + Console.WriteLine("+------------------------------------------+"); + Console.WriteLine(); + + // 1. Create a signing key for client_assertion JWTs. + // In production this would be the key registered with the identity + // provider for private_key_jwt authentication. + var signingCredentials = ClientAssertionService.CreateSigningCredentials(); + + // 2. Create the assertion service. + var assertionService = new ClientAssertionService(ClientId, Authority, signingCredentials); + + // 3. Build the HTTP client. In a real DPoP scenario you would wrap this + // with a ProofTokenMessageHandler, but this sample focuses on the + // client assertion factory pattern alone. + var client = new HttpClient(); + + // 4. Build the token request with a ClientAssertionFactory. + // The factory is called once for the initial request and stored on + // HttpRequestMessage.Options so that retry handlers can call it again. + var tokenRequest = new ClientCredentialsTokenRequest + { + Address = TokenEndpoint, + ClientId = ClientId, + ClientCredentialStyle = ClientCredentialStyle.PostBody, + Scope = Scope, + + // The key feature: a factory that produces a fresh assertion each time. + ClientAssertionFactory = assertionService.CreateAssertionAsync, + }; + + Console.WriteLine("Requesting token with client_assertion..."); + Console.WriteLine($" Endpoint : {TokenEndpoint}"); + Console.WriteLine($" ClientId : {ClientId}"); + Console.WriteLine(); + + // 5. Send the token request. + var response = await client.RequestClientCredentialsTokenAsync(tokenRequest); + + // 6. Display the result. + if (response.IsError) + { + Console.WriteLine($"Error: {response.Error}"); + Console.WriteLine($"Description: {response.ErrorDescription}"); + + if (response.HttpStatusCode != 0) + { + Console.WriteLine($"HTTP {(int)response.HttpStatusCode}"); + } + } + else + { + Console.WriteLine("Success!"); + Console.WriteLine($" Token type : {response.TokenType}"); + Console.WriteLine($" Expires in : {response.ExpiresIn}s"); + Console.WriteLine($" Access token : {response.AccessToken}"); + } + } +} diff --git a/identity-model/src/IdentityModel/Client/HttpClientPushedAuthorizationExtensions.cs b/identity-model/src/IdentityModel/Client/HttpClientPushedAuthorizationExtensions.cs index 8d389929..f6a7ffa1 100644 --- a/identity-model/src/IdentityModel/Client/HttpClientPushedAuthorizationExtensions.cs +++ b/identity-model/src/IdentityModel/Client/HttpClientPushedAuthorizationExtensions.cs @@ -66,6 +66,15 @@ public static Task PushAuthorizationAsync(this Http internal static async Task PushAuthorizationAsync(this HttpMessageInvoker client, ProtocolRequest request, CancellationToken cancellationToken = default) { + // If a factory is set, invoke it to get a fresh assertion for this attempt and + // store it on Options so that DPoP retry handlers can invoke it again on each + // subsequent attempt. The factory always takes precedence over a fixed ClientAssertion value. + if (request.ClientAssertionFactory != null) + { + request.ClientAssertion = await request.ClientAssertionFactory().ConfigureAwait(); + request.Options.Set(ProtocolRequestOptions.ClientAssertionFactory, request.ClientAssertionFactory); + } + request.Prepare(); request.Method = HttpMethod.Post; diff --git a/identity-model/src/IdentityModel/Client/HttpClientTokenRequestExtensions.cs b/identity-model/src/IdentityModel/Client/HttpClientTokenRequestExtensions.cs index b4155031..209c5d14 100644 --- a/identity-model/src/IdentityModel/Client/HttpClientTokenRequestExtensions.cs +++ b/identity-model/src/IdentityModel/Client/HttpClientTokenRequestExtensions.cs @@ -213,6 +213,15 @@ public static async Task RequestTokenRawAsync(this HttpMessageInv internal static async Task RequestTokenAsync(this HttpMessageInvoker client, ProtocolRequest request, CancellationToken cancellationToken = default) { + // If a factory is set, invoke it to get a fresh assertion for this attempt and + // store it on Options so that DPoP retry handlers can invoke it again on each + // subsequent attempt. The factory always takes precedence over a fixed ClientAssertion value. + if (request.ClientAssertionFactory != null) + { + request.ClientAssertion = await request.ClientAssertionFactory().ConfigureAwait(); + request.Options.Set(ProtocolRequestOptions.ClientAssertionFactory, request.ClientAssertionFactory); + } + request.Prepare(); request.Method = HttpMethod.Post; diff --git a/identity-model/src/IdentityModel/Client/Messages/ProtocolRequest.cs b/identity-model/src/IdentityModel/Client/Messages/ProtocolRequest.cs index 4e589a1c..dc5a5365 100644 --- a/identity-model/src/IdentityModel/Client/Messages/ProtocolRequest.cs +++ b/identity-model/src/IdentityModel/Client/Messages/ProtocolRequest.cs @@ -6,6 +6,21 @@ namespace Duende.IdentityModel.Client; +/// +/// Well-known keys used by Duende IdentityModel +/// when passing data through handler chains. +/// +public static class ProtocolRequestOptions +{ + /// + /// The key used to store a on + /// . DPoP retry handlers read this key to obtain + /// a fresh for each attempt. + /// + public static readonly HttpRequestOptionsKey>?> ClientAssertionFactory = + new HttpRequestOptionsKey>?>("Duende.IdentityModel.ClientAssertionFactory"); +} + /// /// Models a base OAuth/OIDC request with client credentials /// @@ -73,6 +88,14 @@ public ProtocolRequest() /// public string? DPoPProofToken { get; set; } + /// + /// Gets or sets a factory function that creates a fresh on demand. + /// When set, this factory is stored on so that DPoP retry + /// handlers can invoke it to obtain a new assertion (with a fresh jti and iat) on + /// each attempt, avoiding client-assertion replay rejected by servers that enforce uniqueness. + /// + public Func>? ClientAssertionFactory { get; set; } + /// /// Gets or sets additional protocol parameters. /// @@ -101,6 +124,7 @@ public T Clone() Address = Address, AuthorizationHeaderStyle = AuthorizationHeaderStyle, ClientAssertion = ClientAssertion, + ClientAssertionFactory = ClientAssertionFactory, ClientCredentialStyle = ClientCredentialStyle, ClientId = ClientId, ClientSecret = ClientSecret, diff --git a/identity-model/test/IdentityModel.Tests/HttpClientExtensions/PushedAuthorizationTests.cs b/identity-model/test/IdentityModel.Tests/HttpClientExtensions/PushedAuthorizationTests.cs index 4f2557cf..50bc0cc4 100644 --- a/identity-model/test/IdentityModel.Tests/HttpClientExtensions/PushedAuthorizationTests.cs +++ b/identity-model/test/IdentityModel.Tests/HttpClientExtensions/PushedAuthorizationTests.cs @@ -213,4 +213,65 @@ public async Task Pushed_authorization_with_request_uri_should_fail() var exception = await act.ShouldThrowAsync(); exception.ParamName.ShouldBe("request_uri"); } + + [Fact] + public async Task ClientAssertionFactoryShouldBeInvokedAndResultSentInBody() + { + var document = File.ReadAllText(FileName.Create("success_par_response.json")); + var handler = new NetworkHandler(document, HttpStatusCode.OK); + var client = new HttpClient(handler); + + var factoryCallCount = 0; + + var response = await client.PushAuthorizationAsync(new PushedAuthorizationRequest + { + Address = Endpoint, + ClientId = "client", + ResponseType = "code", + ClientCredentialStyle = ClientCredentialStyle.PostBody, + ClientAssertionFactory = () => + { + factoryCallCount++; + return Task.FromResult(new ClientAssertion + { + Type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + Value = "fresh-jwt-value" + }); + } + }, _ct); + + factoryCallCount.ShouldBe(1); + + var fields = QueryHelpers.ParseQuery(handler.Body); + fields["client_assertion_type"].First().ShouldBe("urn:ietf:params:oauth:client-assertion-type:jwt-bearer"); + fields["client_assertion"].First().ShouldBe("fresh-jwt-value"); + } + + [Fact] + public async Task ClientAssertionFactoryShouldBeStoredOnRequestOptions() + { + var document = File.ReadAllText(FileName.Create("success_par_response.json")); + var handler = new NetworkHandler(document, HttpStatusCode.OK); + var client = new HttpClient(handler); + + Func> factory = () => Task.FromResult(new ClientAssertion + { + Type = "type", + Value = "value" + }); + + await client.PushAuthorizationAsync(new PushedAuthorizationRequest + { + Address = Endpoint, + ClientId = "client", + ResponseType = "code", + ClientCredentialStyle = ClientCredentialStyle.PostBody, + ClientAssertionFactory = factory + }, _ct); + + handler.Request.Options + .TryGetValue(ProtocolRequestOptions.ClientAssertionFactory, out var storedFactory) + .ShouldBeTrue(); + storedFactory.ShouldBe(factory); + } } diff --git a/identity-model/test/IdentityModel.Tests/HttpClientExtensions/TokenRequestExtensionsRequestTests.cs b/identity-model/test/IdentityModel.Tests/HttpClientExtensions/TokenRequestExtensionsRequestTests.cs index 79af5596..ebba9878 100644 --- a/identity-model/test/IdentityModel.Tests/HttpClientExtensions/TokenRequestExtensionsRequestTests.cs +++ b/identity-model/test/IdentityModel.Tests/HttpClientExtensions/TokenRequestExtensionsRequestTests.cs @@ -718,4 +718,131 @@ public async Task Setting_assertion_without_client_id_and_authz_header_should_ha fields["client_assertion_type"].First().ShouldBe("type"); fields["client_assertion"].First().ShouldBe("value"); } + + [Fact] + public async Task ClientAssertionFactoryShouldBeInvokedAndResultSentInBody() + { + var factoryCallCount = 0; + + var response = await _client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest + { + ClientId = "client", + ClientCredentialStyle = ClientCredentialStyle.PostBody, + Scope = "scope", + ClientAssertionFactory = () => + { + factoryCallCount++; + return Task.FromResult(new ClientAssertion + { + Type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + Value = "fresh-jwt-value" + }); + } + }, _ct); + + factoryCallCount.ShouldBe(1); + + var fields = QueryHelpers.ParseQuery(_handler.Body); + fields["client_assertion_type"].First().ShouldBe("urn:ietf:params:oauth:client-assertion-type:jwt-bearer"); + fields["client_assertion"].First().ShouldBe("fresh-jwt-value"); + } + + [Fact] + public async Task ClientAssertionFactoryShouldBeStoredOnRequestOptions() + { + Func> factory = () => Task.FromResult(new ClientAssertion + { + Type = "type", + Value = "value" + }); + + await _client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest + { + ClientId = "client", + ClientCredentialStyle = ClientCredentialStyle.PostBody, + ClientAssertionFactory = factory + }, _ct); + + _handler.Request.Options + .TryGetValue(ProtocolRequestOptions.ClientAssertionFactory, out var storedFactory) + .ShouldBeTrue(); + storedFactory.ShouldBe(factory); + } + + [Fact] + public async Task ClientAssertionFactoryShouldTakePrecedenceOverFixedAssertion() + { + await _client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest + { + ClientId = "client", + ClientCredentialStyle = ClientCredentialStyle.PostBody, + ClientAssertion = { Type = "fixed-type", Value = "fixed-value" }, + ClientAssertionFactory = () => Task.FromResult(new ClientAssertion + { + Type = "factory-type", + Value = "factory-value" + }) + }, _ct); + + var fields = QueryHelpers.ParseQuery(_handler.Body); + fields["client_assertion_type"].First().ShouldBe("factory-type"); + fields["client_assertion"].First().ShouldBe("factory-value"); + } + + [Fact] + public async Task NullClientAssertionFactoryShouldNotAffectFixedAssertion() + { + await _client.RequestTokenAsync(new TokenRequest + { + GrantType = "test", + ClientId = "client", + ClientCredentialStyle = ClientCredentialStyle.PostBody, + ClientAssertion = { Type = "fixed-type", Value = "fixed-value" }, + ClientAssertionFactory = null + }, _ct); + + var fields = QueryHelpers.ParseQuery(_handler.Body); + fields["client_assertion_type"].First().ShouldBe("fixed-type"); + fields["client_assertion"].First().ShouldBe("fixed-value"); + + _handler.Request.Options + .TryGetValue(ProtocolRequestOptions.ClientAssertionFactory, out _) + .ShouldBeFalse(); + } + + [Fact] + public void CloneShouldCopyClientAssertionFactory() + { + Func> factory = () => Task.FromResult(new ClientAssertion + { + Type = "type", + Value = "value" + }); + + var original = new TokenRequest + { + GrantType = "test", + ClientId = "client", + ClientAssertionFactory = factory + }; + + var clone = original.Clone(); + + clone.ClientAssertionFactory.ShouldBe(factory); + } + + [Fact] + public void CloneShouldCopyNullClientAssertionFactory() + { + var original = new TokenRequest + { + GrantType = "test", + ClientId = "client", + ClientAssertionFactory = null + }; + + var clone = original.Clone(); + + clone.ClientAssertionFactory.ShouldBeNull(); + } } diff --git a/identity-model/test/IdentityModel.Tests/Verifications/PublicApiVerificationTests.VerifyPublicApi.verified.txt b/identity-model/test/IdentityModel.Tests/Verifications/PublicApiVerificationTests.VerifyPublicApi.verified.txt index 600dbc10..d6f4a344 100644 --- a/identity-model/test/IdentityModel.Tests/Verifications/PublicApiVerificationTests.VerifyPublicApi.verified.txt +++ b/identity-model/test/IdentityModel.Tests/Verifications/PublicApiVerificationTests.VerifyPublicApi.verified.txt @@ -1159,6 +1159,7 @@ namespace Duende.IdentityModel.Client public string? Address { get; set; } public Duende.IdentityModel.Client.BasicAuthenticationHeaderStyle AuthorizationHeaderStyle { get; set; } public Duende.IdentityModel.Client.ClientAssertion ClientAssertion { get; set; } + public System.Func>? ClientAssertionFactory { get; set; } public Duende.IdentityModel.Client.ClientCredentialStyle ClientCredentialStyle { get; set; } public string ClientId { get; set; } public string? ClientSecret { get; set; } @@ -1169,6 +1170,10 @@ namespace Duende.IdentityModel.Client where T : Duende.IdentityModel.Client.ProtocolRequest, new () { } public void Prepare() { } } + public static class ProtocolRequestOptions + { + public static readonly System.Net.Http.HttpRequestOptionsKey>?> ClientAssertionFactory; + } public class ProtocolResponse : System.IDisposable { public ProtocolResponse() { } @@ -1484,4 +1489,4 @@ namespace Duende.IdentityModel.Validation { void Validate(string rawJwtResponse); } -} +} \ No newline at end of file