fix(admin): complete Admin-006 — inject IAntiforgery into LogoutAsync for explicit token validation

The previous Admin-006 commit added <AntiforgeryToken /> to the logout form
and updated the comment on the endpoint, but did not update LogoutAsync to
actually call IAntiforgery.ValidateRequestAsync. Blazor's UseAntiforgery()
middleware does not automatically validate minimal-API endpoints, so a
tokenless POST still succeeded. This commit injects IAntiforgery into the
handler, wraps ValidateRequestAsync in a try/catch, and returns 400 on
AntiforgeryValidationException. The endpoint keeps .DisableAntiforgery() to
prevent the middleware from also trying to read the body (which would cause
a double-read). The regression test is updated to log in first (to get an
authenticated session) before asserting 400 on a tokenless logout POST.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-22 07:51:11 -04:00
parent 1db8736515
commit a0aa4a4819
3 changed files with 99 additions and 89 deletions

View File

@@ -148,19 +148,31 @@ public sealed class AuthEndpointsTests : IClassFixture<AuthEndpointsTests.Stubbe
}
[Fact]
public async Task Logout_without_antiforgery_token_is_rejected()
public async Task Logout_with_valid_session_but_no_antiforgery_token_is_rejected()
{
// Admin-006: the logout endpoint no longer calls .DisableAntiforgery(), so the
// UseAntiforgery() middleware must reject a POST that carries no token with 400.
// This regression guards against CSRF-logout (attacker tricking the browser into
// signing the operator out by posting to /auth/logout from a foreign origin).
// This regression guards against CSRF-logout (attacker tricking the operator's
// already-authenticated browser into posting to /auth/logout from a foreign origin).
//
// To reach the antiforgery check we need an authenticated session — an
// unauthenticated POST is redirected to /login before the check is reached.
// We obtain the auth cookie via a valid /auth/login round-trip first.
using var client = _factory.CreateNonRedirectingClient();
var response = await client.PostAsync("/auth/logout",
// Step 1: log in to get the session cookie.
var loginResponse = await client.PostAsync("/auth/login",
Form(("username", "good"), ("password", "pw")));
loginResponse.StatusCode.ShouldBeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found);
// The cookie jar on the client now holds the auth cookie; subsequent requests are
// authenticated. Step 2: POST to /auth/logout without an antiforgery token.
var logoutResponse = await client.PostAsync("/auth/logout",
new FormUrlEncodedContent(Array.Empty<KeyValuePair<string, string>>()));
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest,
"/auth/logout without an antiforgery token must be rejected (Admin-006)");
// The antiforgery middleware must reject the missing token with 400.
logoutResponse.StatusCode.ShouldBe(HttpStatusCode.BadRequest,
"/auth/logout from an authenticated session without an antiforgery token must be rejected (Admin-006)");
}
// ── Admin-003: SignalR hubs reject anonymous connections ────────────────────