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:
co-authored by
Claude Opus 4.7
parent
0e71f31aa1
commit
8a780fbe45
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user