Slice 9: Auth — Logout (Revoke Refresh Token) - #33
Merged
Conversation
Implements Slice 9: POST /api/v1/auth/logout, [Authorize]-protected. Extends IAuthService/AuthService (same reasoning as Slice 8 - kept refresh_tokens revocation logic together rather than splitting into a separate RefreshTokenService) with LogoutAsync(userId, refreshToken): looks up the token by hash scoped to the caller's own user_id (from the JWT's sub claim via ClaimTypes.NameIdentifier after ASP.NET Core's default inbound claim mapping), sets revoked_at, and is deliberately quiet/idempotent on a missing, foreign, or already-revoked token rather than erroring - logout shouldn't leak whether a token string exists or belongs to someone else. Tests cover the 2 required cases plus unauthenticated request rejection and a same-endpoint proof that logout cannot revoke another user's refresh token even when the caller supplies its literal value. Closes #10
Adds Swashbuckle.AspNetCore.SwaggerUI (UI only) pointed at the OpenAPI document .NET 10's built-in AddOpenApi()/MapOpenApi() already generates - one source of truth for the spec, Swashbuckle just renders it at /swagger. Development-only, same as the raw JSON endpoint. Enables GenerateDocumentationFile on SubVora.Api and adds XML doc comments (<summary>/<remarks>/<response>) plus matching [ProducesResponseType] attributes to all 4 AuthController actions, so the generated spec has real descriptions instead of bare method names. Suppresses CS1591 rather than requiring doc comments on every member - only public controller actions are documented. Verified locally: ran the API, confirmed /openapi/v1.json includes all 4 endpoints with correct summaries/response descriptions and /swagger serves the UI shell correctly. Also: fixes a .gitignore gap found along the way (.vs/ and *.user were untracked but not actually ignored - the streamlined dotnet-only .gitignore this repo started from doesn't cover Visual Studio artifacts), and refreshes README's stale "implementation not yet started" status with real setup steps (docker compose, user-secrets, migrations, run, swagger link).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds
POST /api/v1/auth/logout— revokes the caller's presented refresh token so it can no longer be used at/refresh. This completes the 3-slice auth foundation (register/login, refresh rotation, logout).Implements Slice 9 of the backend foundation PRD (#1).
Changes
src/SubVora.Application/Auth/IAuthService.cs: addsLogoutAsync(Guid userId, string presentedRefreshToken, ...)src/SubVora.Infrastructure/Auth/AuthService.cs: implementation — looks up the token by hash scoped to the caller's ownuser_id, setsrevoked_at, quietly no-ops on a missing/foreign/already-revoked tokensrc/SubVora.Api/Controllers/AuthController.cs:POST /api/v1/auth/logout,[Authorize]-protected, reads the caller's user id from the JWT'ssubclaimtests/SubVora.Api.Tests/LogoutControllerTests.cs: 4 new testsDeviation from the issue (same as Slice 8)
Extended the existing
IAuthService/AuthServicerather than introducing theRefreshTokenServicethe issue's routing metadata referenced — that file doesn't exist in this codebase's actual Slice 7/8 design; refresh-token revocation logic already lives inAuthServicealongside login and refresh-rotation.A scoping decision worth flagging
The issue's acceptance criteria just says "revokes the caller's refresh token." I scoped the lookup to
WHERE token_hash = @hash AND user_id = @callerUserIdrather than justWHERE token_hash = @hash— an authenticated user should only be able to revoke their own sessions, not anyone else's token if they somehow knew its literal value (impractical to guess, since it's 32 random bytes, but scoping costs nothing and is the correct invariant for a logout endpoint). Verified with a dedicated test: attacker calls/logoutwith another user's real refresh token, gets204(logout is deliberately quiet, no info leakage) but the victim's token is confirmed still usable at/refreshafterward.Verification
dotnet build SubVora.slnx— 0 warnings, 0 errorsdotnet test SubVora.slnx— 32/32 pass (2 smoke + 17 Infrastructure.Tests + 14 in Api.Tests, up from 10)Logout_WithValidRefreshToken_RevokesIt/Logout_ThenAttemptRefresh_Returns401— the 2 required casesLogout_WithoutAccessToken_Returns401— confirms[Authorize]actually gates the endpointLogout_WithAnotherUsersRefreshToken_DoesNotRevokeIt— the ownership-scoping proof described aboveAcceptance criteria (from #10)
POST /api/v1/auth/logoutrequires a valid access token and revokes the presented refresh token./api/v1/auth/refresh(returns 401).Also in this PR: Swagger UI + API docs
Requested separately, bundled in here since the auth surface (4 endpoints now) was the natural point to add it.
Swashbuckle.AspNetCore.SwaggerUI(UI package only) pointed at the OpenAPI document .NET 10's built-inAddOpenApi()/MapOpenApi()already generates — one source of truth for the spec itself, Swashbuckle just renders it. Served at/swaggerinDevelopmentonly, same gating as the raw/openapi/v1.jsonendpoint.GenerateDocumentationFileonSubVora.Apiand added XML doc comments (<summary>/<remarks>/<response>) plus matching[ProducesResponseType]attributes to all 4AuthControlleractions, so the generated spec has real descriptions instead of bare method names. SuppressedCS1591rather than requiring doc comments on every member — only public controller actions are documented, not DTOs/Program.cs.dotnet run, curled/openapi/v1.jsonand confirmed all 4 endpoints present with correct summaries and response descriptions, curled/swagger/index.htmland confirmed the UI shell serves correctly..gitignoregap along the way:.vs/and*.userwere sitting untracked in the working tree but weren't actually covered by this repo's.gitignore(it's the streamlined dotnet-only template, which explicitly excludes IDE-specific entries) — added a small Visual Studio section.README.md's stale "implementation not yet started" status line and filled in realGetting Startedsteps (docker compose, user-secrets, migrations, run, swagger link) now that there's an actual backend to run.Not done: wiring a Bearer-auth "Authorize" button into Swagger UI (so
/logout's[Authorize]requirement can be tested from the UI directly) — the .NET 9/10 native-OpenAPI security-scheme document-transformer API has shifted across recent versions and I didn't want to gamble on it working without a live check in a slice that wasn't asking for it. Flagging as a reasonable follow-up if it'd be useful.Note: a local (uncommitted) change to
src/SubVora.Api/appsettings.jsonsetting a real-looking value forJwt:Secretwas sitting in the working tree when I picked this up — I did not commit it (matches the documented convention of empty placeholders + user-secrets/env vars only). Flagging in case it was your own local testing setup and you want to keep/manage it separately.Closes #10