From 68330ba247ce9e8ce3d887ece54c66d11401218f Mon Sep 17 00:00:00 2001 From: Matthew Thomas Date: Fri, 4 Oct 2024 13:53:14 -0500 Subject: [PATCH 01/20] Initial support for OAuth based clients --- .../ClientCredentialsOAuthTokenProvider.cs | 94 +++++++++++++++++++ .../Authentication/ExpirableToken.cs | 31 ++++++ .../Authentication/IOAuthTokenProvider.cs | 18 ++++ .../Authentication/OAuthTokenProvider.cs | 94 +++++++++++++++++++ .../Configuration/FlurlHttpSettings.cs | 19 ++++ src/Flurl.Http/FlurlClient.cs | 9 ++ 6 files changed, 265 insertions(+) create mode 100644 src/Flurl.Http/Authentication/ClientCredentialsOAuthTokenProvider.cs create mode 100644 src/Flurl.Http/Authentication/ExpirableToken.cs create mode 100644 src/Flurl.Http/Authentication/IOAuthTokenProvider.cs create mode 100644 src/Flurl.Http/Authentication/OAuthTokenProvider.cs diff --git a/src/Flurl.Http/Authentication/ClientCredentialsOAuthTokenProvider.cs b/src/Flurl.Http/Authentication/ClientCredentialsOAuthTokenProvider.cs new file mode 100644 index 00000000..62933899 --- /dev/null +++ b/src/Flurl.Http/Authentication/ClientCredentialsOAuthTokenProvider.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Tasks; + +namespace Flurl.Http.Authentication +{ + /// + /// + /// + public class ClientCredentialsTokenProvider : OAuthTokenProvider + { + private readonly string _clientId; + private readonly string _clientSecret; + private readonly IFlurlClient _fc; + + /// + /// Instantiates a new OAuthTokenProvider that uses clientId/clientSecret as an authentication mechanism + /// + /// The client id to send when making requests for the OAuth token + /// The to use when making requests for the OAuth token. Leave null to omit this property from the body of the request + /// The client secret to send when making requests for the OAuth token + /// The amount of time that defines how much earlier a entry should be considered expired relative to its actual expiration + /// The authentication scheme this provider will provide in the resolved authentication header. Usually "Bearer" or "OAuth" + public ClientCredentialsTokenProvider(string clientId, + string clientSecret, + IFlurlClient client, + TimeSpan? earlyExpiration = null, + string authenticationScheme = "Bearer") + : base(earlyExpiration, authenticationScheme) + { + _clientId = clientId; + _clientSecret = clientSecret; + _fc = client; + } + + /// + /// Gets the OAuth authentication header for the specified scope + /// + /// The desired scope + /// + protected override async Task GetToken(string scope) + { + var now = DateTimeOffset.Now; + + var body = new Dictionary + { + ["client_id"] = _clientId, + ["scope"] = scope, + ["grant_type"] = "client_credentials" + }; + + if (string.IsNullOrWhiteSpace(_clientSecret) == false) + { body["client_secret"] = _clientSecret; } + + var rawResponse = await _fc.Request("connect", "token") + .AllowAnyHttpStatus() + .PostUrlEncodedAsync(body); + + if (rawResponse.StatusCode >= 200 && rawResponse.StatusCode < 300) + { + var tokenResponse = await rawResponse.GetJsonAsync(); + var expiration = now.AddSeconds(tokenResponse["expires_in"].GetValue()); + + return new ExpirableToken(tokenResponse["access_token"].GetValue(), expiration); + } + if (rawResponse.StatusCode == 400) + { + var errorMessage = default(string); + try + { + var response = await rawResponse.GetJsonAsync(); + errorMessage = response["error"].GetValue(); + + if (errorMessage == "invalid_scope") + { errorMessage = $"{_clientId} is not allowed to utilize scope {scope}, or {scope} is not a valid scope. Verify the allowed scopes for {_clientId} and try again."; } + } + catch (Exception) + { + errorMessage = $"{_clientId} is not allowed to utilize scope {scope}, or {scope} is not a valid scope. Verify the allowed scopes for {_clientId} and try again."; + } + + throw new UnauthorizedAccessException(errorMessage); + } + else + { + throw new UnauthorizedAccessException("Unable to acquire OAuth token"); + } + } + } +} + + diff --git a/src/Flurl.Http/Authentication/ExpirableToken.cs b/src/Flurl.Http/Authentication/ExpirableToken.cs new file mode 100644 index 00000000..a0245e63 --- /dev/null +++ b/src/Flurl.Http/Authentication/ExpirableToken.cs @@ -0,0 +1,31 @@ +using System; + +namespace Flurl.Http.Authentication +{ + /// + /// An expirable token used in token-based authentication + /// + public sealed class ExpirableToken + { + /// + /// Gets and sets the token value + /// + public string Value { get; } + + /// + /// Gets and sets the expiration date of the token + /// + public DateTimeOffset Expiration { get; } + + /// + /// Instantiates a new ExpirableToken, setting value and expiration + /// + /// The value of this token + /// The date and time that this token expires + public ExpirableToken(string value, DateTimeOffset expiration) + { + Value = value; + Expiration = expiration; + } + } +} diff --git a/src/Flurl.Http/Authentication/IOAuthTokenProvider.cs b/src/Flurl.Http/Authentication/IOAuthTokenProvider.cs new file mode 100644 index 00000000..e4a63157 --- /dev/null +++ b/src/Flurl.Http/Authentication/IOAuthTokenProvider.cs @@ -0,0 +1,18 @@ +using System.Net.Http.Headers; +using System.Threading.Tasks; + +namespace Flurl.Http.Authentication +{ + /// + /// Provides the authentication header + /// + public interface IOAuthTokenProvider + { + /// + /// Gets the authentication header for a specified scope. + /// + /// The desired scope + /// + Task GetAuthenticationHeader(string scope); + } +} diff --git a/src/Flurl.Http/Authentication/OAuthTokenProvider.cs b/src/Flurl.Http/Authentication/OAuthTokenProvider.cs new file mode 100644 index 00000000..b9b22430 --- /dev/null +++ b/src/Flurl.Http/Authentication/OAuthTokenProvider.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Concurrent; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; + +namespace Flurl.Http.Authentication +{ + /// + /// The base class for OAuth token providers + /// + public abstract class OAuthTokenProvider : IOAuthTokenProvider + { + private class CacheEntry + { + public SemaphoreSlim Semaphore { get; } + public ExpirableToken Token { get; set; } + public AuthenticationHeaderValue AuthHeader { get; set; } + + public CacheEntry() + { + Semaphore = new SemaphoreSlim(1, 1); + } + } + + private readonly ConcurrentDictionary _tokens; + private readonly TimeSpan _earlyExpiration; + private readonly string _scheme; + + + /// + /// Instantiates a new OAuthTokenProvider + /// + /// The amount of time that defines how much earlier a entry should be considered expired relative to its actual expiration + /// The authentication scheme this provider will provide in the resolved authentication header. Usually "Bearer" or "OAuth" + protected OAuthTokenProvider( + TimeSpan? earlyExpiration = null, + string authenticationScheme = "Bearer") + { + _tokens = new ConcurrentDictionary(); + _earlyExpiration = earlyExpiration ?? TimeSpan.Zero; + _scheme = authenticationScheme; + } + + /// + /// Gets the OAuth authentication header for the specified scope + /// + /// The desired scope + /// + public async Task GetAuthenticationHeader(string scope) + { + var now = DateTimeOffset.Now; + + //if the scope is not in the cache, add it as an expired entry so we force a refresh + var entry = _tokens.GetOrAdd(scope, s => + { + return new CacheEntry + { + Token = new ExpirableToken("", now - TimeSpan.FromMinutes(1)) + }; + }); + + var tokenIsValid = (entry.AuthHeader != null) && (now < entry.Token.Expiration); + + if (tokenIsValid == false) + { + await entry.Semaphore.WaitAsync(); + try + { + tokenIsValid = (entry.AuthHeader != null) && (now < entry.Token.Expiration); + + if (tokenIsValid == false) + { + var generatedToken = await GetToken(scope); + entry.Token = new ExpirableToken(generatedToken.Value, generatedToken.Expiration - _earlyExpiration); + entry.AuthHeader = new AuthenticationHeaderValue(_scheme, entry.Token.Value); + } + } + finally + { + entry.Semaphore.Release(); + } + } + + return entry.AuthHeader; + } + + /// + /// Retrieves the OAuth token for the specified scope + /// + /// The refreshed OAuth token + protected abstract Task GetToken(string scope); + } +} diff --git a/src/Flurl.Http/Configuration/FlurlHttpSettings.cs b/src/Flurl.Http/Configuration/FlurlHttpSettings.cs index 7b50b9ca..287a1d48 100644 --- a/src/Flurl.Http/Configuration/FlurlHttpSettings.cs +++ b/src/Flurl.Http/Configuration/FlurlHttpSettings.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Runtime.CompilerServices; +using Flurl.Http.Authentication; using Flurl.Http.Testing; namespace Flurl.Http.Configuration @@ -83,6 +84,24 @@ public ISerializer UrlEncodedSerializer { set => Set(value); } + /// + /// Gets or sets the OAuth token provider + /// + public IOAuthTokenProvider OAuthTokenProvider + { + get => Get(); + set => Set(value); + } + + /// + /// Gets or sets the OAuth scope to request from + /// + public string OAuthTokenScope + { + get => Get(); + set => Set(value); + } + /// /// Gets object whose properties describe how Flurl.Http should handle redirect (3xx) responses. /// diff --git a/src/Flurl.Http/FlurlClient.cs b/src/Flurl.Http/FlurlClient.cs index 474d8568..8fa7faff 100644 --- a/src/Flurl.Http/FlurlClient.cs +++ b/src/Flurl.Http/FlurlClient.cs @@ -159,6 +159,15 @@ public async Task SendAsync(IFlurlRequest request, HttpCompletio reqMsg.RequestUri = request.Url.ToUri(); SyncHeaders(request, reqMsg); + //if the settings and handlers didn't set the authorization header, + //resolve it with the configured provider + if (call.HttpRequestMessage.Headers.Authorization == null && + settings.OAuthTokenProvider != null) + { + var scope = string.IsNullOrWhiteSpace(settings.OAuthTokenScope) ? string.Empty : settings.OAuthTokenScope; + call.HttpRequestMessage.Headers.Authorization = await settings.OAuthTokenProvider.GetAuthenticationHeader(scope); + } + call.StartedUtc = DateTime.UtcNow; var ct = GetCancellationTokenWithTimeout(cancellationToken, settings.Timeout, out var cts); From a03aa4c0e2f11a3e69154a395ce38b55f948eb6d Mon Sep 17 00:00:00 2001 From: Matthew Thomas Date: Fri, 4 Oct 2024 14:09:19 -0500 Subject: [PATCH 02/20] adding WithOAuthTokenProvider --- src/Flurl.CodeGen/Metadata.cs | 4 +++- src/Flurl.Http/ISettingsContainer.cs | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/Flurl.CodeGen/Metadata.cs b/src/Flurl.CodeGen/Metadata.cs index 0bf9f392..bd1e48d0 100644 --- a/src/Flurl.CodeGen/Metadata.cs +++ b/src/Flurl.CodeGen/Metadata.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -144,6 +144,8 @@ public static IEnumerable GetRequestReturningExtensions(MethodA yield return Create("AllowAnyHttpStatus", "Creates a new FlurlRequest and configures it to allow any returned HTTP status without throwing a FlurlHttpException."); yield return Create("WithAutoRedirect", "Creates a new FlurlRequest and configures whether redirects are automatically followed.") .AddArg("enabled", "bool", "true if Flurl should automatically send a new request to the redirect URL, false if it should not."); + yield return Create("WithOAuthTokenProvider", "Creates a new FlurlRequest and configures it to use the supplied OAuth token provider for the request's authentication header") + .AddArg("tokenProvider","IOAuthTokenProvider", "the token provider"); // event handler extensions foreach (var name in new[] { "BeforeCall", "AfterCall", "OnError", "OnRedirect" }) { diff --git a/src/Flurl.Http/ISettingsContainer.cs b/src/Flurl.Http/ISettingsContainer.cs index 698fd87f..d4d075ff 100644 --- a/src/Flurl.Http/ISettingsContainer.cs +++ b/src/Flurl.Http/ISettingsContainer.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using System.Net; +using Flurl.Http.Authentication; using Flurl.Http.Configuration; namespace Flurl.Http @@ -102,5 +103,16 @@ public static T WithAutoRedirect(this T obj, bool enabled) where T : ISetting obj.Settings.Redirects.Enabled = enabled; return obj; } + + /// + /// Configures the OAuth token provider which authenticates the request + /// + /// Object containing settings. + /// The token provider + /// this settings container. + public static T WithOAuthTokenProvider(this T obj, IOAuthTokenProvider tokenProvider) where T : ISettingsContainer { + obj.Settings.OAuthTokenProvider = tokenProvider; + return obj; + } } } \ No newline at end of file From b545a84d22255aca254a34b9dc5fa87e7a6c200f Mon Sep 17 00:00:00 2001 From: Matthew Thomas Date: Fri, 4 Oct 2024 14:11:01 -0500 Subject: [PATCH 03/20] Adding generated functions for WithOAuthTokenProvider --- src/Flurl.Http/GeneratedExtensions.cs | 30 +++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/Flurl.Http/GeneratedExtensions.cs b/src/Flurl.Http/GeneratedExtensions.cs index 75b39ab3..bff14f26 100644 --- a/src/Flurl.Http/GeneratedExtensions.cs +++ b/src/Flurl.Http/GeneratedExtensions.cs @@ -721,6 +721,16 @@ public static IFlurlRequest WithAutoRedirect(this Url url, bool enabled) { return new FlurlRequest(url).WithAutoRedirect(enabled); } + /// + /// Creates a new FlurlRequest and configures it to use the supplied OAuth token provider for the request's authentication header + /// + /// This Flurl.Url. + /// the token provider + /// A new IFlurlRequest. + public static IFlurlRequest WithOAuthTokenProvider(this Url url, IOAuthTokenProvider tokenProvider) { + return new FlurlRequest(url).WithOAuthTokenProvider(tokenProvider); + } + /// /// Creates a new FlurlRequest and adds a new BeforeCall event handler. /// @@ -1240,6 +1250,16 @@ public static IFlurlRequest WithAutoRedirect(this string url, bool enabled) { return new FlurlRequest(url).WithAutoRedirect(enabled); } + /// + /// Creates a new FlurlRequest and configures it to use the supplied OAuth token provider for the request's authentication header + /// + /// This URL. + /// the token provider + /// A new IFlurlRequest. + public static IFlurlRequest WithOAuthTokenProvider(this string url, IOAuthTokenProvider tokenProvider) { + return new FlurlRequest(url).WithOAuthTokenProvider(tokenProvider); + } + /// /// Creates a new FlurlRequest and adds a new BeforeCall event handler. /// @@ -1759,6 +1779,16 @@ public static IFlurlRequest WithAutoRedirect(this Uri uri, bool enabled) { return new FlurlRequest(uri).WithAutoRedirect(enabled); } + /// + /// Creates a new FlurlRequest and configures it to use the supplied OAuth token provider for the request's authentication header + /// + /// This System.Uri. + /// the token provider + /// A new IFlurlRequest. + public static IFlurlRequest WithOAuthTokenProvider(this Uri uri, IOAuthTokenProvider tokenProvider) { + return new FlurlRequest(uri).WithOAuthTokenProvider(tokenProvider); + } + /// /// Creates a new FlurlRequest and adds a new BeforeCall event handler. /// From a1727ddccba5d7f783d7a411d600092fa8a0b6d4 Mon Sep 17 00:00:00 2001 From: Matthew Thomas Date: Fri, 4 Oct 2024 15:32:49 -0500 Subject: [PATCH 04/20] adding using for code gen --- src/Flurl.CodeGen/Program.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Flurl.CodeGen/Program.cs b/src/Flurl.CodeGen/Program.cs index db0da26a..4327f9a0 100644 --- a/src/Flurl.CodeGen/Program.cs +++ b/src/Flurl.CodeGen/Program.cs @@ -55,6 +55,7 @@ static int Main(string[] args) { .WriteLine("using System.Net.Http;") .WriteLine("using System.Threading;") .WriteLine("using System.Threading.Tasks;") + .WriteLine("using Flurl.Http.Authentication;") .WriteLine("using Flurl.Http.Configuration;") .WriteLine("using Flurl.Http.Content;") .WriteLine("") From ddc98546f08f76257f1fd07eafd81f3ffe9befc5 Mon Sep 17 00:00:00 2001 From: Matthew Thomas Date: Fri, 4 Oct 2024 17:21:24 -0500 Subject: [PATCH 05/20] adding with oauth scope --- src/Flurl.CodeGen/Metadata.cs | 6 ++-- .../ClientCredentialsOAuthTokenProvider.cs | 1 + src/Flurl.Http/GeneratedExtensions.cs | 31 +++++++++++++++++++ src/Flurl.Http/ISettingsContainer.cs | 11 +++++++ 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/Flurl.CodeGen/Metadata.cs b/src/Flurl.CodeGen/Metadata.cs index bd1e48d0..7173e9f4 100644 --- a/src/Flurl.CodeGen/Metadata.cs +++ b/src/Flurl.CodeGen/Metadata.cs @@ -8,7 +8,7 @@ namespace Flurl.CodeGen public static class Metadata { /// - /// Mirrors methods defined on Url. We'll auto-gen them for Uri and string. + /// Mirrors methods defined on Url. We'll auto-gen them for Uri and string. /// public static IEnumerable GetUrlReturningExtensions(MethodArg extendedArg) { ExtensionMethod Create(string name, string descrip) => new ExtensionMethod(name, descrip) @@ -97,7 +97,7 @@ public static IEnumerable GetUrlReturningExtensions(MethodArg e } /// - /// Mirrors methods defined on IFlurlRequest and IFlurlClient. We'll auto-gen them for Url, Uri, and string. + /// Mirrors methods defined on IFlurlRequest and IFlurlClient. We'll auto-gen them for Url, Uri, and string. /// public static IEnumerable GetRequestReturningExtensions(MethodArg extendedArg) { ExtensionMethod Create(string name, string descrip) => new ExtensionMethod(name, descrip) @@ -146,6 +146,8 @@ public static IEnumerable GetRequestReturningExtensions(MethodA .AddArg("enabled", "bool", "true if Flurl should automatically send a new request to the redirect URL, false if it should not."); yield return Create("WithOAuthTokenProvider", "Creates a new FlurlRequest and configures it to use the supplied OAuth token provider for the request's authentication header") .AddArg("tokenProvider","IOAuthTokenProvider", "the token provider"); + yield return Create("WithOAuthScope", "Creates a new FlurlRequest and configures it to request an OAuth bearer token of the specified scope from the OAuth token provider") + .AddArg("scope","string","The scope of the token"); // event handler extensions foreach (var name in new[] { "BeforeCall", "AfterCall", "OnError", "OnRedirect" }) { diff --git a/src/Flurl.Http/Authentication/ClientCredentialsOAuthTokenProvider.cs b/src/Flurl.Http/Authentication/ClientCredentialsOAuthTokenProvider.cs index 62933899..b08e2eef 100644 --- a/src/Flurl.Http/Authentication/ClientCredentialsOAuthTokenProvider.cs +++ b/src/Flurl.Http/Authentication/ClientCredentialsOAuthTokenProvider.cs @@ -55,6 +55,7 @@ protected override async Task GetToken(string scope) { body["client_secret"] = _clientSecret; } var rawResponse = await _fc.Request("connect", "token") + .WithHeader("accept","application/json") .AllowAnyHttpStatus() .PostUrlEncodedAsync(body); diff --git a/src/Flurl.Http/GeneratedExtensions.cs b/src/Flurl.Http/GeneratedExtensions.cs index bff14f26..30bee3ce 100644 --- a/src/Flurl.Http/GeneratedExtensions.cs +++ b/src/Flurl.Http/GeneratedExtensions.cs @@ -5,6 +5,7 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Flurl.Http.Authentication; using Flurl.Http.Configuration; using Flurl.Http.Content; @@ -731,6 +732,16 @@ public static IFlurlRequest WithOAuthTokenProvider(this Url url, IOAuthTokenProv return new FlurlRequest(url).WithOAuthTokenProvider(tokenProvider); } + /// + /// Creates a new FlurlRequest and configures it to request an OAuth bearer token of the specified scope from the OAuth token provider + /// + /// This Flurl.Url. + /// The scope of the token + /// A new IFlurlRequest. + public static IFlurlRequest WithOAuthScope(this Url url, string scope) { + return new FlurlRequest(url).WithOAuthScope(scope); + } + /// /// Creates a new FlurlRequest and adds a new BeforeCall event handler. /// @@ -1260,6 +1271,16 @@ public static IFlurlRequest WithOAuthTokenProvider(this string url, IOAuthTokenP return new FlurlRequest(url).WithOAuthTokenProvider(tokenProvider); } + /// + /// Creates a new FlurlRequest and configures it to request an OAuth bearer token of the specified scope from the OAuth token provider + /// + /// This URL. + /// The scope of the token + /// A new IFlurlRequest. + public static IFlurlRequest WithOAuthScope(this string url, string scope) { + return new FlurlRequest(url).WithOAuthScope(scope); + } + /// /// Creates a new FlurlRequest and adds a new BeforeCall event handler. /// @@ -1789,6 +1810,16 @@ public static IFlurlRequest WithOAuthTokenProvider(this Uri uri, IOAuthTokenProv return new FlurlRequest(uri).WithOAuthTokenProvider(tokenProvider); } + /// + /// Creates a new FlurlRequest and configures it to request an OAuth bearer token of the specified scope from the OAuth token provider + /// + /// This System.Uri. + /// The scope of the token + /// A new IFlurlRequest. + public static IFlurlRequest WithOAuthScope(this Uri uri, string scope) { + return new FlurlRequest(uri).WithOAuthScope(scope); + } + /// /// Creates a new FlurlRequest and adds a new BeforeCall event handler. /// diff --git a/src/Flurl.Http/ISettingsContainer.cs b/src/Flurl.Http/ISettingsContainer.cs index d4d075ff..74fa607d 100644 --- a/src/Flurl.Http/ISettingsContainer.cs +++ b/src/Flurl.Http/ISettingsContainer.cs @@ -114,5 +114,16 @@ public static T WithOAuthTokenProvider(this T obj, IOAuthTokenProvider tokenP obj.Settings.OAuthTokenProvider = tokenProvider; return obj; } + + /// + /// Configures the OAuth scope to obtain from the configured OAuthTokenProvider + /// + /// Object containing settings. + /// The scope of the token + /// + public static T WithOAuthScope(this T obj, string scope) where T : ISettingsContainer { + obj.Settings.OAuthTokenScope = scope; + return obj; + } } } \ No newline at end of file From 9b202b6bfde600f5ebfaa10e48eca54c7ff93bd6 Mon Sep 17 00:00:00 2001 From: Matthew Thomas Date: Fri, 4 Oct 2024 21:09:34 -0500 Subject: [PATCH 06/20] move early expiration check --- src/Flurl.Http/Authentication/OAuthTokenProvider.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Flurl.Http/Authentication/OAuthTokenProvider.cs b/src/Flurl.Http/Authentication/OAuthTokenProvider.cs index b9b22430..bf739692 100644 --- a/src/Flurl.Http/Authentication/OAuthTokenProvider.cs +++ b/src/Flurl.Http/Authentication/OAuthTokenProvider.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Net.Http.Headers; using System.Threading; @@ -56,7 +56,7 @@ public async Task GetAuthenticationHeader(string scop { return new CacheEntry { - Token = new ExpirableToken("", now - TimeSpan.FromMinutes(1)) + Token = new ExpirableToken("", now) }; }); @@ -72,7 +72,12 @@ public async Task GetAuthenticationHeader(string scop if (tokenIsValid == false) { var generatedToken = await GetToken(scope); - entry.Token = new ExpirableToken(generatedToken.Value, generatedToken.Expiration - _earlyExpiration); + + //if we're configured to expire tokens early, adjust the expiration time + if (_earlyExpiration > TimeSpan.Zero) + { generatedToken = new ExpirableToken(generatedToken.Value, generatedToken.Expiration - _earlyExpiration); } + + entry.Token = generatedToken; entry.AuthHeader = new AuthenticationHeaderValue(_scheme, entry.Token.Value); } } From e548f4b10d0fdf87704164c788124a058f7eb3c0 Mon Sep 17 00:00:00 2001 From: Matthew Thomas Date: Fri, 4 Oct 2024 21:10:33 -0500 Subject: [PATCH 07/20] intellisense documentation --- src/Flurl.Http/Authentication/OAuthTokenProvider.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Flurl.Http/Authentication/OAuthTokenProvider.cs b/src/Flurl.Http/Authentication/OAuthTokenProvider.cs index bf739692..11273772 100644 --- a/src/Flurl.Http/Authentication/OAuthTokenProvider.cs +++ b/src/Flurl.Http/Authentication/OAuthTokenProvider.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Net.Http.Headers; using System.Threading; @@ -6,10 +6,10 @@ namespace Flurl.Http.Authentication { - /// - /// The base class for OAuth token providers - /// - public abstract class OAuthTokenProvider : IOAuthTokenProvider + /// + /// The base class for OAuth token providers + /// + public abstract class OAuthTokenProvider : IOAuthTokenProvider { private class CacheEntry { From e87c246ac1de5e413e6640000c62e9e1b01d5f9a Mon Sep 17 00:00:00 2001 From: Matthew Thomas Date: Fri, 4 Oct 2024 21:10:47 -0500 Subject: [PATCH 08/20] intellisense documentation --- src/Flurl.Http/Authentication/ExpirableToken.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Flurl.Http/Authentication/ExpirableToken.cs b/src/Flurl.Http/Authentication/ExpirableToken.cs index a0245e63..c2c4a796 100644 --- a/src/Flurl.Http/Authentication/ExpirableToken.cs +++ b/src/Flurl.Http/Authentication/ExpirableToken.cs @@ -1,10 +1,12 @@ using System; +using System.Diagnostics.CodeAnalysis; namespace Flurl.Http.Authentication { - /// - /// An expirable token used in token-based authentication - /// + /// + /// An expirable token used in token-based authentication + /// + [ExcludeFromCodeCoverage] public sealed class ExpirableToken { /// From 83150b930bffee8d259443da6679fdc485fef3ee Mon Sep 17 00:00:00 2001 From: Matthew Thomas Date: Mon, 7 Oct 2024 12:34:24 -0500 Subject: [PATCH 09/20] adding unit tests --- ...r.cs => ClientCredentialsTokenProvider.cs} | 8 +- .../ClientCredentialsTokenProviderTests.cs | 104 ++++++++++++++++++ .../Authentication/OAuthTokenProviderTests.cs | 40 +++++++ 3 files changed, 146 insertions(+), 6 deletions(-) rename src/Flurl.Http/Authentication/{ClientCredentialsOAuthTokenProvider.cs => ClientCredentialsTokenProvider.cs} (91%) create mode 100644 test/Flurl.Test/Http/Authentication/ClientCredentialsTokenProviderTests.cs create mode 100644 test/Flurl.Test/Http/Authentication/OAuthTokenProviderTests.cs diff --git a/src/Flurl.Http/Authentication/ClientCredentialsOAuthTokenProvider.cs b/src/Flurl.Http/Authentication/ClientCredentialsTokenProvider.cs similarity index 91% rename from src/Flurl.Http/Authentication/ClientCredentialsOAuthTokenProvider.cs rename to src/Flurl.Http/Authentication/ClientCredentialsTokenProvider.cs index b08e2eef..e80af92a 100644 --- a/src/Flurl.Http/Authentication/ClientCredentialsOAuthTokenProvider.cs +++ b/src/Flurl.Http/Authentication/ClientCredentialsTokenProvider.cs @@ -1,13 +1,12 @@ using System; using System.Collections.Generic; -using System.Text.Json; using System.Text.Json.Nodes; using System.Threading.Tasks; namespace Flurl.Http.Authentication { /// - /// + /// OAuth token provider that uses client credentials as an authentication mechanism /// public class ClientCredentialsTokenProvider : OAuthTokenProvider { @@ -55,7 +54,7 @@ protected override async Task GetToken(string scope) { body["client_secret"] = _clientSecret; } var rawResponse = await _fc.Request("connect", "token") - .WithHeader("accept","application/json") + .WithHeader("accept", "application/json") .AllowAnyHttpStatus() .PostUrlEncodedAsync(body); @@ -73,9 +72,6 @@ protected override async Task GetToken(string scope) { var response = await rawResponse.GetJsonAsync(); errorMessage = response["error"].GetValue(); - - if (errorMessage == "invalid_scope") - { errorMessage = $"{_clientId} is not allowed to utilize scope {scope}, or {scope} is not a valid scope. Verify the allowed scopes for {_clientId} and try again."; } } catch (Exception) { diff --git a/test/Flurl.Test/Http/Authentication/ClientCredentialsTokenProviderTests.cs b/test/Flurl.Test/Http/Authentication/ClientCredentialsTokenProviderTests.cs new file mode 100644 index 00000000..e043a807 --- /dev/null +++ b/test/Flurl.Test/Http/Authentication/ClientCredentialsTokenProviderTests.cs @@ -0,0 +1,104 @@ +using Flurl.Http; +using Flurl.Http.Authentication; +using Flurl.Http.Testing; +using NUnit.Framework; +using System; +using System.Net.Http; +using System.Threading.Tasks; + +namespace Flurl.Test.Http.Authentication +{ + [TestFixture] + public class ClientCredentialsTokenProviderTests : HttpTestFixtureBase + { + [TestCase("secret")] + [TestCase("")] + [TestCase(null)] + public async Task GetAuthenticationHeader_OnlyPassesClientSecretIfSet(string clientSecret) + { + var body = new + { + access_token = "UnitTestAccessToken", + expires_in = 3600 + }; + + HttpTest.RespondWithJson(body); + + var cli = new FlurlClient("https://flurl.dev"); + + var provider = new ClientCredentialsTokenProvider("unitTestClient", clientSecret, cli); + + var authHeader = await provider.GetAuthenticationHeader("scope"); + + HttpTest.ShouldHaveCalled("https://flurl.dev/connect/token") + .WithVerb(HttpMethod.Post) + .WithContentType("application/x-www-form-urlencoded") + .WithRequestBody($"client_id=clientId&scope=scope&grant_type=client_credentials{(string.IsNullOrWhiteSpace(clientSecret) ? "" : $"&client_secret={clientSecret}")}") + .WithHeader("accept", "application/json") + .Times(1); + } + + [Test] + public async Task GetAuthenticationHeader_ReturnsTokenForSuccessfulResponse() + { + var body = new + { + access_token = "UnitTestAccessToken", + expires_in = 3600 + }; + + HttpTest.RespondWithJson(body); + + var cli = new FlurlClient("https://flurl.dev"); + + var provider = new ClientCredentialsTokenProvider("unitTestClient", "secret", cli); + + var authHeader = await provider.GetAuthenticationHeader("scope"); + + Assert.AreEqual("UnitTestAccessToken", authHeader.Parameter); + } + + [Test] + public void GetAuthenticationHeader_ThrowsUnauthorizedForErrorMessageResponse() + { + var body = new + { + error = "invalid_scope", + }; + + HttpTest.RespondWithJson(body, 400); + + var cli = new FlurlClient("https://flurl.dev"); + + var provider = new ClientCredentialsTokenProvider("unitTestClient", "secret", cli); + + Assert.ThrowsAsync(() => provider.GetAuthenticationHeader("testScope")); + } + + [Test] + public void GetAuthenticationHeader_ThrowsUnauthorizedForGarbageResponse() + { + HttpTest.RespondWith("garbage", 400); + + var cli = new FlurlClient("https://flurl.dev"); + + var provider = new ClientCredentialsTokenProvider("unitTestClient", "secret", cli); + + var expectedMessage = $"unitTestClient is not allowed to utilize scope testScope, or testScope is not a valid scope. Verify the allowed scopes for unitTestClient and try again."; + Assert.ThrowsAsync(() => provider.GetAuthenticationHeader("testScope"), expectedMessage); + } + + [Test] + public void GetAuthenticationHeader_ThrowsUnauthorizedForNon400Response() + { + HttpTest.RespondWith("garbage", 500); + + var cli = new FlurlClient("https://flurl.dev"); + + var provider = new ClientCredentialsTokenProvider("unitTestClient", "secret", cli); + + var expectedMessage = "Unable to acquire OAuth token"; + Assert.ThrowsAsync(() => provider.GetAuthenticationHeader("testScope"), expectedMessage); + } + } +} diff --git a/test/Flurl.Test/Http/Authentication/OAuthTokenProviderTests.cs b/test/Flurl.Test/Http/Authentication/OAuthTokenProviderTests.cs new file mode 100644 index 00000000..24a5dbf0 --- /dev/null +++ b/test/Flurl.Test/Http/Authentication/OAuthTokenProviderTests.cs @@ -0,0 +1,40 @@ +using Flurl.Http.Authentication; +using NUnit.Framework; +using System; +using System.Threading.Tasks; + +namespace Flurl.Test.Http.Authentication +{ + [TestFixture] + public class OAuthTokenProviderTests + { + internal class UnitTestTokenProvider : OAuthTokenProvider + { + private int _generationCount = 0; + public UnitTestTokenProvider() : base() + { + } + + protected override Task GetToken(string scope) + { + return Task.FromResult(new ExpirableToken((++_generationCount).ToString(), DateTimeOffset.Now.AddSeconds(1))); + } + } + + [Test] + public async Task GetAuthenticationHeader_ReusesValidTokens() + { + var provider = new UnitTestTokenProvider(); + + var header1 = await provider.GetAuthenticationHeader("scope"); + var header2 = await provider.GetAuthenticationHeader("scope"); + + await Task.Delay(TimeSpan.FromSeconds(1)); + + var header3 = await provider.GetAuthenticationHeader("scope"); + + Assert.AreEqual(header1.Parameter, header2.Parameter); + Assert.AreNotEqual(header1.Parameter, header3.Parameter); + } + } +} From 4693238b555c5d55338270da5b8b730a50041493 Mon Sep 17 00:00:00 2001 From: Matthew Thomas Date: Mon, 7 Oct 2024 12:52:29 -0500 Subject: [PATCH 10/20] moving unit test token provider --- .../Authentication/OAuthTokenProviderTests.cs | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/test/Flurl.Test/Http/Authentication/OAuthTokenProviderTests.cs b/test/Flurl.Test/Http/Authentication/OAuthTokenProviderTests.cs index 24a5dbf0..ef4311c4 100644 --- a/test/Flurl.Test/Http/Authentication/OAuthTokenProviderTests.cs +++ b/test/Flurl.Test/Http/Authentication/OAuthTokenProviderTests.cs @@ -5,22 +5,22 @@ namespace Flurl.Test.Http.Authentication { - [TestFixture] - public class OAuthTokenProviderTests + internal class UnitTestTokenProvider : OAuthTokenProvider { - internal class UnitTestTokenProvider : OAuthTokenProvider + private int _generationCount = 0; + public UnitTestTokenProvider() : base() { - private int _generationCount = 0; - public UnitTestTokenProvider() : base() - { - } + } - protected override Task GetToken(string scope) - { - return Task.FromResult(new ExpirableToken((++_generationCount).ToString(), DateTimeOffset.Now.AddSeconds(1))); - } + protected override Task GetToken(string scope) + { + return Task.FromResult(new ExpirableToken((++_generationCount).ToString(), DateTimeOffset.Now.AddSeconds(1))); } + } + [TestFixture] + public class OAuthTokenProviderTests + { [Test] public async Task GetAuthenticationHeader_ReusesValidTokens() { From a98594c2239a08ec9807d207b4faa7e7869a4f6f Mon Sep 17 00:00:00 2001 From: Matthew Thomas Date: Mon, 7 Oct 2024 13:15:01 -0500 Subject: [PATCH 11/20] fixing unit tests --- .../Authentication/ClientCredentialsTokenProviderTests.cs | 6 +++--- .../Http/Authentication/OAuthTokenProviderTests.cs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/Flurl.Test/Http/Authentication/ClientCredentialsTokenProviderTests.cs b/test/Flurl.Test/Http/Authentication/ClientCredentialsTokenProviderTests.cs index e043a807..1bdd8274 100644 --- a/test/Flurl.Test/Http/Authentication/ClientCredentialsTokenProviderTests.cs +++ b/test/Flurl.Test/Http/Authentication/ClientCredentialsTokenProviderTests.cs @@ -8,7 +8,7 @@ namespace Flurl.Test.Http.Authentication { - [TestFixture] + [TestFixture] public class ClientCredentialsTokenProviderTests : HttpTestFixtureBase { [TestCase("secret")] @@ -28,12 +28,12 @@ public async Task GetAuthenticationHeader_OnlyPassesClientSecretIfSet(string cli var provider = new ClientCredentialsTokenProvider("unitTestClient", clientSecret, cli); - var authHeader = await provider.GetAuthenticationHeader("scope"); + var authHeader = await provider.GetAuthenticationHeader("unitTestScope"); HttpTest.ShouldHaveCalled("https://flurl.dev/connect/token") .WithVerb(HttpMethod.Post) .WithContentType("application/x-www-form-urlencoded") - .WithRequestBody($"client_id=clientId&scope=scope&grant_type=client_credentials{(string.IsNullOrWhiteSpace(clientSecret) ? "" : $"&client_secret={clientSecret}")}") + .WithRequestBody($"client_id=unitTestClient&scope=unitTestScope&grant_type=client_credentials{(string.IsNullOrWhiteSpace(clientSecret) ? "" : $"&client_secret={clientSecret}")}") .WithHeader("accept", "application/json") .Times(1); } diff --git a/test/Flurl.Test/Http/Authentication/OAuthTokenProviderTests.cs b/test/Flurl.Test/Http/Authentication/OAuthTokenProviderTests.cs index ef4311c4..77884169 100644 --- a/test/Flurl.Test/Http/Authentication/OAuthTokenProviderTests.cs +++ b/test/Flurl.Test/Http/Authentication/OAuthTokenProviderTests.cs @@ -5,7 +5,7 @@ namespace Flurl.Test.Http.Authentication { - internal class UnitTestTokenProvider : OAuthTokenProvider + internal class UnitTestTokenProvider : OAuthTokenProvider { private int _generationCount = 0; public UnitTestTokenProvider() : base() From 3af71a2f39e79cf82fa78856477066334a1cf1b8 Mon Sep 17 00:00:00 2001 From: Matthew Thomas Date: Mon, 7 Oct 2024 13:31:51 -0500 Subject: [PATCH 12/20] sync headers after setting the authorization header --- src/Flurl.Http/FlurlClient.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Flurl.Http/FlurlClient.cs b/src/Flurl.Http/FlurlClient.cs index 8fa7faff..4d7b0536 100644 --- a/src/Flurl.Http/FlurlClient.cs +++ b/src/Flurl.Http/FlurlClient.cs @@ -157,17 +157,18 @@ public async Task SendAsync(IFlurlRequest request, HttpCompletio // in case URL or headers were modified in the handler above reqMsg.RequestUri = request.Url.ToUri(); - SyncHeaders(request, reqMsg); //if the settings and handlers didn't set the authorization header, //resolve it with the configured provider - if (call.HttpRequestMessage.Headers.Authorization == null && + if (reqMsg.Headers.Authorization == null && settings.OAuthTokenProvider != null) { var scope = string.IsNullOrWhiteSpace(settings.OAuthTokenScope) ? string.Empty : settings.OAuthTokenScope; - call.HttpRequestMessage.Headers.Authorization = await settings.OAuthTokenProvider.GetAuthenticationHeader(scope); + reqMsg.Headers.Authorization = await settings.OAuthTokenProvider.GetAuthenticationHeader(scope); } + SyncHeaders(request, reqMsg); + call.StartedUtc = DateTime.UtcNow; var ct = GetCancellationTokenWithTimeout(cancellationToken, settings.Timeout, out var cts); From 6ef32d461bbab9b9d420ee51b0cf9258854a89bf Mon Sep 17 00:00:00 2001 From: Matthew Thomas Date: Mon, 7 Oct 2024 13:44:47 -0500 Subject: [PATCH 13/20] detect and set the oauth header through flurl request headers for compatibility and testing --- src/Flurl.Http/FlurlClient.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Flurl.Http/FlurlClient.cs b/src/Flurl.Http/FlurlClient.cs index 4d7b0536..cda21a70 100644 --- a/src/Flurl.Http/FlurlClient.cs +++ b/src/Flurl.Http/FlurlClient.cs @@ -160,14 +160,16 @@ public async Task SendAsync(IFlurlRequest request, HttpCompletio //if the settings and handlers didn't set the authorization header, //resolve it with the configured provider - if (reqMsg.Headers.Authorization == null && + if (request.Headers.TryGetFirst("Authorization", out var authValue) == false && + string.IsNullOrWhiteSpace(authValue) && settings.OAuthTokenProvider != null) { var scope = string.IsNullOrWhiteSpace(settings.OAuthTokenScope) ? string.Empty : settings.OAuthTokenScope; - reqMsg.Headers.Authorization = await settings.OAuthTokenProvider.GetAuthenticationHeader(scope); + var authHeader = await settings.OAuthTokenProvider.GetAuthenticationHeader(scope); + request.Headers.Add("Authorization", authHeader.ToString()); } - SyncHeaders(request, reqMsg); + SyncHeaders(request, reqMsg); call.StartedUtc = DateTime.UtcNow; var ct = GetCancellationTokenWithTimeout(cancellationToken, settings.Timeout, out var cts); From 79e65e90f1cacf758ce9537526903dc9b2b1b4be Mon Sep 17 00:00:00 2001 From: Matthew Thomas Date: Mon, 7 Oct 2024 13:46:23 -0500 Subject: [PATCH 14/20] adding test for auth token provider --- test/Flurl.Test/Http/SettingsTests.cs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/Flurl.Test/Http/SettingsTests.cs b/test/Flurl.Test/Http/SettingsTests.cs index ee5763c7..f0f59882 100644 --- a/test/Flurl.Test/Http/SettingsTests.cs +++ b/test/Flurl.Test/Http/SettingsTests.cs @@ -5,6 +5,7 @@ using Flurl.Http; using Flurl.Http.Configuration; using Flurl.Http.Testing; +using Flurl.Test.Http.Authentication; using NUnit.Framework; namespace Flurl.Test.Http @@ -149,6 +150,30 @@ public async Task can_allow_any_http_status() { Assert.Fail("Exception should not have been thrown."); } } + + [Test] + public async Task set_oauthTokenProvider_sets_authenticationHeader_on_request() + { + using var test = new HttpTest(); + test.RespondWith("OK", 200); + + try + { + var c = CreateContainer(); + c.Settings.OAuthTokenProvider = new UnitTestTokenProvider(); + + var result = await GetRequest(c).GetAsync(); + + var h = test.CallLog[0].Request.Headers; + Assert.True(h.TryGetFirst("authorization", out var authHeaderValue)); + Assert.AreEqual("Bearer 1", authHeaderValue); + + } + catch (Exception ex) + { + Assert.Fail("Exception should not have been thrown."); + } + } } [TestFixture] From b164fcdae0e798fe524a7939c08494ef34aac426 Mon Sep 17 00:00:00 2001 From: Matthew Thomas Date: Mon, 7 Oct 2024 13:46:53 -0500 Subject: [PATCH 15/20] whitespace --- test/Flurl.Test/Http/SettingsTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/Flurl.Test/Http/SettingsTests.cs b/test/Flurl.Test/Http/SettingsTests.cs index f0f59882..c6f30cec 100644 --- a/test/Flurl.Test/Http/SettingsTests.cs +++ b/test/Flurl.Test/Http/SettingsTests.cs @@ -167,7 +167,6 @@ public async Task set_oauthTokenProvider_sets_authenticationHeader_on_request() var h = test.CallLog[0].Request.Headers; Assert.True(h.TryGetFirst("authorization", out var authHeaderValue)); Assert.AreEqual("Bearer 1", authHeaderValue); - } catch (Exception ex) { From 9b3d3078edc8985af7b2128f2eef3eb37a647a1a Mon Sep 17 00:00:00 2001 From: Matthew Thomas Date: Mon, 7 Oct 2024 13:47:40 -0500 Subject: [PATCH 16/20] removing unused reference to ex --- test/Flurl.Test/Http/SettingsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Flurl.Test/Http/SettingsTests.cs b/test/Flurl.Test/Http/SettingsTests.cs index c6f30cec..a320049f 100644 --- a/test/Flurl.Test/Http/SettingsTests.cs +++ b/test/Flurl.Test/Http/SettingsTests.cs @@ -168,7 +168,7 @@ public async Task set_oauthTokenProvider_sets_authenticationHeader_on_request() Assert.True(h.TryGetFirst("authorization", out var authHeaderValue)); Assert.AreEqual("Bearer 1", authHeaderValue); } - catch (Exception ex) + catch (Exception) { Assert.Fail("Exception should not have been thrown."); } From b1e16168a21c638e8f05f56efeb8f9ada832eb85 Mon Sep 17 00:00:00 2001 From: Matthew Thomas Date: Mon, 7 Oct 2024 14:51:38 -0500 Subject: [PATCH 17/20] missed rename --- src/Flurl.CodeGen/Metadata.cs | 2 +- src/Flurl.Http/GeneratedExtensions.cs | 4 ++-- src/Flurl.Http/ISettingsContainer.cs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Flurl.CodeGen/Metadata.cs b/src/Flurl.CodeGen/Metadata.cs index 7173e9f4..cb999a1d 100644 --- a/src/Flurl.CodeGen/Metadata.cs +++ b/src/Flurl.CodeGen/Metadata.cs @@ -146,7 +146,7 @@ public static IEnumerable GetRequestReturningExtensions(MethodA .AddArg("enabled", "bool", "true if Flurl should automatically send a new request to the redirect URL, false if it should not."); yield return Create("WithOAuthTokenProvider", "Creates a new FlurlRequest and configures it to use the supplied OAuth token provider for the request's authentication header") .AddArg("tokenProvider","IOAuthTokenProvider", "the token provider"); - yield return Create("WithOAuthScope", "Creates a new FlurlRequest and configures it to request an OAuth bearer token of the specified scope from the OAuth token provider") + yield return Create("WithOAuthTokenFromProvider", "Creates a new FlurlRequest and configures it to request an OAuth bearer token of the specified scope from the OAuth token provider") .AddArg("scope","string","The scope of the token"); // event handler extensions diff --git a/src/Flurl.Http/GeneratedExtensions.cs b/src/Flurl.Http/GeneratedExtensions.cs index 30bee3ce..64326027 100644 --- a/src/Flurl.Http/GeneratedExtensions.cs +++ b/src/Flurl.Http/GeneratedExtensions.cs @@ -1816,8 +1816,8 @@ public static IFlurlRequest WithOAuthTokenProvider(this Uri uri, IOAuthTokenProv /// This System.Uri. /// The scope of the token /// A new IFlurlRequest. - public static IFlurlRequest WithOAuthScope(this Uri uri, string scope) { - return new FlurlRequest(uri).WithOAuthScope(scope); + public static IFlurlRequest WithOAuthTokenFromProvider(this Uri uri, string scope) { + return new FlurlRequest(uri).WithOAuthTokenFromProvider(scope); } /// diff --git a/src/Flurl.Http/ISettingsContainer.cs b/src/Flurl.Http/ISettingsContainer.cs index 74fa607d..56725f46 100644 --- a/src/Flurl.Http/ISettingsContainer.cs +++ b/src/Flurl.Http/ISettingsContainer.cs @@ -121,7 +121,7 @@ public static T WithOAuthTokenProvider(this T obj, IOAuthTokenProvider tokenP /// Object containing settings. /// The scope of the token /// - public static T WithOAuthScope(this T obj, string scope) where T : ISettingsContainer { + public static T WithOAuthTokenFromProvider(this T obj, string scope) where T : ISettingsContainer { obj.Settings.OAuthTokenScope = scope; return obj; } From e1dd46f0552c3009239a89409628c9bde32de915 Mon Sep 17 00:00:00 2001 From: Matthew Thomas Date: Mon, 7 Oct 2024 14:52:47 -0500 Subject: [PATCH 18/20] missed rename --- src/Flurl.Http/GeneratedExtensions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Flurl.Http/GeneratedExtensions.cs b/src/Flurl.Http/GeneratedExtensions.cs index 64326027..0749bfc7 100644 --- a/src/Flurl.Http/GeneratedExtensions.cs +++ b/src/Flurl.Http/GeneratedExtensions.cs @@ -738,8 +738,8 @@ public static IFlurlRequest WithOAuthTokenProvider(this Url url, IOAuthTokenProv /// This Flurl.Url. /// The scope of the token /// A new IFlurlRequest. - public static IFlurlRequest WithOAuthScope(this Url url, string scope) { - return new FlurlRequest(url).WithOAuthScope(scope); + public static IFlurlRequest WithOAuthTokenFromProvider(this Url url, string scope) { + return new FlurlRequest(url).WithOAuthTokenFromProvider(scope); } /// From 39dd67eb15f3cc7c61cdc67ece6b5e55cc4a0eb5 Mon Sep 17 00:00:00 2001 From: Matthew Thomas Date: Mon, 7 Oct 2024 14:53:50 -0500 Subject: [PATCH 19/20] missed rename --- src/Flurl.Http/GeneratedExtensions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Flurl.Http/GeneratedExtensions.cs b/src/Flurl.Http/GeneratedExtensions.cs index 0749bfc7..06aa8e07 100644 --- a/src/Flurl.Http/GeneratedExtensions.cs +++ b/src/Flurl.Http/GeneratedExtensions.cs @@ -1277,8 +1277,8 @@ public static IFlurlRequest WithOAuthTokenProvider(this string url, IOAuthTokenP /// This URL. /// The scope of the token /// A new IFlurlRequest. - public static IFlurlRequest WithOAuthScope(this string url, string scope) { - return new FlurlRequest(url).WithOAuthScope(scope); + public static IFlurlRequest WithOAuthTokenFromProvider(this string url, string scope) { + return new FlurlRequest(url).WithOAuthTokenFromProvider(scope); } /// From 7bb00e2bcd0a2d2d70453126394568bae819078d Mon Sep 17 00:00:00 2001 From: Matthew Thomas Date: Wed, 6 Nov 2024 17:08:03 -0600 Subject: [PATCH 20/20] support the option of multiple scopes when requesting an authentication token --- src/Flurl.CodeGen/Metadata.cs | 4 +-- .../ClientCredentialsTokenProvider.cs | 16 +++++++---- .../Authentication/IOAuthTokenProvider.cs | 7 +++-- .../Authentication/OAuthTokenProvider.cs | 23 +++++++++++---- .../Configuration/FlurlHttpSettings.cs | 4 +-- src/Flurl.Http/FlurlClient.cs | 3 +- src/Flurl.Http/GeneratedExtensions.cs | 24 ++++++++-------- src/Flurl.Http/ISettingsContainer.cs | 10 ++++--- .../ClientCredentialsTokenProviderTests.cs | 28 +++++++++++-------- .../Authentication/OAuthTokenProviderTests.cs | 13 +++++---- 10 files changed, 79 insertions(+), 53 deletions(-) diff --git a/src/Flurl.CodeGen/Metadata.cs b/src/Flurl.CodeGen/Metadata.cs index cb999a1d..7061b61c 100644 --- a/src/Flurl.CodeGen/Metadata.cs +++ b/src/Flurl.CodeGen/Metadata.cs @@ -146,8 +146,8 @@ public static IEnumerable GetRequestReturningExtensions(MethodA .AddArg("enabled", "bool", "true if Flurl should automatically send a new request to the redirect URL, false if it should not."); yield return Create("WithOAuthTokenProvider", "Creates a new FlurlRequest and configures it to use the supplied OAuth token provider for the request's authentication header") .AddArg("tokenProvider","IOAuthTokenProvider", "the token provider"); - yield return Create("WithOAuthTokenFromProvider", "Creates a new FlurlRequest and configures it to request an OAuth bearer token of the specified scope from the OAuth token provider") - .AddArg("scope","string","The scope of the token"); + yield return Create("WithOAuthTokenFromProvider", "Creates a new FlurlRequest and configures it to request an OAuth bearer token of the specified scope(s) from the OAuth token provider") + .AddArg("scopes","params string[]","The scope(s) of the token"); // event handler extensions foreach (var name in new[] { "BeforeCall", "AfterCall", "OnError", "OnRedirect" }) { diff --git a/src/Flurl.Http/Authentication/ClientCredentialsTokenProvider.cs b/src/Flurl.Http/Authentication/ClientCredentialsTokenProvider.cs index e80af92a..da814f88 100644 --- a/src/Flurl.Http/Authentication/ClientCredentialsTokenProvider.cs +++ b/src/Flurl.Http/Authentication/ClientCredentialsTokenProvider.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text.Json.Nodes; using System.Threading.Tasks; @@ -37,22 +38,25 @@ public ClientCredentialsTokenProvider(string clientId, /// /// Gets the OAuth authentication header for the specified scope /// - /// The desired scope + /// The desired set of scopes /// - protected override async Task GetToken(string scope) + protected override async Task GetToken(ISet scopes) { var now = DateTimeOffset.Now; var body = new Dictionary { ["client_id"] = _clientId, - ["scope"] = scope, - ["grant_type"] = "client_credentials" }; - + if (string.IsNullOrWhiteSpace(_clientSecret) == false) { body["client_secret"] = _clientSecret; } + body["grant_type"] = "client_credentials"; + + if (scopes.Any()) + { body["scope"] = string.Join(" ", scopes); } + var rawResponse = await _fc.Request("connect", "token") .WithHeader("accept", "application/json") .AllowAnyHttpStatus() @@ -75,7 +79,7 @@ protected override async Task GetToken(string scope) } catch (Exception) { - errorMessage = $"{_clientId} is not allowed to utilize scope {scope}, or {scope} is not a valid scope. Verify the allowed scopes for {_clientId} and try again."; + errorMessage = $"Verify the allowed scopes for {_clientId} and try again."; } throw new UnauthorizedAccessException(errorMessage); diff --git a/src/Flurl.Http/Authentication/IOAuthTokenProvider.cs b/src/Flurl.Http/Authentication/IOAuthTokenProvider.cs index e4a63157..d51d6eb7 100644 --- a/src/Flurl.Http/Authentication/IOAuthTokenProvider.cs +++ b/src/Flurl.Http/Authentication/IOAuthTokenProvider.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Net.Http.Headers; using System.Threading.Tasks; @@ -9,10 +10,10 @@ namespace Flurl.Http.Authentication public interface IOAuthTokenProvider { /// - /// Gets the authentication header for a specified scope. + /// Gets the authentication header for a specified set of scopes. /// - /// The desired scope + /// The desired set of scopes /// - Task GetAuthenticationHeader(string scope); + Task GetAuthenticationHeader(ISet scopes); } } diff --git a/src/Flurl.Http/Authentication/OAuthTokenProvider.cs b/src/Flurl.Http/Authentication/OAuthTokenProvider.cs index 11273772..8491bccd 100644 --- a/src/Flurl.Http/Authentication/OAuthTokenProvider.cs +++ b/src/Flurl.Http/Authentication/OAuthTokenProvider.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; @@ -11,6 +13,11 @@ namespace Flurl.Http.Authentication /// public abstract class OAuthTokenProvider : IOAuthTokenProvider { + /// + /// The default set of empty scopes + /// + protected static readonly ISet EmptyScope = new HashSet(); + private class CacheEntry { public SemaphoreSlim Semaphore { get; } @@ -45,14 +52,18 @@ protected OAuthTokenProvider( /// /// Gets the OAuth authentication header for the specified scope /// - /// The desired scope + /// The desired set of scopes /// - public async Task GetAuthenticationHeader(string scope) + public async Task GetAuthenticationHeader(ISet scopes) { var now = DateTimeOffset.Now; + scopes??= EmptyScope; + + var cacheKey = string.Join(" ", scopes); + //if the scope is not in the cache, add it as an expired entry so we force a refresh - var entry = _tokens.GetOrAdd(scope, s => + var entry = _tokens.GetOrAdd(cacheKey, s => { return new CacheEntry { @@ -71,7 +82,7 @@ public async Task GetAuthenticationHeader(string scop if (tokenIsValid == false) { - var generatedToken = await GetToken(scope); + var generatedToken = await GetToken(scopes); //if we're configured to expire tokens early, adjust the expiration time if (_earlyExpiration > TimeSpan.Zero) @@ -91,9 +102,9 @@ public async Task GetAuthenticationHeader(string scop } /// - /// Retrieves the OAuth token for the specified scope + /// Retrieves the OAuth token for the specified scopes /// /// The refreshed OAuth token - protected abstract Task GetToken(string scope); + protected abstract Task GetToken(ISet scopes); } } diff --git a/src/Flurl.Http/Configuration/FlurlHttpSettings.cs b/src/Flurl.Http/Configuration/FlurlHttpSettings.cs index 287a1d48..19cf82f1 100644 --- a/src/Flurl.Http/Configuration/FlurlHttpSettings.cs +++ b/src/Flurl.Http/Configuration/FlurlHttpSettings.cs @@ -96,9 +96,9 @@ public IOAuthTokenProvider OAuthTokenProvider /// /// Gets or sets the OAuth scope to request from /// - public string OAuthTokenScope + public ISet OAuthTokenScopes { - get => Get(); + get => Get>(); set => Set(value); } diff --git a/src/Flurl.Http/FlurlClient.cs b/src/Flurl.Http/FlurlClient.cs index cda21a70..2d0f9354 100644 --- a/src/Flurl.Http/FlurlClient.cs +++ b/src/Flurl.Http/FlurlClient.cs @@ -164,8 +164,7 @@ public async Task SendAsync(IFlurlRequest request, HttpCompletio string.IsNullOrWhiteSpace(authValue) && settings.OAuthTokenProvider != null) { - var scope = string.IsNullOrWhiteSpace(settings.OAuthTokenScope) ? string.Empty : settings.OAuthTokenScope; - var authHeader = await settings.OAuthTokenProvider.GetAuthenticationHeader(scope); + var authHeader = await settings.OAuthTokenProvider.GetAuthenticationHeader(settings.OAuthTokenScopes); request.Headers.Add("Authorization", authHeader.ToString()); } diff --git a/src/Flurl.Http/GeneratedExtensions.cs b/src/Flurl.Http/GeneratedExtensions.cs index 06aa8e07..ffc3481a 100644 --- a/src/Flurl.Http/GeneratedExtensions.cs +++ b/src/Flurl.Http/GeneratedExtensions.cs @@ -733,13 +733,13 @@ public static IFlurlRequest WithOAuthTokenProvider(this Url url, IOAuthTokenProv } /// - /// Creates a new FlurlRequest and configures it to request an OAuth bearer token of the specified scope from the OAuth token provider + /// Creates a new FlurlRequest and configures it to request an OAuth bearer token of the specified scope(s) from the OAuth token provider /// /// This Flurl.Url. - /// The scope of the token + /// The scope(s) of the token /// A new IFlurlRequest. - public static IFlurlRequest WithOAuthTokenFromProvider(this Url url, string scope) { - return new FlurlRequest(url).WithOAuthTokenFromProvider(scope); + public static IFlurlRequest WithOAuthTokenFromProvider(this Url url, params string[] scopes) { + return new FlurlRequest(url).WithOAuthTokenFromProvider(scopes); } /// @@ -1272,13 +1272,13 @@ public static IFlurlRequest WithOAuthTokenProvider(this string url, IOAuthTokenP } /// - /// Creates a new FlurlRequest and configures it to request an OAuth bearer token of the specified scope from the OAuth token provider + /// Creates a new FlurlRequest and configures it to request an OAuth bearer token of the specified scope(s) from the OAuth token provider /// /// This URL. - /// The scope of the token + /// The scope(s) of the token /// A new IFlurlRequest. - public static IFlurlRequest WithOAuthTokenFromProvider(this string url, string scope) { - return new FlurlRequest(url).WithOAuthTokenFromProvider(scope); + public static IFlurlRequest WithOAuthTokenFromProvider(this string url, params string[] scopes) { + return new FlurlRequest(url).WithOAuthTokenFromProvider(scopes); } /// @@ -1811,13 +1811,13 @@ public static IFlurlRequest WithOAuthTokenProvider(this Uri uri, IOAuthTokenProv } /// - /// Creates a new FlurlRequest and configures it to request an OAuth bearer token of the specified scope from the OAuth token provider + /// Creates a new FlurlRequest and configures it to request an OAuth bearer token of the specified scope(s) from the OAuth token provider /// /// This System.Uri. - /// The scope of the token + /// The scope(s) of the token /// A new IFlurlRequest. - public static IFlurlRequest WithOAuthTokenFromProvider(this Uri uri, string scope) { - return new FlurlRequest(uri).WithOAuthTokenFromProvider(scope); + public static IFlurlRequest WithOAuthTokenFromProvider(this Uri uri, params string[] scopes) { + return new FlurlRequest(uri).WithOAuthTokenFromProvider(scopes); } /// diff --git a/src/Flurl.Http/ISettingsContainer.cs b/src/Flurl.Http/ISettingsContainer.cs index 56725f46..9e318e69 100644 --- a/src/Flurl.Http/ISettingsContainer.cs +++ b/src/Flurl.Http/ISettingsContainer.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Net; using Flurl.Http.Authentication; @@ -116,13 +117,14 @@ public static T WithOAuthTokenProvider(this T obj, IOAuthTokenProvider tokenP } /// - /// Configures the OAuth scope to obtain from the configured OAuthTokenProvider + /// Configures the OAuth scope(s) to obtain from the configured OAuthTokenProvider /// /// Object containing settings. - /// The scope of the token + /// The scope(s) of the token /// - public static T WithOAuthTokenFromProvider(this T obj, string scope) where T : ISettingsContainer { - obj.Settings.OAuthTokenScope = scope; + public static T WithOAuthTokenFromProvider(this T obj, params string[] scopes) where T : ISettingsContainer { + var distinctScopes = new HashSet(scopes ?? Array.Empty()); + obj.Settings.OAuthTokenScopes = distinctScopes; return obj; } } diff --git a/test/Flurl.Test/Http/Authentication/ClientCredentialsTokenProviderTests.cs b/test/Flurl.Test/Http/Authentication/ClientCredentialsTokenProviderTests.cs index 1bdd8274..806195a1 100644 --- a/test/Flurl.Test/Http/Authentication/ClientCredentialsTokenProviderTests.cs +++ b/test/Flurl.Test/Http/Authentication/ClientCredentialsTokenProviderTests.cs @@ -3,6 +3,7 @@ using Flurl.Http.Testing; using NUnit.Framework; using System; +using System.Collections.Generic; using System.Net.Http; using System.Threading.Tasks; @@ -11,10 +12,10 @@ namespace Flurl.Test.Http.Authentication [TestFixture] public class ClientCredentialsTokenProviderTests : HttpTestFixtureBase { - [TestCase("secret")] - [TestCase("")] - [TestCase(null)] - public async Task GetAuthenticationHeader_OnlyPassesClientSecretIfSet(string clientSecret) + [TestCase("secret", "client_id=unitTestClient&client_secret=secret&grant_type=client_credentials&scope=unitTestScope")] + [TestCase("", "client_id=unitTestClient&grant_type=client_credentials&scope=unitTestScope")] + [TestCase(null, "client_id=unitTestClient&grant_type=client_credentials&scope=unitTestScope")] + public async Task GetAuthenticationHeader_OnlyPassesClientSecretIfSet(string clientSecret, string expectedBody) { var body = new { @@ -28,12 +29,13 @@ public async Task GetAuthenticationHeader_OnlyPassesClientSecretIfSet(string cli var provider = new ClientCredentialsTokenProvider("unitTestClient", clientSecret, cli); - var authHeader = await provider.GetAuthenticationHeader("unitTestScope"); + var scopes = new HashSet(new[] { "unitTestScope" }); + var authHeader = await provider.GetAuthenticationHeader(scopes); HttpTest.ShouldHaveCalled("https://flurl.dev/connect/token") .WithVerb(HttpMethod.Post) .WithContentType("application/x-www-form-urlencoded") - .WithRequestBody($"client_id=unitTestClient&scope=unitTestScope&grant_type=client_credentials{(string.IsNullOrWhiteSpace(clientSecret) ? "" : $"&client_secret={clientSecret}")}") + .WithRequestBody(expectedBody) .WithHeader("accept", "application/json") .Times(1); } @@ -53,7 +55,8 @@ public async Task GetAuthenticationHeader_ReturnsTokenForSuccessfulResponse() var provider = new ClientCredentialsTokenProvider("unitTestClient", "secret", cli); - var authHeader = await provider.GetAuthenticationHeader("scope"); + var scopes = new HashSet(new[] { "unitTestScope" }); + var authHeader = await provider.GetAuthenticationHeader(scopes); Assert.AreEqual("UnitTestAccessToken", authHeader.Parameter); } @@ -72,7 +75,8 @@ public void GetAuthenticationHeader_ThrowsUnauthorizedForErrorMessageResponse() var provider = new ClientCredentialsTokenProvider("unitTestClient", "secret", cli); - Assert.ThrowsAsync(() => provider.GetAuthenticationHeader("testScope")); + var scopes = new HashSet(new[] { "unitTestScope" }); + Assert.ThrowsAsync(() => provider.GetAuthenticationHeader(scopes)); } [Test] @@ -84,8 +88,9 @@ public void GetAuthenticationHeader_ThrowsUnauthorizedForGarbageResponse() var provider = new ClientCredentialsTokenProvider("unitTestClient", "secret", cli); - var expectedMessage = $"unitTestClient is not allowed to utilize scope testScope, or testScope is not a valid scope. Verify the allowed scopes for unitTestClient and try again."; - Assert.ThrowsAsync(() => provider.GetAuthenticationHeader("testScope"), expectedMessage); + var scopes = new HashSet(new[] { "unitTestScope" }); + var expectedMessage = $"Verify the allowed scopes for unitTestClient and try again."; + Assert.ThrowsAsync(() => provider.GetAuthenticationHeader(scopes), expectedMessage); } [Test] @@ -97,8 +102,9 @@ public void GetAuthenticationHeader_ThrowsUnauthorizedForNon400Response() var provider = new ClientCredentialsTokenProvider("unitTestClient", "secret", cli); + var scopes = new HashSet(new[] { "unitTestScope" }); var expectedMessage = "Unable to acquire OAuth token"; - Assert.ThrowsAsync(() => provider.GetAuthenticationHeader("testScope"), expectedMessage); + Assert.ThrowsAsync(() => provider.GetAuthenticationHeader(scopes), expectedMessage); } } } diff --git a/test/Flurl.Test/Http/Authentication/OAuthTokenProviderTests.cs b/test/Flurl.Test/Http/Authentication/OAuthTokenProviderTests.cs index 77884169..1f3e0455 100644 --- a/test/Flurl.Test/Http/Authentication/OAuthTokenProviderTests.cs +++ b/test/Flurl.Test/Http/Authentication/OAuthTokenProviderTests.cs @@ -1,6 +1,7 @@ using Flurl.Http.Authentication; using NUnit.Framework; using System; +using System.Collections.Generic; using System.Threading.Tasks; namespace Flurl.Test.Http.Authentication @@ -12,7 +13,7 @@ public UnitTestTokenProvider() : base() { } - protected override Task GetToken(string scope) + protected override Task GetToken(ISet scopes) { return Task.FromResult(new ExpirableToken((++_generationCount).ToString(), DateTimeOffset.Now.AddSeconds(1))); } @@ -26,12 +27,14 @@ public async Task GetAuthenticationHeader_ReusesValidTokens() { var provider = new UnitTestTokenProvider(); - var header1 = await provider.GetAuthenticationHeader("scope"); - var header2 = await provider.GetAuthenticationHeader("scope"); + var scopes = new HashSet(new[] { "scope1" }); + + var header1 = await provider.GetAuthenticationHeader(scopes); + var header2 = await provider.GetAuthenticationHeader(scopes); - await Task.Delay(TimeSpan.FromSeconds(1)); + await Task.Delay(TimeSpan.FromSeconds(2)); - var header3 = await provider.GetAuthenticationHeader("scope"); + var header3 = await provider.GetAuthenticationHeader(scopes); Assert.AreEqual(header1.Parameter, header2.Parameter); Assert.AreNotEqual(header1.Parameter, header3.Parameter);