Mastodon clients can sign in: OAuth with a persona per token

OpenIddict 7.7 (MongoDB stores, keys kept in Mongo so tokens survive
restarts) serves /oauth/authorize, /oauth/token, /oauth/revoke and the
discovery documents, including /.well-known/oauth-authorization-server:
- authorization code (PKCE optional) and client credentials, Mastodon's
  scopes including the granular ones, non-expiring reference tokens,
  `created_at` in the token response, and the urn:ietf:wg:oauth:2.0:oob
  page that shows the code;
- /oauth/login signs the private login into a fifteen-minute cookie that
  only /oauth sees (rate limited, antiforgery-protected); /oauth/authorize
  then asks which persona the application acts as. The token's subject
  is that persona's id and nothing else; no root id reaches a token, an
  authorization or a log line;
- the token exchange refuses a persona whose login is banned or deleted,
  and every API request checks the same.

/api/v1/apps registers applications dynamically, /api/v1/apps/
verify_credentials, /api/v1/instance (v1 and v2, "4.2.0 (compatible;
PrivaPub)") and verify_credentials answer in Mastodon's shapes: snake_case
with explicit nulls, Rails-style parameters from query, form or JSON,
{"error": ...} on failure, Link paging. CORS exposes Link.

/api goes to OpenIddict validation and everything else to the existing
JWT; the JWT failure handler no longer sends the exception and stack trace
to the client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-01 11:52:31 +02:00
1 parent e6a6fe46d7
commit 4d9be37c1c
22 files changed
+1394 -7

No files matched your search

@@ -0,0 +1,105 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using MongoDB.Entities;
using OpenIddict.Abstractions;
using OpenIddict.Validation.AspNetCore;
using PrivaPub.Api.Mastodon.Auth;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.Federation;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace PrivaPub.Api.Mastodon.Infrastructure
{
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public sealed class ScopeAttribute : Attribute
{
public ScopeAttribute(string scope, bool requiresUser = true)
{
Scope = scope;
RequiresUser = requiresUser;
}
public string Scope { get; }
public bool RequiresUser { get; }
}
[ApiController, Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)]
public abstract class MastodonController : ControllerBase, IAsyncActionFilter
{
MastodonParams _params;
protected LocalActor Me { get; private set; }
protected string MyId => Me?.Id;
protected MastodonParams Params => _params;
[NonAction]
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
_params = await MastodonParams.Read(Request, HttpContext.RequestAborted);
var scope = context.ActionDescriptor.EndpointMetadata.OfType<ScopeAttribute>().LastOrDefault();
if (User.Identity?.IsAuthenticated == true)
{
if (scope != default && !MastodonScopes.Grants(User.GetScopes(), scope.Scope))
{
context.Result = Error(StatusCodes.Status403Forbidden, "This action is outside the authorized scopes");
return;
}
Me = await Resolve(User.GetClaim(Claims.Subject), HttpContext.RequestAborted);
}
if (scope?.RequiresUser == true && Me == default)
{
context.Result = Error(StatusCodes.Status401Unauthorized, "This method requires an authenticated user");
return;
}
await next();
}
async Task<LocalActor> Resolve(string avatarId, CancellationToken token)
{
if (string.IsNullOrEmpty(avatarId))
return default;
var services = HttpContext.RequestServices;
var db = services.GetRequiredService<DbEntities>();
var link = await db.RootToAvatars.Match(r => r.AvatarId == avatarId).ExecuteFirstAsync(token);
if (link == default)
return default;
var root = await db.RootUsers.MatchID(link.RootId).ExecuteFirstAsync(token);
if (root is not { IsBanned: false, DeletedAt: null })
return default;
return await services.GetRequiredService<ILocalActorService>().FindById(LocalActorKind.Person, avatarId, token);
}
protected JsonResult Json(object value, int status = StatusCodes.Status200OK) =>
new(value, MastodonJson.Options) { StatusCode = status };
protected static JsonResult Error(int status, string message) =>
new(new { error = message }, MastodonJson.Options) { StatusCode = status };
protected JsonResult NotFoundError() => Error(StatusCodes.Status404NotFound, "Record not found");
protected void Link(string path, string maxId, string minId)
{
var links = new List<string>();
var baseUrl = $"{Request.Scheme}://{Request.Host}{path}";
var query = Request.Query.Where(q => q.Key is not ("max_id" or "min_id" or "since_id")).Select(q => $"{q.Key}={Uri.EscapeDataString(q.Value.ToString())}").ToList();
string With(string key, string value) => baseUrl + "?" + string.Join("&", query.Append($"{key}={value}"));
if (maxId != default)
links.Add($"<{With("max_id", maxId)}>; rel=\"next\"");
if (minId != default)
links.Add($"<{With("min_id", minId)}>; rel=\"prev\"");
if (links.Count > 0)
Response.Headers["Link"] = string.Join(", ", links);
}
protected int Limit(int fallback = 20, int max = 40) => Math.Clamp(Params.Int("limit") ?? fallback, 1, max);
}
}