feat(server): ghost-session guard — JWTs for deleted users stop authenticating

The JWT middleware validated signature + expiry and fabricated the
request user purely from token claims, so a signature-valid token for a
DELETED user kept full API access until token expiry — observed live on
hardware (a phone session survived the test suite's user-table
recreation and kept serving its true empty sets; open handoff bug 1).

An ensureUserExistsMiddleware already existed but was only wired on the
frontend SSR group (frontend.go) — every /api group used bare
createJWTMiddleware. The existence check now lives INSIDE
createJWTMiddleware's SuccessHandler so every protected group (SSR and
API alike) gets it:

- After claim validation, GetUser by the token's user_id; a missing row
  clears the token cookie and answers 401 user_not_found for API calls
  (the app's TokenAuthenticator then re-logins with stored credentials)
  or redirects to /login?session=invalid for page requests — same
  shape-aware split the error handler already uses.
- Cost: one primary-key lookup per authenticated request (~0.2ms local);
  instant revocation, no TTL semantics. The redundant SSR-only
  middleware is deleted (its redirect behavior is preserved inside the
  guard).

The guard exposed two processing-issues tests that were unknowingly
RELYING on ghost semantics: they nested a second setupTestServer whose
bootstrap deleted the first setup's regular user, then asserted 403
from the dead token. De-nested: a createLibraryDirect helper inserts
the library under the live setup's user, no second bootstrap.

New TestDeletedUserTokenRejected pins the contract: token works while
the user exists -> 401 user_not_found after deletion. Full suite: 219
groups, zero failures.
This commit is contained in:
John O'Keefe committed 2026-10-09 22:33:30 -04:00
1 parent 4f05a232fe
commit 9cd6356892
5 files changed
+125 -57

No files matched your search

+1 -1
View File
@@ -120,7 +120,7 @@ func registerFrontendRoutes(cfg *Config) {
// ============================================================================
// Protected frontend routes (no /api prefix)
frontendProtected := e.Group("", jwtMiddleware, ensureUserExistsMiddleware(cfg))
frontendProtected := e.Group("", jwtMiddleware)
// Series browse page
frontendProtected.GET("/series", func(c *echo.Context) error {
+32 -38
View File
@@ -69,7 +69,7 @@ type Config struct {
LoginTracker *ratelimit.LoginAttemptTracker
ScannerHandler *handlers.Handler
JobsHandler *handlers.JobsHandler
SidecarHandler *handlers.SidecarHandler
SidecarHandler *handlers.SidecarHandler
ReaderHandler *handlers.ReaderHandler
LibraryService *services.LibraryService
}
@@ -113,6 +113,31 @@ func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
return echo.NewHTTPError(http.StatusBadRequest, "invalid user ID in token")
}
// Ghost-session guard: a signature-valid token for a user that
// no longer exists must not authenticate (deleted users kept
// full access until token expiry before this check). A
// primary-key lookup per request — instant revocation, no TTL
// semantics; the guard also covers the SSR groups that used to
// chain ensureUserExistsMiddleware.
if !userExists(cfg, userUUID) {
log.Printf("Ghost session rejected: user %s no longer exists", userIDStr)
c.SetCookie(&http.Cookie{
Name: "token",
Value: "",
Expires: time.Now().Add(-24 * time.Hour),
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
if wantsHTML(c.Request().Header) {
return c.Redirect(http.StatusFound, "/login?session=invalid")
}
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "user_not_found",
"message": "This account no longer exists. Please log in again.",
})
}
c.Set("user", database.Users{
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
Email: claims["user_email"].(string),
@@ -138,43 +163,12 @@ func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
})
}
// ensureUserExistsMiddleware checks if the authenticated user still exists in the database
func ensureUserExistsMiddleware(cfg *Config) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
userIDStr, ok := c.Get("user_id").(string)
if !ok {
return next(c)
}
userUUID, err := uuid.Parse(userIDStr)
if err != nil {
log.Printf("Invalid UUID in user existence check: %v", err)
return next(c)
}
// Check if user exists in database
_, err = cfg.Queries.GetUser(c.Request().Context(), uuidToPGType(userUUID))
if err != nil {
log.Printf("User not found in database: %s", userIDStr)
// Clear invalid cookie
c.SetCookie(&http.Cookie{
Name: "token",
Value: "",
Expires: time.Now().Add(-24 * time.Hour),
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
// Redirect to login with session=invalid
return c.Redirect(http.StatusFound, "/login?session=invalid")
}
return next(c)
}
}
// userExists checks the users table for the token's claimed user. Runs on
// every authenticated request: a primary-key lookup, cheap at this scale,
// and it makes deletion revocable the moment it happens.
func userExists(cfg *Config, id uuid.UUID) bool {
_, err := cfg.Queries.GetUser(context.Background(), uuidToPGType(id))
return err == nil
}
// wantsHTML determines if the request expects HTML response