// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Concurrent; using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using HordeServer.Users; using HordeServer.Utilities; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Tokens; namespace HordeServer.Authentication { /// /// JWT bearer handler capable of processing tokens generated by external OIDC providers (ie. access tokens issued by the OIDC server rather than the Horde server) /// public class ExternalJwtAuthHandler { /// /// Default name of the authentication scheme /// public const string AuthenticationScheme = "ExternalJwt"; // Caches the lookup of "sub" claim in access token to actual user used internally by Horde. // Required to avoid database lookup on every request when authenticating. private readonly ConcurrentDictionary _subToUser = new(); readonly ServerSettings _settings; /// /// Constructor /// public ExternalJwtAuthHandler(ServerSettings settings) { _settings = settings; } /// /// Callback when JWT bearer request has been received /// Looks up and extracts the token from Authorization header. /// This cannot be done by the default JwtBearer handler as it can only handle "Bearer" prefix. /// /// Message context private Task OnMessageReceivedAsync(MessageReceivedContext context) { string? authorization = context.Request.Headers.Authorization; if (!String.IsNullOrEmpty(authorization)) { string prefix = AuthenticationScheme + " "; if (authorization.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { context.Token = authorization.Substring(prefix.Length).Trim(); } } return Task.CompletedTask; } private Task OnAuthenticationFailedAsync(AuthenticationFailedContext context) { if (_settings.OidcDebugMode) { IServiceProvider serviceProvider = context.HttpContext.RequestServices; ILogger logger = serviceProvider.GetRequiredService>(); logger.LogError(context.Exception, "JWT bearer auth failed. Auth header: {AuthHeader}", context.Request.Headers.Authorization.ToString()); } return Task.CompletedTask; } /// /// Callback when JWT bearer token is being validated /// If user is not cached, it will look up additional info via /userinfo and cache it /// /// Token validation context private async Task OnTokenValidatedAsync(TokenValidatedContext context) { IServiceProvider serviceProvider = context.HttpContext.RequestServices; ILogger logger = serviceProvider.GetRequiredService>(); logger.LogInformation("Running HordeJwtBearerHandler.OnTokenValidatedAsync()"); if (context.Principal == null) { ReportError(logger, context, "Principal not set in context"); return; } JwtSecurityToken? accessToken = context.SecurityToken as JwtSecurityToken; if (accessToken == null) { ReportError(logger, context, "Unable to read access token"); return; } ClaimsIdentity? identity = (ClaimsIdentity?)context.Principal?.Identity; if (identity == null) { ReportError(logger, context, "No identity specified"); return; } if (_settings.OidcAuthority == null) { ReportError(logger, context, "OidcAuthority not set in settings"); return; } logger.LogInformation("Using HordeJwtBearerHandler"); if (!_subToUser.TryGetValue(accessToken.Subject, out IUser? user)) { string login = accessToken.Subject; string? name = accessToken.Claims.FirstOrDefault(c => c.Type == "name")?.Value; string? email = accessToken.Claims.FirstOrDefault(c => c.Type == "email")?.Value; IUserCollection userCollection = serviceProvider.GetRequiredService(); user = await userCollection.FindOrAddUserByLoginAsync(login, name, email); await userCollection.UpdateClaimsAsync(user.Id, accessToken.Claims.Select(x => new UserClaim(x.Type, x.Value)), context.HttpContext.RequestAborted); _subToUser[accessToken.Subject] = user; } identity.AddClaim(new Claim(HordeClaimTypes.Version, HordeClaimTypes.CurrentVersion)); identity.AddClaim(new Claim(HordeClaimTypes.UserId, user.Id.ToString())); foreach (Claim claim in accessToken.Claims.Where(x => x.Type == "groups")) { identity.AddClaim(new Claim(ClaimTypes.Role, claim.Value)); } OidcAuthHandler.MapAdminClaim(_settings, identity); } private static void ReportError(ILogger logger, ResultContext context, string message) { #pragma warning disable CA2254 // Template should be a static expression logger.LogError(message); #pragma warning restore CA2254 context.Fail(message); } /// /// Registers this instance as a JWT handler /// /// Authentication builder public void AddHordeJwtBearerConfiguration(AuthenticationBuilder authBuilder) { authBuilder.AddJwtBearer(AuthenticationScheme, options => { options.Authority = _settings.OidcAuthority; options.Audience = _settings.OidcAudience; options.Events = new JwtBearerEvents() { OnAuthenticationFailed = OnAuthenticationFailedAsync, OnMessageReceived = OnMessageReceivedAsync, OnTokenValidated = OnTokenValidatedAsync }; options.RequireHttpsMetadata = !_settings.OidcDebugMode; options.TokenValidationParameters.ValidAudience = _settings.OidcAudience; options.TokenValidationParameters.RequireExpirationTime = true; options.TokenValidationParameters.RequireSignedTokens = true; options.TokenValidationParameters.ValidateIssuer = true; options.TokenValidationParameters.ValidIssuer = _settings.OidcAuthority; options.TokenValidationParameters.ValidateAudience = true; options.TokenValidationParameters.ValidateIssuerSigningKey = true; options.TokenValidationParameters.ValidateLifetime = true; options.TokenHandlers.Clear(); options.TokenHandlers.Add(new StrictTokenHandler()); }); } private class StrictTokenHandler : JwtSecurityTokenHandler { public override ClaimsPrincipal ValidateToken(string token, TokenValidationParameters validationParameters, out SecurityToken validatedToken) { ClaimsPrincipal claimsPrincipal = base.ValidateToken(token, validationParameters, out validatedToken); JwtSecurityToken jwtSecurityToken = ReadJwtToken(token); if (jwtSecurityToken.Header?.Alg is not SecurityAlgorithms.RsaSha256) { throw new SecurityTokenValidationException("The JWT algorithm is not RS256."); } return claimsPrincipal; } } } }