// Copyright Epic Games, Inc. All Rights Reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace EpicGames.Horde.Auth
{
///
/// Exception thrown due to failed authorization
///
public class AuthenticationException : Exception
{
///
/// Constructor
///
public AuthenticationException(string message, Exception? innerException)
: base(message, innerException)
{
}
}
///
/// Options for authenticating particular requests
///
public interface IOAuthOptions
{
///
/// Url of the auth server
///
Uri? AuthUrl { get; }
///
/// Type of grant
///
string GrantType { get; }
///
/// Client id
///
string ClientId { get; }
///
/// Client secret
///
string ClientSecret { get; }
///
/// Scope of the token
///
string Scope { get; }
}
///
/// Http message handler which adds an OAuth authorization header using a cached/periodically refreshed bearer token
///
public class OAuthHandler : HttpClientHandler
{
[SuppressMessage("Style", "IDE1006:Naming Styles")]
class ClientCredentialsResponse
{
public string? access_token { get; set; }
public string? token_type { get; set; }
public int? expires_in { get; set; }
public string? scope { get; set; }
}
readonly HttpClient _client;
readonly IOAuthOptions _options;
string _cachedAccessToken = String.Empty;
DateTime _expiresAt = DateTime.MinValue;
///
/// Constructor
///
///
///
public OAuthHandler(HttpClient client, IOAuthOptions options)
{
_client = client;
_options = options;
}
///
protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (DateTime.UtcNow > _expiresAt)
{
await UpdateAccessTokenAsync(cancellationToken);
}
request.Headers.Add("Authorization", $"Bearer {_cachedAccessToken}");
return await base.SendAsync(request, cancellationToken);
}
///
/// Updates the current access token
///
///
async Task UpdateAccessTokenAsync(CancellationToken cancellationToken)
{
KeyValuePair[] content = new KeyValuePair[]
{
new KeyValuePair("grant_type", _options.GrantType),
new KeyValuePair("client_id", _options.ClientId),
new KeyValuePair("client_secret", _options.ClientSecret),
new KeyValuePair("scope", _options.Scope)
};
try
{
using HttpRequestMessage message = new HttpRequestMessage(HttpMethod.Post, _options.AuthUrl);
message.Content = new FormUrlEncodedContent(content);
HttpResponseMessage response = await _client.SendAsync(message, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new AuthenticationException($"Authentication failed. Response: {response.Content}", null);
}
byte[] responseData = await response.Content.ReadAsByteArrayAsync(cancellationToken);
ClientCredentialsResponse result = JsonSerializer.Deserialize(responseData)!;
string? accessToken = result?.access_token;
if (String.IsNullOrEmpty(accessToken))
{
throw new AuthenticationException("The authentication token received by the server is null or empty. Body received was: " + Encoding.UTF8.GetString(responseData), null);
}
_cachedAccessToken = accessToken;
// renew after half the renewal time
_expiresAt = DateTime.UtcNow + TimeSpan.FromSeconds((result?.expires_in ?? 3200) / 2.0);
}
catch (WebException ex)
{
throw new AuthenticationException("Unable to authenticate.", ex);
}
}
}
///
/// Factory for creating OAuth2AuthProvider instances from a set of options
///
public class OAuthHandlerFactory
{
readonly HttpClient _httpClient;
///
/// Constructor
///
///
public OAuthHandlerFactory(HttpClient httpClient)
{
_httpClient = httpClient;
}
///
/// Create an instance of the auth provider
///
///
///
public OAuthHandler Create(IOAuthOptions options) => new OAuthHandler(_httpClient, options);
}
}