T5: OAuth and the client API over HTTP

96 integration tests through PrivaPubHost, the real pipeline end to end:
- OAuth: the token's subject is the persona, and neither the token response,
  verify_credentials nor the stored token entries name the root. A wrong password shows
  an error and sets no login cookie; a login without the antiforgery token is a 400; the
  return address never leaves the site; deny answers access_denied with no code; another
  root's persona re-renders the choice with no code; a banned root is sent back to the
  login and a code issued before the ban buys no token; force_login asks again; a code
  works once and its reuse revokes the token it bought; password and refresh_token
  grants are refused; a client_credentials token gets 401 on user routes; a read-only
  token gets 403 with a Mastodon error on POST /api/v1/statuses; follow covers
  read:follows; revoke works; the login and authorize pages send their CSP and no-store;
  the 11th /oauth/login from one address in a minute is a 429.
- Accounts: sign-up, duplicates in any case, invalid models answer 400 with a message,
  login and logout, recovery email, settings, password change, invitation sign-up and
  login (refusing a persona named after the login), recovery without an email, through
  an unreachable mail server, with a wrong and with a valid code, token refresh, the 11th
  sign-up from one address, expired, garbage and foreign-key JWTs.
- Personas: a rootId in the body is ignored, the username regex and reserved names hold,
  personas and groups share ReservedName, an update delivers Update{Person} to followers,
  PublishedOn and the id's day fall within two weeks before creation, the list holds only
  one's own personas, another root's persona cannot be updated.
- Groups: communities and circles are created, joining takes the code and the password,
  members leave and owners cannot, a remote follow request becomes a member only on
  approval, a circle never shows in lookup, account by id, v2 search or /@name, and its
  /flock and /wardens answer 404 unsigned and to non-members, 200 to a member's signed GET.
- Moderation (Exclusive, it suspends localhost): ban, unban and remove; non-admins get
  403; reports are listed without the reporter and resolved; domain blocks are inserted,
  listed and deleted, bad domains refused, and a suspended server's delivery is answered
  202 and kept nowhere; the data endpoints.
- AdminCommands: exit codes 0, 1 and 2 and the resulting policies.

Fixed:
- A banned or removed root kept using /clientapi with its JWT until it expired: only /api
  re-checked the root. JwtEvents.TokenValidated now loads the root and fails the request
  when it is banned or deleted, and takes the policy claims from the database, so a
  demoted admin loses admin at once (and a promoted one gains it).
- The 401 and 403 bodies JwtEvents writes were PascalCase while every other /clientapi
  answer is camelCase; they now use the web defaults.
- /clientapi/user/sniff/again (token refresh) answered an empty 200; it now answers a
  fresh JwtUser, like login.
- Password recovery answered SMTP reply codes as HTTP statuses (421, 454, 554, and 550 for
  an invalid address); a mail server failure is now 503 and an invalid address 400.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-03 11:53:23 +02:00
1 parent c301f0c498
commit c5e4934ba6
10 files changed
+1879 -10

No files matched your search

+31 -2
View File
@@ -2,8 +2,11 @@
using Microsoft.Extensions.Localization;
using PrivaPub.ClientModels;
using PrivaPub.Extensions;
using PrivaPub.Resources;
using PrivaPub.StaticServices;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
@@ -11,9 +14,35 @@ namespace PrivaPub.Services
{
public class JwtEvents : JwtBearerEvents
{
static readonly string[] PolicyClaims = { Policies.IsUser, Policies.IsModerator, Policies.IsAdmin };
static readonly JsonSerializerOptions Web = new(JsonSerializerDefaults.Web);
ILogger<JwtEvents> _logger { get; set; }
const string contentType = "application/json";
//a token outlives a ban, a removal or a demotion, so every request asks the database who the root is now
public override async Task TokenValidated(TokenValidatedContext context)
{
var rootId = context.Principal.GetUserId();
var root = string.IsNullOrEmpty(rootId)
? default
: await context.HttpContext.RequestServices.GetRequiredService<DbEntities>().RootUsers.MatchID(rootId)
.ExecuteFirstAsync(context.HttpContext.RequestAborted);
if (root is not { IsBanned: false, DeletedAt: null })
{
context.Fail("The account can no longer be used.");
return;
}
if (context.Principal.Identity is not ClaimsIdentity identity)
return;
foreach (var policy in PolicyClaims)
{
foreach (var claim in identity.FindAll(policy).ToList())
identity.TryRemoveClaim(claim);
identity.AddClaim(new Claim(policy, root.Policies.Contains(policy) ? "true" : "false"));
}
}
public override async Task Challenge(JwtBearerChallengeContext context)
{
context.HandleResponse();
@@ -24,7 +53,7 @@ namespace PrivaPub.Services
var localizer = context.HttpContext.RequestServices.GetRequiredService<IStringLocalizer<GenericRes>>();
var webResult = new WebResult().Invalidate(localizer["Unauthorized."], StatusCodes.Status401Unauthorized);
context.Response.ContentType = contentType;
await context.Response.BodyWriter.WriteAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(webResult)));
await context.Response.BodyWriter.WriteAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(webResult, Web)));
}
catch (Exception ex)
{
@@ -40,7 +69,7 @@ namespace PrivaPub.Services
var localizer = context.HttpContext.RequestServices.GetRequiredService<IStringLocalizer<GenericRes>>();
var webResult = new WebResult().Invalidate(localizer["Forbidden."], StatusCodes.Status403Forbidden);
context.Response.ContentType = contentType;
await context.Response.BodyWriter.WriteAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(webResult)));
await context.Response.BodyWriter.WriteAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(webResult, Web)));
}
catch (Exception ex)
{
+6 -6
View File
@@ -411,13 +411,13 @@ namespace PrivaPub.Services
if (!smtpClient.IsConnected)
{
Logger.LogError($"Failed to connect to the SMTP server({AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpServer}).");
return result.Invalidate(Localizer["Failed to send email."], (int)SmtpStatusCode.ServiceNotAvailable);
return result.Invalidate(Localizer["Failed to send email."], StatusCodes.Status503ServiceUnavailable);
}
}
catch (Exception ex)
{
Logger.LogError(ex, $"Error at connection to the SMTP server({AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpServer}).");
return result.Invalidate(Localizer["Failed to send email."], (int)SmtpStatusCode.ServiceNotAvailable, exception: ex);
return result.Invalidate(Localizer["Failed to send email."], StatusCodes.Status503ServiceUnavailable, exception: ex);
}
try
@@ -427,21 +427,21 @@ namespace PrivaPub.Services
{
Logger.LogError($"Failed SMTP authentication of {AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpUsername}.");
return result.Invalidate(Localizer["Failed to send email."],
(int)SmtpStatusCode.TemporaryAuthenticationFailure);
StatusCodes.Status503ServiceUnavailable);
}
}
catch (Exception ex)
{
Logger.LogError(ex, $"Failed SMTP authentication of {AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpUsername}.");
return result.Invalidate(Localizer["Failed to send email."],
(int)SmtpStatusCode.TemporaryAuthenticationFailure, exception: ex);
StatusCodes.Status503ServiceUnavailable, exception: ex);
}
try
{
var toParsed = await smtpClient.VerifyAsync(user.Email);
if (toParsed == null)
return result.Invalidate(Localizer["Invalid email."], (int)SmtpStatusCode.MailboxUnavailable);
return result.Invalidate(Localizer["Invalid email."], StatusCodes.Status400BadRequest);
}
catch (OperationCanceledException ex)
{
@@ -483,7 +483,7 @@ Eugene from collAnon, following is the password recovery link:
catch (Exception ex)
{
Logger.LogError(ex, $"Error at email sending to {user.Email} from {AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpUsername}.");
return result.Invalidate(Localizer["Failed to send email."], (int)SmtpStatusCode.TransactionFailed, exception: ex);
return result.Invalidate(Localizer["Failed to send email."], StatusCodes.Status503ServiceUnavailable, exception: ex);
}
await smtpClient.DisconnectAsync(quit: true);