diff --git a/internal/audiobooks/abs/handler.go b/internal/audiobooks/abs/handler.go index 50178486..b45ae408 100644 --- a/internal/audiobooks/abs/handler.go +++ b/internal/audiobooks/abs/handler.go @@ -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 diff --git a/internal/audiobooks/abs/login.go b/internal/audiobooks/abs/login.go index 6a0224f3..a022de3d 100644 --- a/internal/audiobooks/abs/login.go +++ b/internal/audiobooks/abs/login.go @@ -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, + }) +} diff --git a/internal/audiobooks/abs/login_refresh_test.go b/internal/audiobooks/abs/login_refresh_test.go new file mode 100644 index 00000000..7870fa3c --- /dev/null +++ b/internal/audiobooks/abs/login_refresh_test.go @@ -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) + } +}