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
This commit is contained in:
1 parent
c7e8760ffe
commit
2cd75176a4
10 files changed
+432
-30
No files matched your search
@@ -0,0 +1,53 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user