Skip to content

Commit daa5aaa

Browse files
authored
Merge pull request #1656 from Lyphion/dev-token
Added ability to use Issuer to receive Token Endpoint for the OAuth2ClientBuilder
2 parents f3f6bb2 + 95f09b7 commit daa5aaa

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)