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

@@ -0,0 +1,166 @@
using MongoDB.Bson;
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Models.User;
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace PrivaPub.Tests.Support.Host
{
public sealed record OAuthApp(string ClientId, string ClientSecret, string Scopes)
{
public string Query(string extra = default) =>
$"client_id={Uri.EscapeDataString(ClientId)}&redirect_uri={Uri.EscapeDataString(ClientApi.OutOfBand)}&response_type=code&scope={Uri.EscapeDataString(Scopes)}"
+ (extra == default ? string.Empty : "&" + extra);
public string ReturnUrl(string extra = default) => "/oauth/authorize?" + Query(extra);
}
public static partial class ClientApi
{
public const string OutOfBand = "urn:ietf:wg:oauth:2.0:oob";
public static HttpClient ClientAt(this PrivaPubHost host, string address, bool cookies = false)
{
var client = host.Client(cookies);
client.DefaultRequestHeaders.Remove(PrivaPubHost.ClientHeader);
client.DefaultRequestHeaders.Add(PrivaPubHost.ClientHeader, address);
return client;
}
public static async Task<JsonObject> JsonBody(this HttpResponseMessage response) =>
JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsObject();
public static async Task<JsonArray> JsonItems(this HttpResponseMessage response) =>
JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsArray();
public static Task<HttpResponseMessage> PostJson(this HttpClient client, string path, object body) =>
client.PostAsJsonAsync(path, body);
public static Task<HttpResponseMessage> Form(this HttpClient client, string path, params (string Key, string Value)[] fields) =>
client.PostAsync(path, new FormUrlEncodedContent(fields.Select(f => new KeyValuePair<string, string>(f.Key, f.Value))));
public static async Task<OAuthApp> RegisterApp(this HttpClient client, string scopes = "read write follow")
{
var response = await client.Form("/api/v1/apps", ("client_name", "privapub-tests"), ("redirect_uris", OutOfBand), ("scopes", scopes));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var app = await response.JsonBody();
return new OAuthApp(app["client_id"]!.GetValue<string>(), app["client_secret"]!.GetValue<string>(), scopes);
}
public static async Task<HttpResponseMessage> SignIn(this HttpClient client, Root root, string returnUrl, string password = default)
{
var login = await client.GetStringAsync("/oauth/login?returnUrl=" + Uri.EscapeDataString(returnUrl));
return await client.PostAsync("/oauth/login", new FormUrlEncodedContent(new Dictionary<string, string>
{
["returnUrl"] = returnUrl,
["__RequestVerificationToken"] = Antiforgery(login),
["userName"] = root.UserName,
["password"] = password ?? root.Password
}));
}
public static string Antiforgery(string page) => AntiforgeryToken().Match(page).Groups[1].Value;
public static async Task<List<KeyValuePair<string, string>>> Choice(this HttpClient client, OAuthApp app)
{
var page = await client.GetStringAsync(app.ReturnUrl() + "&signed_in=1");
return HiddenInput().Matches(page).Select(m => new KeyValuePair<string, string>(m.Groups[1].Value, WebUtility.HtmlDecode(m.Groups[2].Value))).ToList();
}
public static Task<HttpResponseMessage> Decide(this HttpClient client, IEnumerable<KeyValuePair<string, string>> choice, string avatarId, string decision = "allow") =>
client.PostAsync("/oauth/authorize", new FormUrlEncodedContent(choice.Append(new("avatarId", avatarId)).Append(new("decision", decision))));
public static async Task<HttpResponseMessage> Choose(this HttpClient client, OAuthApp app, string avatarId, string decision = "allow") =>
await client.Decide(await client.Choice(app), avatarId, decision);
public static string CodeIn(string page) => CodeElement().Match(page) is { Success: true } match ? match.Groups[1].Value : default;
public static async Task<string> Code(this HttpClient client, OAuthApp app, Persona persona)
{
Assert.Equal(HttpStatusCode.Redirect, (await client.SignIn(persona.Root, app.ReturnUrl())).StatusCode);
var code = CodeIn(await (await client.Choose(app, persona.Id)).Content.ReadAsStringAsync());
Assert.False(string.IsNullOrEmpty(code));
return code;
}
public static Task<HttpResponseMessage> Exchange(this HttpClient client, OAuthApp app, string code) =>
client.Form("/oauth/token", ("grant_type", "authorization_code"), ("code", code), ("client_id", app.ClientId),
("client_secret", app.ClientSecret), ("redirect_uri", OutOfBand));
public static async Task<string> Token(this HttpClient client, OAuthApp app, Persona persona)
{
var response = await client.Exchange(app, await client.Code(app, persona));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return (await response.JsonBody())["access_token"]!.GetValue<string>();
}
public static async Task<JsonObject> Group(this PrivaPubHost host, Persona owner, bool community, string password = default, string name = "group")
{
using var client = host.As(owner.Root.Jwt);
var response = await client.PostJson("/clientapi/group/insert", new
{
avatarId = owner.Id,
userName = $"{name}{Guid.NewGuid():N}"[..20],
name,
isCommunity = community,
invitationPassword = password
});
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return await response.JsonBody();
}
public static async Task Ban(string rootId, bool banned = true) =>
await DB.Default.Update<RootUser>().MatchID(rootId).Modify(u => u.IsBanned, banned).ExecuteAsync();
public static async Task<JsonObject> Follow(this PrivaPubHost host, RemoteActor follower, string origin, string userName)
{
var follow = new JsonObject
{
["id"] = $"{origin}/follows/{Guid.NewGuid():N}",
["type"] = "Follow",
["actor"] = follower.Id,
["object"] = $"{PrivaPubHost.Base}/peasants/{userName}"
};
using var client = host.Client();
Assert.Equal(HttpStatusCode.Accepted, (await client.SendAsync(follower.SignedPost($"/peasants/{userName}/mouth", follow))).StatusCode);
Assert.Equal(1, await host.RunInbox(follow["id"]!.GetValue<string>()));
return follow;
}
public static async Task<List<BsonDocument>> StoredTokens(string clientId)
{
var database = DB.Default.Database();
var application = await database.GetCollection<BsonDocument>("openiddict.applications")
.Find(Builders<BsonDocument>.Filter.Eq("client_id", clientId)).FirstAsync();
return await database.GetCollection<BsonDocument>("openiddict.tokens")
.Find(Builders<BsonDocument>.Filter.Eq("application_id", application["_id"])).ToListAsync();
}
public static bool IsAccessToken(BsonDocument token) =>
token.GetValue("type", BsonNull.Value) is { IsString: true } type && type.AsString.EndsWith("access_token", StringComparison.Ordinal);
public static string JwtPayload(string jwt)
{
var parts = jwt.Split('.');
if (parts.Length != 3)
return string.Empty;
var segment = parts[1].Replace('-', '+').Replace('_', '/');
return Encoding.UTF8.GetString(Convert.FromBase64String(segment.PadRight(segment.Length + (4 - segment.Length % 4) % 4, '=')));
}
[GeneratedRegex("name=\"__RequestVerificationToken\" type=\"hidden\" value=\"([^\"]*)\"")]
private static partial Regex AntiforgeryToken();
[GeneratedRegex("<input type=\"hidden\" name=\"([^\"]*)\" value=\"([^\"]*)\"")]
private static partial Regex HiddenInput();
[GeneratedRegex("<code>([^<]*)</code>")]
private static partial Regex CodeElement();
}
}