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(); var clientId = FirstPartyClientId(scope.ServiceProvider.GetRequiredService()); 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 { public static OpenIddictServerHandlerDescriptor Descriptor { get; } = OpenIddictServerHandlerDescriptor.CreateBuilder() .UseScopedHandler() .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; } } }