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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
70b4c45eb0
commit
d8f163b5ce
7 files changed
+157
-3
No files matched your search
@@ -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"
|
||||
@@ -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<T> 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`.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<OAuthPruner> _logger;
|
||||
|
||||
public OAuthPruner(IServiceProvider services, ILogger<OAuthPruner> 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<IOpenIddictTokenManager>().PruneAsync(threshold, stoppingToken);
|
||||
var authorizations = await scope.ServiceProvider.GetRequiredService<IOpenIddictAuthorizationManager>().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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ namespace PrivaPub.Api.Mastodon.Auth
|
||||
public static IServiceCollection PrivaPubOAuth(this IServiceCollection services, IWebHostEnvironment environment)
|
||||
{
|
||||
services.AddSingleton<IMongoDatabase>(_ => DB.Default.Database());
|
||||
services.AddHostedService<OAuthPruner>();
|
||||
|
||||
services.AddAuthentication()
|
||||
.AddCookie(LoginScheme, options =>
|
||||
|
||||
@@ -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<string, string>(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();
|
||||
}
|
||||
|
||||
Executable
+27
@@ -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"
|
||||
Reference in new issue
Block a user