From 2fc2adadd1dac8c6de287dadb74e50c1f33d27e4 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Fri, 24 Apr 2026 12:22:26 +0100 Subject: [PATCH] feat(auth): AuthenticateContext + InvalidateToken for long-running syncs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two helpers tailored for cron-driven or watcher-style clients: - AuthenticateContext(ctx) — cancellable variant of Authenticate(). Bypasses the non-context Query() path and POSTs /api/2.0/authentication.json directly, so a stalled auth call never outlives the caller's deadline. - InvalidateToken() — zeroes the cached *Token. Next request (or Authenticate*) forces a fresh auth. Intended for mid-sync 401 recovery when the server has revoked/rotated the session while local Expires still looks fresh. Plain Authenticate() is unchanged; it remains a convenience wrapper. Unit tests cover cache-hit, forced refresh, and context-cancellation paths. Refs eSlider/inventar-sync#3, ASR-0008. Made-with: Cursor --- CHANGELOG.md | 16 +++++++++++ httpx.go | 55 +++++++++++++++++++++++++++++++++++++ httpx_test.go | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e22f188..00295e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.3.1] - 2026-04-24 + +### Added + +- `AuthenticateContext(ctx)` — context-aware auth that honours cancellation and + deadlines. Preferred entry point for long-running syncs (cron, watchers). +- `InvalidateToken()` — clears the cached token to force re-auth on the next + request. Use this to recover from a mid-sync 401 when the server has revoked + the session while the local `Expires` timestamp still looks fresh. + +### Notes + +- Plain `Authenticate()` is unchanged and remains a convenience wrapper around + `AuthenticateContext(context.Background())`. +- No breaking changes; a patch release. + ## [0.3.0] - 2026-04-24 ### Added diff --git a/httpx.go b/httpx.go index ccd5307..8727b72 100644 --- a/httpx.go +++ b/httpx.go @@ -45,8 +45,63 @@ func (c *Client) authHeader() (string, error) { // Authenticate validates credentials and primes the token. Library users may // call this eagerly to surface auth errors at startup; otherwise the token is // fetched lazily on the first request. +// +// Prefer AuthenticateContext in long-running jobs — it honours cancellation. func (c *Client) Authenticate() error { return c.ensureToken() } +// AuthenticateContext is the context-aware variant of Authenticate. If the +// cached token is still valid it returns immediately; otherwise it performs a +// POST to /api/2.0/authentication.json that is cancellable via ctx. +// +// This is the recommended entry point for long-running syncs (cron, watchers) +// because it guarantees that a stalled auth call will not block the caller +// past its deadline. +func (c *Client) AuthenticateContext(ctx context.Context) error { + if c.token != nil && !time.Time(c.token.Expires).Before(time.Now()) { + return nil + } + body, err := json.Marshal(c.credentials) + if err != nil { + return fmt.Errorf("marshal credentials: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+"/api/2.0/authentication.json", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := c.client.Do(req) + if err != nil { + return fmt.Errorf("auth request: %w", err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode >= 400 { + return fmt.Errorf("auth: %d %s", resp.StatusCode, truncate(string(raw), 400)) + } + var env struct { + Response *Token `json:"response"` + } + if err := json.Unmarshal(raw, &env); err != nil { + return fmt.Errorf("auth decode: %w", err) + } + if env.Response == nil || env.Response.Value == "" { + return fmt.Errorf("auth: empty token in response") + } + c.token = env.Response + return nil +} + +// InvalidateToken clears the cached authentication token. The next request +// (or call to Authenticate / AuthenticateContext) will re-authenticate. +// +// Use this to recover from a mid-sync 401 when the server has revoked or +// rotated the session while the Expires timestamp still looks fresh locally. +func (c *Client) InvalidateToken() { c.token = nil } + // baseURL returns the configured base URL without trailing slash. func (c *Client) baseURL() string { return strings.TrimRight(c.credentials.Url, "/") diff --git a/httpx_test.go b/httpx_test.go index 1aadb44..fabf482 100644 --- a/httpx_test.go +++ b/httpx_test.go @@ -192,3 +192,79 @@ func TestResponseFieldMissing(t *testing.T) { t.Fatal("expected error on missing field") } } + +func TestAuthenticateContextUsesCacheWhenFresh(t *testing.T) { + var authHits int + mux := http.NewServeMux() + mux.HandleFunc("/api/2.0/authentication.json", func(w http.ResponseWriter, r *http.Request) { + authHits++ + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"response":{"token":"FRESH_TOKEN","expires":"2099-01-01T00:00:00.0000000-00:00"}}`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + c := NewClient(Credentials{Url: srv.URL, User: "u", Password: "p"}) + + if err := c.AuthenticateContext(context.Background()); err != nil { + t.Fatalf("first AuthenticateContext: %v", err) + } + if authHits != 1 { + t.Errorf("expected 1 auth hit after first call, got %d", authHits) + } + if c.token == nil || c.token.Value != "FRESH_TOKEN" { + t.Errorf("token not cached: %+v", c.token) + } + if err := c.AuthenticateContext(context.Background()); err != nil { + t.Fatalf("second AuthenticateContext: %v", err) + } + if authHits != 1 { + t.Errorf("cache bypassed: expected 1 auth hit, got %d", authHits) + } +} + +func TestInvalidateTokenForcesReauth(t *testing.T) { + var authHits int + mux := http.NewServeMux() + mux.HandleFunc("/api/2.0/authentication.json", func(w http.ResponseWriter, r *http.Request) { + authHits++ + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"response":{"token":"T","expires":"2099-01-01T00:00:00.0000000-00:00"}}`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + c := NewClient(Credentials{Url: srv.URL, User: "u", Password: "p"}) + + if err := c.AuthenticateContext(context.Background()); err != nil { + t.Fatalf("auth: %v", err) + } + c.InvalidateToken() + if c.token != nil { + t.Fatalf("token still cached after Invalidate: %+v", c.token) + } + if err := c.AuthenticateContext(context.Background()); err != nil { + t.Fatalf("re-auth: %v", err) + } + if authHits != 2 { + t.Errorf("expected 2 auth hits after invalidate, got %d", authHits) + } +} + +func TestAuthenticateContextRespectsCancellation(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/2.0/authentication.json", func(w http.ResponseWriter, r *http.Request) { + select { + case <-r.Context().Done(): + return + case <-time.After(2 * time.Second): + _, _ = io.WriteString(w, `{"response":{"token":"T","expires":"2099-01-01T00:00:00.0000000-00:00"}}`) + } + }) + srv := httptest.NewServer(mux) + defer srv.Close() + c := NewClient(Credentials{Url: srv.URL, User: "u", Password: "p"}) + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + if err := c.AuthenticateContext(ctx); err == nil { + t.Fatal("expected error on context timeout") + } +}