Files
echolot/server/internal/adminui/auth.go
T
mrambossekandClaude Fable 5 0eaba6150b
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 36s
server-release / release (push) Successful in 38s
adminui: an admin interface, behind authentication without exception
Replaces the unauthenticated admin mux. Everything but /healthz requires a
session, and that is the point: the previous arrangement relied on binding to
loopback, which worked exactly until the address changed and then failed
silently and publicly. A binding address is a deployment detail, not an access
control, and this package does not treat it as one.

Two ways in. OIDC through the confidential client, with state and PKCE - PKCE
even here, because it costs one hash and closes code interception independently
of the secret. And the break-glass password, throttled, for when the IdP is the
thing that is broken. Signing in without the admin group is refused with the
group named, because "you are not an admin" is a different problem from "your
password is wrong" and the remedy is elsewhere.

Sessions are MAC-checked cookies: HttpOnly, SameSite=Lax, Secure when TLS is on.
CSRF tokens are derived from the session rather than stored, so there is no
server-side table to keep in sync, and they are required on every state-changing
POST - SameSite already blocks cross-site posts in current browsers, but this is
the control that does not depend on the browser being current.

Server-rendered with html/template and no JavaScript: the pages are lists and
forms, and a framework would add a build step, a dependency tree and an update
treadmill to a program that has none of those. The CSP is default-src 'none'
accordingly.

Pages: overview, devices (with revocation and enrolment-link minting), uploaded
runs and a run viewer. Revocations and deletions are logged with who did them.
Runs are shown exactly as uploaded, at the privacy level their uploader chose -
nothing in the UI can un-redact one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 19:31:06 +02:00

347 lines
12 KiB
Go

// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package adminui serves the operator's web interface.
//
// Everything here is behind authentication, without exception. The previous arrangement — an
// unauthenticated listener kept safe by binding to loopback — worked exactly until the address
// changed, and then failed silently and publicly. Binding address is a deployment detail; it is
// not an access control, and this package does not treat it as one.
//
// Rendered server-side with html/template and no JavaScript. The pages are lists and forms; a
// framework would add a build step, a dependency tree and an update treadmill to a program that
// currently has none of those.
package adminui
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"echo-lot.app/server/internal/adminauth"
"echo-lot.app/server/internal/oidc"
"echo-lot.app/server/internal/runs"
"echo-lot.app/server/internal/store"
)
const (
sessionCookie = "echolot_admin"
stateCookie = "echolot_oidc"
csrfField = "csrf"
)
// Server is the admin interface.
type Server struct {
Store *store.Store
Runs *runs.Store
OIDC *oidc.Verifier // admin client; nil when no IdP is configured
Sessions *adminauth.Sessions
Throttle *adminauth.Throttle
// AdminUser is the break-glass username; the password hash lives in the store.
AdminUser string
// BaseURL is where this UI is reachable, for building the OIDC redirect. Must match the URI
// registered at the IdP exactly.
BaseURL string
// ClientSecret authenticates the confidential admin client at the token endpoint.
ClientSecret string
// Secure marks cookies Secure. Off only for loopback HTTP, where there is no network to
// intercept and browsers refuse Secure cookies over plaintext anyway.
Secure bool
// EnrollLink builds the §2.1 bootstrap link for a token. Injected rather than rebuilt here,
// so the SPKI pin and public URL stay owned by the control server that actually knows them.
EnrollLink func(token string) string
// SelfTest and Version render on the dashboard.
SelfTest func() any
Version string
}
// Handler builds the routes. Only /healthz is reachable without a session.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
// Unauthenticated: a health check that required a session would be no use to a monitor, and
// it discloses nothing beyond "the process is up".
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"ok":true,"version":%q}`+"\n", s.Version)
})
mux.HandleFunc("GET /login", s.loginForm)
mux.HandleFunc("POST /login", s.loginSubmit)
mux.HandleFunc("GET /auth/start", s.oidcStart)
mux.HandleFunc("GET /admin/callback", s.oidcCallback)
mux.HandleFunc("POST /logout", s.logout)
mux.HandleFunc("GET /", s.guard(s.dashboard))
mux.HandleFunc("GET /devices", s.guard(s.devices))
mux.HandleFunc("POST /devices/{id}/revoke", s.guard(s.revokeDevice))
mux.HandleFunc("POST /enroll-tokens", s.guard(s.mintToken))
mux.HandleFunc("GET /runs", s.guard(s.runsList))
mux.HandleFunc("GET /runs/{device}/{id}", s.guard(s.runView))
mux.HandleFunc("POST /runs/{device}/{id}/delete", s.guard(s.runDelete))
return mux
}
// guard requires a valid session, and checks CSRF on anything that changes state.
func (s *Server) guard(h func(http.ResponseWriter, *http.Request, *adminauth.Session)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess := s.session(r)
if sess == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
// SameSite=Lax already blocks cross-site form posts in current browsers, but this
// is the control that does not depend on the browser being current.
if !s.csrfOK(r, sess) {
http.Error(w, "stale form — reload the page and try again", http.StatusForbidden)
return
}
}
h(w, r, sess)
}
}
func (s *Server) session(r *http.Request) *adminauth.Session {
c, err := r.Cookie(sessionCookie)
if err != nil {
return nil
}
sess, err := s.Sessions.Parse(c.Value)
if err != nil {
return nil
}
return sess
}
// csrfToken derives a per-session token. Derived rather than stored so it needs no server-side
// state and cannot drift out of sync with the session it belongs to.
func (s *Server) csrfToken(sess *adminauth.Session) string {
sum := sha256.Sum256([]byte("csrf|" + sess.Subject + "|" + sess.Expires.String()))
return base64.RawURLEncoding.EncodeToString(sum[:16])
}
func (s *Server) csrfOK(r *http.Request, sess *adminauth.Session) bool {
if err := r.ParseForm(); err != nil {
return false
}
return r.PostFormValue(csrfField) == s.csrfToken(sess)
}
func (s *Server) setSession(w http.ResponseWriter, subject, display string) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: s.Sessions.Issue(subject, display),
Path: "/",
HttpOnly: true, // the cookie is a bearer credential; script has no business reading it
Secure: s.Secure,
SameSite: http.SameSiteLaxMode,
})
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie, Value: "", Path: "/", MaxAge: -1,
HttpOnly: true, Secure: s.Secure, SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// ---- local password ---------------------------------------------------------------------
func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
// The delay is applied before the answer, so a wrong guess costs time whether or not the
// username exists — the timing carries no information either way.
if d := s.Throttle.Delay(); d > 0 {
time.Sleep(d)
}
user := r.PostFormValue("username")
pass := r.PostFormValue("password")
cred := s.Store.LocalAdmin()
if cred == nil || !cred.Verify(user, pass) {
s.Throttle.Failed()
slog.Info("admin login failed", "user", user, "from", clientIP(r))
s.render(w, r, "login", map[string]any{
"Error": "Incorrect username or password.",
"OIDC": s.oidcAvailable(),
})
return
}
s.Throttle.Succeeded()
slog.Info("admin login", "user", user, "method", "local", "from", clientIP(r))
s.setSession(w, "local:"+cred.Username, cred.Username)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// ---- OIDC -------------------------------------------------------------------------------
func (s *Server) oidcAvailable() bool {
return s.OIDC != nil && s.OIDC.Config().Enabled() && s.BaseURL != ""
}
// oidcStart redirects to the IdP with state and PKCE.
//
// PKCE even though this is a confidential client: it costs one hash and closes code interception
// independently of the secret, which is worth having when the redirect crosses a browser.
func (s *Server) oidcStart(w http.ResponseWriter, r *http.Request) {
if !s.oidcAvailable() {
http.Error(w, "no identity provider is configured on this server", http.StatusNotImplemented)
return
}
d, err := s.OIDC.Discover(r.Context())
if err != nil {
http.Error(w, "identity provider unreachable: "+err.Error(), http.StatusBadGateway)
return
}
state, verifier := randomToken(), randomToken()
challenge := sha256.Sum256([]byte(verifier))
// state and the PKCE verifier ride in one short-lived cookie: the callback must prove it
// belongs to the browser that started the flow, or an attacker can feed us their own code.
http.SetCookie(w, &http.Cookie{
Name: stateCookie, Value: state + "." + verifier, Path: "/",
HttpOnly: true, Secure: s.Secure, SameSite: http.SameSiteLaxMode, MaxAge: 600,
})
q := url.Values{
"response_type": {"code"},
"client_id": {s.OIDC.Config().ClientID},
"redirect_uri": {s.redirectURI()},
"scope": {"openid profile email"},
"state": {state},
"code_challenge": {base64.RawURLEncoding.EncodeToString(challenge[:])},
"code_challenge_method": {"S256"},
}
http.Redirect(w, r, d.AuthorizationEndpoint+"?"+q.Encode(), http.StatusSeeOther)
}
func (s *Server) redirectURI() string {
return strings.TrimRight(s.BaseURL, "/") + "/admin/callback"
}
func (s *Server) oidcCallback(w http.ResponseWriter, r *http.Request) {
if !s.oidcAvailable() {
http.Error(w, "no identity provider configured", http.StatusNotImplemented)
return
}
c, err := r.Cookie(stateCookie)
if err != nil {
http.Error(w, "sign-in did not start here — try again from the login page", http.StatusBadRequest)
return
}
http.SetCookie(w, &http.Cookie{Name: stateCookie, Value: "", Path: "/", MaxAge: -1})
state, verifier, ok := strings.Cut(c.Value, ".")
if !ok || state == "" || r.URL.Query().Get("state") != state {
http.Error(w, "sign-in state did not match — start again", http.StatusBadRequest)
return
}
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, "no authorization code returned: "+r.URL.Query().Get("error"), http.StatusBadRequest)
return
}
idToken, err := s.exchange(r.Context(), code, verifier)
if err != nil {
slog.Info("admin oidc exchange failed", "err", err, "from", clientIP(r))
http.Error(w, "could not complete sign-in", http.StatusBadGateway)
return
}
claims, err := s.OIDC.Verify(r.Context(), idToken)
if err != nil {
slog.Info("admin oidc token rejected", "err", err, "from", clientIP(r))
http.Error(w, "the identity token was not accepted", http.StatusForbidden)
return
}
if !s.OIDC.IsAdmin(claims) {
// Named explicitly: "you signed in but you are not an admin" is a different problem from
// "your password is wrong", and the group is the thing to go and check.
slog.Info("admin access denied: not in group", "account", claims.AccountID(),
"want_group", s.OIDC.Config().AdminGroup, "have", claims.Groups)
http.Error(w, fmt.Sprintf(
"Signed in as %s, but that account is not in the %q group, so it cannot administer "+
"this server.", claims.Display(), s.OIDC.Config().AdminGroup), http.StatusForbidden)
return
}
slog.Info("admin login", "account", claims.AccountID(), "method", "oidc", "from", clientIP(r))
s.setSession(w, claims.AccountID(), claims.Display())
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// exchange trades the authorization code for tokens at the IdP.
func (s *Server) exchange(ctx context.Context, code, verifier string) (string, error) {
d, err := s.OIDC.Discover(ctx)
if err != nil {
return "", err
}
form := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {s.redirectURI()},
"client_id": {s.OIDC.Config().ClientID},
"code_verifier": {verifier},
}
if s.ClientSecret != "" {
form.Set("client_secret", s.ClientSecret)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.TokenEndpoint,
strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("token endpoint: %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
var tok struct {
IDToken string `json:"id_token"`
}
if err := json.Unmarshal(body, &tok); err != nil {
return "", err
}
if tok.IDToken == "" {
return "", fmt.Errorf("token endpoint returned no id_token")
}
return tok.IDToken, nil
}
func randomToken() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
// clientIP is for logs only. X-Forwarded-For is deliberately ignored: nothing is meant to sit in
// front of this listener, so a header claiming otherwise is a caller's assertion about itself.
func clientIP(r *http.Request) string {
if i := strings.LastIndex(r.RemoteAddr, ":"); i > 0 {
return r.RemoteAddr[:i]
}
return r.RemoteAddr
}