Files
SocialPub/PrivaPub/Api/Mastodon/Controllers/AppsController.cs
T
thepraandClaude Opus 5.5 4d9be37c1c 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
2026-10-01 11:52:31 +02:00

98 lines
3.8 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;
using PrivaPub.Api.Mastodon.Auth;
using PrivaPub.Api.Mastodon.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using System.Security.Cryptography;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace PrivaPub.Api.Mastodon.Controllers
{
public class AppsController : MastodonController
{
readonly IOpenIddictApplicationManager _applications;
public AppsController(IOpenIddictApplicationManager applications)
{
_applications = applications;
}
[HttpPost("/api/v1/apps"), AllowAnonymous]
public async Task<IActionResult> Create(CancellationToken token)
{
var name = Params.Get("client_name")?.Trim();
if (string.IsNullOrEmpty(name) || name.Length > 200)
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Application name can't be blank");
var redirects = Params.List("redirect_uris").SelectMany(r => r.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)).Distinct().ToList();
if (redirects.Count == 0 || redirects.Any(r => !Uri.TryCreate(r, UriKind.Absolute, out var uri) || uri.Scheme is "javascript" or "data"))
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Redirect URI must be an absolute URI.");
var scopes = MastodonScopes.Parse(string.Join(' ', Params.List("scopes")));
var descriptor = new OpenIddictApplicationDescriptor
{
ClientId = Secret(32),
ClientSecret = Secret(32),
ClientType = ClientTypes.Confidential,
ConsentType = ConsentTypes.Explicit,
DisplayName = name,
Permissions =
{
Permissions.Endpoints.Authorization,
Permissions.Endpoints.Token,
Permissions.Endpoints.Revocation,
Permissions.GrantTypes.AuthorizationCode,
Permissions.GrantTypes.ClientCredentials,
Permissions.ResponseTypes.Code
}
};
foreach (var scope in scopes)
descriptor.Permissions.Add(Permissions.Prefixes.Scope + scope);
foreach (var redirect in redirects)
descriptor.RedirectUris.Add(new Uri(redirect));
var website = Params.Get("website");
if (Uri.TryCreate(website, UriKind.Absolute, out var site) && site.Scheme is "https" or "http")
descriptor.Properties["website"] = System.Text.Json.JsonSerializer.SerializeToElement(website);
var application = await _applications.CreateAsync(descriptor, token);
return Json(new Application
{
Id = await _applications.GetIdAsync(application, token),
Name = name,
Website = website,
Scopes = scopes.ToList(),
RedirectUris = redirects,
RedirectUri = string.Join("\n", redirects),
ClientId = descriptor.ClientId,
ClientSecret = descriptor.ClientSecret
});
}
[HttpGet("/api/v1/apps/verify_credentials")]
public async Task<IActionResult> Verify(CancellationToken token)
{
var clientId = User.GetPresenters().FirstOrDefault() ?? User.GetClaim(Claims.ClientId);
var application = clientId == default ? default : await _applications.FindByClientIdAsync(clientId, token);
if (application == default)
return Error(StatusCodes.Status401Unauthorized, "The access token is invalid");
var properties = await _applications.GetPropertiesAsync(application, token);
return Json(new
{
id = await _applications.GetIdAsync(application, token),
name = await _applications.GetDisplayNameAsync(application, token),
website = properties.TryGetValue("website", out var website) ? website.GetString() : default,
scopes = User.GetScopes().ToList(),
redirect_uris = (await _applications.GetRedirectUrisAsync(application, token)).ToList(),
vapid_key = string.Empty
});
}
static string Secret(int bytes) => Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
}