mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
* MUL-2744: feat(auth): auto-renew daemon PAT in-place within 7-day window Daemons currently hold a 90-day PAT and have no renewal path: once the token's expires_at passes, every request 401s and the user has to find the silent failure in the daemon log and re-run `multica login`. This adds an in-place renewal: - New `POST /api/tokens/current/renew` (Auth-protected, mul_ only). The server checks remaining lifetime: ≥ 7 days is a no-op; < 7 days bumps expires_at to now + 90 days via a guarded UPDATE that makes concurrent renews idempotent (the WHERE expires_at < $2 clause means only one writer wins; the loser sees pgx.ErrNoRows and reports the already- extended value). No raw token rotation — the same secret stays in every CLI/daemon process sharing the config. - Daemon-side `tokenRenewalLoop`: fires once on startup (covers machine-was-off cases) and then every 3 days. With a 7-day server threshold this gives at least two renewal attempts before the window closes, so a single network blip can't push the token out. - 401 fallback: when the renew call comes back 401 (token already revoked/expired), the daemon logs a user-actionable WARN telling the operator to run `multica login` — instead of the current silent failure mode. Loop keeps running so the warning repeats until fixed. PAT cache (auth.AuthCacheTTL = 10m) doesn't need invalidation: the next miss after the UPDATE re-reads the row and re-caches with the bumped TTL automatically. Co-authored-by: multica-agent <github@multica.ai> * MUL-2744: fix(auth): renew PAT before first sync; CAS against renewal threshold Addresses the two issues Elon raised on #3360. Must-fix: if the PAT is already revoked/expired when the daemon starts, syncWorkspacesFromAPI 401s and Run returns before the background tokenRenewalLoop ever fires its initial renewal. The operator only sees a generic auth failure in the workspace-sync log with no hint that 'multica login' is the fix. Now the startup path runs an inline tryRenewToken first, surfacing the existing 401 WARN before anything else gets a chance to fail. Pulled the renew + first-sync pair into preflightAuth so the ordering invariant is enforced at one site and tests can exercise the failure modes without spinning up the full Run setup. Removed the redundant initial tryRenewToken from tokenRenewalLoop — startup now owns the first call. Nit: the previous WHERE clause on ExtendPersonalAccessTokenExpiry (expires_at < $2) did not actually make concurrent renews idempotent the way the comment claimed. Two callers race-computing $2 = now + 90d produce strictly-different values, and the second writer's $2 always exceeds the row the first writer just wrote, so the UPDATE re-matches and bumps again. Switched to a CAS against the renewal threshold (expires_at <= $renew_threshold_at, i.e. now + 7d): once writer A pushes expires_at past the threshold, writer B's UPDATE matches zero rows and the loser falls back to reporting the already-extended value as a no-op. Tests: - TestPreflightAuth_RenewsBeforeWorkspaceSyncOnExpiredToken locks in the call ordering — renew endpoint is hit before workspaces, and the re-login WARN appears even though both endpoints 401. - TestPreflightAuth_SyncProceedsWhenRenewIsNoOp covers steady-state startup: a renew=false no-op must still progress to workspace sync. - TestPreflightAuth_TransientRenewFailureDoesNotBlockStartup covers a 500 from the renew endpoint — startup must continue, no WARN. - TestRenewPAT_ParallelRenewExtendsExactlyOnce fires N=8 concurrent renews at one row and asserts exactly one returns renewed=true with the others reporting the same already-extended expires_at, plus the DB carries only that single bumped value. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
326 lines
10 KiB
Go
326 lines
10 KiB
Go
package daemon
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
)
|
|
|
|
// captureLogger returns a *slog.Logger whose output lands in buf, so tests
|
|
// can assert on the daemon's user-facing warning text without scraping
|
|
// stderr.
|
|
func captureLogger(buf *bytes.Buffer) *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
|
}
|
|
|
|
func TestClient_RenewToken_PostsToCorrectEndpoint(t *testing.T) {
|
|
var called atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
called.Add(1)
|
|
if r.Method != http.MethodPost {
|
|
t.Errorf("expected POST, got %s", r.Method)
|
|
}
|
|
if r.URL.Path != "/api/tokens/current/renew" {
|
|
t.Errorf("expected /api/tokens/current/renew, got %s", r.URL.Path)
|
|
}
|
|
if got := r.Header.Get("Authorization"); got != "Bearer mul_abc" {
|
|
t.Errorf("expected Bearer mul_abc, got %q", got)
|
|
}
|
|
// Body must be valid JSON — postJSON marshals an empty object when
|
|
// reqBody is a non-nil map[string]any{}.
|
|
var body map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Errorf("decode body: %v", err)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"expires_at": "2099-01-02T03:04:05Z",
|
|
"renewed": true,
|
|
})
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
c := NewClient(srv.URL)
|
|
c.SetToken("mul_abc")
|
|
|
|
resp, err := c.RenewToken(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("RenewToken: %v", err)
|
|
}
|
|
if called.Load() != 1 {
|
|
t.Fatalf("expected 1 server call, got %d", called.Load())
|
|
}
|
|
if !resp.Renewed {
|
|
t.Fatal("expected renewed=true")
|
|
}
|
|
if resp.ExpiresAt != "2099-01-02T03:04:05Z" {
|
|
t.Fatalf("expected expires_at to round-trip, got %q", resp.ExpiresAt)
|
|
}
|
|
}
|
|
|
|
func TestTryRenewToken_LogsRenewalOnSuccess(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"expires_at": "2099-01-02T03:04:05Z",
|
|
"renewed": true,
|
|
})
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
var buf bytes.Buffer
|
|
d := &Daemon{client: NewClient(srv.URL), logger: captureLogger(&buf)}
|
|
d.tryRenewToken(context.Background())
|
|
|
|
out := buf.String()
|
|
if !strings.Contains(out, "auth token renewed") {
|
|
t.Fatalf("expected 'auth token renewed' log, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, "2099-01-02T03:04:05Z") {
|
|
t.Fatalf("expected new expiry in log, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestTryRenewToken_LogsNotEligibleOnNoOp(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"expires_at": "2099-01-02T03:04:05Z",
|
|
"renewed": false,
|
|
})
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
var buf bytes.Buffer
|
|
d := &Daemon{client: NewClient(srv.URL), logger: captureLogger(&buf)}
|
|
d.tryRenewToken(context.Background())
|
|
|
|
out := buf.String()
|
|
// Non-renewal must NOT emit the warning that an operator would interpret
|
|
// as "something is wrong" — it's the normal steady-state for tokens with
|
|
// plenty of life left.
|
|
if strings.Contains(out, "WARN") {
|
|
t.Fatalf("no-op renewal should not log at WARN, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestTryRenewToken_SurfacesReloginWarningOn401(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"error":"invalid token"}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
var buf bytes.Buffer
|
|
d := &Daemon{client: NewClient(srv.URL), logger: captureLogger(&buf)}
|
|
d.tryRenewToken(context.Background())
|
|
|
|
out := buf.String()
|
|
if !strings.Contains(out, "level=WARN") {
|
|
t.Fatalf("401 must surface as WARN, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, "multica login") {
|
|
t.Fatalf("401 warning must tell the user to run 'multica login', got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestTryRenewToken_SurfacesReloginWarningOn401_WithProfile(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"error":"invalid token"}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
var buf bytes.Buffer
|
|
d := &Daemon{
|
|
client: NewClient(srv.URL),
|
|
logger: captureLogger(&buf),
|
|
cfg: Config{Profile: "staging"},
|
|
}
|
|
d.tryRenewToken(context.Background())
|
|
|
|
out := buf.String()
|
|
if !strings.Contains(out, "--profile staging") {
|
|
t.Fatalf("profile-aware login hint missing, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestTryRenewToken_TransientErrorIsDebugNotWarn(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte(`{"error":"db down"}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
var buf bytes.Buffer
|
|
d := &Daemon{client: NewClient(srv.URL), logger: captureLogger(&buf)}
|
|
d.tryRenewToken(context.Background())
|
|
|
|
out := buf.String()
|
|
// A 500 is transient — the next tick will retry, so the operator should
|
|
// NOT see a re-login warning that doesn't reflect the actual cause.
|
|
if strings.Contains(out, "level=WARN") {
|
|
t.Fatalf("transient 500 should not log at WARN, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, "token renewal failed") {
|
|
t.Fatalf("expected debug log about renewal failure, got: %s", out)
|
|
}
|
|
}
|
|
|
|
// TestPreflightAuth_RenewsBeforeWorkspaceSyncOnExpiredToken locks in the
|
|
// must-fix from MUL-2744 review: when the daemon starts with an already-
|
|
// revoked or expired PAT, the renewal call has to happen BEFORE the first
|
|
// workspace sync, because the workspace sync's 401 would short-circuit Run
|
|
// and the operator would never see a "run multica login" hint.
|
|
func TestPreflightAuth_RenewsBeforeWorkspaceSyncOnExpiredToken(t *testing.T) {
|
|
var mu sync.Mutex
|
|
var seen []string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
seen = append(seen, r.URL.Path)
|
|
mu.Unlock()
|
|
// Both endpoints 401 — this is the "PAT already revoked/expired
|
|
// before the daemon even started" failure mode.
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"error":"invalid token"}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
var buf bytes.Buffer
|
|
d := &Daemon{client: NewClient(srv.URL), logger: captureLogger(&buf)}
|
|
d.client.SetToken("mul_already_revoked")
|
|
|
|
err := d.preflightAuth(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected workspace sync to fail with 401")
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(seen) < 2 {
|
|
t.Fatalf("expected both endpoints to be called; got %v", seen)
|
|
}
|
|
if seen[0] != "/api/tokens/current/renew" {
|
|
t.Fatalf("renew must be the first API call so the WARN fires before the sync 401s; got order %v", seen)
|
|
}
|
|
if seen[1] != "/api/workspaces" {
|
|
t.Fatalf("workspace sync should follow renew; got order %v", seen)
|
|
}
|
|
out := buf.String()
|
|
if !strings.Contains(out, "level=WARN") {
|
|
t.Fatalf("expected re-login WARN, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, "multica login") {
|
|
t.Fatalf("expected the actionable 'run multica login' hint in the WARN, got: %s", out)
|
|
}
|
|
}
|
|
|
|
// TestPreflightAuth_SyncProceedsWhenRenewIsNoOp covers the steady-state
|
|
// startup: a PAT well outside the renewal window returns renewed=false,
|
|
// and preflightAuth must still go on to do the workspace sync. The
|
|
// renewal is best-effort and must not gate startup.
|
|
func TestPreflightAuth_SyncProceedsWhenRenewIsNoOp(t *testing.T) {
|
|
var syncCalled atomic.Bool
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/tokens/current/renew":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"expires_at": "2099-01-02T03:04:05Z",
|
|
"renewed": false,
|
|
})
|
|
case "/api/workspaces":
|
|
syncCalled.Store(true)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`[]`))
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
var buf bytes.Buffer
|
|
d := &Daemon{client: NewClient(srv.URL), logger: captureLogger(&buf)}
|
|
d.client.SetToken("mul_healthy")
|
|
|
|
if err := d.preflightAuth(context.Background()); err != nil {
|
|
t.Fatalf("preflightAuth returned error on healthy startup: %v", err)
|
|
}
|
|
if !syncCalled.Load() {
|
|
t.Fatal("preflightAuth must run the workspace sync after a no-op renewal")
|
|
}
|
|
}
|
|
|
|
// TestPreflightAuth_TransientRenewFailureDoesNotBlockStartup covers the
|
|
// "renewal endpoint is briefly down" path. The renewal failure must not
|
|
// kill the daemon — the workspace sync still happens, and the daemon is
|
|
// up and serving. The background renewal loop will retry later.
|
|
func TestPreflightAuth_TransientRenewFailureDoesNotBlockStartup(t *testing.T) {
|
|
var syncCalled atomic.Bool
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/tokens/current/renew":
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte(`{"error":"db down"}`))
|
|
case "/api/workspaces":
|
|
syncCalled.Store(true)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`[]`))
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
var buf bytes.Buffer
|
|
d := &Daemon{client: NewClient(srv.URL), logger: captureLogger(&buf)}
|
|
d.client.SetToken("mul_healthy")
|
|
|
|
if err := d.preflightAuth(context.Background()); err != nil {
|
|
t.Fatalf("preflightAuth must not surface transient renew failures: %v", err)
|
|
}
|
|
if !syncCalled.Load() {
|
|
t.Fatal("transient renew failure must not skip the workspace sync")
|
|
}
|
|
if strings.Contains(buf.String(), "level=WARN") {
|
|
t.Fatalf("transient 500 must not emit the re-login WARN, got: %s", buf.String())
|
|
}
|
|
}
|
|
|
|
func TestTryRenewToken_RespectsContextTimeout(t *testing.T) {
|
|
// Server that never responds — the per-call 15s timeout inside
|
|
// tryRenewToken is too long for a unit test, so cancel the parent
|
|
// context immediately and verify the call returns.
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
<-r.Context().Done()
|
|
_, _ = io.Copy(io.Discard, r.Body)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
var buf bytes.Buffer
|
|
d := &Daemon{client: NewClient(srv.URL), logger: captureLogger(&buf)}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
d.tryRenewToken(ctx)
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
// Expected: tryRenewToken returns once the cancelled ctx propagates
|
|
// through the HTTP client.
|
|
case <-context.Background().Done():
|
|
}
|
|
}
|