diff --git a/.github/workflows/master-pr.yml b/.github/workflows/master-pr.yml index 7a42034..1e4aa96 100644 --- a/.github/workflows/master-pr.yml +++ b/.github/workflows/master-pr.yml @@ -22,10 +22,10 @@ jobs: steps: - name: Checkout source uses: actions/checkout@v4 - - name: Setup .NET 8.0.x + - name: Setup .NET 9.0.x uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0.x' + dotnet-version: '9.0.x' - name: Restore solution run: dotnet restore - name: Build solution diff --git a/.github/workflows/master-publish.yml b/.github/workflows/master-publish.yml index a292d32..b53d9c1 100644 --- a/.github/workflows/master-publish.yml +++ b/.github/workflows/master-publish.yml @@ -16,10 +16,10 @@ jobs: echo "VERSION=${VERSION#v}" >> $GITHUB_ENV - name: Checkout source uses: actions/checkout@v4 - - name: Setup .NET 8.0.x + - name: Setup .NET 9.0.x uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0.x' + dotnet-version: '9.0.x' - name: Restore solution run: dotnet restore - name: Build solution (pre-release) diff --git a/.github/workflows/master-push.yml b/.github/workflows/master-push.yml index 980d9a2..1bccccf 100644 --- a/.github/workflows/master-push.yml +++ b/.github/workflows/master-push.yml @@ -19,10 +19,10 @@ jobs: steps: - name: Checkout source uses: actions/checkout@v4 - - name: Setup .NET 8.0.x + - name: Setup .NET 9.0.x uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0.x' + dotnet-version: '9.0.x' - name: Restore solution run: dotnet restore - name: Build solution diff --git a/.gitignore b/.gitignore index d0357ba..c8ac887 100644 --- a/.gitignore +++ b/.gitignore @@ -344,5 +344,8 @@ healthchecksdb /test/coverage.opencover.xml /test/result.json +# POC related +/poc + # Custom exclusions *.AssemblyAttributes diff --git a/README.md b/README.md index 3ada81f..417de8c 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,6 @@ The package currently supports the following authentication methods: - OAuth2 - Client credentials -> Please note: The .NET Standard 2.0 and .NET 6 targeted packages references the LTS version 6 of the dependent -NuGet packages. The reason for this is that while it is possible to run .NET 7 NuGet packages on .NET 6, it is known that some -of them may introduce unexpected behavior. It is recommended (especially from the ASP.NET Core team) limit usage of .NET 7 -based NuGet packages on .NET 6 runtime. - ## USAGE Add the NuGet package `KISS.HttpClientAuthentication` to your project and whenever a @@ -75,16 +70,25 @@ Authentication using OAuth2. ##### Client credentials -Using OAuth2 client credentials, all settings except `DisableTokenCache` and `Scope` is required. +Using OAuth2 client credentials, all settings except `DisableTokenCache`, `Scope` and +`TokenEndpoint`'s `Additional*Parameters` is required. ``` "
": { "AuthenticationProvider": "OAuth2", "OAuth2": { - "AuthorizationEndpoint": "", "DisableTokenCache": false, "GrantType": "ClientCredentials", "Scope": "", + "TokenEndpoint": { + "Url": "", + "AdditionalHeaderParameters": { + }, + "AdditionalBodyParameters": { + }, + "AdditionalQueryParameters": { + } + }, "ClientCredentials": { "ClientId": "", "ClientSecret": "" @@ -93,6 +97,9 @@ Using OAuth2 client credentials, all settings except `DisableTokenCache` and `Sc } ``` +The `Additional*Parameters` configuration is dynamic, any configuration in these will +be added to their respective parts of the request accordingly. Please note that the +`AdditionalQueryParameters` will be url encoded. ### Examples diff --git a/src/HttpClientAuthentication/AssemblyInfo.cs b/src/HttpClientAuthentication/AssemblyInfo.cs index 9be9855..0141421 100644 --- a/src/HttpClientAuthentication/AssemblyInfo.cs +++ b/src/HttpClientAuthentication/AssemblyInfo.cs @@ -1,4 +1,4 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. using System.Runtime.CompilerServices; diff --git a/src/HttpClientAuthentication/Configuration/ApiKeyConfiguration.cs b/src/HttpClientAuthentication/Configuration/ApiKeyConfiguration.cs index 17133ac..3180c31 100644 --- a/src/HttpClientAuthentication/Configuration/ApiKeyConfiguration.cs +++ b/src/HttpClientAuthentication/Configuration/ApiKeyConfiguration.cs @@ -1,4 +1,4 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. namespace KISS.HttpClientAuthentication.Configuration diff --git a/src/HttpClientAuthentication/Configuration/BasicConfiguration.cs b/src/HttpClientAuthentication/Configuration/BasicConfiguration.cs index df82c67..0bccaef 100644 --- a/src/HttpClientAuthentication/Configuration/BasicConfiguration.cs +++ b/src/HttpClientAuthentication/Configuration/BasicConfiguration.cs @@ -1,4 +1,4 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. namespace KISS.HttpClientAuthentication.Configuration diff --git a/src/HttpClientAuthentication/Configuration/ClientCredentialsConfiguration.cs b/src/HttpClientAuthentication/Configuration/ClientCredentialsConfiguration.cs index d858720..2055148 100644 --- a/src/HttpClientAuthentication/Configuration/ClientCredentialsConfiguration.cs +++ b/src/HttpClientAuthentication/Configuration/ClientCredentialsConfiguration.cs @@ -1,4 +1,4 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. namespace KISS.HttpClientAuthentication.Configuration diff --git a/src/HttpClientAuthentication/Configuration/HttpClientAuthenticationConfiguration.cs b/src/HttpClientAuthentication/Configuration/HttpClientAuthenticationConfiguration.cs index bf6d9d8..6983793 100644 --- a/src/HttpClientAuthentication/Configuration/HttpClientAuthenticationConfiguration.cs +++ b/src/HttpClientAuthentication/Configuration/HttpClientAuthenticationConfiguration.cs @@ -1,4 +1,4 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. using KISS.HttpClientAuthentication.Constants; diff --git a/src/HttpClientAuthentication/Configuration/OAuth2Configuration.cs b/src/HttpClientAuthentication/Configuration/OAuth2Configuration.cs index 6486ffb..fb95b59 100644 --- a/src/HttpClientAuthentication/Configuration/OAuth2Configuration.cs +++ b/src/HttpClientAuthentication/Configuration/OAuth2Configuration.cs @@ -1,4 +1,4 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. using KISS.HttpClientAuthentication.Constants; @@ -10,12 +10,6 @@ namespace KISS.HttpClientAuthentication.Configuration /// public sealed class OAuth2Configuration { - /// - /// Gets or sets the authorization endpoint used by some - /// configuration. - /// - public Uri AuthorizationEndpoint { get; set; } = default!; - /// /// Gets or sets the authorization scheme to use if is /// Authorization or the selected uses Authorization as default header. @@ -47,5 +41,13 @@ public sealed class OAuth2Configuration /// Scopes must be separated with a space. /// public string? Scope { get; set; } + + /// + /// Gets or sets the token endpoint. + /// + /// + /// Replaces . + /// + public OAuth2Endpoint TokenEndpoint { get; set; } = default!; } } diff --git a/src/HttpClientAuthentication/Configuration/OAuth2Endpoint.cs b/src/HttpClientAuthentication/Configuration/OAuth2Endpoint.cs new file mode 100644 index 0000000..500d48f --- /dev/null +++ b/src/HttpClientAuthentication/Configuration/OAuth2Endpoint.cs @@ -0,0 +1,40 @@ +// Copyright © 2025 Rune Gulbrandsen. +// All rights reserved. Licensed under the MIT License; see LICENSE.txt. + +namespace KISS.HttpClientAuthentication.Configuration +{ + /// + /// Endpoint configuration for OAuth2 endpoints + /// + public sealed partial class OAuth2Endpoint + { + /// + /// Gets or sets the Url to the OAuth2 endpoint. + /// + public Uri Url { get; set; } = default!; + + /// + /// Gets a dictionary that can contain additional headers that will be + /// supplied when requesting . + /// + public Dictionary AdditionalHeaderParameters { get; } = []; + + + /// + /// Gets a collection of additional form body parameters that will be + /// supplied when requesting . + /// + public Dictionary AdditionalBodyParameters { get; } = []; + + /// + /// Gets a collection of additional query string parameters that will + /// be supplied when requesting . + /// + public Dictionary AdditionalQueryParameters { get; } = []; + + public override string ToString() + { + return Url.ToString(); + } + } +} diff --git a/src/HttpClientAuthentication/Constants/AuthenticationProvider.cs b/src/HttpClientAuthentication/Constants/AuthenticationProvider.cs index eec6f79..6ed74dc 100644 --- a/src/HttpClientAuthentication/Constants/AuthenticationProvider.cs +++ b/src/HttpClientAuthentication/Constants/AuthenticationProvider.cs @@ -1,4 +1,4 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. namespace KISS.HttpClientAuthentication.Constants diff --git a/src/HttpClientAuthentication/Constants/OAuth2GrantType.cs b/src/HttpClientAuthentication/Constants/OAuth2GrantType.cs index e4e091c..ab5e8f9 100644 --- a/src/HttpClientAuthentication/Constants/OAuth2GrantType.cs +++ b/src/HttpClientAuthentication/Constants/OAuth2GrantType.cs @@ -1,4 +1,4 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. namespace KISS.HttpClientAuthentication.Constants diff --git a/src/HttpClientAuthentication/Constants/OAuth2Keyword.cs b/src/HttpClientAuthentication/Constants/OAuth2Keyword.cs index 1f6120f..63e2218 100644 --- a/src/HttpClientAuthentication/Constants/OAuth2Keyword.cs +++ b/src/HttpClientAuthentication/Constants/OAuth2Keyword.cs @@ -1,4 +1,4 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. namespace KISS.HttpClientAuthentication.Constants diff --git a/src/HttpClientAuthentication/Handlers/ApiKeyAuthenticationHandler.cs b/src/HttpClientAuthentication/Handlers/ApiKeyAuthenticationHandler.cs index fc9f93a..0b19585 100644 --- a/src/HttpClientAuthentication/Handlers/ApiKeyAuthenticationHandler.cs +++ b/src/HttpClientAuthentication/Handlers/ApiKeyAuthenticationHandler.cs @@ -1,4 +1,4 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. using KISS.HttpClientAuthentication.Configuration; diff --git a/src/HttpClientAuthentication/Handlers/BaseAuthenticationHandler.cs b/src/HttpClientAuthentication/Handlers/BaseAuthenticationHandler.cs index 41099f6..c3c2747 100644 --- a/src/HttpClientAuthentication/Handlers/BaseAuthenticationHandler.cs +++ b/src/HttpClientAuthentication/Handlers/BaseAuthenticationHandler.cs @@ -1,4 +1,4 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. namespace KISS.HttpClientAuthentication.Handlers diff --git a/src/HttpClientAuthentication/Handlers/BasicAuthenticationHandler.cs b/src/HttpClientAuthentication/Handlers/BasicAuthenticationHandler.cs index c6cfe3a..deb22ef 100644 --- a/src/HttpClientAuthentication/Handlers/BasicAuthenticationHandler.cs +++ b/src/HttpClientAuthentication/Handlers/BasicAuthenticationHandler.cs @@ -1,4 +1,4 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. using System.Net.Http.Headers; diff --git a/src/HttpClientAuthentication/Handlers/NoAuthenticationHandler.cs b/src/HttpClientAuthentication/Handlers/NoAuthenticationHandler.cs index dfe8eb7..e9f586a 100644 --- a/src/HttpClientAuthentication/Handlers/NoAuthenticationHandler.cs +++ b/src/HttpClientAuthentication/Handlers/NoAuthenticationHandler.cs @@ -1,4 +1,4 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. namespace KISS.HttpClientAuthentication.Handlers diff --git a/src/HttpClientAuthentication/Handlers/OAuth2AuthenticationHandler.cs b/src/HttpClientAuthentication/Handlers/OAuth2AuthenticationHandler.cs index f918acf..1b9f624 100644 --- a/src/HttpClientAuthentication/Handlers/OAuth2AuthenticationHandler.cs +++ b/src/HttpClientAuthentication/Handlers/OAuth2AuthenticationHandler.cs @@ -1,4 +1,4 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. using System.Net.Http.Headers; diff --git a/src/HttpClientAuthentication/Helpers/AccessTokenResponse.cs b/src/HttpClientAuthentication/Helpers/AccessTokenResponse.cs index fe7dba7..81bc364 100644 --- a/src/HttpClientAuthentication/Helpers/AccessTokenResponse.cs +++ b/src/HttpClientAuthentication/Helpers/AccessTokenResponse.cs @@ -1,4 +1,4 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. using System.Text.Json.Serialization; diff --git a/src/HttpClientAuthentication/Helpers/ErrorResponse.cs b/src/HttpClientAuthentication/Helpers/ErrorResponse.cs index 9065c72..78dbc0e 100644 --- a/src/HttpClientAuthentication/Helpers/ErrorResponse.cs +++ b/src/HttpClientAuthentication/Helpers/ErrorResponse.cs @@ -1,4 +1,4 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. using System.Text.Json.Serialization; diff --git a/src/HttpClientAuthentication/Helpers/IOAuth2Provider.cs b/src/HttpClientAuthentication/Helpers/IOAuth2Provider.cs index fa479c9..875ef5a 100644 --- a/src/HttpClientAuthentication/Helpers/IOAuth2Provider.cs +++ b/src/HttpClientAuthentication/Helpers/IOAuth2Provider.cs @@ -1,4 +1,4 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. using KISS.HttpClientAuthentication.Configuration; diff --git a/src/HttpClientAuthentication/Helpers/OAuth2Provider.cs b/src/HttpClientAuthentication/Helpers/OAuth2Provider.cs index 63dc494..978e9af 100644 --- a/src/HttpClientAuthentication/Helpers/OAuth2Provider.cs +++ b/src/HttpClientAuthentication/Helpers/OAuth2Provider.cs @@ -1,8 +1,8 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. +using System.Globalization; using System.Net; -using System.Net.Http.Headers; using System.Text; using System.Text.Json; using KISS.HttpClientAuthentication.Configuration; @@ -23,48 +23,30 @@ internal sealed class OAuth2Provider(IHttpClientFactory clientFactory, ILogger public async ValueTask GetClientCredentialsAccessTokenAsync(OAuth2Configuration configuration, CancellationToken cancellationToken = default) { - if (configuration.GrantType is not OAuth2GrantType.ClientCredentials) - { - throw new ArgumentException($"{nameof(configuration.GrantType)} must be {OAuth2GrantType.ClientCredentials}.", nameof(configuration)); - } + ValidateClientCredentialParameters(configuration); - if (configuration.ClientCredentials is null) - { - throw new ArgumentException($"No valid {nameof(configuration.ClientCredentials)} found.", nameof(configuration)); - } + string cacheKey = $"{configuration.GrantType}#{configuration.TokenEndpoint}#{configuration.ClientCredentials!.ClientId}"; - if (string.IsNullOrWhiteSpace(configuration.ClientCredentials.ClientId)) - { - throw new ArgumentException($"{nameof(configuration.ClientCredentials)}.{nameof(configuration.ClientCredentials.ClientId)} must be specified.", - nameof(configuration)); - } + AccessTokenResponse? token; - if (string.IsNullOrWhiteSpace(configuration.ClientCredentials.ClientSecret)) + if (!configuration.DisableTokenCache) { - throw new ArgumentException($"{nameof(configuration.ClientCredentials)}.{nameof(configuration.ClientCredentials.ClientSecret)} must be specified.", - nameof(configuration)); - } - - - string cacheKey = $"{configuration.GrantType}#{configuration.AuthorizationEndpoint}#{configuration.ClientCredentials!.ClientId}"; + if (memoryCache.TryGetValue(cacheKey, out token)) + { + logger.LogDebug("Token for {TokenEndpoint} with client id {ClientId} found in cache, using this.", + configuration.TokenEndpoint, configuration.ClientCredentials.ClientId); + return token; + } - if (memoryCache.TryGetValue(cacheKey, out AccessTokenResponse? token)) - { - logger.LogInformation("Token for {AuthorizationEndpoint} with client id {ClientId} found in cache, using this.", - configuration.AuthorizationEndpoint, configuration.ClientCredentials.ClientId); - return token; + logger.LogDebug("Could not find existing token in cache, requesting token from endpoint {TokenEndpoint} with client id {ClientId}.", + configuration.TokenEndpoint, configuration.ClientCredentials.ClientId); } - logger.LogDebug("Could not find existing token in cache, requesting token from endpoint {AuthorizationEndpoint} with client id {ClientId}.", - configuration.AuthorizationEndpoint, configuration.ClientCredentials.ClientId); + using HttpRequestMessage request = GetTokenRequest(configuration); - using FormUrlEncodedContent requestContent = GetClientCredentialsContent(configuration.ClientCredentials!, configuration.Scope); + using HttpResponseMessage response = await _client.SendAsync(request, cancellationToken); - using HttpResponseMessage result = configuration.ClientCredentials!.UseBasicAuthorizationHeader - ? await PostWithBasicAuthenticationAsync(configuration, requestContent, cancellationToken).ConfigureAwait(false) - : await _client.PostAsync(configuration.AuthorizationEndpoint, requestContent, cancellationToken).ConfigureAwait(false); - - token = await ParseResponseAsync(configuration, result, cancellationToken).ConfigureAwait(false); + token = await ParseResponseAsync(configuration, response, cancellationToken).ConfigureAwait(false); if (token is null) { @@ -73,63 +55,115 @@ internal sealed class OAuth2Provider(IHttpClientFactory clientFactory, ILogger 0) { double cacheExpiresIn = (int)token.ExpiresIn * 0.95; memoryCache.Set(cacheKey, token, TimeSpan.FromSeconds(cacheExpiresIn)); - logger.LogInformation("Token retrieved from {AuthorizationEndpoint} with client id {ClientId} and cached for {CacheExpiresIn} seconds.", - configuration.AuthorizationEndpoint, configuration.ClientCredentials!.ClientId, cacheExpiresIn); + logger.LogDebug("Token retrieved from {TokenEndpoint} with client id {ClientId} and cached for {CacheExpiresIn} seconds.", + configuration.TokenEndpoint, configuration.ClientCredentials!.ClientId, cacheExpiresIn); } else { - logger.LogInformation("Token retrieved from {AuthorizationEndpoint} with client id {ClientId}, but not cached since it is missing expires_in information.", - configuration.AuthorizationEndpoint, configuration.ClientCredentials!.ClientId); + logger.LogDebug("Token retrieved from {TokenEndpoint} with client id {ClientId}, but not cached since it is missing expires_in information.", + configuration.TokenEndpoint, configuration.ClientCredentials!.ClientId); } return token; } - private static FormUrlEncodedContent GetClientCredentialsContent(ClientCredentialsConfiguration configuration, string? scope) + private static void AddTokenRequestHeaders(HttpRequestMessage request, OAuth2Configuration configuration) + { + if (configuration.ClientCredentials!.UseBasicAuthorizationHeader) + { + string encodedAuthorization = Convert.ToBase64String( + Encoding.ASCII.GetBytes($"{configuration.ClientCredentials!.ClientId}:{configuration.ClientCredentials.ClientSecret}")); + + request.Headers.Authorization = new("Basic", encodedAuthorization); + } + + foreach (KeyValuePair parameter in configuration.TokenEndpoint.AdditionalHeaderParameters) + { + request.Headers.Add(parameter.Key, parameter.Value); + } + } + + private static FormUrlEncodedContent GetClientCredentialsContent(OAuth2Configuration configuration) { Dictionary requestBody = new() { { OAuth2Keyword.GrantType, OAuth2Keyword.ClientCredentials } }; - if (!configuration.UseBasicAuthorizationHeader) + if (!configuration.ClientCredentials!.UseBasicAuthorizationHeader) + { + requestBody.Add(OAuth2Keyword.ClientId, configuration.ClientCredentials.ClientId); + requestBody.Add(OAuth2Keyword.ClientSecret, configuration.ClientCredentials.ClientSecret); + } + + if (!string.IsNullOrWhiteSpace(configuration.Scope)) { - requestBody.Add(OAuth2Keyword.ClientId, configuration.ClientId); - requestBody.Add(OAuth2Keyword.ClientSecret, configuration.ClientSecret); + requestBody.Add(OAuth2Keyword.Scope, configuration.Scope.Trim()); } - if (!string.IsNullOrWhiteSpace(scope)) + foreach (KeyValuePair parameter in configuration.TokenEndpoint.AdditionalBodyParameters) { - requestBody.Add(OAuth2Keyword.Scope, scope!.Trim()); + requestBody.Add(parameter.Key, parameter.Value); } - return new FormUrlEncodedContent(requestBody!); + return new FormUrlEncodedContent(requestBody); + } + + private static Uri GetCompleteTokenUrl(OAuth2Endpoint tokenEndpoint) + { + if (tokenEndpoint.AdditionalQueryParameters.Count == 0) + { + return tokenEndpoint.Url; + } + + UriBuilder uriBuilder = new(tokenEndpoint.Url); + + StringBuilder stringBuilder = new(); + + foreach (KeyValuePair parameter in tokenEndpoint.AdditionalQueryParameters) + { + stringBuilder.Append(CultureInfo.InvariantCulture, $"{parameter.Key}={WebUtility.UrlEncode(parameter.Value)}"); + } + + stringBuilder.Replace("&", "?", 0, 1); + + uriBuilder.Query = stringBuilder.ToString(); + + return uriBuilder.Uri; } - private async Task ParseResponseAsync(OAuth2Configuration configuration, HttpResponseMessage result, + private static HttpRequestMessage GetTokenRequest(OAuth2Configuration configuration) + { + HttpRequestMessage request = new(HttpMethod.Get, GetCompleteTokenUrl(configuration.TokenEndpoint)) + { + Method = HttpMethod.Post, + Content = GetClientCredentialsContent(configuration) + }; + + AddTokenRequestHeaders(request, configuration); + return request; + } + + private async Task ParseResponseAsync(OAuth2Configuration configuration, HttpResponseMessage response, CancellationToken cancellationToken) { -#if NET6_0_OR_GREATER - string body = await result.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); -#else - string body = await result.Content.ReadAsStringAsync().ConfigureAwait(false); -#endif + string body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - if (!result.IsSuccessStatusCode) + if (!response.IsSuccessStatusCode) { - if (result.StatusCode != HttpStatusCode.BadRequest || - !TryParseAndLogOAuth2Error(body, configuration.AuthorizationEndpoint, configuration.ClientCredentials!.ClientId)) + if (response.StatusCode != HttpStatusCode.BadRequest || + !TryParseAndLogOAuth2Error(body, configuration.TokenEndpoint.Url, configuration.ClientCredentials!.ClientId)) { - logger.LogError("Could not authenticate against {AuthorizationEndpoint}, the returned status code was {StatusCode}. Response body: {Body}.", - configuration.AuthorizationEndpoint, result.StatusCode, body); + logger.LogError("Could not authenticate against {TokenEndpoint}, the returned status code was {StatusCode}. Response body: {Body}.", + configuration.TokenEndpoint, response.StatusCode, body); } return null; @@ -139,7 +173,7 @@ private static FormUrlEncodedContent GetClientCredentialsContent(ClientCredentia if (token?.AccessToken is null) { - logger.LogError("The result from {AuthorizationEndpoint} is not a valid OAuth2 result.", configuration.AuthorizationEndpoint); + logger.LogError("The result from {TokenEndpoint} is not a valid OAuth2 result.", configuration.TokenEndpoint); return null; } @@ -154,27 +188,7 @@ private static FormUrlEncodedContent GetClientCredentialsContent(ClientCredentia return token; } - private async Task PostWithBasicAuthenticationAsync(OAuth2Configuration configuration, FormUrlEncodedContent requestContent, - CancellationToken cancellationToken) - { - string encodedAuthorization = Convert.ToBase64String( - Encoding.ASCII.GetBytes($"{configuration.ClientCredentials!.ClientId}:{configuration.ClientCredentials.ClientSecret}")); - - using HttpRequestMessage request = new() - { - Content = requestContent, - Method = HttpMethod.Post, - RequestUri = configuration.AuthorizationEndpoint, - Headers = - { - Authorization = new AuthenticationHeaderValue("Basic", encodedAuthorization) - } - }; - - return await _client.SendAsync(request, cancellationToken).ConfigureAwait(false); - } - - private bool TryParseAndLogOAuth2Error(string errorContent, Uri authorizationEndpoint, string? clientId) + private bool TryParseAndLogOAuth2Error(string errorContent, Uri tokenEndpoint, string? clientId) { ErrorResponse? response = null; @@ -193,7 +207,7 @@ private bool TryParseAndLogOAuth2Error(string errorContent, Uri authorizationEnd StringBuilder logMessage = new($"Could not authenticate against "); - logMessage.Append(authorizationEndpoint); + logMessage.Append(tokenEndpoint); if (!string.IsNullOrWhiteSpace(clientId)) { @@ -224,5 +238,40 @@ private bool TryParseAndLogOAuth2Error(string errorContent, Uri authorizationEnd return true; } + + private static void ValidateClientCredentialParameters(OAuth2Configuration configuration) + { + if (configuration.GrantType is not OAuth2GrantType.ClientCredentials) + { + throw new ArgumentException($"{nameof(configuration.GrantType)} must be {OAuth2GrantType.ClientCredentials}.", nameof(configuration)); + } + + if (configuration.ClientCredentials is null) + { + throw new ArgumentException($"{nameof(configuration.ClientCredentials)} is null.", nameof(configuration)); + } + + if (string.IsNullOrWhiteSpace(configuration.ClientCredentials.ClientId)) + { + throw new ArgumentException($"{nameof(configuration.ClientCredentials)}.{nameof(configuration.ClientCredentials.ClientId)} must be specified.", + nameof(configuration)); + } + + if (string.IsNullOrWhiteSpace(configuration.ClientCredentials.ClientSecret)) + { + throw new ArgumentException($"{nameof(configuration.ClientCredentials)}.{nameof(configuration.ClientCredentials.ClientSecret)} must be specified.", + nameof(configuration)); + } + + if (configuration.TokenEndpoint is null) + { + throw new ArgumentException($"{nameof(configuration.TokenEndpoint)} is null.", nameof(configuration)); + } + + if (configuration.TokenEndpoint.Url is null) + { + throw new ArgumentException($"{nameof(configuration.TokenEndpoint)}.{nameof(configuration.TokenEndpoint.Url)} must be specified.", nameof(configuration)); + } + } } } diff --git a/src/HttpClientAuthentication/HttpClientAuthentication.csproj b/src/HttpClientAuthentication/HttpClientAuthentication.csproj index c43842c..d68f88e 100644 --- a/src/HttpClientAuthentication/HttpClientAuthentication.csproj +++ b/src/HttpClientAuthentication/HttpClientAuthentication.csproj @@ -1,6 +1,6 @@  - netstandard2.0;net6.0;net8.0 + net8.0;net9.0 KISS.HttpClientAuthentication KISS.HttpClientAuthentication @@ -9,9 +9,9 @@ enable - 2.0.2 + 3.0.0 Rune Gulbrandsen - Copyright (c) 2024 Rune Gulbrandsen. All rights reserved. + Copyright (c) 2025 Rune Gulbrandsen. All rights reserved. Extension methods to apply authentication handling to HttpClient based on configuration from .NET configuration providers. @@ -23,20 +23,19 @@ true snupkg - - - - - - + + + + + + - - - - - - + + + + + diff --git a/src/HttpClientAuthentication/HttpClientAuthenticationExtensions.cs b/src/HttpClientAuthentication/HttpClientAuthenticationExtensions.cs index d4f3771..b2e67db 100644 --- a/src/HttpClientAuthentication/HttpClientAuthenticationExtensions.cs +++ b/src/HttpClientAuthentication/HttpClientAuthenticationExtensions.cs @@ -1,4 +1,4 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. using KISS.HttpClientAuthentication.Configuration; @@ -50,20 +50,8 @@ public static IHttpClientBuilder AddAuthenticatedHttpMessageHandler(this IHttpCl /// public static IHttpClientBuilder AddAuthenticatedHttpMessageHandler(this IHttpClientBuilder builder, string configSection) { -#if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(configSection); -#else - if (builder is null) - { - throw new ArgumentNullException(nameof(builder)); - } - - if (configSection is null) - { - throw new ArgumentNullException(nameof(configSection)); - } -#endif builder.Services.AddMemoryCache(); diff --git a/test/HttpClientAuthentication.Test/AssemblyInfo.cs b/test/HttpClientAuthentication.Test/AssemblyInfo.cs index 402c6a0..a7b8d57 100644 --- a/test/HttpClientAuthentication.Test/AssemblyInfo.cs +++ b/test/HttpClientAuthentication.Test/AssemblyInfo.cs @@ -1,4 +1,4 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. [assembly: CLSCompliant(false)] diff --git a/test/HttpClientAuthentication.Test/Handlers/ApiKeyAuthenticationHandlerTests.cs b/test/HttpClientAuthentication.Test/Handlers/ApiKeyAuthenticationHandlerTests.cs index 1a835a2..6e4a3f7 100644 --- a/test/HttpClientAuthentication.Test/Handlers/ApiKeyAuthenticationHandlerTests.cs +++ b/test/HttpClientAuthentication.Test/Handlers/ApiKeyAuthenticationHandlerTests.cs @@ -1,4 +1,4 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. using FluentAssertions; diff --git a/test/HttpClientAuthentication.Test/Handlers/BasicAuthenticationHandlerTests.cs b/test/HttpClientAuthentication.Test/Handlers/BasicAuthenticationHandlerTests.cs index 53f1130..2bb4750 100644 --- a/test/HttpClientAuthentication.Test/Handlers/BasicAuthenticationHandlerTests.cs +++ b/test/HttpClientAuthentication.Test/Handlers/BasicAuthenticationHandlerTests.cs @@ -1,4 +1,4 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. using System.Text; diff --git a/test/HttpClientAuthentication.Test/Handlers/HandlerTestBase.cs b/test/HttpClientAuthentication.Test/Handlers/HandlerTestBase.cs index 61fa5dd..8f94aa3 100644 --- a/test/HttpClientAuthentication.Test/Handlers/HandlerTestBase.cs +++ b/test/HttpClientAuthentication.Test/Handlers/HandlerTestBase.cs @@ -1,4 +1,4 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. using System.Net; diff --git a/test/HttpClientAuthentication.Test/Handlers/OAuth2AuthenticationHandlerTests.cs b/test/HttpClientAuthentication.Test/Handlers/OAuth2AuthenticationHandlerTests.cs index 9b7a7ba..f309770 100644 --- a/test/HttpClientAuthentication.Test/Handlers/OAuth2AuthenticationHandlerTests.cs +++ b/test/HttpClientAuthentication.Test/Handlers/OAuth2AuthenticationHandlerTests.cs @@ -1,4 +1,4 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. using FluentAssertions; diff --git a/test/HttpClientAuthentication.Test/Helpers/OAuth2ProviderTests/GetClientCredentialsAccessTokenAsyncTests.cs b/test/HttpClientAuthentication.Test/Helpers/OAuth2ProviderTests/GetClientCredentialsAccessTokenAsyncTests.cs index a4428d5..af682b0 100644 --- a/test/HttpClientAuthentication.Test/Helpers/OAuth2ProviderTests/GetClientCredentialsAccessTokenAsyncTests.cs +++ b/test/HttpClientAuthentication.Test/Helpers/OAuth2ProviderTests/GetClientCredentialsAccessTokenAsyncTests.cs @@ -1,11 +1,10 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. using System.Net; using System.Net.Http.Json; using System.Text; using FluentAssertions; -using FluentAssertions.Execution; using KISS.HttpClientAuthentication.Configuration; using KISS.HttpClientAuthentication.Constants; using KISS.HttpClientAuthentication.Helpers; @@ -38,7 +37,7 @@ public async Task TestMissingClientCredentialsConfigurationSectionThrowsArgument Func act = () => provider.GetClientCredentialsAccessTokenAsync(new() { GrantType = OAuth2GrantType.ClientCredentials }, default!) .AsTask(); - await act.Should().ThrowAsync().WithParameterName("configuration").WithMessage("No valid ClientCredentials found.*"); + await act.Should().ThrowAsync().WithParameterName("configuration").WithMessage("ClientCredentials is null.*"); } [Theory] @@ -54,7 +53,7 @@ public async Task TestClientIdIsNullEmptyOrWhitespacesThrowsArgumentException(st OAuth2Configuration configuration = new() { GrantType = OAuth2GrantType.ClientCredentials, - AuthorizationEndpoint = new("https://somehost/"), + TokenEndpoint = new() { Url = new("https://somehost/") }, ClientCredentials = new() { ClientId = clientId!, @@ -83,7 +82,7 @@ public async Task TestClientSecretIsNullEmptyOrWhitespacesThrowsArgumentExceptio OAuth2Configuration configuration = new() { GrantType = OAuth2GrantType.ClientCredentials, - AuthorizationEndpoint = new("https://somehost/"), + TokenEndpoint = new() { Url = new("https://somehost/") }, ClientCredentials = new() { ClientId = "client_id", @@ -99,6 +98,43 @@ await act.Should().ThrowAsync() .WithMessage("ClientCredentials.ClientSecret must be specified.*"); } + [Fact] + public async Task TestMissingTokenEndpointConfigurationSectionThrowsArgumentException() + { + OAuth2Provider provider = BuildServices().GetRequiredService(); + + Func act = () => provider.GetClientCredentialsAccessTokenAsync(new() + { + GrantType = OAuth2GrantType.ClientCredentials, + ClientCredentials = new() + { + ClientId = "client_id", + ClientSecret = "client_secret" + } + }, default!).AsTask(); + + await act.Should().ThrowAsync().WithParameterName("configuration").WithMessage("TokenEndpoint is null.*"); + } + + [Fact] + public async Task TestMissingTokenEndpointUrlThrowsArgumentException() + { + OAuth2Provider provider = BuildServices().GetRequiredService(); + + Func act = () => provider.GetClientCredentialsAccessTokenAsync(new() + { + GrantType = OAuth2GrantType.ClientCredentials, + ClientCredentials = new() + { + ClientId = "client_id", + ClientSecret = "client_secret" + }, + TokenEndpoint = new() + }, default!).AsTask(); + + await act.Should().ThrowAsync().WithParameterName("configuration").WithMessage("TokenEndpoint.Url must be specified.*"); + } + [Fact] public async Task TestCacheHitIsReturnedAsToken() { @@ -120,7 +156,7 @@ public async Task TestCacheHitIsReturnedAsToken() OAuth2Configuration configuration = new() { GrantType = OAuth2GrantType.ClientCredentials, - AuthorizationEndpoint = new("https://somehost/"), + TokenEndpoint = new() { Url = new("https://somehost/") }, ClientCredentials = new() { ClientId = "client_id", @@ -138,7 +174,7 @@ public async Task TestCacheHitIsReturnedAsToken() Mock> loggerMock = services.GetRequiredService>>(); - loggerMock.VerifyExt(l => l.LogInformation("Token for {AuthorizationEndpoint} with client id {ClientId} found in cache, using this.", + loggerMock.VerifyExt(l => l.LogDebug("Token for {TokenEndpoint} with client id {ClientId} found in cache, using this.", "https://somehost/", "client_id"), Times.Once); } @@ -165,7 +201,7 @@ public async Task TestGetAndCacheAccessTokenResponse() OAuth2Configuration configuration = new() { GrantType = OAuth2GrantType.ClientCredentials, - AuthorizationEndpoint = new("https://somehost/"), + TokenEndpoint = new() { Url = new("https://somehost/") }, ClientCredentials = new() { ClientId = "client_id", @@ -191,10 +227,10 @@ public async Task TestGetAndCacheAccessTokenResponse() Mock> loggerMock = services.GetRequiredService>>(); - loggerMock.VerifyExt(l => l.LogDebug("Could not find existing token in cache, requesting token from endpoint {AuthorizationEndpoint} with client id {ClientId}.", + loggerMock.VerifyExt(l => l.LogDebug("Could not find existing token in cache, requesting token from endpoint {TokenEndpoint} with client id {ClientId}.", "https://somehost/", "client_id"), Times.Once); - loggerMock.VerifyExt(l => l.LogInformation("Token retrieved from {AuthorizationEndpoint} with client id {ClientId} and cached for {CacheExpiresIn} seconds.", + loggerMock.VerifyExt(l => l.LogDebug("Token retrieved from {TokenEndpoint} with client id {ClientId} and cached for {CacheExpiresIn} seconds.", "https://somehost/", "client_id", 3420), Times.Once); } @@ -221,7 +257,7 @@ public async Task TestNoCachingOfAccessTokenResponseWithMissingExpiresIn() OAuth2Configuration configuration = new() { GrantType = OAuth2GrantType.ClientCredentials, - AuthorizationEndpoint = new("https://somehost/"), + TokenEndpoint = new() { Url = new("https://somehost/") }, ClientCredentials = new() { ClientId = "client_id", @@ -238,7 +274,7 @@ public async Task TestNoCachingOfAccessTokenResponseWithMissingExpiresIn() Mock> loggerMock = services.GetRequiredService>>(); - loggerMock.VerifyExt(l => l.LogInformation("Token retrieved from {AuthorizationEndpoint} with client id {ClientId}, but not cached since it is missing expires_in information.", + loggerMock.VerifyExt(l => l.LogDebug("Token retrieved from {TokenEndpoint} with client id {ClientId}, but not cached since it is missing expires_in information.", "https://somehost/", "client_id"), Times.Once); } @@ -265,7 +301,7 @@ public async Task TestNoCachingOfAccessTokenResponseWhenCacheIsDiabled() OAuth2Configuration configuration = new() { GrantType = OAuth2GrantType.ClientCredentials, - AuthorizationEndpoint = new("https://somehost/"), + TokenEndpoint = new() { Url = new("https://somehost/") }, ClientCredentials = new() { ClientId = "client_id", @@ -283,7 +319,7 @@ public async Task TestNoCachingOfAccessTokenResponseWhenCacheIsDiabled() Mock> loggerMock = services.GetRequiredService>>(); - loggerMock.VerifyExt(l => l.LogInformation("Token retrieved from {AuthorizationEndpoint} with client id {ClientId}, but the token cache is disabled.", + loggerMock.VerifyExt(l => l.LogDebug("Token retrieved from {TokenEndpoint} with client id {ClientId}, but the token cache is disabled.", "https://somehost/", "client_id"), Times.Once); } @@ -294,21 +330,24 @@ public async Task TestUseFormBasedAuthentication() Mock httpClientMock = services.GetRequiredService>(); + HttpRequestMessage? actualRequest = null; + httpClientMock.Setup(httpClient => httpClient.SendAsync(It.IsAny(), It.IsAny())) - .Callback(async (HttpRequestMessage request, CancellationToken _) => + .Callback((HttpRequestMessage request, CancellationToken _) => actualRequest = request) + .ReturnsAsync(() => { - request.Should().NotBeNull("Missing SendAsync request."); - request.Headers.Authorization.Should().BeNull(); + actualRequest!.Headers.Authorization.Should().BeNull(); - string content = await request.Content!.ReadAsStringAsync(default); - content.Should().Be($"grant_type=client_credentials&client_id=client_id&client_secret=client_secret{Environment.NewLine}"); - }) - .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.NotFound)); + string content = actualRequest.Content!.ReadAsStringAsync(default).GetAwaiter().GetResult(); + content.Should().Be($"grant_type=client_credentials&client_id=client_id&client_secret=client_secret"); + + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); OAuth2Configuration configuration = new() { GrantType = OAuth2GrantType.ClientCredentials, - AuthorizationEndpoint = new("https://somehost/"), + TokenEndpoint = new() { Url = new("https://somehost/") }, ClientCredentials = new() { ClientId = "client_id", @@ -328,29 +367,26 @@ public async Task TestUseBasicAuthentication() Mock httpClientMock = services.GetRequiredService>(); - HttpRequestMessage? actualRequest = null!; + HttpRequestMessage? actualRequest = null; httpClientMock.Setup(httpClient => httpClient.SendAsync(It.IsAny(), It.IsAny())) - .Callback(async (HttpRequestMessage request, CancellationToken _) => + .Callback((HttpRequestMessage request, CancellationToken _) => actualRequest = request) + .ReturnsAsync(() => { - actualRequest.Should().NotBeNull("Missing SendAsync request."); - actualRequest.Headers.Authorization.Should().NotBeNull(); + actualRequest!.Headers.Authorization.Should().NotBeNull(); - using (new AssertionScope()) - { - actualRequest.Headers.Authorization!.Scheme.Should().Be("Basic"); - actualRequest.Headers.Authorization!.Parameter.Should().Be(Convert.ToBase64String(Encoding.ASCII.GetBytes($"client_id:client_secret"))); - } + actualRequest.Headers.Authorization!.Scheme.Should().Be("Basic"); + actualRequest.Headers.Authorization!.Parameter.Should().Be(Convert.ToBase64String(Encoding.ASCII.GetBytes($"client_id:client_secret"))); - string content = await request.Content!.ReadAsStringAsync(default); - content.Should().Be($"grant_type=client_credentials{Environment.NewLine}"); - }) - .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.NotFound)); + string content = actualRequest.Content!.ReadAsStringAsync(default).GetAwaiter().GetResult(); + content.Should().Be($"grant_type=client_credentials"); + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); OAuth2Configuration configuration = new() { GrantType = OAuth2GrantType.ClientCredentials, - AuthorizationEndpoint = new("https://somehost/"), + TokenEndpoint = new() { Url = new("https://somehost/") }, ClientCredentials = new() { UseBasicAuthorizationHeader = true, @@ -371,21 +407,24 @@ public async Task TestRequestContainsScopeWhenSpecified() Mock httpClientMock = services.GetRequiredService>(); + HttpRequestMessage? actualRequest = null; + httpClientMock.Setup(httpClient => httpClient.SendAsync(It.IsAny(), It.IsAny())) - .Callback(async (HttpRequestMessage request, CancellationToken _) => + .Callback((HttpRequestMessage request, CancellationToken _) => actualRequest = request) + .ReturnsAsync(() => { - request.Should().NotBeNull("Missing SendAsync request."); - request.Headers.Authorization.Should().BeNull(); + actualRequest!.Headers.Authorization.Should().BeNull(); + + string content = actualRequest.Content!.ReadAsStringAsync(default).GetAwaiter().GetResult(); + content.Should().Contain($"scope=test_scope"); - string content = await request.Content!.ReadAsStringAsync(default); - content.Should().Be($"*scope=test_scope*"); - }) - .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.NotFound)); + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); OAuth2Configuration configuration = new() { GrantType = OAuth2GrantType.ClientCredentials, - AuthorizationEndpoint = new("https://somehost/"), + TokenEndpoint = new() { Url = new("https://somehost/") }, ClientCredentials = new() { ClientId = "client_id", @@ -410,21 +449,24 @@ public async Task TestRequestHasNoScopeWhenNullEmptyOrWhitespace(string? scope) Mock httpClientMock = services.GetRequiredService>(); + HttpRequestMessage? actualRequest = null; + httpClientMock.Setup(httpClient => httpClient.SendAsync(It.IsAny(), It.IsAny())) - .Callback(async (HttpRequestMessage request, CancellationToken _) => + .Callback((HttpRequestMessage request, CancellationToken _) => actualRequest = request) + .ReturnsAsync(() => { - request.Should().NotBeNull("Missing SendAsync request."); - request.Headers.Authorization.Should().BeNull(); + actualRequest!.Headers.Authorization.Should().BeNull(); - string content = await request.Content!.ReadAsStringAsync(default); - content.Should().NotBe($"*scope=*"); - }) - .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.NotFound)); + string content = actualRequest.Content!.ReadAsStringAsync(default).GetAwaiter().GetResult(); + content.Should().NotContain($"scope="); + + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); OAuth2Configuration configuration = new() { GrantType = OAuth2GrantType.ClientCredentials, - AuthorizationEndpoint = new("https://somehost/"), + TokenEndpoint = new() { Url = new("https://somehost/") }, ClientCredentials = new() { ClientId = "client_id", @@ -437,5 +479,62 @@ public async Task TestRequestHasNoScopeWhenNullEmptyOrWhitespace(string? scope) await provider.GetClientCredentialsAccessTokenAsync(configuration, default); } + + + [Fact] + public async Task TestRequestContainsAdditionalParametersSpecified() + { + IServiceProvider services = BuildServices(); + + Mock httpClientMock = services.GetRequiredService>(); + + HttpRequestMessage? actualRequest = null; + + httpClientMock.Setup(httpClient => httpClient.SendAsync(It.IsAny(), It.IsAny())) + .Callback((HttpRequestMessage request, CancellationToken _) => actualRequest = request) + .ReturnsAsync(() => + { + actualRequest!.RequestUri!.Query.Should().Be("?query=query_value_with_%3F"); + + actualRequest.Headers.Should().Contain(kvp => + kvp.Key == "header" && kvp.Value.All(v => "header_value".Equals(v))); + + string content = actualRequest.Content!.ReadAsStringAsync(default).GetAwaiter().GetResult(); + + content.Should().Contain("body=body_value"); + + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + + OAuth2Configuration configuration = new() + { + GrantType = OAuth2GrantType.ClientCredentials, + TokenEndpoint = new() + { + Url = new("https://somehost/"), + AdditionalBodyParameters = + { + { "body", "body_value" } + }, + AdditionalHeaderParameters = + { + { "header", "header_value" } + }, + AdditionalQueryParameters = + { + { "query", "query_value_with_?" } + } + }, + ClientCredentials = new() + { + ClientId = "client_id", + ClientSecret = "client_secret" + } + }; + + OAuth2Provider provider = services.GetRequiredService(); + + await provider.GetClientCredentialsAccessTokenAsync(configuration, default); + } } } diff --git a/test/HttpClientAuthentication.Test/Helpers/OAuth2ProviderTests/ParseResponseAsyncTests.cs b/test/HttpClientAuthentication.Test/Helpers/OAuth2ProviderTests/ParseResponseAsyncTests.cs index 892780b..00bdf10 100644 --- a/test/HttpClientAuthentication.Test/Helpers/OAuth2ProviderTests/ParseResponseAsyncTests.cs +++ b/test/HttpClientAuthentication.Test/Helpers/OAuth2ProviderTests/ParseResponseAsyncTests.cs @@ -1,4 +1,4 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. using System.Net; @@ -41,7 +41,7 @@ public async Task TestSetsTokenTypeToBearerWhenNotSpecified() OAuth2Configuration configuration = new() { GrantType = OAuth2GrantType.ClientCredentials, - AuthorizationEndpoint = new("https://somehost/"), + TokenEndpoint = new() { Url = new("https://somehost/") }, ClientCredentials = new() { ClientId = "client_id", @@ -81,7 +81,7 @@ public async Task TestUsesConfiguredAuthorizationSchemeAsTokenType() OAuth2Configuration configuration = new() { GrantType = OAuth2GrantType.ClientCredentials, - AuthorizationEndpoint = new("https://somehost/"), + TokenEndpoint = new() { Url = new("https://somehost/") }, AuthorizationScheme = "Authorization_Scheme", ClientCredentials = new() { @@ -110,7 +110,7 @@ public async Task TestFailedRequestReturnsNullAndLogsError() OAuth2Configuration configuration = new() { GrantType = OAuth2GrantType.ClientCredentials, - AuthorizationEndpoint = new("https://somehost/"), + TokenEndpoint = new() { Url = new("https://somehost/") }, ClientCredentials = new() { ClientId = "client_id", @@ -126,7 +126,7 @@ public async Task TestFailedRequestReturnsNullAndLogsError() Mock> loggerMock = services.GetRequiredService>>(); - loggerMock.VerifyExt(l => l.LogError("Could not authenticate against {AuthorizationEndpoint}, the returned status code was {StatusCode}. Response body: {Body}.", + loggerMock.VerifyExt(l => l.LogError("Could not authenticate against {TokenEndpoint}, the returned status code was {StatusCode}. Response body: {Body}.", "https://somehost/", HttpStatusCode.NotFound, "ERROR_BODY"), Times.Once); } @@ -146,7 +146,7 @@ public async Task TestInvalidResponseReturnsNullAndLogsError(string response) OAuth2Configuration configuration = new() { GrantType = OAuth2GrantType.ClientCredentials, - AuthorizationEndpoint = new("https://somehost/"), + TokenEndpoint = new() { Url = new("https://somehost/") }, ClientCredentials = new() { ClientId = "client_id", @@ -162,7 +162,7 @@ public async Task TestInvalidResponseReturnsNullAndLogsError(string response) Mock> loggerMock = services.GetRequiredService>>(); - loggerMock.VerifyExt(l => l.LogError("The result from {AuthorizationEndpoint} is not a valid OAuth2 result.", "https://somehost/"), + loggerMock.VerifyExt(l => l.LogError("The result from {TokenEndpoint} is not a valid OAuth2 result.", "https://somehost/"), Times.Once); } } diff --git a/test/HttpClientAuthentication.Test/Helpers/OAuth2ProviderTests/TestBase.cs b/test/HttpClientAuthentication.Test/Helpers/OAuth2ProviderTests/TestBase.cs index 3304015..fcba625 100644 --- a/test/HttpClientAuthentication.Test/Helpers/OAuth2ProviderTests/TestBase.cs +++ b/test/HttpClientAuthentication.Test/Helpers/OAuth2ProviderTests/TestBase.cs @@ -1,4 +1,4 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. using KISS.HttpClientAuthentication.Helpers; diff --git a/test/HttpClientAuthentication.Test/Helpers/OAuth2ProviderTests/TryParseAndLogOAuth2ErrorTests.cs b/test/HttpClientAuthentication.Test/Helpers/OAuth2ProviderTests/TryParseAndLogOAuth2ErrorTests.cs index 63df3fe..d25b7df 100644 --- a/test/HttpClientAuthentication.Test/Helpers/OAuth2ProviderTests/TryParseAndLogOAuth2ErrorTests.cs +++ b/test/HttpClientAuthentication.Test/Helpers/OAuth2ProviderTests/TryParseAndLogOAuth2ErrorTests.cs @@ -1,4 +1,4 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. using System.Net; @@ -37,7 +37,7 @@ public async Task TestErrorIsFullyParsedAndLogged() OAuth2Configuration configuration = new() { GrantType = OAuth2GrantType.ClientCredentials, - AuthorizationEndpoint = new("https://somehost/"), + TokenEndpoint = new() { Url = new("https://somehost/") }, ClientCredentials = new() { ClientId = "client_id", @@ -76,7 +76,7 @@ public async Task TestDescriptionIsSkippedWhenNotSpecified() OAuth2Configuration configuration = new() { GrantType = OAuth2GrantType.ClientCredentials, - AuthorizationEndpoint = new("https://somehost/"), + TokenEndpoint = new() { Url = new("https://somehost/") }, ClientCredentials = new() { ClientId = "client_id", @@ -115,7 +115,7 @@ public async Task TestUriIsSkippedWhenNotSpecified() OAuth2Configuration configuration = new() { GrantType = OAuth2GrantType.ClientCredentials, - AuthorizationEndpoint = new("https://somehost/"), + TokenEndpoint = new() { Url = new("https://somehost/") }, ClientCredentials = new() { ClientId = "client_id", @@ -154,7 +154,7 @@ public async Task TestDescriptionAndUriIsSkippedWhenNotSpecified() OAuth2Configuration configuration = new() { GrantType = OAuth2GrantType.ClientCredentials, - AuthorizationEndpoint = new("https://somehost/"), + TokenEndpoint = new() { Url = new("https://somehost/") }, ClientCredentials = new() { ClientId = "client_id", @@ -191,7 +191,7 @@ public async Task TestParsingIsSkippedOnInvalidErrorContent(string content) OAuth2Configuration configuration = new() { GrantType = OAuth2GrantType.ClientCredentials, - AuthorizationEndpoint = new("https://somehost/"), + TokenEndpoint = new() { Url = new("https://somehost/") }, ClientCredentials = new() { ClientId = "client_id", @@ -207,7 +207,7 @@ public async Task TestParsingIsSkippedOnInvalidErrorContent(string content) Mock> loggerMock = services.GetRequiredService>>(); - loggerMock.VerifyExt(l => l.LogError("Could not authenticate against {AuthorizationEndpoint}, the returned status code was {StatusCode}. Response body: {Body}.", + loggerMock.VerifyExt(l => l.LogError("Could not authenticate against {TokenEndpoint}, the returned status code was {StatusCode}. Response body: {Body}.", "https://somehost/", HttpStatusCode.BadRequest, content), Times.Once); } } diff --git a/test/HttpClientAuthentication.Test/HttpClientAuthentication.Test.csproj b/test/HttpClientAuthentication.Test/HttpClientAuthentication.Test.csproj index ba829d0..4a8f680 100644 --- a/test/HttpClientAuthentication.Test/HttpClientAuthentication.Test.csproj +++ b/test/HttpClientAuthentication.Test/HttpClientAuthentication.Test.csproj @@ -1,7 +1,7 @@  - net8.0 + net9.0 KISS.HttpClientAuthentication.Test KISS.HttpClientAuthentication.Test @@ -14,21 +14,21 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - + + - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/test/HttpClientAuthentication.Test/HttpClientAuthenticationExtensionsTests.cs b/test/HttpClientAuthentication.Test/HttpClientAuthenticationExtensionsTests.cs index 53967ec..5942a36 100644 --- a/test/HttpClientAuthentication.Test/HttpClientAuthenticationExtensionsTests.cs +++ b/test/HttpClientAuthentication.Test/HttpClientAuthenticationExtensionsTests.cs @@ -1,4 +1,4 @@ -// Copyright © 2024 Rune Gulbrandsen. +// Copyright © 2025 Rune Gulbrandsen. // All rights reserved. Licensed under the MIT License; see LICENSE.txt. using FluentAssertions;