From d8f163b5cea98a90375c6f27ec935eebf071bfb1 Mon Sep 17 00:00:00 2001 From: thepra Date: Thu, 1 Oct 2026 12:09:19 +0200 Subject: [PATCH] Persona separation is tested, the API is smoke-checked on deploy, OAuth rows are pruned - PersonaSeparationTests: two personas of one login follow the same account and post; nothing the API maps for one contains the other's id, username or the root id. - tools/smoke/mastodon-api.sh checks what a client meets first (instance v1/v2, discovery, app registration, client credentials, an app token refused by a user endpoint, the public timeline, revocation) and, given a persona token, verify_credentials, home and notifications. deploy.yml runs it after every deploy. - OAuthPruner removes invalid tokens and authorizations older than two weeks, every six hours. - The consent page no longer sets form-action, which browsers apply to the redirect back to the client after the form is posted. CLAUDE.md gains the Mastodon API layout and invariants. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB --- .gitea/workflows/deploy.yml | 1 + CLAUDE.md | 24 +++++++- PrivaPub.Tests/Api/PersonaSeparationTests.cs | 61 ++++++++++++++++++++ PrivaPub/Api/Mastodon/Auth/OAuthPruner.cs | 44 ++++++++++++++ PrivaPub/Api/Mastodon/Auth/OAuthSetup.cs | 1 + PrivaPub/Web/Pages/OAuth/OAuthPages.cs | 2 +- tools/smoke/mastodon-api.sh | 27 +++++++++ 7 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 PrivaPub.Tests/Api/PersonaSeparationTests.cs create mode 100644 PrivaPub/Api/Mastodon/Auth/OAuthPruner.cs create mode 100755 tools/smoke/mastodon-api.sh diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 90d7890..387c14d 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -104,4 +104,5 @@ jobs: code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/activity+json' \ --data '{"type":"Follow","actor":"https://example.org/users/x","object":"'"$PUBLIC_URL"'/peasants/privapub"}' "$PUBLIC_URL/human-centipede") [ "$code" = "401" ] || { echo "::error::an unsigned inbox POST answered $code"; exit 1; } + tools/smoke/mastodon-api.sh "$PUBLIC_URL" echo "::notice::serving $served" diff --git a/CLAUDE.md b/CLAUDE.md index 95cd944..bfbe211 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,7 +90,14 @@ PrivaPub/ ASP.NET Core Web API, net10.0 Social/ FollowService (local in-process, remote Follow/Accept), Notifications Timelines/ Fanout (TimelineEntry rows, Mastodon's home rules), TimelineService Privacy/ VisibilityPolicy (IsPublic expression, CanSee) - Web/Pages/ Razor: /@{user}, /@{user}/{id} (public posts only, strict CSP, noindex) + Domain/Statuses/ StatusService: publish, edit, remove, favourite, reblog, for a persona (both client APIs use it) + Api/Mastodon/ + Auth/ OpenIddict setup (keys in Mongo), MastodonScopes, TokenController, OAuthPruner + Infrastructure/ MastodonController (avatar context, scopes, errors, Link), MastodonParams, MastodonJson, Page + Entities/ Mappers/ Mastodon entities; MastodonMapper (Account, Status), AccountSearch + Controllers/ apps, instance, accounts, statuses, timelines, notifications, search, stubs + Web/Pages/ Razor: /@{user}, /@{user}/{id} (public posts only, strict CSP, noindex); + OAuth/: /oauth/login (root password), /oauth/authorize (choose persona, consent) Services/ RootUsersService, GroupUsersService, PostsService, AppConfigurationService, … Models/ Mongo entities: User/, Group/, Post/, Federation/, Jobs/, AppConfiguration StaticServices/ DbEntities (Find accessors), AuthTokenManager (JWT), PasswordHasher @@ -171,6 +178,18 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ 13. **Home timelines are written, not computed:** every stored or created post goes through `Fanout.Distribute`, and every delete removes its `TimelineEntry` rows. Local deletes are soft (content cleared, 410 Tombstone). +## Mastodon client API invariants + +1. **A token is one persona.** Its subject is the avatar id; the root id lives only in the fifteen-minute `/oauth` + cookie used while choosing the persona, and never in a token, an authorization or a response. +2. `/api/*` authenticates with OpenIddict validation, everything else with the old JWT (`PrivaPub` policy scheme). + Every `/api` request re-checks that the persona's root is neither banned nor deleted (`MastodonController`). +3. Read parameters through `Params` (query, form and JSON merged Rails-style), never MVC binding. A value type read + from a conditional must say `(int?)null`, not `default`: that bug once made every list one item long. +4. Answer with `Json(...)` (snake_case, explicit nulls) or `Error(status, message)`; page lists with `Page` and `Link`. +5. Unsupported features answer empty lists or 422 with a message, never 404 or 500, so clients degrade. +6. Advertise `4.2.0 (compatible; PrivaPub)` until grouped notifications exist. + ## Privacy invariants - **No root id in federation output, NodeInfo or logs, no IP next to an identity in logs, and no `ex.Message` to a @@ -233,7 +252,8 @@ the owner's GoToSocial at social.arasaka.software. **Ask before acting from the - **CI/CD:** push to `master` runs `build.yml` (build + tests) on the instance-wide `build` runner. A `v*` tag runs `deploy.yml`: tests, self-contained linux-x64 publish, snapshot and `mongodump` to `/var/backups/privapub.thepra.dev`, stop → rsync → start, a `127.0.0.1:6970/build.json` health loop with rollback, then public checks (actor, NodeInfo, - Swagger 404, inbox junk 400, unsigned 401). + Swagger 404, inbox junk 400, unsigned 401) and `tools/smoke/mastodon-api.sh` (app registration, client credentials, + discovery, instance, public timeline; pass a persona token as a second argument to check the signed-in side). - **The box:** Max (`nuvola.xyz`). Unit `privapub` runs as www-data from `/var/www/privapub.thepra.dev` with `ASPNETCORE_ENVIRONMENT=Production`. - **One-time root setup:** `deploy/max/setup.sh`, run through `../arasaka.software/tools/max/run.sh`. diff --git a/PrivaPub.Tests/Api/PersonaSeparationTests.cs b/PrivaPub.Tests/Api/PersonaSeparationTests.cs new file mode 100644 index 0000000..dd8c011 --- /dev/null +++ b/PrivaPub.Tests/Api/PersonaSeparationTests.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.Caching.Memory; + +using MongoDB.Entities; + +using PrivaPub.Api.Mastodon.Infrastructure; +using PrivaPub.Api.Mastodon.Mappers; +using PrivaPub.ClientModels.Post; +using PrivaPub.ClientModels.Social; +using PrivaPub.Models.Post; +using PrivaPub.Tests.Support; + +using System.Text.Json; + +namespace PrivaPub.Tests.Api +{ + [Trait("Category", "Integration")] + public sealed class PersonaSeparationTests : IAsyncLifetime + { + Harness _harness; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _harness = await Harness.Start(); + } + + public async ValueTask DisposeAsync() + { + if (_harness != default) + await _harness.DisposeAsync(); + } + + [Fact] + public async Task Nothing_shown_to_one_persona_names_its_sibling() + { + var token = TestContext.Current.CancellationToken; + var (root, alice) = await _harness.Persona("alice"); + var (_, sibling) = await _harness.Persona("sibling", root); + var (bobRoot, bob) = await _harness.Persona("bob"); + await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = bob.UserName }, token); + await _harness.Follows.Follow(root, new FollowForm { AvatarId = sibling.Id, Target = bob.UserName }, token); + await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = sibling.Id, Text = "from the sibling" }, token); + await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "from alice" }, token); + await _harness.Posts.InsertPost(bobRoot, new InsertPostForm { AvatarId = bob.Id, Text = "hi both" }, token); + + var mapper = new MastodonMapper(_harness.Db, _harness.Local); + var account = await mapper.Local(alice, withSource: true, token); + var homeIds = (await _harness.Db.TimelineEntries.Match(e => e.AvatarId == alice.Id).ExecuteAsync(token)).Select(e => e.PostId).ToList(); + var home = await _harness.Db.Posts.Match(p => homeIds.Contains(p.ID)).ExecuteAsync(token); + var statuses = await mapper.Statuses(home, alice.Id, token); + var bobAsSeenByAlice = await mapper.Account(bob.Id, token); + + var json = JsonSerializer.Serialize(new object[] { account, statuses, bobAsSeenByAlice }, MastodonJson.Options); + Assert.DoesNotContain(sibling.Id, json); + Assert.DoesNotContain(sibling.UserName, json); + Assert.DoesNotContain(root, json); + Assert.Contains("hi both", json); + Assert.Equal(2, bobAsSeenByAlice.FollowersCount); + } + } +} diff --git a/PrivaPub/Api/Mastodon/Auth/OAuthPruner.cs b/PrivaPub/Api/Mastodon/Auth/OAuthPruner.cs new file mode 100644 index 0000000..b2b8e7f --- /dev/null +++ b/PrivaPub/Api/Mastodon/Auth/OAuthPruner.cs @@ -0,0 +1,44 @@ +using OpenIddict.Abstractions; + +namespace PrivaPub.Api.Mastodon.Auth +{ + public class OAuthPruner : BackgroundService + { + static readonly TimeSpan Interval = TimeSpan.FromHours(6); + static readonly TimeSpan Threshold = TimeSpan.FromDays(14); + + readonly IServiceProvider _services; + readonly ILogger _logger; + + public OAuthPruner(IServiceProvider services, ILogger logger) + { + _services = services; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await Task.Delay(Interval, stoppingToken); + using var scope = _services.CreateScope(); + var threshold = DateTimeOffset.UtcNow - Threshold; + var tokens = await scope.ServiceProvider.GetRequiredService().PruneAsync(threshold, stoppingToken); + var authorizations = await scope.ServiceProvider.GetRequiredService().PruneAsync(threshold, stoppingToken); + if (tokens + authorizations > 0) + _logger.LogInformation("{Service} removed {Tokens} tokens and {Authorizations} authorizations", nameof(OAuthPruner), tokens, authorizations); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "{Service} pass failed", nameof(OAuthPruner)); + } + } + } + } +} diff --git a/PrivaPub/Api/Mastodon/Auth/OAuthSetup.cs b/PrivaPub/Api/Mastodon/Auth/OAuthSetup.cs index e2e0456..389c02d 100644 --- a/PrivaPub/Api/Mastodon/Auth/OAuthSetup.cs +++ b/PrivaPub/Api/Mastodon/Auth/OAuthSetup.cs @@ -26,6 +26,7 @@ namespace PrivaPub.Api.Mastodon.Auth public static IServiceCollection PrivaPubOAuth(this IServiceCollection services, IWebHostEnvironment environment) { services.AddSingleton(_ => DB.Default.Database()); + services.AddHostedService(); services.AddAuthentication() .AddCookie(LoginScheme, options => diff --git a/PrivaPub/Web/Pages/OAuth/OAuthPages.cs b/PrivaPub/Web/Pages/OAuth/OAuthPages.cs index 10063be..94ccd91 100644 --- a/PrivaPub/Web/Pages/OAuth/OAuthPages.cs +++ b/PrivaPub/Web/Pages/OAuth/OAuthPages.cs @@ -147,7 +147,7 @@ namespace PrivaPub.Web.Pages.OAuth .Where(p => p.Key is not ("avatarId" or "decision" or "signed_in")) .Select(p => new KeyValuePair(p.Key, (string)p.Value)) .ToList(); - Response.Headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'"; + Response.Headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'"; Response.Headers["Cache-Control"] = "no-store"; return Page(); } diff --git a/tools/smoke/mastodon-api.sh b/tools/smoke/mastodon-api.sh new file mode 100755 index 0000000..03a3b17 --- /dev/null +++ b/tools/smoke/mastodon-api.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Checks the Mastodon client API the way a client first meets it. +# usage: tools/smoke/mastodon-api.sh https://privapub.thepra.dev [ACCESS_TOKEN] +set -euo pipefail +BASE="${1:?base url}"; TOKEN="${2:-}" +fail() { echo "::error::$*"; exit 1; } +json() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; } + +version=$(curl -fsS "$BASE/api/v1/instance" | json "print(d['version'])") || fail "instance v1" +curl -fsS "$BASE/api/v2/instance" | json "assert d['configuration']['statuses']['max_characters'] > 0" || fail "instance v2" +curl -fsS "$BASE/.well-known/oauth-authorization-server" | json "assert d['token_endpoint'].endswith('/oauth/token')" || fail "oauth discovery" + +app=$(curl -fsS -X POST "$BASE/api/v1/apps" -d 'client_name=privapub-smoke&redirect_uris=urn:ietf:wg:oauth:2.0:oob&scopes=read') || fail "app registration" +id=$(echo "$app" | json "print(d['client_id'])"); secret=$(echo "$app" | json "print(d['client_secret'])") +app_token=$(curl -fsS -X POST "$BASE/oauth/token" -d "grant_type=client_credentials&client_id=$id&client_secret=$secret&scope=read" | json "print(d['access_token'])") || fail "client credentials" +curl -fsS -H "Authorization: Bearer $app_token" "$BASE/api/v1/apps/verify_credentials" | json "assert d['name'] == 'privapub-smoke'" || fail "app verify_credentials" +code=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $app_token" "$BASE/api/v1/accounts/verify_credentials") +[ "$code" = "401" ] || fail "an app token reached a user endpoint ($code)" +curl -fsS "$BASE/api/v1/timelines/public?limit=2" | json "assert isinstance(d, list)" || fail "public timeline" +curl -fsS -X POST "$BASE/oauth/revoke" -d "token=$app_token&client_id=$id&client_secret=$secret" -o /dev/null || fail "revoke" + +if [ -n "$TOKEN" ]; then + curl -fsS -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/accounts/verify_credentials" | json "assert d['source'] is not None" || fail "verify_credentials" + curl -fsS -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/timelines/home?limit=5" | json "assert isinstance(d, list)" || fail "home" + curl -fsS -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/notifications?limit=5" | json "assert isinstance(d, list)" || fail "notifications" +fi +echo "mastodon api ok: $version"