// 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); } }