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:
1 parent
c301f0c498
commit
c5e4934ba6
10 files changed
+1879
-10
No files matched your search
@@ -0,0 +1,346 @@
|
||||
using PrivaPub.Tests.Support;
|
||||
using PrivaPub.Tests.Support.Host;
|
||||
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Http
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class OAuthTests : IAsyncLifetime
|
||||
{
|
||||
PrivaPubHost _host;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_host = await PrivaPubHost.Shared();
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
async Task<Persona> NewPersona(string name = "oauth") => await _host.Persona(await _host.SignUp(), name);
|
||||
|
||||
static async Task<bool> SignedIn(HttpClient client, OAuthApp app)
|
||||
{
|
||||
var response = await client.GetAsync(app.ReturnUrl(), TestContext.Current.CancellationToken);
|
||||
return response.StatusCode == HttpStatusCode.OK;
|
||||
}
|
||||
|
||||
static async Task AssertMastodonError(HttpResponseMessage response, HttpStatusCode status)
|
||||
{
|
||||
Assert.Equal(status, response.StatusCode);
|
||||
Assert.False(string.IsNullOrEmpty((await response.JsonBody())["error"]?.GetValue<string>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_token_names_the_persona_and_never_the_root()
|
||||
{
|
||||
var persona = await NewPersona();
|
||||
var root = persona.Root;
|
||||
using var client = _host.Client(cookies: true);
|
||||
var app = await client.RegisterApp();
|
||||
|
||||
var exchanged = await client.Exchange(app, await client.Code(app, persona));
|
||||
var body = await exchanged.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, exchanged.StatusCode);
|
||||
Assert.DoesNotContain(root.Id, body);
|
||||
Assert.DoesNotContain(root.UserName, body);
|
||||
var token = JsonNode.Parse(body)!["access_token"]!.GetValue<string>();
|
||||
using var api = _host.As(token);
|
||||
var account = await api.GetStringAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken);
|
||||
Assert.Contains($"\"id\":\"{persona.Id}\"", account);
|
||||
Assert.DoesNotContain(root.Id, account);
|
||||
Assert.DoesNotContain(root.UserName, account);
|
||||
|
||||
var stored = await ClientApi.StoredTokens(app.ClientId);
|
||||
Assert.NotEmpty(stored);
|
||||
foreach (var entry in stored)
|
||||
{
|
||||
Assert.Equal(persona.Id, entry["subject"].AsString);
|
||||
var raw = entry.ToString();
|
||||
var payload = entry.Contains("payload") && entry["payload"].IsString ? ClientApi.JwtPayload(entry["payload"].AsString) : string.Empty;
|
||||
Assert.DoesNotContain(root.Id, raw + payload);
|
||||
Assert.DoesNotContain(root.UserName, raw + payload);
|
||||
}
|
||||
var access = stored.Single(t => ClientApi.IsAccessToken(t));
|
||||
Assert.Equal(persona.Id, JsonNode.Parse(ClientApi.JwtPayload(access["payload"].AsString))!["sub"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_wrong_password_shows_an_error_and_signs_nobody_in()
|
||||
{
|
||||
var persona = await NewPersona();
|
||||
using var client = _host.Client(cookies: true);
|
||||
var app = await client.RegisterApp();
|
||||
|
||||
var response = await client.SignIn(persona.Root, app.ReturnUrl(), password: "Wrong-Pass-1!");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("That username and password do not match.", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
|
||||
Assert.DoesNotContain(response.Headers.TryGetValues("Set-Cookie", out var cookies) ? cookies : Enumerable.Empty<string>(),
|
||||
c => c.StartsWith("privapub.oauth=", StringComparison.Ordinal));
|
||||
var authorize = await client.GetAsync(app.ReturnUrl(), TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Redirect, authorize.StatusCode);
|
||||
Assert.StartsWith("/oauth/login?", authorize.Headers.Location!.OriginalString);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_login_without_the_antiforgery_token_is_refused()
|
||||
{
|
||||
var persona = await NewPersona();
|
||||
using var client = _host.Client(cookies: true);
|
||||
await client.GetStringAsync("/oauth/login", TestContext.Current.CancellationToken);
|
||||
|
||||
var response = await client.Form("/oauth/login", ("returnUrl", "/oauth/authorize?x=1"), ("userName", persona.Root.UserName), ("password", persona.Root.Password));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.False(response.Headers.TryGetValues("Set-Cookie", out var cookies) && cookies.Any(c => c.StartsWith("privapub.oauth=", StringComparison.Ordinal)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://evil.example/oauth/authorize?client_id=x")]
|
||||
[InlineData("//evil.example/oauth/authorize?client_id=x")]
|
||||
[InlineData("/\\evil.example/oauth/authorize?client_id=x")]
|
||||
[InlineData("/somewhere/else")]
|
||||
public async Task The_return_address_never_leaves_the_site(string returnUrl)
|
||||
{
|
||||
var persona = await NewPersona();
|
||||
using var client = _host.Client(cookies: true);
|
||||
|
||||
var response = await client.SignIn(persona.Root, returnUrl);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
||||
Assert.Equal("/oauth/authorize?signed_in=1", response.Headers.Location!.OriginalString);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Denying_answers_access_denied_and_no_code()
|
||||
{
|
||||
var persona = await NewPersona();
|
||||
using var client = _host.Client(cookies: true);
|
||||
var app = await client.RegisterApp();
|
||||
Assert.Equal(HttpStatusCode.Redirect, (await client.SignIn(persona.Root, app.ReturnUrl())).StatusCode);
|
||||
|
||||
var response = await client.Choose(app, persona.Id, decision: "deny");
|
||||
var page = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Null(ClientApi.CodeIn(page));
|
||||
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
||||
var location = response.Headers.Location!.OriginalString;
|
||||
Assert.StartsWith(ClientApi.OutOfBand + "?error=access_denied", location);
|
||||
Assert.DoesNotContain("code=", location);
|
||||
Assert.DoesNotContain(await ClientApi.StoredTokens(app.ClientId), ClientApi.IsAccessToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Choosing_another_roots_persona_shows_the_choice_again_without_a_code()
|
||||
{
|
||||
var mine = await NewPersona();
|
||||
var theirs = await NewPersona("theirs");
|
||||
using var client = _host.Client(cookies: true);
|
||||
var app = await client.RegisterApp();
|
||||
Assert.Equal(HttpStatusCode.Redirect, (await client.SignIn(mine.Root, app.ReturnUrl())).StatusCode);
|
||||
|
||||
var response = await client.Choose(app, theirs.Id);
|
||||
var page = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Null(ClientApi.CodeIn(page));
|
||||
Assert.Contains(mine.Id, page);
|
||||
Assert.DoesNotContain(theirs.Id, page);
|
||||
Assert.DoesNotContain(theirs.UserName, page);
|
||||
Assert.DoesNotContain(await ClientApi.StoredTokens(app.ClientId), ClientApi.IsAccessToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_banned_root_is_sent_back_to_the_login()
|
||||
{
|
||||
var persona = await NewPersona();
|
||||
using var client = _host.Client(cookies: true);
|
||||
var app = await client.RegisterApp();
|
||||
Assert.Equal(HttpStatusCode.Redirect, (await client.SignIn(persona.Root, app.ReturnUrl())).StatusCode);
|
||||
var choice = await client.Choice(app);
|
||||
|
||||
await ClientApi.Ban(persona.Root.Id);
|
||||
var authorize = await client.GetAsync(app.ReturnUrl(), TestContext.Current.CancellationToken);
|
||||
var chosen = await client.Decide(choice, persona.Id);
|
||||
var again = await client.SignIn(persona.Root, app.ReturnUrl());
|
||||
|
||||
Assert.Equal(HttpStatusCode.Redirect, authorize.StatusCode);
|
||||
Assert.StartsWith("/oauth/login?returnUrl=", authorize.Headers.Location!.OriginalString);
|
||||
Assert.Equal(HttpStatusCode.Redirect, chosen.StatusCode);
|
||||
Assert.StartsWith("/oauth/login?returnUrl=", chosen.Headers.Location!.OriginalString);
|
||||
Assert.Equal(HttpStatusCode.OK, again.StatusCode);
|
||||
Assert.Contains("That username and password do not match.", await again.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_code_issued_before_a_ban_buys_no_token()
|
||||
{
|
||||
var persona = await NewPersona();
|
||||
using var client = _host.Client(cookies: true);
|
||||
var app = await client.RegisterApp();
|
||||
var code = await client.Code(app, persona);
|
||||
|
||||
await ClientApi.Ban(persona.Root.Id);
|
||||
var response = await client.Exchange(app, code);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal("invalid_grant", (await response.JsonBody())["error"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Force_login_asks_for_the_password_again()
|
||||
{
|
||||
var persona = await NewPersona();
|
||||
using var client = _host.Client(cookies: true);
|
||||
var app = await client.RegisterApp();
|
||||
Assert.Equal(HttpStatusCode.Redirect, (await client.SignIn(persona.Root, app.ReturnUrl())).StatusCode);
|
||||
Assert.True(await SignedIn(client, app));
|
||||
|
||||
var forced = await client.GetAsync(app.ReturnUrl("force_login=true"), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Redirect, forced.StatusCode);
|
||||
var location = forced.Headers.Location!.OriginalString;
|
||||
Assert.StartsWith("/oauth/login?returnUrl=", location);
|
||||
var returnUrl = Uri.UnescapeDataString(location["/oauth/login?returnUrl=".Length..]);
|
||||
Assert.Contains("force_login=true", returnUrl);
|
||||
Assert.DoesNotContain("signed_in", returnUrl);
|
||||
var signedIn = await client.SignIn(persona.Root, returnUrl);
|
||||
Assert.Equal(HttpStatusCode.Redirect, signedIn.StatusCode);
|
||||
Assert.EndsWith("&signed_in=1", signedIn.Headers.Location!.OriginalString);
|
||||
var page = await client.GetAsync(signedIn.Headers.Location!.OriginalString, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, page.StatusCode);
|
||||
Assert.Contains(persona.Id, await page.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_authorization_code_works_once()
|
||||
{
|
||||
var persona = await NewPersona();
|
||||
using var client = _host.Client(cookies: true);
|
||||
var app = await client.RegisterApp();
|
||||
var code = await client.Code(app, persona);
|
||||
|
||||
var first = await client.Exchange(app, code);
|
||||
var second = await client.Exchange(app, code);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode);
|
||||
Assert.Equal("invalid_grant", (await second.JsonBody())["error"]!.GetValue<string>());
|
||||
using var api = _host.As((await first.JsonBody())["access_token"]!.GetValue<string>());
|
||||
await AssertMastodonError(await api.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken), HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("password")]
|
||||
[InlineData("refresh_token")]
|
||||
public async Task Grants_other_than_code_and_client_credentials_are_refused(string grant)
|
||||
{
|
||||
var persona = await NewPersona();
|
||||
using var client = _host.Client();
|
||||
var app = await client.RegisterApp();
|
||||
|
||||
var response = await client.Form("/oauth/token", ("grant_type", grant), ("client_id", app.ClientId), ("client_secret", app.ClientSecret),
|
||||
("username", persona.Root.UserName), ("password", persona.Root.Password), ("refresh_token", "anything"), ("scope", "read"));
|
||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.DoesNotContain("access_token", body);
|
||||
Assert.Equal("unsupported_grant_type", JsonNode.Parse(body)!["error"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_client_credentials_token_is_no_persona()
|
||||
{
|
||||
using var client = _host.Client();
|
||||
var app = await client.RegisterApp("read write");
|
||||
var response = await client.Form("/oauth/token", ("grant_type", "client_credentials"), ("client_id", app.ClientId), ("client_secret", app.ClientSecret), ("scope", "read"));
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var token = (await response.JsonBody())["access_token"]!.GetValue<string>();
|
||||
using var api = _host.As(token);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, (await api.GetAsync("/api/v1/apps/verify_credentials", TestContext.Current.CancellationToken)).StatusCode);
|
||||
await AssertMastodonError(await api.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken), HttpStatusCode.Unauthorized);
|
||||
await AssertMastodonError(await api.GetAsync("/api/v1/notifications", TestContext.Current.CancellationToken), HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_read_only_token_cannot_post()
|
||||
{
|
||||
var token = await _host.MastodonToken(await NewPersona(), "read");
|
||||
using var api = _host.As(token);
|
||||
|
||||
var response = await api.Form("/api/v1/statuses", ("status", "nope"));
|
||||
|
||||
await AssertMastodonError(response, HttpStatusCode.Forbidden);
|
||||
Assert.Equal(HttpStatusCode.OK, (await api.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_follow_scope_covers_reading_follows()
|
||||
{
|
||||
var token = await _host.MastodonToken(await NewPersona(), "follow");
|
||||
using var api = _host.As(token);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, (await api.GetAsync("/api/v1/follow_requests", TestContext.Current.CancellationToken)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, (await api.GetAsync("/api/v1/blocks", TestContext.Current.CancellationToken)).StatusCode);
|
||||
await AssertMastodonError(await api.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken), HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_revoked_token_stops_working()
|
||||
{
|
||||
var persona = await NewPersona();
|
||||
using var client = _host.Client(cookies: true);
|
||||
var app = await client.RegisterApp();
|
||||
var token = await client.Token(app, persona);
|
||||
using var api = _host.As(token);
|
||||
Assert.Equal(HttpStatusCode.OK, (await api.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)).StatusCode);
|
||||
|
||||
var revoked = await client.Form("/oauth/revoke", ("token", token), ("client_id", app.ClientId), ("client_secret", app.ClientSecret));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, revoked.StatusCode);
|
||||
await AssertMastodonError(await api.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken), HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_login_and_authorize_pages_are_hardened()
|
||||
{
|
||||
var persona = await NewPersona();
|
||||
using var client = _host.Client(cookies: true);
|
||||
var app = await client.RegisterApp();
|
||||
|
||||
var login = await client.GetAsync("/oauth/login?returnUrl=" + Uri.EscapeDataString(app.ReturnUrl()), TestContext.Current.CancellationToken);
|
||||
var failed = await client.SignIn(persona.Root, app.ReturnUrl(), password: "Wrong-Pass-1!");
|
||||
Assert.Equal(HttpStatusCode.Redirect, (await client.SignIn(persona.Root, app.ReturnUrl())).StatusCode);
|
||||
var authorize = await client.GetAsync(app.ReturnUrl(), TestContext.Current.CancellationToken);
|
||||
|
||||
foreach (var response in new[] { login, failed, authorize })
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var csp = string.Join(";", response.Headers.GetValues("Content-Security-Policy"));
|
||||
Assert.Contains("default-src 'none'", csp);
|
||||
Assert.Contains("frame-ancestors 'none'", csp);
|
||||
Assert.Contains("no-store", response.Headers.CacheControl!.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_eleventh_login_from_one_address_in_a_minute_is_refused()
|
||||
{
|
||||
using var client = _host.ClientAt("198.51.100.12");
|
||||
for (var attempt = 1; attempt <= 10; attempt++)
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/oauth/login", TestContext.Current.CancellationToken)).StatusCode);
|
||||
|
||||
var eleventh = await client.GetAsync("/oauth/login", TestContext.Current.CancellationToken);
|
||||
using var elsewhere = _host.ClientAt("198.51.100.13");
|
||||
var other = await elsewhere.GetAsync("/oauth/login", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, eleventh.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, other.StatusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user