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
54 lines
2.3 KiB
C#
54 lines
2.3 KiB
C#
using Microsoft.IdentityModel.JsonWebTokens;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
|
|
using PrivaPub.Extensions;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.StaticServices;
|
|
|
|
using System.Security.Claims;
|
|
using System.Text;
|
|
|
|
namespace PrivaPub.Services
|
|
{
|
|
// The /clientapi token: how it is validated (JwtBearer through AddPrivaPubAuth, and the persona token exchange on
|
|
// /oauth/token), and when a valid one can no longer be used. One place, so the two never drift apart.
|
|
public static class RootJwt
|
|
{
|
|
public static TokenValidationParameters Parameters(IConfiguration configuration) => new()
|
|
{
|
|
ValidateIssuer = true,
|
|
ValidateAudience = true,
|
|
ValidateLifetime = true,
|
|
ValidateIssuerSigningKey = true,
|
|
ValidIssuer = configuration["AppConfiguration:Jwt:Issuer"],
|
|
ValidAudience = configuration["AppConfiguration:Jwt:Audience"],
|
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["AppConfiguration:Jwt:Key"]))
|
|
};
|
|
|
|
//why a token that validated can no longer be used, or default when it can: a token outlives a ban, a removal and a
|
|
//password recovery (RootSessions gives the root a new stamp), so the database has the last word
|
|
public static string Refusal(RootUser root, ClaimsPrincipal principal)
|
|
{
|
|
if (root is not { IsBanned: false, DeletedAt: null })
|
|
return "The account can no longer be used.";
|
|
if ((root.SessionStamp ?? string.Empty) != (principal.FindFirst(AuthTokenManager.SessionStamp)?.Value ?? string.Empty))
|
|
return "The account's sessions were ended.";
|
|
return default;
|
|
}
|
|
|
|
//the root a token belongs to, when the token is valid and can still be used; default otherwise, never an exception
|
|
public static async Task<RootUser> Root(string jwt, IConfiguration configuration, DbEntities dbEntities, CancellationToken token)
|
|
{
|
|
if (string.IsNullOrEmpty(jwt))
|
|
return default;
|
|
var result = await new JsonWebTokenHandler { MapInboundClaims = false }.ValidateTokenAsync(jwt, Parameters(configuration));
|
|
if (!result.IsValid)
|
|
return default;
|
|
var principal = new ClaimsPrincipal(result.ClaimsIdentity);
|
|
var rootId = principal.GetUserId();
|
|
var root = string.IsNullOrEmpty(rootId) ? default : await dbEntities.RootUsers.MatchID(rootId).ExecuteFirstAsync(token);
|
|
return Refusal(root, principal) == default ? root : default;
|
|
}
|
|
}
|
|
}
|