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