No more JWTs for Scripts + Polish (#4274)

Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com>
This commit is contained in:
Joe Milazzo
2025-12-13 06:55:02 -07:00
committed by GitHub
parent b67680c639
commit 8043650aa5
131 changed files with 7804 additions and 1849 deletions
+23 -153
View File
@@ -1,15 +1,12 @@
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using API.Data;
using API.Entities.Progress;
using API.Extensions;
using API.Services;
using API.Services.Store;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Logging;
namespace API.Middleware;
@@ -20,50 +17,33 @@ namespace API.Middleware;
/// (JWT, Auth Key, OIDC) and provides a unified IUserContext for downstream components.
/// Must run after UseAuthentication() and UseAuthorization().
/// </summary>
public class UserContextMiddleware(RequestDelegate next, ILogger<UserContextMiddleware> logger, HybridCache cache)
public class UserContextMiddleware(RequestDelegate next, ILogger<UserContextMiddleware> logger)
{
private static readonly HybridCacheEntryOptions ApiKeyCacheOptions = new()
{
Expiration = TimeSpan.FromMinutes(15),
LocalCacheExpiration = TimeSpan.FromMinutes(15)
};
public async Task InvokeAsync(
HttpContext context,
UserContext userContext, // Scoped service
IUnitOfWork unitOfWork)
UserContext userContext)
{
try
{
// Clear any previous context (shouldn't be necessary, but defensive)
userContext.Clear();
// Check if endpoint allows anonymous access
var endpoint = context.GetEndpoint();
var allowAnonymous = endpoint?.Metadata.GetMetadata<IAllowAnonymous>() != null;
// ALWAYS attempt to resolve user identity, regardless of [AllowAnonymous]
var (userId, username, authType) = await ResolveUserIdentityAsync(context, unitOfWork);
if (userId.HasValue)
if (context.User.Identity?.IsAuthenticated == true)
{
userContext.SetUserContext(userId.Value, username!, authType);
var userId = TryGetUserIdFromClaim(context.User, ClaimTypes.NameIdentifier);
logger.LogTrace(
"Resolved user context: UserId={UserId}, AuthType={AuthType}",
userId, authType);
}
else if (!allowAnonymous)
{
// No user resolved on a protected endpoint - this is a problem
// Authorization middleware will handle returning 401/403
logger.LogWarning("Could not resolve user identity for protected endpoint: {Path}", context.Request.Path.ToString().Sanitize());
}
else
{
// No user resolved but endpoint allows anonymous - this is fine
logger.LogTrace("No user identity resolved for anonymous endpoint: {Path}", context.Request.Path.ToString().Sanitize());
var username = context.User.FindFirst(JwtRegisteredClaimNames.Name)?.Value;
var roles = context.User.FindAll(ClaimTypes.Role)
.Select(c => c.Value)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (userId.HasValue && username != null)
{
var authType = TryParseAuthTypeClaim(context.User) ?? AuthenticationType.Unknown;
userContext.SetUserContext(userId.Value, username, authType, roles);
}
}
}
catch (Exception ex)
@@ -75,122 +55,12 @@ public class UserContextMiddleware(RequestDelegate next, ILogger<UserContextMidd
await next(context);
}
private async Task<(int? userId, string? username, AuthenticationType authType)> ResolveUserIdentityAsync(
HttpContext context,
IUnitOfWork unitOfWork)
private static AuthenticationType? TryParseAuthTypeClaim(ClaimsPrincipal user)
{
// Priority 1: ALWAYS check for Auth Key first (query string or path parameter)
// Auth Keys work even on [AllowAnonymous] endpoints (like OPDS)
var apiKeyResult = await TryResolveFromAuthKeyAsync(context, unitOfWork);
if (apiKeyResult.userId.HasValue)
{
return apiKeyResult;
}
// Priority 3: Check for JWT or OIDC claims
if (context.User.Identity?.IsAuthenticated == true)
{
return ResolveFromClaims(context);
}
return (null, null, AuthenticationType.Unknown);
}
/// <summary>
/// Tries to resolve auth key and support apiKey from pre-v0.8.6 (switching from apikey -> auth keys)
/// </summary>
private async Task<(int? userId, string? username, AuthenticationType authType)> TryResolveFromAuthKeyAsync(
HttpContext context,
IUnitOfWork unitOfWork)
{
string? apiKey = null;
// Check query string: ?apiKey=xxx
if (context.Request.Query.TryGetValue("apiKey", out var apiKeyQuery))
{
apiKey = apiKeyQuery.ToString();
}
// Check path for OPDS endpoints: /api/opds/{apiKey}/...
if (string.IsNullOrEmpty(apiKey))
{
var path = context.Request.Path.Value ?? string.Empty;
if (path.Contains("/api/opds/", StringComparison.OrdinalIgnoreCase))
{
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
var opdsIndex = Array.FindIndex(segments, s =>
s.Equals("opds", StringComparison.OrdinalIgnoreCase));
if (opdsIndex >= 0 && opdsIndex + 1 < segments.Length)
{
apiKey = segments[opdsIndex + 1];
}
}
}
// Check if embedded in route parameters (e.g., /api/somepath/{apiKey}/other)
if (string.IsNullOrEmpty(apiKey) && context.Request.RouteValues.TryGetValue("apiKey", out var _))
{
apiKey = apiKeyQuery.ToString();
}
if (string.IsNullOrEmpty(apiKey))
{
return (null, null, AuthenticationType.Unknown);
}
try
{
var cacheKey = $"authKey_{apiKey}";
var result = await cache.GetOrCreateAsync(
cacheKey,
(apiKey, unitOfWork),
async (state, cancel) =>
{
// Auth key will work with legacy apiKey and new auth key
var user = await state.unitOfWork.UserRepository.GetUserDtoByAuthKeyAsync(state.apiKey);
return (user?.Id, user?.Username);
},
ApiKeyCacheOptions,
cancellationToken: context.RequestAborted);
if (result is {Id: not null, Username: not null})
{
logger.LogTrace("Resolved user {UserId} from Auth Key for path {Path}", result.Id, context.Request.Path.ToString().Sanitize());
return (result.Id, result.Username, AuthenticationType.AuthKey);
}
logger.LogWarning("Invalid Auth Key provided for path {Path}", context.Request.Path);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to resolve user from Auth Key");
}
return (null, null, AuthenticationType.Unknown);
}
private static (int? userId, string? username, AuthenticationType authType) ResolveFromClaims(HttpContext context)
{
var claims = context.User;
// Check if OIDC authentication
if (context.Request.Cookies.ContainsKey(OidcService.CookieName))
{
var userId = TryGetUserIdFromClaim(claims, ClaimTypes.NameIdentifier);
var username = claims.FindFirst(JwtRegisteredClaimNames.Name)?.Value;
return (userId, username, AuthenticationType.OIDC);
}
// JWT authentication
var jwtUserId = TryGetUserIdFromClaim(claims, ClaimTypes.NameIdentifier);
var jwtUsername = claims.FindFirst(JwtRegisteredClaimNames.Name)?.Value;
return (jwtUserId, jwtUsername, AuthenticationType.JWT);
var authTypeClaim = user.FindFirst("AuthType")?.Value;
return authTypeClaim != null && Enum.TryParse<AuthenticationType>(authTypeClaim, out var authType)
? authType
: null;
}
private static int? TryGetUserIdFromClaim(ClaimsPrincipal claims, string claimType)