Skip to content

Commit 95f09b7

Browse files
Lyphionlukebakken
authored andcommitted
Implemented basis Token Endpoint fetching
Added retrieving of Token Endpoint if only the Issuer is provided to the builder Added some documentation to OAuth2Client and IOAuth2Client Remove synchronous `Build` method.
1 parent f3f6bb2 commit 95f09b7

File tree

6 files changed

+193
-61
lines changed

6 files changed

+193
-61
lines changed

projects/RabbitMQ.Client.OAuth2/IOAuth2Client.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,19 @@ namespace RabbitMQ.Client.OAuth2
3636
{
3737
public interface IOAuth2Client
3838
{
39+
/// <summary>
40+
/// Request a new AccessToken from the Token Endpoint.
41+
/// </summary>
42+
/// <param name="cancellationToken">Cancellation token for this request</param>
43+
/// <returns>Token with Access and Refresh Token</returns>
3944
Task<IToken> RequestTokenAsync(CancellationToken cancellationToken = default);
45+
46+
/// <summary>
47+
/// Request a new AccessToken using the Refresh Token from the Token Endpoint.
48+
/// </summary>
49+
/// <param name="token">Token with the Refresh Token</param>
50+
/// <param name="cancellationToken">Cancellation token for this request</param>
51+
/// <returns>Token with Access and Refresh Token</returns>
4052
Task<IToken> RefreshTokenAsync(IToken token, CancellationToken cancellationToken = default);
4153
}
4254
}

projects/RabbitMQ.Client.OAuth2/OAuth2Client.cs

Lines changed: 143 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -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
}

projects/RabbitMQ.Client.OAuth2/PublicAPI.Shipped.txt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ RabbitMQ.Client.OAuth2.IToken.HasExpired.get -> bool
77
RabbitMQ.Client.OAuth2.IToken.RefreshToken.get -> string
88
RabbitMQ.Client.OAuth2.OAuth2ClientBuilder
99
RabbitMQ.Client.OAuth2.OAuth2ClientBuilder.AddRequestParameter(string param, string paramValue) -> RabbitMQ.Client.OAuth2.OAuth2ClientBuilder
10-
RabbitMQ.Client.OAuth2.OAuth2ClientBuilder.Build() -> RabbitMQ.Client.OAuth2.IOAuth2Client
11-
RabbitMQ.Client.OAuth2.OAuth2ClientBuilder.OAuth2ClientBuilder(string clientId, string clientSecret, System.Uri tokenEndpoint) -> void
10+
RabbitMQ.Client.OAuth2.OAuth2ClientBuilder.OAuth2ClientBuilder(string! clientId, string! clientSecret, System.Uri? tokenEndpoint = null, System.Uri? issuer = null) -> void
1211
RabbitMQ.Client.OAuth2.OAuth2ClientBuilder.SetScope(string scope) -> RabbitMQ.Client.OAuth2.OAuth2ClientBuilder
1312
RabbitMQ.Client.OAuth2.OAuth2ClientCredentialsProvider
1413
RabbitMQ.Client.OAuth2.OAuth2ClientCredentialsProvider.Name.get -> string

projects/RabbitMQ.Client.OAuth2/PublicAPI.Unshipped.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ RabbitMQ.Client.OAuth2.CredentialsRefresherEventSource.Stopped(string! name) ->
1010
RabbitMQ.Client.OAuth2.IOAuth2Client.RefreshTokenAsync(RabbitMQ.Client.OAuth2.IToken! token, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<RabbitMQ.Client.OAuth2.IToken!>!
1111
RabbitMQ.Client.OAuth2.IOAuth2Client.RequestTokenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<RabbitMQ.Client.OAuth2.IToken!>!
1212
RabbitMQ.Client.OAuth2.NotifyCredentialsRefreshedAsync
13+
RabbitMQ.Client.OAuth2.OAuth2ClientBuilder.BuildAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask<RabbitMQ.Client.OAuth2.IOAuth2Client!>
1314
RabbitMQ.Client.OAuth2.OAuth2ClientBuilder.SetHttpClientHandler(System.Net.Http.HttpClientHandler! handler) -> RabbitMQ.Client.OAuth2.OAuth2ClientBuilder!
1415
RabbitMQ.Client.OAuth2.OAuth2ClientCredentialsProvider.Dispose() -> void
1516
RabbitMQ.Client.OAuth2.OAuth2ClientCredentialsProvider.GetCredentialsAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<RabbitMQ.Client.Credentials!>!

0 commit comments

Comments
 (0)