Files
diskless-workstation/serving/fleet-manager/auth_test.go
T
Audiobuzz c40dd5ac93 fleet-manager: checkpoint 5 — admin WebAuthn/passkey auth (replaces browser mTLS)
The human->UI hop now authenticates with a hardware passkey (YubiKey), not an
mTLS client cert: phishing-resistant, origin-bound, better UX. (manager->broker
stays mTLS — service-to-service.)

- webauthn.go: credential store (registered admins + passkeys + a one-time-token
  jti replay ledger; JSON file, atomic writes) + the webauthn.User impl + RP setup.
- cli.go: 'fleet-manager register-admin <name>' mints a short-lived HMAC-signed
  enrolment link over a shared enrol key; the browser completes the WebAuthn
  create ceremony. No anyone-can-register endpoint; single-use via the ledger.
- authapi.go: register/login ceremonies (usernameless discoverable login,
  userVerification required) + logout + whoami; short-lived in-memory ceremony
  store between begin/finish.
- session.go: opaque server-side sessions (Secure/HttpOnly/SameSite=Strict cookie)
  + requireSession middleware gating /api/*, with a CSRF header check on mutations.
  adminName now comes from the session (audit attribution).
- main.go: register-admin subcommand dispatch; admin TLS switched to SERVER-AUTH
  (no client cert) with a HOT-RELOADING cert (certReloader) so a Let's Encrypt
  DNS-01 renewal is picked up without a restart. RP = diskless-server.bitasylum.org.
  /api/* mounted behind requireSession; /auth/* + /healthz public.

Cert strategy (per design decision): the UI HTTPS cert is a publicly-trusted LE
cert via DNS-01 (mgmt-network UI isn't internet-reachable, so HTTP-01/TLS-ALPN-01
can't validate; DNS-01 proves control of bitasylum.org via TXT + supports
wildcards). External ACME client issues+renews to the cert path; manager reloads.

Deps: github.com/go-webauthn/webauthn (server-side). Tests: enrol-token
mint/verify/tamper/expiry, session middleware (401/403/CSRF/expiry), cred-store
persistence + jti ledger. build/test/vet/gofmt clean. Live passkey ceremony
(virtual authenticator / real YubiKey) is a bench item.
2026-07-04 04:26:30 +00:00

132 lines
3.7 KiB
Go

package main
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-webauthn/webauthn/webauthn"
)
func TestEnrollTokenRoundTrip(t *testing.T) {
key := []byte("0123456789abcdef0123456789abcdef")
tok, err := mintEnrollToken(key, "alice", 10*time.Minute)
if err != nil {
t.Fatalf("mint: %v", err)
}
claims, err := verifyEnrollToken(key, tok)
if err != nil {
t.Fatalf("verify: %v", err)
}
if claims.Name != "alice" || claims.JTI == "" {
t.Fatalf("claims = %+v", claims)
}
// wrong key rejected
if _, err := verifyEnrollToken([]byte("wrongwrongwrongwrongwrongwrongwr"), tok); err == nil {
t.Error("expected wrong-key rejection")
}
// tampered signature rejected
if _, err := verifyEnrollToken(key, tok[:len(tok)-2]+"xx"); err == nil {
t.Error("expected tamper rejection")
}
// expired rejected
exp, _ := mintEnrollToken(key, "bob", -time.Minute)
if _, err := verifyEnrollToken(key, exp); err == nil {
t.Error("expected expiry rejection")
}
}
func TestSessionMiddleware(t *testing.T) {
s := &server{cfg: config{}, sessions: newSessionStore(time.Hour)} // adminInsecure=false
id, csrf := s.sessions.create("alice")
var reached string
guarded := s.requireSession(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached = adminName(r)
w.WriteHeader(http.StatusOK)
}))
// no cookie -> 401
rw := httptest.NewRecorder()
guarded.ServeHTTP(rw, httptest.NewRequest(http.MethodGet, "/api/hosts", nil))
if rw.Code != http.StatusUnauthorized {
t.Fatalf("no cookie: got %d want 401", rw.Code)
}
withCookie := func(method string, csrfHdr string) *http.Request {
r := httptest.NewRequest(method, "/api/x", nil)
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: id})
if csrfHdr != "" {
r.Header.Set(csrfHeader, csrfHdr)
}
return r
}
// valid GET -> reaches handler as alice
reached = ""
rw = httptest.NewRecorder()
guarded.ServeHTTP(rw, withCookie(http.MethodGet, ""))
if rw.Code != http.StatusOK || reached != "alice" {
t.Fatalf("valid GET: code=%d admin=%q", rw.Code, reached)
}
// mutating POST without CSRF -> 403
rw = httptest.NewRecorder()
guarded.ServeHTTP(rw, withCookie(http.MethodPost, ""))
if rw.Code != http.StatusForbidden {
t.Fatalf("POST no CSRF: got %d want 403", rw.Code)
}
// mutating POST with correct CSRF -> reaches handler
reached = ""
rw = httptest.NewRecorder()
guarded.ServeHTTP(rw, withCookie(http.MethodPost, csrf))
if rw.Code != http.StatusOK || reached != "alice" {
t.Fatalf("POST with CSRF: code=%d admin=%q", rw.Code, reached)
}
// unknown cookie -> 401
rw = httptest.NewRecorder()
bad := httptest.NewRequest(http.MethodGet, "/api/x", nil)
bad.AddCookie(&http.Cookie{Name: sessionCookie, Value: "nope"})
guarded.ServeHTTP(rw, bad)
if rw.Code != http.StatusUnauthorized {
t.Fatalf("bad cookie: got %d want 401", rw.Code)
}
}
func TestCredStorePersistence(t *testing.T) {
path := t.TempDir() + "/admins.json"
s, err := newCredStore(path)
if err != nil {
t.Fatalf("new: %v", err)
}
u := s.getOrCreate("alice")
handle := u.IDb
if err := s.addCredential("alice", webauthn.Credential{ID: []byte("cred-1"), PublicKey: []byte("pk")}); err != nil {
t.Fatalf("add: %v", err)
}
s.markTokenUsed("jti-1", time.Now().Add(time.Hour).Unix())
// reload from disk
s2, err := newCredStore(path)
if err != nil {
t.Fatalf("reload: %v", err)
}
if s2.count() != 1 {
t.Fatalf("admins after reload = %d", s2.count())
}
got, ok := s2.byHandle(handle)
if !ok || got.Name != "alice" || len(got.Creds) != 1 {
t.Fatalf("byHandle after reload: ok=%v %+v", ok, got)
}
if !s2.tokenUsed("jti-1") {
t.Error("used-token ledger did not survive reload")
}
if s2.tokenUsed("jti-unknown") {
t.Error("unknown token reported used")
}
}