Files
SocialPub/PrivaPub/Services/JwtEvents.cs
T
thepraandClaude Opus 5.5 2cd75176a4 One sign-in for decePubClient: the root JWT exchanged for a persona's token
The first-party client signs in on /clientapi and exchanges that JWT on
/oauth/token (RFC 8693, subject_token_type jwt, avatar_id) for one
persona's Mastodon token. Only the seeded public application `decepub`
holds the grant; RootJwtSubjectToken validates the JWT through RootJwt,
which JwtBearer now shares (signature, lifetime, ban, deletion, session
stamp). The issued token names the avatar, never the root.

Owner decision recorded in ROADMAP; it supersedes "moving decePubClient
onto the Mastodon API is out of scope".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 10:03:46 +02:00

82 lines
3.0 KiB
C#

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Localization;
using PrivaPub.ClientModels;
using PrivaPub.Extensions;
using PrivaPub.Resources;
using PrivaPub.StaticServices;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
namespace PrivaPub.Services
{
public class JwtEvents : JwtBearerEvents
{
static readonly string[] PolicyClaims = { Policies.IsUser, Policies.IsModerator, Policies.IsAdmin };
static readonly JsonSerializerOptions Web = new(JsonSerializerDefaults.Web);
ILogger<JwtEvents> _logger { get; set; }
const string contentType = "application/json";
//a token outlives a ban, a removal or a demotion, so every request asks the database who the root is now
public override async Task TokenValidated(TokenValidatedContext context)
{
var rootId = context.Principal.GetUserId();
var root = string.IsNullOrEmpty(rootId)
? default
: await context.HttpContext.RequestServices.GetRequiredService<DbEntities>().RootUsers.MatchID(rootId)
.ExecuteFirstAsync(context.HttpContext.RequestAborted);
var refusal = RootJwt.Refusal(root, context.Principal);
if (refusal != default)
{
context.Fail(refusal);
return;
}
if (context.Principal.Identity is not ClaimsIdentity identity)
return;
foreach (var policy in PolicyClaims)
{
foreach (var claim in identity.FindAll(policy).ToList())
identity.TryRemoveClaim(claim);
identity.AddClaim(new Claim(policy, root.Policies.Contains(policy) ? "true" : "false"));
}
}
public override async Task Challenge(JwtBearerChallengeContext context)
{
context.HandleResponse();
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.Headers.WWWAuthenticate = context.AuthenticateFailure == default ? "Bearer" : "Bearer error=\"invalid_token\"";
try
{
var localizer = context.HttpContext.RequestServices.GetRequiredService<IStringLocalizer<GenericRes>>();
var webResult = new WebResult().Invalidate(localizer["Unauthorized."], StatusCodes.Status401Unauthorized);
context.Response.ContentType = contentType;
await context.Response.BodyWriter.WriteAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(webResult, Web)));
}
catch (Exception ex)
{
_logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<JwtEvents>>();
_logger.LogError(ex, "Error at Challenge()");
}
}
public override async Task Forbidden(ForbiddenContext context)
{
try
{
var localizer = context.HttpContext.RequestServices.GetRequiredService<IStringLocalizer<GenericRes>>();
var webResult = new WebResult().Invalidate(localizer["Forbidden."], StatusCodes.Status403Forbidden);
context.Response.ContentType = contentType;
await context.Response.BodyWriter.WriteAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(webResult, Web)));
}
catch (Exception ex)
{
_logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<JwtEvents>>();
_logger.LogError(ex, "Error at Forbidden()");
}
}
}
}