// Copyright Epic Games, Inc. All Rights Reserved. using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System; using System.ComponentModel.DataAnnotations; using System.IO; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using EpicGames.OIDC; namespace OidcToken { public class ZenAddOidcTokenRequest { public string? ProviderName { get; set; } public string? RefreshToken { get; set; } } public class TokenService : IHostedService { private readonly ILogger Logger; private readonly IHostApplicationLifetime AppLifetime; private readonly IOptionsMonitor Settings; private readonly OidcTokenManager TokenManager; private int? ExitCode; public TokenService(ILogger logger, IHostApplicationLifetime appLifetime, IOptionsMonitor settings, OidcTokenManager tokenManager) { this.Logger = logger; this.AppLifetime = appLifetime; this.Settings = settings; this.TokenManager = tokenManager; } public Task StartAsync(CancellationToken cancellationToken) { AppLifetime.ApplicationStarted.Register(async () => { try { await Main(); ExitCode = 0; } catch (UnableToAllocateTokenException) { Logger.LogWarning("Was unable to allocate a token"); ExitCode = 10; } catch (Exception ex) { Logger.LogError(ex, "Unhandled exception!"); ExitCode = 1; } finally { // Stop the application once the work is done AppLifetime.StopApplication(); } }); return Task.CompletedTask; } private async Task Main() { string providerName = Settings.CurrentValue.Service; switch (Settings.CurrentValue.Mode) { case TokenServiceOptions.TokenServiceMode.Query: { await OutputStatus(providerName, TokenManager.GetStatusForProvider(providerName)); break; } case TokenServiceOptions.TokenServiceMode.GetToken: { OidcStatus status = TokenManager.GetStatusForProvider(providerName); Logger.LogInformation("Determined status of provider {ProviderName} was {Status}", providerName, status); OidcTokenInfo? tokenInfo; if (status == OidcStatus.NotLoggedIn && !Settings.CurrentValue.Unattended) { Logger.LogInformation("Logging in to provider {ProviderName}", providerName); tokenInfo = await TokenManager.Login(providerName); } else { Logger.LogInformation("Fetching access token from provider {ProviderName}", providerName); try { tokenInfo = await TokenManager.GetAccessToken(providerName); } catch (NotLoggedInException) { if (Settings.CurrentValue.Unattended) { Logger.LogWarning("Not logged in to provider {ProviderName} but was running unattended so unable to login", providerName); throw new UnableToAllocateTokenException(); } else { Logger.LogInformation("Logging in to provider {ProviderName}", providerName); tokenInfo = await TokenManager.Login(providerName); } } } if (!tokenInfo.IsValid) { throw new Exception("Failed to allocate a token"); } if (Settings.CurrentValue.ResultToConsole) { string s = JsonSerializer.Serialize(new TokenResultFile(tokenInfo.AccessToken!, tokenInfo.TokenExpiry)); Console.WriteLine(s); } if (!string.IsNullOrEmpty(Settings.CurrentValue.OutFile)) { Logger.LogInformation("Token resolved, outputting result to '{OutFile}'", Settings.CurrentValue.OutFile); await OutputToken(tokenInfo.AccessToken!, tokenInfo.TokenExpiry); } if (Settings.CurrentValue.Zen) { Logger.LogInformation("Saving token to Zen instance '{ZenUrl}'", Settings.CurrentValue.ZenUrl); try { using HttpClient client = new(); client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); string zenUrl = $"{Settings.CurrentValue.ZenUrl}/auth/oidc/refreshtoken"; var request = new ZenAddOidcTokenRequest { ProviderName = providerName, RefreshToken = tokenInfo.RefreshToken }; HttpContent content = new StringContent(JsonSerializer.Serialize(request), Encoding.UTF8, "application/json"); HttpResponseMessage result = await client.PostAsync(zenUrl, content); if (result.IsSuccessStatusCode) { Logger.LogInformation("Successfully stored token to Zen"); } else { Logger.LogInformation("Failed to store token to Zen"); } } catch (Exception err) { Logger.LogInformation("An error occurred saving token to Zen, reason '{Reason}'", err.Message); throw new Exception("Failed to store token to Zen", err); } } break; } default: throw new NotImplementedException(); } } private async Task OutputToken(string token, DateTimeOffset expiresAt) { FileInfo fi = new(Settings.CurrentValue.OutFile); Logger.LogInformation("Token output to \"{OutFile}\"", fi.FullName); await using FileStream fs = fi.Open(FileMode.Create, FileAccess.Write); await JsonSerializer.SerializeAsync(fs, new TokenResultFile(token, expiresAt)); } private async Task OutputStatus(string service, OidcStatus status) { FileInfo fi = new(Settings.CurrentValue.OutFile); Logger.LogInformation("Token status output to \"{OutFile}\"", fi.FullName); await using FileStream fs = fi.Open(FileMode.Create, FileAccess.Write); await JsonSerializer.SerializeAsync(fs, new TokenStatusFile(service, status)); } public Task StopAsync(CancellationToken cancellationToken) { Environment.ExitCode = ExitCode.GetValueOrDefault(-1); return Task.CompletedTask; } } public class UnableToAllocateTokenException : Exception { } internal class TokenResultFile { public string Token { get; set; } public DateTimeOffset ExpiresAt { get; set; } public TokenResultFile(string token, DateTimeOffset expiresAt) { this.Token = token; this.ExpiresAt = expiresAt; } } internal class TokenStatusFile { public string Service { get; set; } public OidcStatus Status { get; set; } public TokenStatusFile(string service, OidcStatus status) { this.Service = service; this.Status = status; } } public class TokenServiceOptions { public enum TokenServiceMode { Query, GetToken, } /// /// The provider identifier you wish to login to /// [Required] public string Service { get; set; } = null!; /// /// The mode we are running OidcToken in /// [Required] public TokenServiceMode Mode { get; set; } = TokenServiceMode.GetToken; /// /// Set to output results to stdout (can be combined with a file) /// public bool ResultToConsole { get; set; } = false; /// /// A path to were we output a file with our results, format will depend on mode. Set to empty string to disable. /// public string OutFile { get; set; } = null!; /// /// If set this indicates we should not expect a user to be present /// public bool Unattended { get; set; } = false; /// /// If set this indicates a zen instance exists and we should share the token with it /// public bool Zen { get; set; } = false; /// /// The url to the zen server that we should share a token with (if Zen flag is set) /// public string ZenUrl { get; set; } = "http://localhost:1337"; /// /// Path to the game root directory /// public string? Project { get; set; } = string.Empty; } }