Skip to content

Slice 9: Auth — Logout (Revoke Refresh Token) - #33

Merged
rghvgrv merged 2 commits into
mainfrom
slice-9-auth-logout
Jul 11, 2026
Merged

Slice 9: Auth — Logout (Revoke Refresh Token)#33
rghvgrv merged 2 commits into
mainfrom
slice-9-auth-logout

Conversation

@rghvgrv

@rghvgrv rghvgrv commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

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: adds LogoutAsync(Guid userId, string presentedRefreshToken, ...)
  • src/SubVora.Infrastructure/Auth/AuthService.cs: implementation — looks up the token by hash scoped to the caller's own user_id, sets revoked_at, quietly no-ops on a missing/foreign/already-revoked token
  • src/SubVora.Api/Controllers/AuthController.cs: POST /api/v1/auth/logout, [Authorize]-protected, reads the caller's user id from the JWT's sub claim
  • tests/SubVora.Api.Tests/LogoutControllerTests.cs: 4 new tests

Deviation from the issue (same as Slice 8)

Extended the existing IAuthService/AuthService rather than introducing the RefreshTokenService the 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 in AuthService alongside 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 = @callerUserId rather than just WHERE 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 /logout with another user's real refresh token, gets 204 (logout is deliberately quiet, no info leakage) but the victim's token is confirmed still usable at /refresh afterward.

Verification

  • dotnet build SubVora.slnx — 0 warnings, 0 errors
  • dotnet 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 cases
  • Logout_WithoutAccessToken_Returns401 — confirms [Authorize] actually gates the endpoint
  • Logout_WithAnotherUsersRefreshToken_DoesNotRevokeIt — the ownership-scoping proof described above

Acceptance criteria (from #10)

  • POST /api/v1/auth/logout requires a valid access token and revokes the presented refresh token.
  • A revoked refresh token can no longer be used at /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.

  • Added Swashbuckle.AspNetCore.SwaggerUI (UI package only) pointed at the OpenAPI document .NET 10's built-in AddOpenApi()/MapOpenApi() already generates — one source of truth for the spec itself, Swashbuckle just renders it. Served at /swagger in Development only, same gating as the raw /openapi/v1.json endpoint.
  • Enabled GenerateDocumentationFile on SubVora.Api and added 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. Suppressed CS1591 rather than requiring doc comments on every member — only public controller actions are documented, not DTOs/Program.cs.
  • Verified locally, not just by inspection: ran the API with dotnet run, curled /openapi/v1.json and confirmed all 4 endpoints present with correct summaries and response descriptions, curled /swagger/index.html and confirmed the UI shell serves correctly.
  • Found and fixed a .gitignore gap along the way: .vs/ and *.user were 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.
  • Refreshed README.md's stale "implementation not yet started" status line and filled in real Getting Started steps (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.json setting a real-looking value for Jwt:Secret was 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

rghvgrv added 2 commits July 12, 2026 03:26
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).
@rghvgrv
rghvgrv merged commit 67e1518 into main Jul 11, 2026
@rghvgrv
rghvgrv deleted the slice-9-auth-logout branch July 12, 2026 12:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Slice 9: Auth — Logout (Revoke Refresh Token)

1 participant