Files
kandev/apps/backend/internal/agentctl/server/api/handshake_integration_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

146 lines
4.5 KiB
Go

package api
import (
"context"
"fmt"
"net/http/httptest"
"strings"
"testing"
agentctl "github.com/kandev/kandev/internal/agentctl/client"
"github.com/kandev/kandev/internal/agentctl/server/config"
"github.com/kandev/kandev/internal/common/logger"
)
// TestHandshakeIntegration_FullFlow exercises the complete client↔server
// handshake flow: nonce-based auth → token retrieval → authenticated requests.
// No Docker, no agent binaries — runs in CI as a regular test.
func TestHandshakeIntegration_FullFlow(t *testing.T) {
// 1. Set up a ControlServer with a bootstrap nonce (simulating Docker/Sprites mode)
cfg := &config.Config{
AuthToken: "test-generated-token-xyz",
BootstrapNonce: "integration-test-nonce-abc123",
}
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
cs := NewControlServer(cfg, nil, log)
server := httptest.NewServer(cs.Router())
defer server.Close()
// Parse host:port from test server URL
host, port := parseHostPort(t, server.URL)
// 2. Create a ControlClient with NO auth token (simulating fresh backend)
client := agentctl.NewControlClient(host, port, log)
ctx := context.Background()
// 3. Health check should work without auth
if err := client.Health(ctx); err != nil {
t.Fatalf("health check should work without auth: %v", err)
}
// 4. Authenticated endpoint should be REJECTED (no token yet)
_, err := client.ListInstances(ctx)
if err == nil {
t.Fatal("expected error for unauthenticated request, got nil")
}
// 5. Handshake with correct nonce — should return token
token, err := client.Handshake(ctx, "integration-test-nonce-abc123")
if err != nil {
t.Fatalf("handshake failed: %v", err)
}
if token == "" {
t.Fatal("handshake returned empty token")
}
// 6. After handshake, authenticated requests should WORK
// (ListInstances will return an error because instMgr is nil,
// but it should NOT be a 401 — it should get past auth)
_, err = client.ListInstances(ctx)
if err != nil {
// If the error mentions "unauthorized" or "auth", auth is still failing
if containsAuthError(err.Error()) {
t.Fatalf("request after handshake still rejected by auth: %v", err)
}
// Any other error (e.g., nil instMgr panic recovery) is fine —
// it means auth passed and we hit the handler
}
// 7. Second handshake with same nonce should FAIL (burned)
_, err = client.Handshake(ctx, "integration-test-nonce-abc123")
if err == nil {
t.Fatal("second handshake should fail (nonce burned)")
}
// 8. Handshake with wrong nonce should also fail
_, err = client.Handshake(ctx, "wrong-nonce")
if err == nil {
t.Fatal("handshake with wrong nonce should fail")
}
}
// TestHandshakeIntegration_StandaloneMode verifies that when AuthToken is set
// directly (standalone mode, no nonce), normal Bearer auth works.
func TestHandshakeIntegration_StandaloneMode(t *testing.T) {
cfg := &config.Config{
AuthToken: "standalone-secret-token",
// No BootstrapNonce — standalone mode
}
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
cs := NewControlServer(cfg, nil, log)
server := httptest.NewServer(cs.Router())
defer server.Close()
host, port := parseHostPort(t, server.URL)
ctx := context.Background()
// Without auth token: rejected
noAuthClient := agentctl.NewControlClient(host, port, log)
_, err := noAuthClient.ListInstances(ctx)
if err == nil {
t.Fatal("expected rejection without auth token")
}
// With correct auth token: passes auth
authClient := agentctl.NewControlClient(host, port, log,
agentctl.WithControlAuthToken("standalone-secret-token"))
_, err = authClient.ListInstances(ctx)
if err != nil && containsAuthError(err.Error()) {
t.Fatalf("request with correct token rejected: %v", err)
}
// Handshake should fail (no nonce configured)
_, err = noAuthClient.Handshake(ctx, "any-nonce")
if err == nil {
t.Fatal("handshake should fail when no nonce is configured")
}
}
func parseHostPort(t *testing.T, url string) (string, int) {
t.Helper()
var host string
var port int
// httptest URLs look like "http://127.0.0.1:PORT"
n, err := fmt.Sscanf(url, "http://%s", &host)
if err != nil || n != 1 {
t.Fatalf("failed to parse test server URL %q", url)
}
// Split host:port
for i := len(host) - 1; i >= 0; i-- {
if host[i] == ':' {
_, _ = fmt.Sscanf(host[i+1:], "%d", &port)
host = host[:i]
break
}
}
return host, port
}
func containsAuthError(s string) bool {
lower := strings.ToLower(s)
return strings.Contains(lower, "unauthorized") || strings.Contains(lower, "401")
}