// session.go — post-passkey sessions + the middleware that gates the admin API. // // WebAuthn authenticates a login CEREMONY; from there we carry an opaque, // server-side session (revocable, no secrets in the cookie). Cookie is // Secure + HttpOnly + SameSite=Strict; state-changing requests additionally must // echo the session's CSRF token in a header (belt-and-braces beyond same-origin). package main import ( "context" "crypto/rand" "crypto/subtle" "encoding/base64" "net/http" "sync" "time" ) const ( sessionCookie = "fleet_session" csrfHeader = "X-Fleet-CSRF" ) type ctxKey int const adminCtxKey ctxKey = 0 type session struct { admin string csrf string expires time.Time } type sessionStore struct { mu sync.Mutex ttl time.Duration byID map[string]session } func newSessionStore(ttl time.Duration) *sessionStore { return &sessionStore{ttl: ttl, byID: map[string]session{}} } func randToken() string { b := make([]byte, 32) _, _ = rand.Read(b) return base64.RawURLEncoding.EncodeToString(b) } // create mints a session for a just-authenticated admin, returning the session id // (cookie value) and the CSRF token (returned to the SPA in the login response). func (s *sessionStore) create(admin string) (id, csrf string) { id, csrf = randToken(), randToken() s.mu.Lock() s.byID[id] = session{admin: admin, csrf: csrf, expires: time.Now().Add(s.ttl)} s.mu.Unlock() return id, csrf } func (s *sessionStore) get(id string) (session, bool) { s.mu.Lock() defer s.mu.Unlock() sess, ok := s.byID[id] if !ok { return session{}, false } if time.Now().After(sess.expires) { delete(s.byID, id) return session{}, false } return sess, true } func (s *sessionStore) delete(id string) { s.mu.Lock() delete(s.byID, id) s.mu.Unlock() } // requireSession wraps the admin API. It rejects requests without a valid session // cookie, and (for mutating methods) without the matching CSRF header. The // authenticated admin name is injected into the request context for audit. func (s *server) requireSession(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Insecure dev mode has no passkey auth — open, and adminName reports the // dev sentinel. (main refuses insecure unless explicitly enabled.) if s.cfg.adminInsecure { next.ServeHTTP(w, r) return } c, err := r.Cookie(sessionCookie) if err != nil { writeErr(w, http.StatusUnauthorized, "not signed in") return } sess, ok := s.sessions.get(c.Value) if !ok { writeErr(w, http.StatusUnauthorized, "session expired") return } if isMutating(r.Method) { if subtle.ConstantTimeCompare([]byte(r.Header.Get(csrfHeader)), []byte(sess.csrf)) != 1 { writeErr(w, http.StatusForbidden, "missing or bad CSRF token") return } } next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), adminCtxKey, sess))) }) } // sessionFrom returns the authenticated session injected by requireSession. func sessionFrom(r *http.Request) (session, bool) { sess, ok := r.Context().Value(adminCtxKey).(session) return sess, ok } func isMutating(method string) bool { switch method { case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: return true } return false } // setSessionCookie writes the session cookie. Secure+HttpOnly+SameSite=Strict. func setSessionCookie(w http.ResponseWriter, id string, ttl time.Duration) { http.SetCookie(w, &http.Cookie{ Name: sessionCookie, Value: id, Path: "/", MaxAge: int(ttl.Seconds()), Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, }) } func clearSessionCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode}) } // adminName returns the authenticated admin for audit attribution — the session // identity in secure mode, or the dev sentinel in insecure mode. func adminName(r *http.Request) string { if sess, ok := sessionFrom(r); ok && sess.admin != "" { return sess.admin } return "dev-insecure" }