Files
kandev/apps/backend/internal/agentctl/server/api/handshake_test.go
T
João Salavisa dccff23cc0 feat(backend): per-launch authentication for agentctl (#666)
* feat(backend): per-launch authentication for agentctl

Add nonce-based handshake protocol to secure all agentctl HTTP/WS
endpoints. On launch, the parent (backend) passes a one-time bootstrap
nonce via AGENTCTL_BOOTSTRAP_NONCE env var. agentctl generates its own
auth token, enables Bearer auth immediately, and returns the token to
the parent via POST /auth/handshake (validated by nonce).

Standalone, Docker, and Sprites executors all use the same handshake
flow. Removes the old AGENTCTL_AUTH_TOKEN env var approach. For
Docker/Sprites, the retrieved token is persisted encrypted in
SecretStore (AES-256-GCM) for crash recovery.

Security properties:
- Auth enabled from boot (no unprotected window)
- Token never in env vars (only the burned nonce)
- Nonce is one-shot (replay impossible)
- Constant-time token comparison (timing-safe)

* fix: check fmt.Sscanf return value in integration test

* fix: address review feedback - constant-time nonce comparison, safe ID truncation

* fix(backend): exempt MCP routes from agentctl auth middleware

The agent subprocess connects to agentctl's MCP endpoints (/sse, /message,
/mcp) to use kandev tools (create_task, ask_user_question, etc.). These
endpoints run on the same HTTP server as the authenticated API, but the
agent process doesn't possess the auth token — it was only exchanged
between the backend and agentctl during the handshake.

Exempt /sse, /message, and /mcp from the Bearer auth middleware since the
agent process runs in the same trust boundary as agentctl.

* refactor(backend): remove query param token fallback and document SetAuthToken constraint

Remove the ?token= query parameter fallback from extractBearerToken —
tokens in URLs leak via access logs, referer headers, and browser history.
All WS callers already use Authorization headers via wsAuthHeaders().

Document SetAuthToken's concurrency constraint: it must not be called
concurrently with HTTP requests on the same client.

* fix(backend): enforce mutex in ControlClient/Launcher auth, fix Start() lock scope

- Add sync.Mutex to ControlClient; protect SetAuthToken and AuthToken() reads
- Guard Launcher.AuthToken() with l.mu (written under lock in Start, read unprotected)
- Extract buildAndStartProcess and performHandshake from Start() (was 106 lines, over 80-line limit)
- Release l.mu before blocking health/handshake calls so monitorExit() can close
  l.exited — previously the fast-fail path in waitForHealthy was unreachable
- Update stale executor_backend.go comment (standalone no longer uses env var auth)

Co-authored-by: João Salavisa <undefined@users.noreply.github.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: João Salavisa <undefined@users.noreply.github.com>
2026-04-21 14:00:49 +01:00

137 lines
4.0 KiB
Go

package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/agentctl/server/config"
"github.com/kandev/kandev/internal/common/logger"
)
func setupHandshakeServer(cfg *config.Config) *ControlServer {
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
return NewControlServer(cfg, nil, log)
}
func TestHandshake_ValidNonce(t *testing.T) {
cfg := &config.Config{
AuthToken: "self-generated-token",
BootstrapNonce: "test-nonce-123",
}
cs := setupHandshakeServer(cfg)
body, _ := json.Marshal(map[string]string{"nonce": "test-nonce-123"})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/auth/handshake", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
cs.router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp["token"] != "self-generated-token" {
t.Fatalf("expected self-generated-token, got %q", resp["token"])
}
}
func TestHandshake_NonceBurnedAfterUse(t *testing.T) {
cfg := &config.Config{
AuthToken: "token-abc",
BootstrapNonce: "one-time-nonce",
}
cs := setupHandshakeServer(cfg)
// First handshake succeeds
body, _ := json.Marshal(map[string]string{"nonce": "one-time-nonce"})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/auth/handshake", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
cs.router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("first handshake: expected 200, got %d", w.Code)
}
// Second handshake with same nonce fails (burned)
w2 := httptest.NewRecorder()
req2 := httptest.NewRequest("POST", "/auth/handshake", bytes.NewReader(body))
req2.Header.Set("Content-Type", "application/json")
cs.router.ServeHTTP(w2, req2)
if w2.Code != http.StatusForbidden {
t.Fatalf("second handshake: expected 403, got %d", w2.Code)
}
}
func TestHandshake_WrongNonce(t *testing.T) {
cfg := &config.Config{
AuthToken: "token-abc",
BootstrapNonce: "correct-nonce",
}
cs := setupHandshakeServer(cfg)
body, _ := json.Marshal(map[string]string{"nonce": "wrong-nonce"})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/auth/handshake", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
cs.router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d", w.Code)
}
}
func TestHandshake_MissingNonce(t *testing.T) {
cfg := &config.Config{
AuthToken: "token-abc",
BootstrapNonce: "some-nonce",
}
cs := setupHandshakeServer(cfg)
body, _ := json.Marshal(map[string]string{})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/auth/handshake", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
cs.router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestHandshake_ExemptFromAuth(t *testing.T) {
// When auth is enabled, /auth/handshake should still be accessible without a token
cfg := &config.Config{
AuthToken: "self-generated-token",
BootstrapNonce: "test-nonce",
}
cs := setupHandshakeServer(cfg)
// First verify that a protected endpoint requires auth
w1 := httptest.NewRecorder()
req1 := httptest.NewRequest("GET", "/api/v1/instances", nil)
cs.router.ServeHTTP(w1, req1)
if w1.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for protected endpoint, got %d", w1.Code)
}
// But handshake should work without auth
body, _ := json.Marshal(gin.H{"nonce": "test-nonce"})
w2 := httptest.NewRecorder()
req2 := httptest.NewRequest("POST", "/auth/handshake", bytes.NewReader(body))
req2.Header.Set("Content-Type", "application/json")
cs.router.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("expected 200 for handshake (exempt), got %d: %s", w2.Code, w2.Body.String())
}
}