Files
UnrealEngine/Engine/Source/Programs/OidcToken/Program.cs
2025-05-18 13:04:45 +08:00

302 lines
13 KiB
C#

// Copyright Epic Games, Inc. All Rights Reserved.
using EpicGames.OIDC;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading.Tasks;
using Serilog;
using Serilog.Events;
using System.Runtime.InteropServices;
using System.Text.Json.Serialization;
namespace OidcToken
{
class GetHordeAuthConfigResponse
{
public string Method { get; set; } = String.Empty;
public string ProfileName { get; set; } = null!;
public string ServerUrl { get; set; } = null!;
public string ClientId { get; set; } = null!;
public List<string> LocalRedirectUrls { get; set; } = new List<string>();
public string[]? Scopes { get; set; }
public bool IsAnonymous() => Method.Equals("Anonymous", StringComparison.OrdinalIgnoreCase);
}
[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)]
[JsonSerializable(typeof(GetHordeAuthConfigResponse))]
[JsonSerializable(typeof(Dictionary<string, object>), TypeInfoPropertyName = "SettingsDict")]
internal partial class OidcTokenStateContext : JsonSerializerContext
{
}
class Program
{
static async Task<int> Main(string[] args)
{
if (args.Any(s => s.Equals("--help") || s.Equals("-help")) || args.Length == 0)
{
// print help
Console.WriteLine("Usage: OidcToken [options]");
Console.WriteLine();
Console.WriteLine("Options: ");
Console.WriteLine(" --Service <serviceName> - Indicate which OIDC service you intend to connect to. The connection details of the service is configured in appsettings.json/oidc-configuration.json.");
Console.WriteLine(" --HordeUrl <url> - Specifies the URL of a Horde server to read configuration from.");
Console.WriteLine(" --AuthConfigUrl <url> - Specifies the URL to read auth configuration from");
Console.WriteLine(" --ConfigPath <path> - Specify a local path to read configuration from if you do not wish to use the autodiscovery mechanism, useful if distributing oidctoken outside of a UE sync");
Console.WriteLine(" --Mode [Query/GetToken] - Switch mode to allow you to preview operation without triggering user interaction (result can be used to determine if user interaction is required)");
Console.WriteLine(" --OutFile <path> - Path to create json file of result");
Console.WriteLine(" --ResultToConsole [true/false] - If true the resulting json file is output to stdout (and logs are not created)");
Console.WriteLine(" --Unattended [true/false] - If true we assume no user is present and thus can not rely on their input");
Console.WriteLine(" --Zen [true/false] - If true the resulting refresh token is posted to Zens token endpoints");
Console.WriteLine(" --Project <path> - Project can be used to tell oidc token which game its working in to allow us to read game specific settings");
return 0;
}
// disable reloadConfigOnChange in this process, as this can cause issues under wsl and we disable this for all configuration we actually load anyway
Environment.SetEnvironmentVariable("DOTNET_hostBuilder:reloadConfigOnChange", "false");
ConfigurationBuilder configBuilder = new();
configBuilder.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", true, false)
.AddCommandLine(args);
IConfiguration config = configBuilder.Build();
TokenServiceOptions options = new();
config.Bind(options);
string? profileName = null;
GetHordeAuthConfigResponse? hordeAuthConfig = null;
if (options.HordeUrl != null)
{
hordeAuthConfig = ReadHordeConfigurationAsync(options.HordeUrl).Result;
if (hordeAuthConfig.IsAnonymous())
{
// Indicate to the caller that auth is disabled.
return 11;
}
profileName = hordeAuthConfig.ProfileName;
}
ClientAuthConfigurationV1? remoteAuthConfig = null;
if (options.AuthConfigUrl != null)
{
if (!Uri.IsWellFormedUriString(options.AuthConfigUrl, UriKind.Absolute))
{
throw new FormatException($"AuthConfigUrl {options.AuthConfigUrl} is not a valid url");
}
Uri uri = new Uri(options.AuthConfigUrl);
remoteAuthConfig = await ProviderConfigurationFactory.ReadRemoteAuthConfigurationAsync(uri, options.AuthEncryptionKey);
if (remoteAuthConfig == null)
{
// if we fail to read the config try the old horde path for backwards compatibility
GetHordeAuthConfigResponse hordeResponse = await ReadHordeConfigurationAsync(uri);
profileName = hordeResponse.ProfileName;
if (String.IsNullOrEmpty(profileName))
{
profileName = options.AuthConfigUrl.ToString();
}
Dictionary<string, object> values = new Dictionary<string, object>
{
{ "Method", hordeResponse.Method },
{ "ProfileName", profileName },
{ "ServerUri", hordeResponse.ServerUrl },
{ "ClientId", hordeResponse.ClientId },
{ "PossibleRedirectUri", hordeResponse.LocalRedirectUrls },
{ "Scopes", string.Join(" ", hordeResponse.Scopes ?? Array.Empty<string>()) }
};
remoteAuthConfig = new ClientAuthConfigurationV1()
{
DefaultProvider = profileName,
Method = hordeResponse.Method,
Providers = new Dictionary<string, ProviderInfo>()
{
{
hordeResponse.ProfileName, new ProviderInfo()
{
ServerUri = new Uri(hordeResponse.ServerUrl),
ClientId = hordeResponse.ClientId,
PossibleRedirectUri = hordeResponse.LocalRedirectUrls.Select(s => new Uri(s)).ToList(),
Scopes = string.Join(" ", hordeResponse.Scopes ?? Array.Empty<string>())
}
}
}
};
}
if (!string.IsNullOrEmpty(remoteAuthConfig.DefaultProvider))
{
profileName = remoteAuthConfig.DefaultProvider;
}
else if (remoteAuthConfig.Providers.Count == 1)
{
profileName = remoteAuthConfig.Providers.First().Key;
}
else
{
throw new Exception($"No DefaultProvider set from url {options.AuthConfigUrl} and more then 1 provider returned, this is ambiguous, please set DefaultProvider.");
}
// Horde supports being able to know that auth is disabled, no other service has this as we always assume auth is set to oidc if oidc token is invoked.
if (remoteAuthConfig.Method.Equals("Anonymous", StringComparison.OrdinalIgnoreCase))
{
// Indicate to the caller that auth is disabled.
return 11;
}
}
await Host.CreateDefaultBuilder(args)
.UseSerilog((context, configuration) =>
{
var section = context.Configuration.GetSection("Serilog");
if (!section.GetChildren().Any())
{
// no serilog provider configuration, adding some defaults
configuration.MinimumLevel.Debug();
configuration.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Warning);
}
configuration.ReadFrom.Configuration(context.Configuration);
if (!options.ResultToConsole)
{
configuration.WriteTo.Console(restrictedToMinimumLevel: LogEventLevel.Information);
}
// configure logging output directory match expectation per platform
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
configuration.WriteTo.File(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "UnrealEngine\\Common\\OidcToken\\Logs\\oidc-token.log"), rollingInterval:RollingInterval.Day, restrictedToMinimumLevel: LogEventLevel.Debug, retainedFileCountLimit: 7);
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
configuration.WriteTo.File(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Epic/UnrealEngine/Common/OidcToken/Logs/oidc-token.log"), rollingInterval: RollingInterval.Day, restrictedToMinimumLevel: LogEventLevel.Debug, retainedFileCountLimit: 7);
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
configuration.WriteTo.File(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Epic/UnrealEngine/Common/OidcToken/Logs/oidc-token.log"), rollingInterval: RollingInterval.Day, restrictedToMinimumLevel: LogEventLevel.Debug, retainedFileCountLimit: 7);
}
})
.ConfigureAppConfiguration(builder =>
{
builder.AddConfiguration(config);
if (string.IsNullOrEmpty(options.Service) && !string.IsNullOrEmpty(profileName))
{
Dictionary<string, string?> values = new Dictionary<string, string?>
{
[nameof(TokenServiceOptions.Service)] = profileName
};
builder.AddInMemoryCollection(values);
}
})
.ConfigureServices(
(content, services) =>
{
IConfiguration configuration = content.Configuration;
services.AddOptions<TokenServiceOptions>().Bind(configuration).ValidateDataAnnotations();
IConfiguration serviceConfig;
if (hordeAuthConfig != null)
{
Dictionary<string, string?> values = new Dictionary<string, string?>();
values[$"{nameof(OidcTokenOptions.Providers)}:{hordeAuthConfig.ProfileName}:{nameof(ProviderInfo.DisplayName)}"] = hordeAuthConfig.ProfileName;
values[$"{nameof(OidcTokenOptions.Providers)}:{hordeAuthConfig.ProfileName}:{nameof(ProviderInfo.ServerUri)}"] = hordeAuthConfig.ServerUrl;
values[$"{nameof(OidcTokenOptions.Providers)}:{hordeAuthConfig.ProfileName}:{nameof(ProviderInfo.ClientId)}"] = hordeAuthConfig.ClientId;
values[$"{nameof(OidcTokenOptions.Providers)}:{hordeAuthConfig.ProfileName}:{nameof(ProviderInfo.RedirectUri)}"] = hordeAuthConfig.LocalRedirectUrls![0];
if (hordeAuthConfig.Scopes is { Length: > 0 })
{
values[$"{nameof(OidcTokenOptions.Providers)}:{hordeAuthConfig.ProfileName}:{nameof(ProviderInfo.Scopes)}"] = String.Join(" ", hordeAuthConfig.Scopes);
}
serviceConfig = new ConfigurationBuilder().AddInMemoryCollection(values).Build();
}
else if (remoteAuthConfig != null)
{
serviceConfig = ProviderConfigurationFactory.BindOptions(remoteAuthConfig);
}
else if (options.ConfigPath != null && File.Exists(options.ConfigPath))
{
ConfigurationBuilder oidcConfigurationBuilder = new ConfigurationBuilder();
oidcConfigurationBuilder.AddJsonFile(options.ConfigPath, false, false);
IConfiguration oidcConfig = oidcConfigurationBuilder.Build();
serviceConfig = oidcConfig.GetSection("OidcToken");
}
else
{
// guess where the engine directory is based on the assumption that we are running out of Engine\Binaries\DotNET\OidcToken\<platform>
DirectoryInfo engineDir = new DirectoryInfo(Path.Combine(AppContext.BaseDirectory, "../../../../../Engine"));
if (!engineDir.Exists)
{
// try to see if engine dir can be found from the current code path Engine\Source\Programs\OidcToken\bin\<Configuration>\<.net-version>
engineDir = new DirectoryInfo(Path.Combine(AppContext.BaseDirectory, "../../../../../../../Engine"));
if (!engineDir.Exists)
{
throw new Exception($"Unable to guess engine directory so unable to continue running. Starting directory was: {AppContext.BaseDirectory}");
}
}
serviceConfig = ProviderConfigurationFactory.ReadConfiguration(engineDir, !string.IsNullOrEmpty(options.Project) ? new DirectoryInfo(options.Project) : null);
}
services.AddOptions<OidcTokenOptions>().Bind(serviceConfig).ValidateDataAnnotations();
services.AddSingleton<IOidcTokenManager, OidcTokenManager>();
services.AddSingleton<IOidcTokenClientFactory, OidcTokenClientFactory>();
services.AddSingleton<ITokenStore>(TokenStoreFactory.CreateTokenStore);
services.AddHostedService<TokenService>();
})
.RunConsoleAsync();
return Environment.ExitCode;
}
static async Task<GetHordeAuthConfigResponse> ReadHordeConfigurationAsync(Uri hordeUrl)
{
// Read the configuration settings from the Horde server
GetHordeAuthConfigResponse? authConfig;
using (HttpClient httpClient = new HttpClient())
{
using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, new Uri(hordeUrl, "api/v1/server/auth"));
using HttpResponseMessage response = await httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
authConfig = await response.Content.ReadFromJsonAsync<GetHordeAuthConfigResponse>(OidcTokenStateContext.Default.GetHordeAuthConfigResponse);
if (authConfig == null)
{
throw new InvalidDataException("Server returned an empty auth config object");
}
}
if (!authConfig.IsAnonymous())
{
string? localRedirectUrl = authConfig.LocalRedirectUrls.FirstOrDefault();
if (String.IsNullOrEmpty(authConfig.ServerUrl) || String.IsNullOrEmpty(authConfig.ClientId) || String.IsNullOrEmpty(localRedirectUrl))
{
throw new Exception("No auth server configuration found");
}
if (String.IsNullOrEmpty(authConfig.ProfileName))
{
authConfig.ProfileName = hordeUrl.Host.ToString();
}
}
return authConfig;
}
}
}