feat(audiobooks): add POST /logout for ABS sign-out

Mounted inside bearerAuth so the JTI is already validated. Revokes the
access JTI in abs_sessions and returns 204. Idempotent: re-calling on an
already-revoked JTI still returns 204. Refresh JTI is intentionally NOT
revoked here — clients that want hard sign-out-everywhere will use the
sessions endpoint added in Phase 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
RXWatcher
2026-05-26 07:30:58 +02:00
co-authored by Claude Opus 4.7
parent 0e71f31aa1
commit 8a780fbe45
3 changed files with 109 additions and 0 deletions
+3
View File
@@ -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.
+32
View File
@@ -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)
}
@@ -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)
}
}
}