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
106 lines
4.3 KiB
C#
106 lines
4.3 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|