Skip to content

Commit 9970825

Browse files
Add support for token endpoint parameters
Added support for supplying custom parameters in body, headers and body for the token endpoint request. This is a breaking change since the configuration has changed.
1 parent 3ee78f9 commit 9970825

File tree

8 files changed

+339
-144
lines changed

8 files changed

+339
-144
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,5 +344,8 @@ healthchecksdb
344344
/test/coverage.opencover.xml
345345
/test/result.json
346346

347+
# POC related
348+
/poc
349+
347350
# Custom exclusions
348351
*.AssemblyAttributes

README.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ Authentication using OAuth2.
7070

7171
##### Client credentials
7272

73-
Using OAuth2 client credentials, all settings except `DisableTokenCache` and `Scope` is required.
73+
Using OAuth2 client credentials, all settings except `DisableTokenCache`, `Scope` and
74+
`TokenEndpoint`'s `Additional*Parameters` is required.
7475

7576
```
7677
"<section name>": {
@@ -79,7 +80,15 @@ Using OAuth2 client credentials, all settings except `DisableTokenCache` and `Sc
7980
"DisableTokenCache": false,
8081
"GrantType": "ClientCredentials",
8182
"Scope": "<Optional scopes separated by space>",
82-
"TokenEndpoint": "<OAuth2 token endpoint>",
83+
"TokenEndpoint": {
84+
"Url": "<OAuth2 token endpoint>",
85+
"AdditionalHeaderParameters": {
86+
},
87+
"AdditionalBodyParameters": {
88+
},
89+
"AdditionalQueryParameters": {
90+
}
91+
},
8392
"ClientCredentials": {
8493
"ClientId": "<Unique client id>",
8594
"ClientSecret": "<Secret connected to the client id>"
@@ -88,8 +97,9 @@ Using OAuth2 client credentials, all settings except `DisableTokenCache` and `Sc
8897
}
8998
```
9099

91-
> **NOTE**: The previous `AuthorizationEndpoint` is replaced by `TokenEndpoint`. It still exists,
92-
but is obsoleted and will be removed in a later version.
100+
The `Additional*Parameters` configuration is dynamic, any configuration in these will
101+
be added to their respective parts of the request accordingly. Please note that the
102+
`AdditionalQueryParameters` will be url encoded.
93103

94104
### Examples
95105

src/HttpClientAuthentication/Configuration/OAuth2Configuration.cs

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,6 @@ namespace KISS.HttpClientAuthentication.Configuration
1010
/// </summary>
1111
public sealed class OAuth2Configuration
1212
{
13-
/// <summary>
14-
/// Gets or sets the authorization endpoint used by some <see cref="AuthenticationProvider"/>
15-
/// configuration.
16-
/// </summary>
17-
/// <remarks>
18-
/// Obsolete: Use <see cref="TokenEndpoint"/> instead.
19-
/// </remarks>
20-
[Obsolete("Use TokenEndpoint instead.")]
21-
public Uri AuthorizationEndpoint { get => TokenEndpoint; set => TokenEndpoint = value; }
22-
2313
/// <summary>
2414
/// Gets or sets the authorization scheme to use if <see cref="AuthenticationHeader"/> is
2515
/// Authorization or the selected <see cref="AuthenticationProvider"/> uses Authorization as default header.
@@ -58,6 +48,6 @@ public sealed class OAuth2Configuration
5848
/// <remarks>
5949
/// Replaces <see cref="AuthorizationEndpoint"/>.
6050
/// </remarks>
61-
public Uri TokenEndpoint { get; set; } = default!;
51+
public OAuth2Endpoint TokenEndpoint { get; set; } = default!;
6252
}
6353
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright © 2025 Rune Gulbrandsen.
2+
// All rights reserved. Licensed under the MIT License; see LICENSE.txt.
3+
4+
namespace KISS.HttpClientAuthentication.Configuration
5+
{
6+
/// <summary>
7+
/// Endpoint configuration for OAuth2 endpoints
8+
/// </summary>
9+
public sealed partial class OAuth2Endpoint
10+
{
11+
/// <summary>
12+
/// Gets or sets the Url to the OAuth2 endpoint.
13+
/// </summary>
14+
public Uri Url { get; set; } = default!;
15+
16+
/// <summary>
17+
/// Gets a dictionary that can contain additional headers that will be
18+
/// supplied when requesting <see cref="Url"/>.
19+
/// </summary>
20+
public Dictionary<string, string> AdditionalHeaderParameters { get; } = [];
21+
22+
23+
/// <summary>
24+
/// Gets a collection of additional form body parameters that will be
25+
/// supplied when requesting <see cref="Url"/>.
26+
/// </summary>
27+
public Dictionary<string, string> AdditionalBodyParameters { get; } = [];
28+
29+
/// <summary>
30+
/// Gets a collection of additional query string parameters that will
31+
/// be supplied when requesting <see cref="Url"/>.
32+
/// </summary>
33+
public Dictionary<string, string> AdditionalQueryParameters { get; } = [];
34+
35+
public override string ToString()
36+
{
37+
return Url.ToString();
38+
}
39+
}
40+
}

src/HttpClientAuthentication/Helpers/OAuth2Provider.cs

Lines changed: 123 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// Copyright © 2025 Rune Gulbrandsen.
22
// All rights reserved. Licensed under the MIT License; see LICENSE.txt.
33

4+
using System.Globalization;
45
using System.Net;
5-
using System.Net.Http.Headers;
66
using System.Text;
77
using System.Text.Json;
88
using KISS.HttpClientAuthentication.Configuration;
@@ -23,48 +23,30 @@ internal sealed class OAuth2Provider(IHttpClientFactory clientFactory, ILogger<O
2323
/// <inheritdoc />
2424
public async ValueTask<AccessTokenResponse?> GetClientCredentialsAccessTokenAsync(OAuth2Configuration configuration, CancellationToken cancellationToken = default)
2525
{
26-
if (configuration.GrantType is not OAuth2GrantType.ClientCredentials)
27-
{
28-
throw new ArgumentException($"{nameof(configuration.GrantType)} must be {OAuth2GrantType.ClientCredentials}.", nameof(configuration));
29-
}
30-
31-
if (configuration.ClientCredentials is null)
32-
{
33-
throw new ArgumentException($"No valid {nameof(configuration.ClientCredentials)} found.", nameof(configuration));
34-
}
35-
36-
if (string.IsNullOrWhiteSpace(configuration.ClientCredentials.ClientId))
37-
{
38-
throw new ArgumentException($"{nameof(configuration.ClientCredentials)}.{nameof(configuration.ClientCredentials.ClientId)} must be specified.",
39-
nameof(configuration));
40-
}
41-
42-
if (string.IsNullOrWhiteSpace(configuration.ClientCredentials.ClientSecret))
43-
{
44-
throw new ArgumentException($"{nameof(configuration.ClientCredentials)}.{nameof(configuration.ClientCredentials.ClientSecret)} must be specified.",
45-
nameof(configuration));
46-
}
47-
26+
ValidateClientCredentialParameters(configuration);
4827

4928
string cacheKey = $"{configuration.GrantType}#{configuration.TokenEndpoint}#{configuration.ClientCredentials!.ClientId}";
5029

51-
if (memoryCache.TryGetValue(cacheKey, out AccessTokenResponse? token))
30+
AccessTokenResponse? token;
31+
32+
if (!configuration.DisableTokenCache)
5233
{
53-
logger.LogInformation("Token for {TokenEndpoint} with client id {ClientId} found in cache, using this.",
54-
configuration.TokenEndpoint, configuration.ClientCredentials.ClientId);
55-
return token;
56-
}
34+
if (memoryCache.TryGetValue(cacheKey, out token))
35+
{
36+
logger.LogDebug("Token for {TokenEndpoint} with client id {ClientId} found in cache, using this.",
37+
configuration.TokenEndpoint, configuration.ClientCredentials.ClientId);
38+
return token;
39+
}
5740

58-
logger.LogDebug("Could not find existing token in cache, requesting token from endpoint {TokenEndpoint} with client id {ClientId}.",
41+
logger.LogDebug("Could not find existing token in cache, requesting token from endpoint {TokenEndpoint} with client id {ClientId}.",
5942
configuration.TokenEndpoint, configuration.ClientCredentials.ClientId);
43+
}
6044

61-
using FormUrlEncodedContent requestContent = GetClientCredentialsContent(configuration.ClientCredentials!, configuration.Scope);
45+
using HttpRequestMessage request = GetTokenRequest(configuration);
6246

63-
using HttpResponseMessage result = configuration.ClientCredentials!.UseBasicAuthorizationHeader
64-
? await PostWithBasicAuthenticationAsync(configuration, requestContent, cancellationToken).ConfigureAwait(false)
65-
: await _client.PostAsync(configuration.TokenEndpoint, requestContent, cancellationToken).ConfigureAwait(false);
47+
using HttpResponseMessage response = await _client.SendAsync(request, cancellationToken);
6648

67-
token = await ParseResponseAsync(configuration, result, cancellationToken).ConfigureAwait(false);
49+
token = await ParseResponseAsync(configuration, response, cancellationToken).ConfigureAwait(false);
6850

6951
if (token is null)
7052
{
@@ -73,59 +55,115 @@ internal sealed class OAuth2Provider(IHttpClientFactory clientFactory, ILogger<O
7355

7456
if (configuration.DisableTokenCache)
7557
{
76-
logger.LogInformation("Token retrieved from {TokenEndpoint} with client id {ClientId}, but the token cache is disabled.",
58+
logger.LogDebug("Token retrieved from {TokenEndpoint} with client id {ClientId}, but the token cache is disabled.",
7759
configuration.TokenEndpoint, configuration.ClientCredentials!.ClientId);
7860
}
7961
else if (token.ExpiresIn > 0)
8062
{
8163
double cacheExpiresIn = (int)token.ExpiresIn * 0.95;
8264
memoryCache.Set(cacheKey, token, TimeSpan.FromSeconds(cacheExpiresIn));
8365

84-
logger.LogInformation("Token retrieved from {TokenEndpoint} with client id {ClientId} and cached for {CacheExpiresIn} seconds.",
66+
logger.LogDebug("Token retrieved from {TokenEndpoint} with client id {ClientId} and cached for {CacheExpiresIn} seconds.",
8567
configuration.TokenEndpoint, configuration.ClientCredentials!.ClientId, cacheExpiresIn);
8668
}
8769
else
8870
{
89-
logger.LogInformation("Token retrieved from {TokenEndpoint} with client id {ClientId}, but not cached since it is missing expires_in information.",
71+
logger.LogDebug("Token retrieved from {TokenEndpoint} with client id {ClientId}, but not cached since it is missing expires_in information.",
9072
configuration.TokenEndpoint, configuration.ClientCredentials!.ClientId);
9173
}
9274

9375
return token;
9476
}
9577

96-
private static FormUrlEncodedContent GetClientCredentialsContent(ClientCredentialsConfiguration configuration, string? scope)
78+
private static void AddTokenRequestHeaders(HttpRequestMessage request, OAuth2Configuration configuration)
79+
{
80+
if (configuration.ClientCredentials!.UseBasicAuthorizationHeader)
81+
{
82+
string encodedAuthorization = Convert.ToBase64String(
83+
Encoding.ASCII.GetBytes($"{configuration.ClientCredentials!.ClientId}:{configuration.ClientCredentials.ClientSecret}"));
84+
85+
request.Headers.Authorization = new("Basic", encodedAuthorization);
86+
}
87+
88+
foreach (KeyValuePair<string, string> parameter in configuration.TokenEndpoint.AdditionalHeaderParameters)
89+
{
90+
request.Headers.Add(parameter.Key, parameter.Value);
91+
}
92+
}
93+
94+
private static FormUrlEncodedContent GetClientCredentialsContent(OAuth2Configuration configuration)
9795
{
9896
Dictionary<string, string> requestBody = new()
9997
{
10098
{ OAuth2Keyword.GrantType, OAuth2Keyword.ClientCredentials }
10199
};
102100

103-
if (!configuration.UseBasicAuthorizationHeader)
101+
if (!configuration.ClientCredentials!.UseBasicAuthorizationHeader)
102+
{
103+
requestBody.Add(OAuth2Keyword.ClientId, configuration.ClientCredentials.ClientId);
104+
requestBody.Add(OAuth2Keyword.ClientSecret, configuration.ClientCredentials.ClientSecret);
105+
}
106+
107+
if (!string.IsNullOrWhiteSpace(configuration.Scope))
104108
{
105-
requestBody.Add(OAuth2Keyword.ClientId, configuration.ClientId);
106-
requestBody.Add(OAuth2Keyword.ClientSecret, configuration.ClientSecret);
109+
requestBody.Add(OAuth2Keyword.Scope, configuration.Scope.Trim());
107110
}
108111

109-
if (!string.IsNullOrWhiteSpace(scope))
112+
foreach (KeyValuePair<string, string> parameter in configuration.TokenEndpoint.AdditionalBodyParameters)
110113
{
111-
requestBody.Add(OAuth2Keyword.Scope, scope!.Trim());
114+
requestBody.Add(parameter.Key, parameter.Value);
112115
}
113116

114-
return new FormUrlEncodedContent(requestBody!);
117+
return new FormUrlEncodedContent(requestBody);
115118
}
116119

117-
private async Task<AccessTokenResponse?> ParseResponseAsync(OAuth2Configuration configuration, HttpResponseMessage result,
120+
private static Uri GetCompleteTokenUrl(OAuth2Endpoint tokenEndpoint)
121+
{
122+
if (tokenEndpoint.AdditionalQueryParameters.Count == 0)
123+
{
124+
return tokenEndpoint.Url;
125+
}
126+
127+
UriBuilder uriBuilder = new(tokenEndpoint.Url);
128+
129+
StringBuilder stringBuilder = new();
130+
131+
foreach (KeyValuePair<string, string> parameter in tokenEndpoint.AdditionalQueryParameters)
132+
{
133+
stringBuilder.Append(CultureInfo.InvariantCulture, $"{parameter.Key}={WebUtility.UrlEncode(parameter.Value)}");
134+
}
135+
136+
stringBuilder.Replace("&", "?", 0, 1);
137+
138+
uriBuilder.Query = stringBuilder.ToString();
139+
140+
return uriBuilder.Uri;
141+
}
142+
143+
private static HttpRequestMessage GetTokenRequest(OAuth2Configuration configuration)
144+
{
145+
HttpRequestMessage request = new(HttpMethod.Get, GetCompleteTokenUrl(configuration.TokenEndpoint))
146+
{
147+
Method = HttpMethod.Post,
148+
Content = GetClientCredentialsContent(configuration)
149+
};
150+
151+
AddTokenRequestHeaders(request, configuration);
152+
return request;
153+
}
154+
155+
private async Task<AccessTokenResponse?> ParseResponseAsync(OAuth2Configuration configuration, HttpResponseMessage response,
118156
CancellationToken cancellationToken)
119157
{
120-
string body = await result.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
158+
string body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
121159

122-
if (!result.IsSuccessStatusCode)
160+
if (!response.IsSuccessStatusCode)
123161
{
124-
if (result.StatusCode != HttpStatusCode.BadRequest ||
125-
!TryParseAndLogOAuth2Error(body, configuration.TokenEndpoint, configuration.ClientCredentials!.ClientId))
162+
if (response.StatusCode != HttpStatusCode.BadRequest ||
163+
!TryParseAndLogOAuth2Error(body, configuration.TokenEndpoint.Url, configuration.ClientCredentials!.ClientId))
126164
{
127165
logger.LogError("Could not authenticate against {TokenEndpoint}, the returned status code was {StatusCode}. Response body: {Body}.",
128-
configuration.TokenEndpoint, result.StatusCode, body);
166+
configuration.TokenEndpoint, response.StatusCode, body);
129167
}
130168

131169
return null;
@@ -150,26 +188,6 @@ private static FormUrlEncodedContent GetClientCredentialsContent(ClientCredentia
150188
return token;
151189
}
152190

153-
private async Task<HttpResponseMessage> PostWithBasicAuthenticationAsync(OAuth2Configuration configuration, FormUrlEncodedContent requestContent,
154-
CancellationToken cancellationToken)
155-
{
156-
string encodedAuthorization = Convert.ToBase64String(
157-
Encoding.ASCII.GetBytes($"{configuration.ClientCredentials!.ClientId}:{configuration.ClientCredentials.ClientSecret}"));
158-
159-
using HttpRequestMessage request = new()
160-
{
161-
Content = requestContent,
162-
Method = HttpMethod.Post,
163-
RequestUri = configuration.TokenEndpoint,
164-
Headers =
165-
{
166-
Authorization = new AuthenticationHeaderValue("Basic", encodedAuthorization)
167-
}
168-
};
169-
170-
return await _client.SendAsync(request, cancellationToken).ConfigureAwait(false);
171-
}
172-
173191
private bool TryParseAndLogOAuth2Error(string errorContent, Uri tokenEndpoint, string? clientId)
174192
{
175193
ErrorResponse? response = null;
@@ -220,5 +238,40 @@ private bool TryParseAndLogOAuth2Error(string errorContent, Uri tokenEndpoint, s
220238

221239
return true;
222240
}
241+
242+
private static void ValidateClientCredentialParameters(OAuth2Configuration configuration)
243+
{
244+
if (configuration.GrantType is not OAuth2GrantType.ClientCredentials)
245+
{
246+
throw new ArgumentException($"{nameof(configuration.GrantType)} must be {OAuth2GrantType.ClientCredentials}.", nameof(configuration));
247+
}
248+
249+
if (configuration.ClientCredentials is null)
250+
{
251+
throw new ArgumentException($"{nameof(configuration.ClientCredentials)} is null.", nameof(configuration));
252+
}
253+
254+
if (string.IsNullOrWhiteSpace(configuration.ClientCredentials.ClientId))
255+
{
256+
throw new ArgumentException($"{nameof(configuration.ClientCredentials)}.{nameof(configuration.ClientCredentials.ClientId)} must be specified.",
257+
nameof(configuration));
258+
}
259+
260+
if (string.IsNullOrWhiteSpace(configuration.ClientCredentials.ClientSecret))
261+
{
262+
throw new ArgumentException($"{nameof(configuration.ClientCredentials)}.{nameof(configuration.ClientCredentials.ClientSecret)} must be specified.",
263+
nameof(configuration));
264+
}
265+
266+
if (configuration.TokenEndpoint is null)
267+
{
268+
throw new ArgumentException($"{nameof(configuration.TokenEndpoint)} is null.", nameof(configuration));
269+
}
270+
271+
if (configuration.TokenEndpoint.Url is null)
272+
{
273+
throw new ArgumentException($"{nameof(configuration.TokenEndpoint)}.{nameof(configuration.TokenEndpoint.Url)} must be specified.", nameof(configuration));
274+
}
275+
}
223276
}
224277
}

0 commit comments

Comments
 (0)