Files
NexusGate/agent-go/sessiontoken_test.go
T
aaaamoooo 408b03ee68 feat(agent-auth): session tokens replace per-request scrypt (item 25)
Panel: after one scrypt-verified key auth, issue a random 24h
session token; store only its SHA-256 hash + expiry on the agent
record. authenticateAgent tries the token path first (constant-time
hash compare, active + unexpired), then the original fingerprint /
legacy VerifySecret path, and hands fresh tokens out via the
X-Agent-Session-Token response headers. Revoke() now clears token
material at all three revocation sites.

Agent: persist the token in the config dir (0600, temp+rename like
keys.json), present it as bearer, and on any 401 clear it and retry
once with the key (which re-issues). Old panels never send the
headers, so key-only operation is unchanged; old agents ignore the
headers. Version stays 0.6.8.

Tests: panel — issuance, hash-only storage, scrypt-free token path
(sabotaged KeyHash still authenticates), expiry fallback + rotation,
hash-mismatch fallback, revocation, per-record binding, key-only
rotation. Agent — capture/persist/reuse against a fake panel, 401
fallback and re-issue, old-panel key-only, client-side expiry,
double rejection surfacing without retry loops.
2026-10-21 13:12:19 +00:00

170 lines
5.6 KiB
Go

package main
// Item 25: agent-side session token handling. A fake panel (httptest)
// issues tokens via response headers exactly like the real one; the
// tests pin down capture, persistence, reuse, the 401 fallback to the
// key, and old-panel behavior (no headers → key on every request).
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
// fakePanel records the bearer of every request and answers like the
// real panel: the key always authenticates (and issues issueToken
// when non-empty), the current valid token authenticates, anything
// else is a 401.
type fakePanel struct {
key string
validToken string
issueToken string
bearers []string
}
func (p *fakePanel) handler(w http.ResponseWriter, r *http.Request) {
bearer := ""
if h := r.Header.Get("Authorization"); len(h) > len("Bearer ") {
bearer = h[len("Bearer "):]
}
p.bearers = append(p.bearers, bearer)
switch bearer {
case p.key:
if p.issueToken != "" {
w.Header().Set("X-Agent-Session-Token", p.issueToken)
w.Header().Set("X-Agent-Session-Token-Expires", time.Now().Add(24*time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"))
}
w.WriteHeader(200)
fmt.Fprint(w, `{"ok":true}`)
case p.validToken:
w.WriteHeader(200)
fmt.Fprint(w, `{"ok":true}`)
default:
w.WriteHeader(401)
fmt.Fprint(w, `{"message":"Agent 身份校验失败"}`)
}
}
func setupTokenAgent(t *testing.T, panel *fakePanel) func() {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(panel.handler))
oldCfg := cfg
cfg.Controller = srv.URL
cfg.AgentKey = panel.key
cfg.SessionTokenFile = filepath.Join(t.TempDir(), "session-token.json")
sessionTokenState.mu.Lock()
oldState := sessionTokenState.value
oldLoaded := sessionTokenState.loaded
sessionTokenState.value = nil
sessionTokenState.loaded = false
sessionTokenState.mu.Unlock()
return func() {
srv.Close()
cfg = oldCfg
sessionTokenState.mu.Lock()
sessionTokenState.value = oldState
sessionTokenState.loaded = oldLoaded
sessionTokenState.mu.Unlock()
}
}
func TestSessionTokenCapturedPersistedAndReused(t *testing.T) {
panel := &fakePanel{key: "the-key", issueToken: "tok-1", validToken: "tok-1"}
defer setupTokenAgent(t, panel)()
if _, err := request("/api/agent/poll", []byte("{}")); err != nil {
t.Fatalf("first request: %v", err)
}
if _, err := request("/api/agent/poll", []byte("{}")); err != nil {
t.Fatalf("second request: %v", err)
}
want := []string{"the-key", "tok-1"}
if fmt.Sprint(panel.bearers) != fmt.Sprint(want) {
t.Fatalf("bearers = %v, want %v", panel.bearers, want)
}
// Persisted with keys.json-grade permissions.
info, err := os.Stat(cfg.SessionTokenFile)
if err != nil {
t.Fatalf("token file not persisted: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("token file perm = %o, want 600", perm)
}
// And it survives a cold start: reset memory, it must load from disk.
sessionTokenState.mu.Lock()
sessionTokenState.value = nil
sessionTokenState.loaded = false
sessionTokenState.mu.Unlock()
if tok := currentSessionToken(); tok == nil || tok.Token != "tok-1" {
t.Fatalf("token not reloaded from disk: %+v", tok)
}
}
func TestSessionToken401FallsBackToKeyAndReissues(t *testing.T) {
panel := &fakePanel{key: "the-key", issueToken: "tok-2", validToken: "tok-2"}
defer setupTokenAgent(t, panel)()
// Plant a stale token the panel no longer honors.
storeSessionToken("tok-stale", time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"))
if _, err := request("/api/agent/poll", []byte("{}")); err != nil {
t.Fatalf("request with stale token: %v", err)
}
want := []string{"tok-stale", "the-key"}
if fmt.Sprint(panel.bearers) != fmt.Sprint(want) {
t.Fatalf("bearers = %v, want %v (token attempt, then key retry)", panel.bearers, want)
}
if tok := currentSessionToken(); tok == nil || tok.Token != "tok-2" {
t.Fatalf("fresh token not stored after retry: %+v", tok)
}
}
func TestSessionTokenAbsentKeepsKeyAuth(t *testing.T) {
// Old panel: never issues tokens.
panel := &fakePanel{key: "the-key"}
defer setupTokenAgent(t, panel)()
for i := 0; i < 2; i++ {
if _, err := request("/api/agent/poll", []byte("{}")); err != nil {
t.Fatalf("request %d: %v", i, err)
}
}
if fmt.Sprint(panel.bearers) != "[the-key the-key]" {
t.Fatalf("bearers = %v, want key on every request", panel.bearers)
}
if _, err := os.Stat(cfg.SessionTokenFile); !os.IsNotExist(err) {
t.Fatalf("no token file expected against an old panel, stat err = %v", err)
}
}
func TestSessionTokenExpiredClientSideUsesKey(t *testing.T) {
panel := &fakePanel{key: "the-key", issueToken: "tok-3", validToken: "tok-3"}
defer setupTokenAgent(t, panel)()
storeSessionToken("tok-old", time.Now().Add(-time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"))
if _, err := request("/api/agent/poll", []byte("{}")); err != nil {
t.Fatalf("request: %v", err)
}
if len(panel.bearers) != 1 || panel.bearers[0] != "the-key" {
t.Fatalf("bearers = %v, want the key used directly (no doomed token attempt)", panel.bearers)
}
}
func TestSessionTokenKeyRejectedSurfacesError(t *testing.T) {
panel := &fakePanel{key: "the-real-key"}
defer setupTokenAgent(t, panel)()
cfg.AgentKey = "wrong-key"
storeSessionToken("tok-stale", time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"))
if _, err := request("/api/agent/poll", []byte("{}")); err == nil {
t.Fatal("expected an error when both token and key are rejected")
}
// Exactly two attempts: token, then key — never a loop.
if len(panel.bearers) != 2 {
t.Fatalf("attempts = %v, want exactly 2", panel.bearers)
}
if tok := currentSessionToken(); tok != nil {
t.Fatalf("stale token should have been cleared, still held: %+v", tok)
}
}