diff --git a/internal/audiobooks/abs/handler.go b/internal/audiobooks/abs/handler.go index b45ae408..5e011076 100644 --- a/internal/audiobooks/abs/handler.go +++ b/internal/audiobooks/abs/handler.go @@ -297,6 +297,9 @@ func (h *Handler) mountRoutes(r chi.Router) { // Real-ABS /authorize: validates the bearer and re-mints the // /me envelope so the client can resume without retyping creds. r.Post(prefix+"/authorize", h.handleABSAuthorize) + // Logout: revokes the caller's access JTI. Mounted inside + // bearerAuth so the JTI is already in context. + r.Post(prefix+"/logout", h.handleLogout) // Continue Listening shelf. r.Get(prefix+"/me/items-in-progress", h.handleItemsInProgress) // Library list + detail. diff --git a/internal/audiobooks/abs/login.go b/internal/audiobooks/abs/login.go index a022de3d..b8ba9fe2 100644 --- a/internal/audiobooks/abs/login.go +++ b/internal/audiobooks/abs/login.go @@ -459,3 +459,35 @@ func (h *Handler) handleRefresh(w http.ResponseWriter, r *http.Request) { "refreshToken": refresh, }) } + +// handleLogout — POST /logout +// +// Mounted inside the bearerAuth group: the middleware has already parsed +// and validated the access JTI. We revoke that JTI (idempotent) and +// return 204. There is no body and no JSON response. +// +// Note: this revokes ONLY the access token. The associated refresh token +// has its own JTI and stays valid until the client also calls /auth/refresh +// with a since-revoked access; the refresh endpoint will then deny the +// rotation. Clients that want a hard "log out everywhere" should iterate +// the sessions list (added in Phase 3) instead. +func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) { + a, ok := absAuthFrom(r) + if !ok || a.JTI == "" { + // No auth context — middleware shouldn't have let us through, but + // be defensive and return 204 anyway (logout is idempotent). + w.WriteHeader(http.StatusNoContent) + return + } + if h.deps.TokenStore == nil { + w.WriteHeader(http.StatusNoContent) + return + } + if err := h.deps.TokenStore.RevokeTokenByJTI(r.Context(), a.JTI); err != nil { + slog.Warn("abs logout: revoke failed", "jti", a.JTI, "user", a.UserID, "err", err) + http.Error(w, "logout failed", http.StatusInternalServerError) + return + } + slog.Debug("abs logout: revoked", "jti", a.JTI, "user", a.UserID) + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/audiobooks/abs/login_logout_test.go b/internal/audiobooks/abs/login_logout_test.go new file mode 100644 index 00000000..14a96e42 --- /dev/null +++ b/internal/audiobooks/abs/login_logout_test.go @@ -0,0 +1,74 @@ +package abs + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHandleLogout_RevokesJTIAndReturns204(t *testing.T) { + store := newMemTokenStore() + jti := "logout-test-jti" + _ = store.InsertToken(context.Background(), ABSToken{ID: jti, UserID: "1", JTI: jti}) + + h := New(Dependencies{TokenStore: store}) + req := httptest.NewRequest(http.MethodPost, "/logout", nil) + // Simulate bearerAuth having populated the context. + ctx := context.WithValue(req.Context(), ctxKey{}, ctxAuth{ + UserID: "1", JTI: jti, Token: "doesnt-matter", + }) + req = req.WithContext(ctx) + + rec := httptest.NewRecorder() + h.handleLogout(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body=%s", rec.Code, rec.Body.String()) + } + tok, _ := store.GetTokenByJTI(context.Background(), jti) + if tok.RevokedAt == nil { + t.Errorf("JTI %s was not revoked", jti) + } +} + +func TestHandleLogout_NoAuthContext_204(t *testing.T) { + store := newMemTokenStore() + h := New(Dependencies{TokenStore: store}) + req := httptest.NewRequest(http.MethodPost, "/logout", nil) + rec := httptest.NewRecorder() + h.handleLogout(rec, req) + if rec.Code != http.StatusNoContent { + t.Errorf("status = %d, want 204", rec.Code) + } +} + +func TestHandleLogout_NilTokenStore_204(t *testing.T) { + h := New(Dependencies{}) + req := httptest.NewRequest(http.MethodPost, "/logout", nil) + ctx := context.WithValue(req.Context(), ctxKey{}, ctxAuth{UserID: "1", JTI: "x"}) + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + h.handleLogout(rec, req) + if rec.Code != http.StatusNoContent { + t.Errorf("status = %d, want 204", rec.Code) + } +} + +func TestHandleLogout_IsIdempotent(t *testing.T) { + store := newMemTokenStore() + jti := "idem-jti" + _ = store.InsertToken(context.Background(), ABSToken{ID: jti, UserID: "1", JTI: jti}) + h := New(Dependencies{TokenStore: store}) + + for i := 0; i < 3; i++ { + req := httptest.NewRequest(http.MethodPost, "/logout", nil) + ctx := context.WithValue(req.Context(), ctxKey{}, ctxAuth{UserID: "1", JTI: jti}) + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + h.handleLogout(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("iter %d: status = %d, want 204", i, rec.Code) + } + } +}