@@ -42,58 +42,147 @@ namespace RabbitMQ.Client.OAuth2
42
42
{
43
43
public class OAuth2ClientBuilder
44
44
{
45
+ /// <summary>
46
+ /// Discovery endpoint subpath for all OpenID Connect issuers.
47
+ /// </summary>
48
+ const string DISCOVERY_ENDPOINT = ".well-known/openid-configuration" ;
49
+
45
50
private readonly string _clientId ;
46
51
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
+
48
57
private string ? _scope ;
49
58
private IDictionary < string , string > ? _additionalRequestParameters ;
50
59
private HttpClientHandler ? _httpClientHandler ;
51
60
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 )
53
72
{
54
73
_clientId = clientId ?? throw new ArgumentNullException ( nameof ( clientId ) ) ;
55
74
_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 ;
57
83
}
58
84
85
+ /// <summary>
86
+ /// Set the requested scopes for the client.
87
+ /// </summary>
88
+ /// <param name="scope">OAuth scopes to request from the Issuer</param>
59
89
public OAuth2ClientBuilder SetScope ( string scope )
60
90
{
61
91
_scope = scope ?? throw new ArgumentNullException ( nameof ( scope ) ) ;
62
92
return this ;
63
93
}
64
94
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>
65
99
public OAuth2ClientBuilder SetHttpClientHandler ( HttpClientHandler handler )
66
100
{
67
101
_httpClientHandler = handler ?? throw new ArgumentNullException ( nameof ( handler ) ) ;
68
102
return this ;
69
103
}
70
104
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>
71
110
public OAuth2ClientBuilder AddRequestParameter ( string param , string paramValue )
72
111
{
73
- if ( param == null )
112
+ if ( param is null )
74
113
{
75
- throw new ArgumentNullException ( " param is null" ) ;
114
+ throw new ArgumentNullException ( nameof ( param ) ) ;
76
115
}
77
116
78
- if ( paramValue == null )
117
+ if ( paramValue is null )
79
118
{
80
- throw new ArgumentNullException ( " paramValue is null" ) ;
119
+ throw new ArgumentNullException ( nameof ( paramValue ) ) ;
81
120
}
82
121
83
- if ( _additionalRequestParameters == null )
84
- {
85
- _additionalRequestParameters = new Dictionary < string , string > ( ) ;
86
- }
122
+ _additionalRequestParameters ??= new Dictionary < string , string > ( ) ;
87
123
_additionalRequestParameters [ param ] = paramValue ;
88
124
89
125
return this ;
90
126
}
91
127
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 )
93
134
{
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
+
94
143
return new OAuth2Client ( _clientId , _clientSecret , _tokenEndpoint ,
95
144
_scope , _additionalRequestParameters , _httpClientHandler ) ;
96
145
}
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
+ }
97
186
}
98
187
99
188
/**
@@ -119,7 +208,7 @@ internal class OAuth2Client : IOAuth2Client, IDisposable
119
208
120
209
public static readonly IDictionary < string , string > EMPTY = new Dictionary < string , string > ( ) ;
121
210
122
- private HttpClient _httpClient ;
211
+ private readonly HttpClient _httpClient ;
123
212
124
213
public OAuth2Client ( string clientId , string clientSecret , Uri tokenEndpoint ,
125
214
string ? scope ,
@@ -132,73 +221,64 @@ public OAuth2Client(string clientId, string clientSecret, Uri tokenEndpoint,
132
221
_additionalRequestParameters = additionalRequestParameters ?? EMPTY ;
133
222
_tokenEndpoint = tokenEndpoint ;
134
223
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 ) ;
143
227
144
228
_httpClient . DefaultRequestHeaders . Accept . Clear ( ) ;
145
229
_httpClient . DefaultRequestHeaders . Accept . Add ( new MediaTypeWithQualityHeaderValue ( "application/json" ) ) ;
146
230
}
147
231
232
+ /// <inheritdoc />
148
233
public async Task < IToken > RequestTokenAsync ( CancellationToken cancellationToken = default )
149
234
{
150
235
using HttpRequestMessage req = new HttpRequestMessage ( HttpMethod . Post , _tokenEndpoint ) ;
151
236
req . Content = new FormUrlEncodedContent ( BuildRequestParameters ( ) ) ;
152
237
153
- using HttpResponseMessage response = await _httpClient . SendAsync ( req )
238
+ using HttpResponseMessage response = await _httpClient . SendAsync ( req , cancellationToken )
154
239
. ConfigureAwait ( false ) ;
155
240
156
241
response . EnsureSuccessStatusCode ( ) ;
157
242
158
- JsonToken ? token = await response . Content . ReadFromJsonAsync < JsonToken > ( )
243
+ JsonToken ? token = await response . Content . ReadFromJsonAsync < JsonToken > ( cancellationToken : cancellationToken )
159
244
. ConfigureAwait ( false ) ;
160
245
161
246
if ( token is null )
162
247
{
163
248
// TODO specific exception?
164
249
throw new InvalidOperationException ( "token is null" ) ;
165
250
}
166
- else
167
- {
168
- return new Token ( token ) ;
169
- }
251
+
252
+ return new Token ( token ) ;
170
253
}
171
254
255
+ /// <inheritdoc />
172
256
public async Task < IToken > RefreshTokenAsync ( IToken token ,
173
257
CancellationToken cancellationToken = default )
174
258
{
175
- if ( token . RefreshToken == null )
259
+ if ( token . RefreshToken is null )
176
260
{
177
261
throw new InvalidOperationException ( "Token has no Refresh Token" ) ;
178
262
}
179
263
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 ) ) ;
184
266
185
- using HttpResponseMessage response = await _httpClient . SendAsync ( req )
267
+ using HttpResponseMessage response = await _httpClient . SendAsync ( req , cancellationToken )
186
268
. ConfigureAwait ( false ) ;
187
269
188
270
response . EnsureSuccessStatusCode ( ) ;
189
271
190
- JsonToken ? refreshedToken = await response . Content . ReadFromJsonAsync < JsonToken > ( )
272
+ JsonToken ? refreshedToken = await response . Content . ReadFromJsonAsync < JsonToken > ( cancellationToken : cancellationToken )
191
273
. ConfigureAwait ( false ) ;
192
274
193
275
if ( refreshedToken is null )
194
276
{
195
277
// TODO specific exception?
196
278
throw new InvalidOperationException ( "refreshed token is null" ) ;
197
279
}
198
- else
199
- {
200
- return new Token ( refreshedToken ) ;
201
- }
280
+
281
+ return new Token ( refreshedToken ) ;
202
282
}
203
283
204
284
public void Dispose ( )
@@ -214,9 +294,9 @@ private Dictionary<string, string> BuildRequestParameters()
214
294
{ CLIENT_SECRET , _clientSecret }
215
295
} ;
216
296
217
- if ( _scope != null && _scope . Length > 0 )
297
+ if ( ! string . IsNullOrEmpty ( _scope ) )
218
298
{
219
- dict . Add ( SCOPE , _scope ) ;
299
+ dict . Add ( SCOPE , _scope ! ) ;
220
300
}
221
301
222
302
dict . Add ( GRANT_TYPE , GRANT_TYPE_CLIENT_CREDENTIALS ) ;
@@ -227,8 +307,7 @@ private Dictionary<string, string> BuildRequestParameters()
227
307
private Dictionary < string , string > BuildRefreshParameters ( IToken token )
228
308
{
229
309
Dictionary < string , string > dict = BuildRequestParameters ( ) ;
230
- dict . Remove ( GRANT_TYPE ) ;
231
- dict . Add ( GRANT_TYPE , REFRESH_TOKEN ) ;
310
+ dict [ GRANT_TYPE ] = REFRESH_TOKEN ;
232
311
233
312
if ( _scope != null )
234
313
{
@@ -284,4 +363,26 @@ public long ExpiresIn
284
363
get ; set ;
285
364
}
286
365
}
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
+ }
287
388
}
0 commit comments