From 8537821d292df300be3ad28c76e8908d272f1a44 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 20 Apr 2026 22:07:16 -0400 Subject: [PATCH] feat(api): POST /api/auth/logout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deletes the session row keyed by the cookie/bearer token and clears the cookie on the client. Best-effort DB delete — logout still succeeds for the client if the row's already gone. Co-Authored-By: Claude Opus 4.7 --- internal/api/api.go | 6 +---- internal/api/auth.go | 37 ++++++++++++++++++++++++++++++ internal/api/auth_test.go | 48 +++++++++++++++++++++++++++++++++++++++ internal/auth/session.go | 5 ++++ 4 files changed, 91 insertions(+), 5 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index 02435cfe..79ffbcea 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -35,11 +35,7 @@ type handlers struct { } // Stub handlers so Mount() compiles. Real implementations in later tasks -// replace these in place (Task 7 logout, Task 8 me). - -func (h *handlers) handleLogout(w http.ResponseWriter, r *http.Request) { - writeErr(w, http.StatusNotImplemented, "not_implemented", "logout not wired") -} +// replace these in place (Task 8 me). func (h *handlers) handleGetMe(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusNotImplemented, "not_implemented", "me not wired") diff --git a/internal/api/auth.go b/internal/api/auth.go index 0e1c6b2f..e29beb37 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -17,6 +17,43 @@ import ( // so an abandoned laptop doesn't stay logged in forever. const sessionCookieMaxAge = 30 * 24 * time.Hour +func (h *handlers) handleLogout(w http.ResponseWriter, r *http.Request) { + // The session token can be on the cookie OR bearer header — RequireUser + // accepted either. Re-resolve it here so we can delete the row. + token := sessionTokenFromHTTP(r) + if token != "" { + if err := dbq.New(h.pool).DeleteSessionByTokenHash(r.Context(), auth.HashSessionToken(token)); err != nil { + h.logger.Warn("api: delete session failed", "err", err) + // Continue — logout is best-effort; the client still gets the + // cookie cleared. + } + } + http.SetCookie(w, &http.Cookie{ + Name: auth.SessionCookieName, + Value: "", + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + MaxAge: -1, + }) + w.WriteHeader(http.StatusNoContent) +} + +// sessionTokenFromHTTP duplicates the internal helper in internal/auth +// because that one is unexported. Cheap to repeat here; keeping the auth +// package's internal helper package-private is worth more than DRY. +func sessionTokenFromHTTP(r *http.Request) string { + if c, err := r.Cookie(auth.SessionCookieName); err == nil && c.Value != "" { + return c.Value + } + h := r.Header.Get("Authorization") + const prefix = "bearer " + if len(h) > len(prefix) && (h[:7] == "Bearer " || h[:7] == "bearer ") { + return h[len(prefix):] + } + return "" +} + func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) { var req LoginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index 4cd8eb1a..961601c7 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -157,3 +157,51 @@ func TestHandleLogin_MalformedBodyReturns400(t *testing.T) { t.Errorf("status = %d, want 400", w.Code) } } + +func userCtxKeyForTest() any { return auth.UserCtxKeyForTest() } + +func TestHandleLogout_DeletesSessionAndClearsCookie(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "hunter2", false) + + // Manually create a session to log out of. + token, err := auth.MintSessionToken() + if err != nil { + t.Fatalf("mint: %v", err) + } + if _, err := dbq.New(pool).InsertSession(context.Background(), dbq.InsertSessionParams{ + UserID: user.ID, + TokenHash: auth.HashSessionToken(token), + }); err != nil { + t.Fatalf("insert: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil) + req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: token}) + // handleLogout runs behind RequireUser in real routing; simulate that by + // putting the user into context here. + req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user)) + w := httptest.NewRecorder() + h.handleLogout(w, req) + + if w.Code != http.StatusNoContent { + t.Errorf("status = %d, want 204", w.Code) + } + + // Cookie should be cleared. + var cleared bool + for _, c := range w.Result().Cookies() { + if c.Name == auth.SessionCookieName && c.MaxAge < 0 { + cleared = true + } + } + if !cleared { + t.Error("session cookie not cleared") + } + + // Session row should be gone. + _, err = dbq.New(pool).GetSessionByTokenHash(context.Background(), auth.HashSessionToken(token)) + if err == nil { + t.Error("session row still present after logout") + } +} diff --git a/internal/auth/session.go b/internal/auth/session.go index a5a5e4fc..b4b12b1b 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -107,6 +107,11 @@ func RequireUser(pool *pgxpool.Pool) func(http.Handler) http.Handler { } } +// UserCtxKeyForTest is exported ONLY for tests in sibling packages that need +// to inject a dbq.User into request context without going through the +// middleware. Do not use this outside _test.go files. +func UserCtxKeyForTest() any { return userCtxKey } + func sessionTokenFromRequest(r *http.Request) string { if c, err := r.Cookie(SessionCookieName); err == nil && c.Value != "" { return c.Value