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:
thepraandClaude Opus 5.5 committed 2026-10-04 10:03:46 +02:00
1 parent c7e8760ffe
commit 2cd75176a4
10 files changed
+432 -30

No files matched your search

+8 -1
View File
@@ -49,7 +49,14 @@ namespace PrivaPub.Api.Mastodon.Auth
.SetTokenEndpointUris("/oauth/token")
.SetRevocationEndpointUris("/oauth/revoke")
.SetConfigurationEndpointUris("/.well-known/openid-configuration", "/.well-known/oauth-authorization-server");
options.AllowAuthorizationCodeFlow().AllowClientCredentialsFlow();
options.AllowAuthorizationCodeFlow().AllowClientCredentialsFlow().AllowTokenExchangeFlow();
//the only subject token accepted is the root JWT, for PersonaExchange; OpenIddict's own tokens are not exchanged
options.Configure(server =>
{
server.SubjectTokenTypes.Clear();
server.SubjectTokenTypes.Add(PersonaExchange.SubjectTokenType);
});
options.AddEventHandler(RootJwtSubjectToken.Descriptor);
options.RegisterScopes(MastodonScopes.All);
options.UseReferenceAccessTokens();
options.SetAccessTokenLifetime(null);
@@ -0,0 +1,105 @@
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using PrivaPub.Services;
using PrivaPub.StaticServices;
using System.Security.Claims;
using static OpenIddict.Abstractions.OpenIddictConstants;
using static OpenIddict.Server.OpenIddictServerEvents;
namespace PrivaPub.Api.Mastodon.Auth
{
// One sign-in for the first-party client (decePubClient): it signs in on /clientapi, then exchanges that root JWT for one
// persona's Mastodon token (RFC 8693, grant_type urn:ietf:params:oauth:grant-type:token-exchange, subject_token_type
// urn:ietf:params:oauth:token-type:jwt, avatar_id). Only the seeded first-party application holds the grant permission,
// and the issued token is the same kind the authorization code flow issues: subject the avatar, never the root.
public static class PersonaExchange
{
public const string SubjectTokenType = "urn:ietf:params:oauth:token-type:jwt";
public const string AvatarParameter = "avatar_id";
//the root the subject token belongs to; it lives on the subject token's principal only, which is never stored or issued
public const string RootClaim = "privapub_root";
public static string FirstPartyClientId(IConfiguration configuration) =>
configuration["ClientApi:FirstPartyClientId"] is { Length: > 0 } clientId ? clientId : "decepub";
//the first-party application: public (a browser keeps no secret), token exchange only, no redirect
public static async Task EnsureFirstPartyClient(IServiceProvider services, CancellationToken token)
{
using var scope = services.CreateScope();
var applications = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();
var clientId = FirstPartyClientId(scope.ServiceProvider.GetRequiredService<IConfiguration>());
var descriptor = new OpenIddictApplicationDescriptor
{
ClientId = clientId,
ClientType = ClientTypes.Public,
ConsentType = ConsentTypes.Implicit,
DisplayName = "decePubClient",
Permissions =
{
Permissions.Endpoints.Token,
Permissions.Endpoints.Revocation,
Permissions.GrantTypes.TokenExchange
}
};
foreach (var scopeName in new[] { "read", "write", "follow" })
descriptor.Permissions.Add(Permissions.Prefixes.Scope + scopeName);
var existing = await applications.FindByClientIdAsync(clientId, token);
if (existing == default)
{
await applications.CreateAsync(descriptor, token);
return;
}
await applications.PopulateAsync(existing, descriptor, token);
await applications.UpdateAsync(existing, token);
}
}
// Validates a root JWT given as a token exchange subject token, before OpenIddict tries it as one of its own tokens.
// The principal it builds names the root under PersonaExchange.RootClaim for TokenController, which signs in a fresh
// identity for the avatar.
public sealed class RootJwtSubjectToken : IOpenIddictServerHandler<ValidateTokenContext>
{
public static OpenIddictServerHandlerDescriptor Descriptor { get; } = OpenIddictServerHandlerDescriptor.CreateBuilder<ValidateTokenContext>()
.UseScopedHandler<RootJwtSubjectToken>()
.SetOrder(OpenIddictServerHandlers.Protection.ValidateIdentityModelToken.Descriptor.Order - 500)
.SetType(OpenIddictServerHandlerType.Custom)
.Build();
readonly IConfiguration _configuration;
readonly DbEntities _dbEntities;
public RootJwtSubjectToken(IConfiguration configuration, DbEntities dbEntities)
{
_configuration = configuration;
_dbEntities = dbEntities;
}
public async ValueTask HandleAsync(ValidateTokenContext context)
{
if (context.ValidTokenTypes.Count != 1 || !context.ValidTokenTypes.Contains(PersonaExchange.SubjectTokenType))
return;
var root = await RootJwt.Root(context.Token, _configuration, _dbEntities, context.Transaction.CancellationToken);
if (root == default)
{
context.Reject(Errors.InvalidGrant, "The session token is not valid.");
return;
}
var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role);
identity.SetClaim(PersonaExchange.RootClaim, root.ID);
var principal = new ClaimsPrincipal(identity)
.SetTokenType(PersonaExchange.SubjectTokenType)
.SetCreationDate(DateTimeOffset.UtcNow);
if (context.ValidPresenters.Count > 0)
principal.SetPresenters(context.ValidPresenters);
context.Principal = principal;
}
}
}
+25 -1
View File
@@ -1,4 +1,4 @@
using Microsoft.AspNetCore;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
@@ -48,9 +48,33 @@ namespace PrivaPub.Api.Mastodon.Auth
return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
if (request.IsTokenExchangeGrantType())
return await Persona(request, token);
return Refuse(Errors.UnsupportedGrantType, "The grant type is not supported.");
}
//PersonaExchange: the root JWT, validated by RootJwtSubjectToken, for a token of one of the root's personas
async Task<IActionResult> Persona(OpenIddictRequest request, CancellationToken token)
{
var subject = (await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)).Principal;
var rootId = subject?.GetClaim(PersonaExchange.RootClaim);
var avatarId = (string)request[PersonaExchange.AvatarParameter];
if (string.IsNullOrEmpty(rootId) || string.IsNullOrEmpty(avatarId))
return Refuse(Errors.InvalidGrant, "The persona can no longer be used.");
var owns = await _dbEntities.RootToAvatars.Match(r => r.RootId == rootId && r.AvatarId == avatarId).ExecuteAnyAsync(token);
var avatar = owns ? await _dbEntities.Avatars.MatchID(avatarId).ExecuteFirstAsync(token) : default;
if (avatar == default || !await Usable(avatarId, token))
return Refuse(Errors.InvalidGrant, "The persona can no longer be used.");
var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role);
identity.SetClaim(Claims.Subject, avatar.ID);
identity.SetClaim(Claims.Name, avatar.UserName);
identity.SetScopes(MastodonScopes.Parse(request.Scope).Intersect(new[] { "read", "write", "follow" }));
identity.SetDestinations(_ => new[] { Destinations.AccessToken });
return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
async Task<bool> Usable(string avatarId, CancellationToken token)
{
if (string.IsNullOrEmpty(avatarId))
+1 -13
View File
@@ -1,10 +1,7 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.IdentityModel.Tokens;
using PrivaPub.Services;
using System.Text;
namespace PrivaPub.Extensions
{
public static class AddAuthExtension
@@ -15,16 +12,7 @@ namespace PrivaPub.Extensions
#if DEBUG
options.RequireHttpsMetadata = false;
#endif
options.TokenValidationParameters = 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"]))
};
options.TokenValidationParameters = RootJwt.Parameters(configuration);
options.Events = new JwtEvents();
});
return builder;
+2 -1
View File
@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
@@ -186,6 +186,7 @@ try
var passwordHasher = app.Services.GetService(typeof(IPasswordHasher)) as IPasswordHasher;
await dbClient.Init(passwordHasher);
await app.Services.GetRequiredService<PrivaPub.Federation.Moderation.IDomainBlocks>().Reload(CancellationToken.None);
await PrivaPub.Api.Mastodon.Auth.PersonaExchange.EnsureFirstPartyClient(app.Services, CancellationToken.None);
}
catch (Exception ex)
{
+3 -8
View File
@@ -28,15 +28,10 @@ namespace PrivaPub.Services
? default
: await context.HttpContext.RequestServices.GetRequiredService<DbEntities>().RootUsers.MatchID(rootId)
.ExecuteFirstAsync(context.HttpContext.RequestAborted);
if (root is not { IsBanned: false, DeletedAt: null })
var refusal = RootJwt.Refusal(root, context.Principal);
if (refusal != default)
{
context.Fail("The account can no longer be used.");
return;
}
// a password recovery ends every session made before it (RootSessions gives the root a new stamp)
if ((root.SessionStamp ?? string.Empty) != (context.Principal.FindFirst(AuthTokenManager.SessionStamp)?.Value ?? string.Empty))
{
context.Fail("The account's sessions were ended.");
context.Fail(refusal);
return;
}
if (context.Principal.Identity is not ClaimsIdentity identity)
+53
View File
@@ -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;
}
}
}