@@ -42,58 +42,147 @@ namespace RabbitMQ.Client.OAuth2
4242{
4343 public class OAuth2ClientBuilder
4444 {
45+ /// <summary>
46+ /// Discovery endpoint subpath for all OpenID Connect issuers.
47+ /// </summary>
48+ const string DISCOVERY_ENDPOINT = ".well-known/openid-configuration" ;
49+
4550 private readonly string _clientId ;
4651 private readonly string _clientSecret ;
47- private readonly Uri _tokenEndpoint ;
52+
53+ // At least one of the following Uris is not null
54+ private readonly Uri ? _tokenEndpoint ;
55+ private readonly Uri ? _issuer ;
56+
4857 private string ? _scope ;
4958 private IDictionary < string , string > ? _additionalRequestParameters ;
5059 private HttpClientHandler ? _httpClientHandler ;
5160
52- public OAuth2ClientBuilder ( string clientId , string clientSecret , Uri tokenEndpoint )
61+ /// <summary>
62+ /// Create a new builder for creating <see cref="OAuth2Client"/>s.
63+ /// </summary>
64+ /// <param name="clientId">Id of the client</param>
65+ /// <param name="clientSecret">Secret of the client</param>
66+ /// <param name="tokenEndpoint">Endpoint to receive the Access Token</param>
67+ /// <param name="issuer">Issuer of the Access Token. Used to automaticly receive the Token Endpoint while building</param>
68+ /// <remarks>
69+ /// Either <paramref name="tokenEndpoint"/> or <paramref name="issuer"/> must be provided.
70+ /// </remarks>
71+ public OAuth2ClientBuilder ( string clientId , string clientSecret , Uri ? tokenEndpoint = null , Uri ? issuer = null )
5372 {
5473 _clientId = clientId ?? throw new ArgumentNullException ( nameof ( clientId ) ) ;
5574 _clientSecret = clientSecret ?? throw new ArgumentNullException ( nameof ( clientSecret ) ) ;
56- _tokenEndpoint = tokenEndpoint ?? throw new ArgumentNullException ( nameof ( tokenEndpoint ) ) ;
75+
76+ if ( tokenEndpoint is null && issuer is null )
77+ {
78+ throw new ArgumentException ( "Either tokenEndpoint or issuer is required" ) ;
79+ }
80+
81+ _tokenEndpoint = tokenEndpoint ;
82+ _issuer = issuer ;
5783 }
5884
85+ /// <summary>
86+ /// Set the requested scopes for the client.
87+ /// </summary>
88+ /// <param name="scope">OAuth scopes to request from the Issuer</param>
5989 public OAuth2ClientBuilder SetScope ( string scope )
6090 {
6191 _scope = scope ?? throw new ArgumentNullException ( nameof ( scope ) ) ;
6292 return this ;
6393 }
6494
95+ /// <summary>
96+ /// Set custom HTTP Client handler for requests of the OAuth2 client.
97+ /// </summary>
98+ /// <param name="handler">Custom handler for HTTP requests</param>
6599 public OAuth2ClientBuilder SetHttpClientHandler ( HttpClientHandler handler )
66100 {
67101 _httpClientHandler = handler ?? throw new ArgumentNullException ( nameof ( handler ) ) ;
68102 return this ;
69103 }
70104
105+ /// <summary>
106+ /// Add a additional request parameter to each HTTP request.
107+ /// </summary>
108+ /// <param name="param">Name of the parameter</param>
109+ /// <param name="paramValue">Value of the parameter</param>
71110 public OAuth2ClientBuilder AddRequestParameter ( string param , string paramValue )
72111 {
73- if ( param == null )
112+ if ( param is null )
74113 {
75- throw new ArgumentNullException ( " param is null" ) ;
114+ throw new ArgumentNullException ( nameof ( param ) ) ;
76115 }
77116
78- if ( paramValue == null )
117+ if ( paramValue is null )
79118 {
80- throw new ArgumentNullException ( " paramValue is null" ) ;
119+ throw new ArgumentNullException ( nameof ( paramValue ) ) ;
81120 }
82121
83- if ( _additionalRequestParameters == null )
84- {
85- _additionalRequestParameters = new Dictionary < string , string > ( ) ;
86- }
122+ _additionalRequestParameters ??= new Dictionary < string , string > ( ) ;
87123 _additionalRequestParameters [ param ] = paramValue ;
88124
89125 return this ;
90126 }
91127
92- public IOAuth2Client Build ( )
128+ /// <summary>
129+ /// Build the <see cref="OAuth2Client"/> with the provided properties of the builder.
130+ /// </summary>
131+ /// <param name="cancellationToken">Cancellation token for this method</param>
132+ /// <returns>Configured OAuth2Client</returns>
133+ public async ValueTask < IOAuth2Client > BuildAsync ( CancellationToken cancellationToken = default )
93134 {
135+ // Check if Token Endpoint is missing -> Use Issuer to receive Token Endpoint
136+ if ( _tokenEndpoint is null )
137+ {
138+ Uri tokenEndpoint = await GetTokenEndpointFromIssuerAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
139+ return new OAuth2Client ( _clientId , _clientSecret , tokenEndpoint ,
140+ _scope , _additionalRequestParameters , _httpClientHandler ) ;
141+ }
142+
94143 return new OAuth2Client ( _clientId , _clientSecret , _tokenEndpoint ,
95144 _scope , _additionalRequestParameters , _httpClientHandler ) ;
96145 }
146+
147+ /// <summary>
148+ /// Receive Token Endpoint from discovery page of the Issuer.
149+ /// </summary>
150+ /// <param name="cancellationToken">Cancellation token for this request</param>
151+ /// <returns>Uri of the Token Endpoint</returns>
152+ private async Task < Uri > GetTokenEndpointFromIssuerAsync ( CancellationToken cancellationToken = default )
153+ {
154+ if ( _issuer is null )
155+ {
156+ throw new InvalidOperationException ( "The issuer is required" ) ;
157+ }
158+
159+ using HttpClient httpClient = _httpClientHandler is null
160+ ? new HttpClient ( )
161+ : new HttpClient ( _httpClientHandler , false ) ;
162+
163+ httpClient . DefaultRequestHeaders . Accept . Clear ( ) ;
164+ httpClient . DefaultRequestHeaders . Accept . Add ( new MediaTypeWithQualityHeaderValue ( "application/json" ) ) ;
165+
166+ // Build endpoint from Issuer and dicovery endpoint, we can't use the Uri overload because the Issuer Uri may not have a trailing '/'
167+ string tempIssuer = _issuer . AbsoluteUri . EndsWith ( "/" ) ? _issuer . AbsoluteUri : _issuer . AbsoluteUri + "/" ;
168+ Uri discoveryEndpoint = new Uri ( tempIssuer + DISCOVERY_ENDPOINT ) ;
169+
170+ using HttpRequestMessage req = new HttpRequestMessage ( HttpMethod . Get , discoveryEndpoint ) ;
171+ using HttpResponseMessage response = await httpClient . SendAsync ( req , cancellationToken )
172+ . ConfigureAwait ( false ) ;
173+
174+ response . EnsureSuccessStatusCode ( ) ;
175+
176+ OpenIDConnectDiscovery ? discovery = await response . Content . ReadFromJsonAsync < OpenIDConnectDiscovery > ( cancellationToken : cancellationToken )
177+ . ConfigureAwait ( false ) ;
178+
179+ if ( discovery is null || string . IsNullOrEmpty ( discovery . TokenEndpoint ) )
180+ {
181+ throw new InvalidOperationException ( "No token endpoint was found" ) ;
182+ }
183+
184+ return new Uri ( discovery . TokenEndpoint ) ;
185+ }
97186 }
98187
99188 /**
@@ -119,7 +208,7 @@ internal class OAuth2Client : IOAuth2Client, IDisposable
119208
120209 public static readonly IDictionary < string , string > EMPTY = new Dictionary < string , string > ( ) ;
121210
122- private HttpClient _httpClient ;
211+ private readonly HttpClient _httpClient ;
123212
124213 public OAuth2Client ( string clientId , string clientSecret , Uri tokenEndpoint ,
125214 string ? scope ,
@@ -132,73 +221,64 @@ public OAuth2Client(string clientId, string clientSecret, Uri tokenEndpoint,
132221 _additionalRequestParameters = additionalRequestParameters ?? EMPTY ;
133222 _tokenEndpoint = tokenEndpoint ;
134223
135- if ( httpClientHandler is null )
136- {
137- _httpClient = new HttpClient ( ) ;
138- }
139- else
140- {
141- _httpClient = new HttpClient ( httpClientHandler , false ) ;
142- }
224+ _httpClient = httpClientHandler is null
225+ ? new HttpClient ( )
226+ : new HttpClient ( httpClientHandler , false ) ;
143227
144228 _httpClient . DefaultRequestHeaders . Accept . Clear ( ) ;
145229 _httpClient . DefaultRequestHeaders . Accept . Add ( new MediaTypeWithQualityHeaderValue ( "application/json" ) ) ;
146230 }
147231
232+ /// <inheritdoc />
148233 public async Task < IToken > RequestTokenAsync ( CancellationToken cancellationToken = default )
149234 {
150235 using HttpRequestMessage req = new HttpRequestMessage ( HttpMethod . Post , _tokenEndpoint ) ;
151236 req . Content = new FormUrlEncodedContent ( BuildRequestParameters ( ) ) ;
152237
153- using HttpResponseMessage response = await _httpClient . SendAsync ( req )
238+ using HttpResponseMessage response = await _httpClient . SendAsync ( req , cancellationToken )
154239 . ConfigureAwait ( false ) ;
155240
156241 response . EnsureSuccessStatusCode ( ) ;
157242
158- JsonToken ? token = await response . Content . ReadFromJsonAsync < JsonToken > ( )
243+ JsonToken ? token = await response . Content . ReadFromJsonAsync < JsonToken > ( cancellationToken : cancellationToken )
159244 . ConfigureAwait ( false ) ;
160245
161246 if ( token is null )
162247 {
163248 // TODO specific exception?
164249 throw new InvalidOperationException ( "token is null" ) ;
165250 }
166- else
167- {
168- return new Token ( token ) ;
169- }
251+
252+ return new Token ( token ) ;
170253 }
171254
255+ /// <inheritdoc />
172256 public async Task < IToken > RefreshTokenAsync ( IToken token ,
173257 CancellationToken cancellationToken = default )
174258 {
175- if ( token . RefreshToken == null )
259+ if ( token . RefreshToken is null )
176260 {
177261 throw new InvalidOperationException ( "Token has no Refresh Token" ) ;
178262 }
179263
180- using HttpRequestMessage req = new HttpRequestMessage ( HttpMethod . Post , _tokenEndpoint )
181- {
182- Content = new FormUrlEncodedContent ( BuildRefreshParameters ( token ) )
183- } ;
264+ using HttpRequestMessage req = new HttpRequestMessage ( HttpMethod . Post , _tokenEndpoint ) ;
265+ req . Content = new FormUrlEncodedContent ( BuildRefreshParameters ( token ) ) ;
184266
185- using HttpResponseMessage response = await _httpClient . SendAsync ( req )
267+ using HttpResponseMessage response = await _httpClient . SendAsync ( req , cancellationToken )
186268 . ConfigureAwait ( false ) ;
187269
188270 response . EnsureSuccessStatusCode ( ) ;
189271
190- JsonToken ? refreshedToken = await response . Content . ReadFromJsonAsync < JsonToken > ( )
272+ JsonToken ? refreshedToken = await response . Content . ReadFromJsonAsync < JsonToken > ( cancellationToken : cancellationToken )
191273 . ConfigureAwait ( false ) ;
192274
193275 if ( refreshedToken is null )
194276 {
195277 // TODO specific exception?
196278 throw new InvalidOperationException ( "refreshed token is null" ) ;
197279 }
198- else
199- {
200- return new Token ( refreshedToken ) ;
201- }
280+
281+ return new Token ( refreshedToken ) ;
202282 }
203283
204284 public void Dispose ( )
@@ -214,9 +294,9 @@ private Dictionary<string, string> BuildRequestParameters()
214294 { CLIENT_SECRET , _clientSecret }
215295 } ;
216296
217- if ( _scope != null && _scope . Length > 0 )
297+ if ( ! string . IsNullOrEmpty ( _scope ) )
218298 {
219- dict . Add ( SCOPE , _scope ) ;
299+ dict . Add ( SCOPE , _scope ! ) ;
220300 }
221301
222302 dict . Add ( GRANT_TYPE , GRANT_TYPE_CLIENT_CREDENTIALS ) ;
@@ -227,8 +307,7 @@ private Dictionary<string, string> BuildRequestParameters()
227307 private Dictionary < string , string > BuildRefreshParameters ( IToken token )
228308 {
229309 Dictionary < string , string > dict = BuildRequestParameters ( ) ;
230- dict . Remove ( GRANT_TYPE ) ;
231- dict . Add ( GRANT_TYPE , REFRESH_TOKEN ) ;
310+ dict [ GRANT_TYPE ] = REFRESH_TOKEN ;
232311
233312 if ( _scope != null )
234313 {
@@ -284,4 +363,26 @@ public long ExpiresIn
284363 get ; set ;
285364 }
286365 }
366+
367+ /// <summary>
368+ /// Minimal version of the properties of the discovery endpoint.
369+ /// </summary>
370+ internal class OpenIDConnectDiscovery
371+ {
372+ public OpenIDConnectDiscovery ( )
373+ {
374+ TokenEndpoint = string . Empty ;
375+ }
376+
377+ public OpenIDConnectDiscovery ( string tokenEndpoint )
378+ {
379+ TokenEndpoint = tokenEndpoint ;
380+ }
381+
382+ [ JsonPropertyName ( "token_endpoint" ) ]
383+ public string TokenEndpoint
384+ {
385+ get ; set ;
386+ }
387+ }
287388}
0 commit comments