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
110 lines
3.3 KiB
C#
110 lines
3.3 KiB
C#
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()
|
|
};
|
|
}
|
|
}
|