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:
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Infrastructure
|
||||
{
|
||||
public static class MastodonJson
|
||||
{
|
||||
public static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DictionaryKeyPolicy = default,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }
|
||||
};
|
||||
|
||||
public static string Time(DateTime value) =>
|
||||
DateTime.SpecifyKind(value, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ss.fffZ", System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
public static string Day(DateTime value) =>
|
||||
DateTime.SpecifyKind(value, DateTimeKind.Utc).Date.ToString("yyyy-MM-ddT00:00:00.000Z", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Infrastructure
|
||||
{
|
||||
public sealed class MastodonParams
|
||||
{
|
||||
readonly Dictionary<string, List<string>> _values;
|
||||
|
||||
MastodonParams(Dictionary<string, List<string>> values)
|
||||
{
|
||||
_values = values;
|
||||
}
|
||||
|
||||
public static async Task<MastodonParams> Read(HttpRequest request, CancellationToken token)
|
||||
{
|
||||
var values = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
foreach (var pair in request.Query)
|
||||
foreach (var value in pair.Value)
|
||||
Add(values, pair.Key, value);
|
||||
|
||||
if (request.HasFormContentType)
|
||||
{
|
||||
var form = await request.ReadFormAsync(token);
|
||||
foreach (var pair in form)
|
||||
foreach (var value in pair.Value)
|
||||
Add(values, pair.Key, value);
|
||||
}
|
||||
else if (request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true && request.ContentLength != 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = await JsonDocument.ParseAsync(request.Body, cancellationToken: token);
|
||||
Flatten(values, default, document.RootElement);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
return new MastodonParams(values);
|
||||
}
|
||||
|
||||
public static MastodonParams From(IEnumerable<KeyValuePair<string, string>> pairs)
|
||||
{
|
||||
var values = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
foreach (var pair in pairs)
|
||||
Add(values, pair.Key, pair.Value);
|
||||
return new MastodonParams(values);
|
||||
}
|
||||
|
||||
public bool Has(string name) => _values.ContainsKey(name);
|
||||
|
||||
public string Get(string name) => _values.TryGetValue(name, out var list) && list.Count > 0 ? list[^1] : default;
|
||||
|
||||
public IReadOnlyList<string> List(string name) => _values.TryGetValue(name, out var list) ? list : (IReadOnlyList<string>)Array.Empty<string>();
|
||||
|
||||
public bool? Bool(string name) => Get(name)?.ToLowerInvariant() switch
|
||||
{
|
||||
"true" or "1" or "on" or "yes" => true,
|
||||
"false" or "0" or "off" or "no" or "" => false,
|
||||
_ => default
|
||||
};
|
||||
|
||||
public int? Int(string name) => int.TryParse(Get(name), out var value) ? value : default;
|
||||
|
||||
static void Add(Dictionary<string, List<string>> values, string key, string value)
|
||||
{
|
||||
key = key.EndsWith("[]", StringComparison.Ordinal) ? key[..^2] : key;
|
||||
if (!values.TryGetValue(key, out var list))
|
||||
values[key] = list = new List<string>();
|
||||
list.Add(value);
|
||||
}
|
||||
|
||||
static void Flatten(Dictionary<string, List<string>> values, string prefix, JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
foreach (var property in element.EnumerateObject())
|
||||
Flatten(values, prefix == default ? property.Name : $"{prefix}[{property.Name}]", property.Value);
|
||||
break;
|
||||
case JsonValueKind.Array:
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
|
||||
Flatten(values, prefix + "[]", item);
|
||||
else
|
||||
Add(values, prefix, Scalar(item));
|
||||
}
|
||||
break;
|
||||
case JsonValueKind.Null or JsonValueKind.Undefined:
|
||||
break;
|
||||
default:
|
||||
if (prefix != default)
|
||||
Add(values, prefix, Scalar(element));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static string Scalar(JsonElement element) => element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => element.GetString(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
_ => element.GetRawText()
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user