11// Copyright © 2025 Rune Gulbrandsen.
22// All rights reserved. Licensed under the MIT License; see LICENSE.txt.
33
4+ using System . Globalization ;
45using System . Net ;
5- using System . Net . Http . Headers ;
66using System . Text ;
77using System . Text . Json ;
88using 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