feat(audiobooks): add POST /auth/refresh for ABS token rotation

Mobile clients call refresh every ~22h to avoid the 24h access-token
re-login trap. Accepts the token via x-refresh-token header (real ABS
convention) or {refreshToken} body (legacy). Mints a fresh pair, persists
both new JTIs, then revokes the old refresh JTI atomically — if any step
in 3-4 fails, the old refresh stays valid and the client can retry.

Returns the user{accessToken, refreshToken} object AND top-level token
fields so mainline and 3rd-party clients both find their expected shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
RXWatcher
2026-05-26 07:27:56 +02:00
co-authored by Claude Opus 4.7
parent 4bbab4ccf0
commit 0e71f31aa1
3 changed files with 292 additions and 0 deletions
+4
View File
@@ -238,6 +238,10 @@ func (h *Handler) mountRoutes(r chi.Router) {
// Stage 2: login (body-creds + host-proxied paths).
r.Post("/login", h.handleLogin)
r.Post("/abs/api/login", h.handleLogin)
// Token rotation — mobile clients call this every ~22h to avoid the
// 24h access-token interactive re-login trap.
r.Post("/auth/refresh", h.handleRefresh)
r.Post("/abs/api/auth/refresh", h.handleRefresh)
// Unauthenticated cover + author-image routes. Real ABS serves covers
// without auth (getDoesServerImagesRequireToken returns false for our
+116
View File
@@ -343,3 +343,119 @@ func (h *Handler) handleABSAuthorize(w http.ResponseWriter, r *http.Request) {
// takes precedence anyway, so the echoed userID is just a harmless seed.
writeJSON(w, http.StatusOK, h.loginEnvelope(r, a.UserID, a.UserID, a.Token, ""))
}
// handleRefresh — POST /auth/refresh
//
// Real ABS clients send the refresh token via x-refresh-token header with
// an empty body; legacy / 3rd-party clients send {refreshToken: "..."} in
// the JSON body. Accept either; header takes precedence when both are sent.
//
// Token rotation semantics (ported from continuum-plugin-audiobooks):
// 1. Validate the refresh token signature + type.
// 2. Confirm the JTI is in the store and not revoked.
// 3. Mint a NEW access + refresh pair with fresh JTIs.
// 4. Persist both new JTIs BEFORE revoking the old one. If anything in
// step 3-4 fails, the old refresh stays valid and the client can retry.
// 5. Revoke the old refresh JTI.
// 6. Return {user:{accessToken, refreshToken}} AND top-level token fields
// for client compatibility — mainline app reads from user{}, third-party
// readers may read from the top level.
func (h *Handler) handleRefresh(w http.ResponseWriter, r *http.Request) {
refreshTok := strings.TrimSpace(r.Header.Get("x-refresh-token"))
if refreshTok == "" {
var p struct {
RefreshToken string `json:"refreshToken"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&p); err == nil {
refreshTok = p.RefreshToken
}
}
if refreshTok == "" {
http.Error(w, "refreshToken required", http.StatusBadRequest)
return
}
if h.deps.Config == nil || h.deps.TokenStore == nil {
http.Error(w, "auth not configured", http.StatusServiceUnavailable)
return
}
secret, err := h.deps.Config.JWTSecret(r.Context())
if err != nil {
http.Error(w, "config unavailable", http.StatusInternalServerError)
return
}
claims, err := ParseToken(secret, refreshTok)
if err != nil || claims.Type != "refresh" {
claimsType := ""
if claims != nil {
claimsType = claims.Type
}
slog.Debug("abs refresh: parse/type failed", "err", err, "type", claimsType)
http.Error(w, "invalid refresh token", http.StatusUnauthorized)
return
}
row, err := h.deps.TokenStore.GetTokenByJTI(r.Context(), claims.JTI)
if err != nil {
slog.Debug("abs refresh: jti lookup failed", "jti", claims.JTI, "err", err)
http.Error(w, "refresh token revoked", http.StatusUnauthorized)
return
}
if row.RevokedAt != nil {
http.Error(w, "refresh token revoked", http.StatusUnauthorized)
return
}
accessTTL, err := h.deps.Config.AccessTTL(r.Context())
if err != nil || accessTTL == 0 {
accessTTL = 24 * time.Hour
}
refreshTTL, err := h.deps.Config.RefreshTTL(r.Context())
if err != nil || refreshTTL == 0 {
refreshTTL = 30 * 24 * time.Hour
}
newAccessJTI := ulid.Make().String()
newRefreshJTI := ulid.Make().String()
access, err := IssueAccessToken(secret, claims.UserID, claims.ProfileID, newAccessJTI, accessTTL)
if err != nil {
http.Error(w, "token mint failed: "+err.Error(), http.StatusInternalServerError)
return
}
refresh, err := IssueRefreshToken(secret, claims.UserID, claims.ProfileID, newRefreshJTI, refreshTTL)
if err != nil {
http.Error(w, "token mint failed: "+err.Error(), http.StatusInternalServerError)
return
}
now := time.Now()
if err := h.deps.TokenStore.InsertToken(r.Context(), ABSToken{
ID: newAccessJTI, UserID: claims.UserID, ProfileID: claims.ProfileID,
JTI: newAccessJTI, ExpiresAt: now.Add(accessTTL),
}); err != nil {
http.Error(w, "token persist failed: "+err.Error(), http.StatusInternalServerError)
return
}
if err := h.deps.TokenStore.InsertToken(r.Context(), ABSToken{
ID: newRefreshJTI, UserID: claims.UserID, ProfileID: claims.ProfileID,
JTI: newRefreshJTI, ExpiresAt: now.Add(refreshTTL),
}); err != nil {
http.Error(w, "token persist failed: "+err.Error(), http.StatusInternalServerError)
return
}
if err := h.deps.TokenStore.RevokeTokenByJTI(r.Context(), claims.JTI); err != nil {
http.Error(w, "token rotation failed: "+err.Error(), http.StatusInternalServerError)
return
}
slog.Debug("abs refresh: rotated", "user", claims.UserID,
"old_jti", claims.JTI, "new_access_jti", newAccessJTI, "new_refresh_jti", newRefreshJTI)
writeJSON(w, http.StatusOK, map[string]any{
"user": map[string]any{
"id": claims.UserID,
"accessToken": access,
"refreshToken": refresh,
},
"accessToken": access,
"refreshToken": refresh,
})
}
@@ -0,0 +1,172 @@
package abs
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// memTokenStore is an in-memory TokenStore for handleRefresh tests.
type memTokenStore struct {
mu sync.Mutex
tokens map[string]ABSToken
}
func newMemTokenStore() *memTokenStore { return &memTokenStore{tokens: map[string]ABSToken{}} }
func (m *memTokenStore) InsertToken(_ context.Context, tok ABSToken) error {
m.mu.Lock()
defer m.mu.Unlock()
m.tokens[tok.JTI] = tok
return nil
}
func (m *memTokenStore) GetTokenByJTI(_ context.Context, jti string) (ABSToken, error) {
m.mu.Lock()
defer m.mu.Unlock()
t, ok := m.tokens[jti]
if !ok {
return ABSToken{}, ErrNotFound
}
return t, nil
}
func (m *memTokenStore) RevokeTokenByJTI(_ context.Context, jti string) error {
m.mu.Lock()
defer m.mu.Unlock()
t, ok := m.tokens[jti]
if !ok {
return nil
}
now := time.Now()
t.RevokedAt = &now
m.tokens[jti] = t
return nil
}
func (m *memTokenStore) TouchToken(_ context.Context, _ string) error { return nil }
// staticConfig satisfies ConfigProvider with fixed values.
type staticConfig struct{ secret []byte }
func (s *staticConfig) JWTSecret(_ context.Context) ([]byte, error) { return s.secret, nil }
func (s *staticConfig) AccessTTL(_ context.Context) (time.Duration, error) { return 24 * time.Hour, nil }
func (s *staticConfig) RefreshTTL(_ context.Context) (time.Duration, error) { return 30 * 24 * time.Hour, nil }
func (s *staticConfig) StandaloneLoginEnabled(_ context.Context) (bool, error) { return true, nil }
func newRefreshTestHandler(t *testing.T) (*Handler, *memTokenStore, *staticConfig) {
t.Helper()
store := newMemTokenStore()
cfg := &staticConfig{secret: []byte("test-secret-32-bytes-aaaaaaaaaaaaa")}
h := New(Dependencies{
Config: cfg,
TokenStore: store,
})
return h, store, cfg
}
func mintAndPersistRefresh(t *testing.T, store *memTokenStore, cfg *staticConfig, userID string) (string, string) {
t.Helper()
jti := "test-refresh-jti-" + userID
refresh, err := IssueRefreshToken(cfg.secret, userID, "", jti, 30*24*time.Hour)
if err != nil {
t.Fatalf("mint refresh: %v", err)
}
if err := store.InsertToken(context.Background(), ABSToken{
ID: jti, UserID: userID, JTI: jti, ExpiresAt: time.Now().Add(30 * 24 * time.Hour),
}); err != nil {
t.Fatalf("insert: %v", err)
}
return refresh, jti
}
func TestHandleRefresh_HeaderToken_RotatesAndReturnsBothForms(t *testing.T) {
h, store, cfg := newRefreshTestHandler(t)
refresh, oldJTI := mintAndPersistRefresh(t, store, cfg, "42")
req := httptest.NewRequest(http.MethodPost, "/auth/refresh", nil)
req.Header.Set("x-refresh-token", refresh)
rec := httptest.NewRecorder()
h.handleRefresh(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp["accessToken"] == "" || resp["accessToken"] == nil {
t.Errorf("top-level accessToken missing")
}
user, ok := resp["user"].(map[string]any)
if !ok {
t.Fatalf("user object missing")
}
if user["accessToken"] == "" || user["accessToken"] == nil {
t.Errorf("user.accessToken missing")
}
old, _ := store.GetTokenByJTI(context.Background(), oldJTI)
if old.RevokedAt == nil {
t.Errorf("old refresh JTI %s was not revoked", oldJTI)
}
}
func TestHandleRefresh_BodyToken_Works(t *testing.T) {
h, store, cfg := newRefreshTestHandler(t)
refresh, _ := mintAndPersistRefresh(t, store, cfg, "1")
body := bytes.NewBufferString(`{"refreshToken":"` + refresh + `"}`)
req := httptest.NewRequest(http.MethodPost, "/auth/refresh", body)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.handleRefresh(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
}
func TestHandleRefresh_NoToken_400(t *testing.T) {
h, _, _ := newRefreshTestHandler(t)
req := httptest.NewRequest(http.MethodPost, "/auth/refresh", nil)
rec := httptest.NewRecorder()
h.handleRefresh(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
func TestHandleRefresh_AccessTokenRejected(t *testing.T) {
h, store, cfg := newRefreshTestHandler(t)
jti := "an-access-jti"
access, _ := IssueAccessToken(cfg.secret, "9", "", jti, time.Hour)
_ = store.InsertToken(context.Background(), ABSToken{ID: jti, UserID: "9", JTI: jti, ExpiresAt: time.Now().Add(time.Hour)})
req := httptest.NewRequest(http.MethodPost, "/auth/refresh", strings.NewReader(""))
req.Header.Set("x-refresh-token", access)
rec := httptest.NewRecorder()
h.handleRefresh(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401; body=%s", rec.Code, rec.Body.String())
}
}
func TestHandleRefresh_RevokedTokenRejected(t *testing.T) {
h, store, cfg := newRefreshTestHandler(t)
refresh, oldJTI := mintAndPersistRefresh(t, store, cfg, "7")
_ = store.RevokeTokenByJTI(context.Background(), oldJTI)
req := httptest.NewRequest(http.MethodPost, "/auth/refresh", nil)
req.Header.Set("x-refresh-token", refresh)
rec := httptest.NewRecorder()
h.handleRefresh(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rec.Code)
}
}