Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Autorenew OAuth Tokens #842

Open
wants to merge 20 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/Flurl.CodeGen/Metadata.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
Expand All @@ -8,7 +8,7 @@ namespace Flurl.CodeGen
public static class Metadata
{
/// <summary>
/// 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.
/// </summary>
public static IEnumerable<ExtensionMethod> GetUrlReturningExtensions(MethodArg extendedArg) {
ExtensionMethod Create(string name, string descrip) => new ExtensionMethod(name, descrip)
Expand Down Expand Up @@ -97,7 +97,7 @@ public static IEnumerable<ExtensionMethod> GetUrlReturningExtensions(MethodArg e
}

/// <summary>
/// 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.
/// </summary>
public static IEnumerable<ExtensionMethod> GetRequestReturningExtensions(MethodArg extendedArg) {
ExtensionMethod Create(string name, string descrip) => new ExtensionMethod(name, descrip)
Expand Down Expand Up @@ -144,6 +144,10 @@ public static IEnumerable<ExtensionMethod> 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");
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" }) {
Expand Down
1 change: 1 addition & 0 deletions src/Flurl.CodeGen/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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("")
Expand Down
95 changes: 95 additions & 0 deletions src/Flurl.Http/Authentication/ClientCredentialsTokenProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading.Tasks;

namespace Flurl.Http.Authentication
{
/// <summary>
/// OAuth token provider that uses client credentials as an authentication mechanism
/// </summary>
public class ClientCredentialsTokenProvider : OAuthTokenProvider
{
private readonly string _clientId;
private readonly string _clientSecret;
private readonly IFlurlClient _fc;

/// <summary>
/// Instantiates a new OAuthTokenProvider that uses clientId/clientSecret as an authentication mechanism
/// </summary>
/// <param name="clientId">The client id to send when making requests for the OAuth token</param>
/// <param name="client">The <see cref="IFlurlClient"/> to use when making requests for the OAuth token. Leave null to omit this property from the body of the request</param>
/// <param name="clientSecret">The client secret to send when making requests for the OAuth token</param>
/// <param name="earlyExpiration">The amount of time that defines how much earlier a entry should be considered expired relative to its actual expiration</param>
/// <param name="authenticationScheme">The authentication scheme this provider will provide in the resolved authentication header. Usually "Bearer" or "OAuth"</param>
public ClientCredentialsTokenProvider(string clientId,
string clientSecret,
IFlurlClient client,
TimeSpan? earlyExpiration = null,
string authenticationScheme = "Bearer")
: base(earlyExpiration, authenticationScheme)
{
_clientId = clientId;
_clientSecret = clientSecret;
_fc = client;
}

/// <summary>
/// Gets the OAuth authentication header for the specified scope
/// </summary>
/// <param name="scopes">The desired set of scopes</param>
/// <returns></returns>
protected override async Task<ExpirableToken> GetToken(ISet<string> scopes)
{
var now = DateTimeOffset.Now;

var body = new Dictionary<string, string>
{
["client_id"] = _clientId,
};

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()
.PostUrlEncodedAsync(body);

if (rawResponse.StatusCode >= 200 && rawResponse.StatusCode < 300)
{
var tokenResponse = await rawResponse.GetJsonAsync<JsonNode>();
var expiration = now.AddSeconds(tokenResponse["expires_in"].GetValue<int>());

return new ExpirableToken(tokenResponse["access_token"].GetValue<string>(), expiration);
}
if (rawResponse.StatusCode == 400)
{
var errorMessage = default(string);
try
{
var response = await rawResponse.GetJsonAsync<JsonNode>();
errorMessage = response["error"].GetValue<string>();
}
catch (Exception)
{
errorMessage = $"Verify the allowed scopes for {_clientId} and try again.";
}

throw new UnauthorizedAccessException(errorMessage);
}
else
{
throw new UnauthorizedAccessException("Unable to acquire OAuth token");
}
}
}
}


33 changes: 33 additions & 0 deletions src/Flurl.Http/Authentication/ExpirableToken.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System;
using System.Diagnostics.CodeAnalysis;

namespace Flurl.Http.Authentication
{
/// <summary>
/// An expirable token used in token-based authentication
/// </summary>
[ExcludeFromCodeCoverage]
public sealed class ExpirableToken
{
/// <summary>
/// Gets and sets the token value
/// </summary>
public string Value { get; }

/// <summary>
/// Gets and sets the expiration date of the token
/// </summary>
public DateTimeOffset Expiration { get; }

/// <summary>
/// Instantiates a new ExpirableToken, setting value and expiration
/// </summary>
/// <param name="value">The value of this token</param>
/// <param name="expiration">The date and time that this token expires</param>
public ExpirableToken(string value, DateTimeOffset expiration)
{
Value = value;
Expiration = expiration;
}
}
}
19 changes: 19 additions & 0 deletions src/Flurl.Http/Authentication/IOAuthTokenProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System.Collections.Generic;
using System.Net.Http.Headers;
using System.Threading.Tasks;

namespace Flurl.Http.Authentication
{
/// <summary>
/// Provides the authentication header
/// </summary>
public interface IOAuthTokenProvider
{
/// <summary>
/// Gets the authentication header for a specified set of scopes.
/// </summary>
/// <param name="scopes">The desired set of scopes</param>
/// <returns></returns>
Task<AuthenticationHeaderValue> GetAuthenticationHeader(ISet<string> scopes);
}
}
110 changes: 110 additions & 0 deletions src/Flurl.Http/Authentication/OAuthTokenProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
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;

namespace Flurl.Http.Authentication
{
/// <summary>
/// The base class for OAuth token providers
/// </summary>
public abstract class OAuthTokenProvider : IOAuthTokenProvider
{
/// <summary>
/// The default set of empty scopes
/// </summary>
protected static readonly ISet<string> EmptyScope = new HashSet<string>();

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<string, CacheEntry> _tokens;
private readonly TimeSpan _earlyExpiration;
private readonly string _scheme;


/// <summary>
/// Instantiates a new OAuthTokenProvider
/// </summary>
/// <param name="earlyExpiration">The amount of time that defines how much earlier a entry should be considered expired relative to its actual expiration</param>
/// <param name="authenticationScheme">The authentication scheme this provider will provide in the resolved authentication header. Usually "Bearer" or "OAuth"</param>
protected OAuthTokenProvider(
TimeSpan? earlyExpiration = null,
string authenticationScheme = "Bearer")
{
_tokens = new ConcurrentDictionary<string, CacheEntry>();
_earlyExpiration = earlyExpiration ?? TimeSpan.Zero;
_scheme = authenticationScheme;
}

/// <summary>
/// Gets the OAuth authentication header for the specified scope
/// </summary>
/// <param name="scopes">The desired set of scopes</param>
/// <returns></returns>
public async Task<AuthenticationHeaderValue> GetAuthenticationHeader(ISet<string> 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(cacheKey, s =>
{
return new CacheEntry
{
Token = new ExpirableToken("", now)
};
});

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(scopes);

//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);
}
}
finally
{
entry.Semaphore.Release();
}
}

return entry.AuthHeader;
}

/// <summary>
/// Retrieves the OAuth token for the specified scopes
/// </summary>
/// <returns>The refreshed OAuth token</returns>
protected abstract Task<ExpirableToken> GetToken(ISet<string> scopes);
}
}
19 changes: 19 additions & 0 deletions src/Flurl.Http/Configuration/FlurlHttpSettings.cs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -83,6 +84,24 @@ public ISerializer UrlEncodedSerializer {
set => Set(value);
}

/// <summary>
/// Gets or sets the OAuth token provider
/// </summary>
public IOAuthTokenProvider OAuthTokenProvider
{
get => Get<IOAuthTokenProvider>();
set => Set(value);
}

/// <summary>
/// Gets or sets the OAuth scope to request from <see cref="OAuthTokenProvider"/>
/// </summary>
public ISet<string> OAuthTokenScopes
{
get => Get<ISet<string>>();
set => Set(value);
}

/// <summary>
/// Gets object whose properties describe how Flurl.Http should handle redirect (3xx) responses.
/// </summary>
Expand Down
13 changes: 12 additions & 1 deletion src/Flurl.Http/FlurlClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,18 @@ public async Task<IFlurlResponse> 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 (request.Headers.TryGetFirst("Authorization", out var authValue) == false &&
string.IsNullOrWhiteSpace(authValue) &&
settings.OAuthTokenProvider != null)
{
var authHeader = await settings.OAuthTokenProvider.GetAuthenticationHeader(settings.OAuthTokenScopes);
request.Headers.Add("Authorization", authHeader.ToString());
}

SyncHeaders(request, reqMsg);

call.StartedUtc = DateTime.UtcNow;
var ct = GetCancellationTokenWithTimeout(cancellationToken, settings.Timeout, out var cts);
Expand Down
Loading