From 14e8482e2ad89873da5c7f0f1a8691ea4180837a Mon Sep 17 00:00:00 2001 From: Chris Behrens Date: Sat, 29 Aug 2026 17:51:02 +0000 Subject: [PATCH] Let the web UI work when an api secret is configured Setting http_listener.secret gated the whole /api group behind the X-Rotom-Secret header, but the UI is served from the same listener and made bare fetch() calls with no headers. The SPA loaded and then every request 401'd, so enabling the secret made the dashboard unusable. The middleware now accepts any one of three credentials: X-Rotom-Secret machine clients, unchanged Authorization: Bearer clients preferring a short-lived token to a static secret session cookie + X-Rotom-Session the web UI Sessions are HS256 tokens in an HttpOnly, SameSite=Strict cookie, minted by POST /api/auth/login in exchange for the configured secret. HttpOnly means an XSS bug in the UI cannot exfiltrate the credential; the secret itself is never persisted anywhere page JavaScript can reach. The signing key is derived from the secret rather than generated at startup, which makes sessions survive a restart and makes rotating http_listener.secret -- including via config reload -- invalidate every outstanding token. That is the only revocation mechanism a stateless token has. Session lifetime is http_listener.ui_session_ttl, defaulting to one day. It is written "24h" rather than "1d" because Go duration syntax has no day unit. The value governs both the token's exp claim and the cookie's Max-Age so the two cannot drift apart, and it is re-read on config reload. Because expiry is signed into each token, shortening the TTL applies to subsequent logins only; it does not retroactively end sessions already issued. Tokens are hand-rolled rather than pulling in a JWT dependency: only one algorithm is ever minted or accepted, and requiring exactly HS256 up front sidesteps the alg-confusion footguns a general-purpose parser brings. No new Go module, no vendor churn. Cookie-authenticated requests must also carry X-Rotom-Session. The browser attaches the cookie on its own, so without that check a cross-site form post would ride a logged-in operator's session; a custom header cannot be set cross-origin without a preflight we never answer. Requests authenticated by header or bearer token do not need it. The session endpoints are registered on their own unauthenticated /api group -- as a separate group rather than relying on gin capturing middleware at registration time, so a later reordering cannot silently gate the only path back in. On the UI side, AuthGate swaps the rendered tree in place instead of routing to /login. The URL never changes, so a session expiring mid-poll leaves the operator where they were and a deep link survives login with no return-to-path plumbing. It watches the query and mutation caches for 401s, so an expired cookie surfaces as the login form without every page handling auth itself. Deployments with no secret configured are unaffected: the server reports auth_required=false and the UI renders straight through. Verified against the built binary with the embedded UI: bare requests 401, login sets the hardened cookie, cookie+header reaches both reads and mutating PUTs, the same PUT without the header is refused, a config reload that changes the secret drops live sessions, and a configured ui_session_ttl of "5s" both lands on the cookie and expires the session server-side. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014usJRTkZk7Zgi7zUwWfY5n --- apps/rotom-ng-ui/src/app/app.tsx | 7 +- apps/rotom-ng/app/app.go | 2 + apps/rotom-ng/app/config/config.go | 12 + apps/rotom-ng/app/config/config_test.go | 10 + configs/rotom-ng.toml.example | 9 + docs/RotomNG-API.md | 84 +++++ libs/auth/middleware.go | 132 +++++++- libs/auth/session_handlers.go | 132 ++++++++ libs/auth/session_test.go | 361 +++++++++++++++++++++ libs/auth/token.go | 161 +++++++++ libs/auth/token_test.go | 175 ++++++++++ libs/base-ui/src/auth/auth-context.ts | 25 ++ libs/base-ui/src/auth/auth-gate.tsx | 159 +++++++++ libs/base-ui/src/auth/index.ts | 3 + libs/base-ui/src/auth/login-card.tsx | 110 +++++++ libs/base-ui/src/devices/device-page.tsx | 3 +- libs/base-ui/src/devices/devices-table.tsx | 5 +- libs/base-ui/src/index.ts | 8 + libs/base-ui/src/layout/layout.tsx | 42 ++- libs/base-ui/src/lib/api.ts | 100 +++++- libs/base-ui/src/lib/query-client.ts | 7 +- libs/services/types.go | 14 +- libs/services/web_server.go | 9 + 23 files changed, 1545 insertions(+), 25 deletions(-) create mode 100644 libs/auth/session_handlers.go create mode 100644 libs/auth/session_test.go create mode 100644 libs/auth/token.go create mode 100644 libs/auth/token_test.go create mode 100644 libs/base-ui/src/auth/auth-context.ts create mode 100644 libs/base-ui/src/auth/auth-gate.tsx create mode 100644 libs/base-ui/src/auth/index.ts create mode 100644 libs/base-ui/src/auth/login-card.tsx diff --git a/apps/rotom-ng-ui/src/app/app.tsx b/apps/rotom-ng-ui/src/app/app.tsx index 822d4eb..3f98366 100644 --- a/apps/rotom-ng-ui/src/app/app.tsx +++ b/apps/rotom-ng-ui/src/app/app.tsx @@ -1,6 +1,7 @@ import "react-toastify/dist/ReactToastify.css"; import { + AuthGate, ControllersPage, createAppQueryClient, DevicePage, @@ -63,7 +64,11 @@ function AppContent() { export function App() { return ( - + {/* Outside AppContent so its polling queries never mount — and never + fire a burst of 401s — before there is a session. */} + + + ); diff --git a/apps/rotom-ng/app/app.go b/apps/rotom-ng/app/app.go index 278126f..a4bf41c 100644 --- a/apps/rotom-ng/app/app.go +++ b/apps/rotom-ng/app/app.go @@ -397,6 +397,7 @@ func (a *App) Init() error { } a.httpAuthMiddleware = auth.NewMiddleware(a.cfg.HTTPListener.Secret) + a.httpAuthMiddleware.SetSessionTTL(a.cfg.HTTPListener.UISessionTTL) a.apiHandlerConfig = handlers.APIHandlerConfig[*Controller, *MITMWorker]{ Logger: a.logger.With(slog.String("component", "api")), ConnectionManager: a.connectionManager, @@ -525,6 +526,7 @@ func (a *App) reload() error { a.controllerAuthMiddleware.SetSecret(cfg.ControllerListener.Secret) a.deviceAuthMiddleware.SetSecret(cfg.DeviceListener.Secret) a.httpAuthMiddleware.SetSecret(cfg.HTTPListener.Secret) + a.httpAuthMiddleware.SetSessionTTL(cfg.HTTPListener.UISessionTTL) a.setShutdownTimeout(cfg.ShutdownTimeout) diff --git a/apps/rotom-ng/app/config/config.go b/apps/rotom-ng/app/config/config.go index 406e1a0..4c598f4 100644 --- a/apps/rotom-ng/app/config/config.go +++ b/apps/rotom-ng/app/config/config.go @@ -39,6 +39,11 @@ const ( // controller connection that receives no data message within this period is // considered dead, independent of ping/pong keep-alive activity. DefaultControllerDataTimeout = 2 * time.Minute + + // DefaultUISessionTTL is how long a web UI login lasts when + // http_listener.ui_session_ttl is not set: one day. Written as 24h because + // Go duration syntax has no day unit. + DefaultUISessionTTL = 24 * time.Hour ) // DeviceListener holds configuration for the device WebSocket listener. @@ -71,6 +76,10 @@ type HTTPListener struct { Address string `koanf:"address"` Listener net.Listener `koanf:"-"` Secret string `koanf:"secret"` + // UISessionTTL is how long a web UI login stays valid (e.g. "30m", "12h"). + // Defaults to DefaultUISessionTTL when unset or <= 0. Only relevant when + // Secret is set, since without a secret the UI never logs in. + UISessionTTL time.Duration `koanf:"ui_session_ttl"` } // Tuning holds performance tuning options. @@ -201,6 +210,9 @@ func (cfg *Config) SetDefaults() { if cfg.HTTPListener.Address == "" { cfg.HTTPListener.Address = DefaultHTTPAddress } + if cfg.HTTPListener.UISessionTTL <= 0 { + cfg.HTTPListener.UISessionTTL = DefaultUISessionTTL + } // Initialize logging config if nil if cfg.Logging == nil { diff --git a/apps/rotom-ng/app/config/config_test.go b/apps/rotom-ng/app/config/config_test.go index e0178ba..f100a95 100644 --- a/apps/rotom-ng/app/config/config_test.go +++ b/apps/rotom-ng/app/config/config_test.go @@ -25,6 +25,7 @@ secret = "test-controller-secret" [http_listener] address = ":8082" secret = "test-api-secret" +ui_session_ttl = "90m" [logging] level = "debug" @@ -76,6 +77,9 @@ compress = true if cfg.HTTPListener.Secret != "test-api-secret" { t.Errorf("Expected HTTPListener.Secret to be 'test-api-secret', got %s", cfg.HTTPListener.Secret) } + if cfg.HTTPListener.UISessionTTL != 90*time.Minute { + t.Errorf("Expected HTTPListener.UISessionTTL to be 90m, got %v", cfg.HTTPListener.UISessionTTL) + } // Verify global shutdown timeout expectedTimeout := 45 * time.Second @@ -335,6 +339,12 @@ func TestConfigSetDefaults(t *testing.T) { if cfg.HTTPListener.Address != DefaultHTTPAddress { t.Errorf("Expected default HTTPListener.Address to be '%s', got %s", DefaultHTTPAddress, cfg.HTTPListener.Address) } + if cfg.HTTPListener.UISessionTTL != DefaultUISessionTTL { + t.Errorf("Expected default HTTPListener.UISessionTTL to be %v, got %v", DefaultUISessionTTL, cfg.HTTPListener.UISessionTTL) + } + if cfg.HTTPListener.UISessionTTL != 24*time.Hour { + t.Errorf("Expected the default UI session TTL to be one day, got %v", cfg.HTTPListener.UISessionTTL) + } // Verify default shutdown timeout if cfg.ShutdownTimeout != DefaultShutdownTimeout { diff --git a/configs/rotom-ng.toml.example b/configs/rotom-ng.toml.example index 2c4c26a..eee371f 100644 --- a/configs/rotom-ng.toml.example +++ b/configs/rotom-ng.toml.example @@ -28,6 +28,15 @@ address = ":7071" # Default: ":7071" [http_listener] address = ":7072" # Default: ":7072" # secret = "your-api-secret-here" # Optional authentication secret for API access + # API clients send it as the X-Rotom-Secret header. + # The web UI prompts for it and exchanges it for a + # session cookie, so the UI keeps working when set. + # Changing it signs out every active UI session. +# ui_session_ttl = "24h" # Default: "24h" (one day). How long a web UI login + # lasts before the operator must sign in again. + # Note: Go duration syntax has no day unit -- write + # "24h", not "1d". Applies to new logins only; + # shortening it does not cut short existing sessions. # Rate limiting configuration (optional) # Controls how frequently a single device's workers can be selected diff --git a/docs/RotomNG-API.md b/docs/RotomNG-API.md index 16f179f..a63affc 100644 --- a/docs/RotomNG-API.md +++ b/docs/RotomNG-API.md @@ -37,6 +37,90 @@ The HTTP API supports header-based authentication when configured. When a `secre **Authentication Responses**: - `401 Unauthorized`: Missing or invalid authentication header when authentication is required +A request is accepted if it carries **any one** of the following credentials: + +| Credential | Intended for | +| --- | --- | +| `X-Rotom-Secret: ` | Machine clients (Dragonite, scripts, Prometheus) | +| `Authorization: Bearer ` | Clients that prefer a short-lived token to a static secret | +| Session cookie + `X-Rotom-Session: 1` | The web UI | + +#### Session Endpoints + +These three endpoints are **not** gated by the auth middleware — they are how a +browser obtains a credential in the first place. + +```http +GET /api/auth/me +POST /api/auth/login +POST /api/auth/logout +``` + +`GET /api/auth/me` reports what the caller needs to do: + +```json +{ "status": "ok", "auth_required": true, "authenticated": false } +``` + +`POST /api/auth/login` exchanges the configured secret for a session: + +```http +POST /api/auth/login +Content-Type: application/json + +{ "secret": "your-api-secret-here" } +``` + +On success the response sets an `HttpOnly`, `SameSite=Strict` cookie holding a +signed HS256 token. The cookie is also flagged `Secure` when the request +arrives over TLS (directly, or via a proxy sending `X-Forwarded-Proto: https`). +A wrong secret returns `401`; if no secret is configured at all, login returns +`400`, since there is no session to create. + +Notes on session tokens: + +- **Rotation is revocation.** The token signing key is derived from the secret, + so changing `http_listener.secret` — including via config reload — + invalidates every outstanding session immediately. There is no other + revocation mechanism; tokens are stateless. +- **Session lifetime** defaults to one day and is set by + `http_listener.ui_session_ttl`: + + ```toml + [http_listener] + secret = "your-api-secret-here" + ui_session_ttl = "12h" # default "24h" + ``` + + Go duration syntax has no day unit, so write `"24h"` rather than `"1d"` — + the latter fails to parse at startup. The value applies to sessions minted + after it takes effect: a token's expiry is signed into its claims, so + shortening the TTL does not retroactively end sessions already issued. + Rotating the secret is what does that. +- **The `X-Rotom-Session` header is required** on cookie-authenticated + requests. The cookie alone is deliberately not sufficient: requiring a custom + header means a cross-site form post cannot ride a logged-in operator's + session. Requests authenticated by `X-Rotom-Secret` or `Authorization` do not + need it. +- **Bearer tokens** are the same tokens the cookie carries, so a client can + call `/api/auth/login` and use the returned cookie value as a bearer token if + it would rather not hold the long-lived secret. +- **Login is not rate limited.** It is an unauthenticated endpoint, so use a + high-entropy secret, and put the listener behind a proxy that throttles if it + is exposed to untrusted networks. Failed attempts are logged at `WARN` with + the client IP. + +#### Web UI + +The UI signs in through these endpoints. When a secret is configured it shows a +sign-in form; when one is not, it loads straight through as before. Because the +token lives in an `HttpOnly` cookie, the secret is never stored anywhere page +JavaScript can read it. + +Operators fronting Rotom with a reverse proxy can skip the UI login entirely by +having the proxy inject `X-Rotom-Secret` on `/api`, and handle authentication +themselves. + ### Configuration Endpoints #### Get Configuration diff --git a/libs/auth/middleware.go b/libs/auth/middleware.go index 6665de9..b39380f 100644 --- a/libs/auth/middleware.go +++ b/libs/auth/middleware.go @@ -4,40 +4,146 @@ package auth import ( "crypto/subtle" "net/http" + "strings" "sync/atomic" + "time" "github.com/gin-gonic/gin" ) +const bearerPrefix = "Bearer " + // Middleware validates requests using a shared secret header. type Middleware struct { secret atomic.Pointer[string] + // sessionTTL is stored as an int64 nanosecond count so config reload can + // change it without racing an in-flight login. Zero means DefaultSessionTTL. + sessionTTLNanos atomic.Int64 } -// NewMiddleware creates a gin middleware that checks for authentication -// via the X-Rotom-Secret header. -// Returns 401 Unauthorized if the header doesn't match the expected secret value. +// NewMiddleware creates a gin middleware that checks for authentication. +// +// A request is accepted when it carries any one of: +// - the X-Rotom-Secret header matching the configured secret (machine clients) +// - an Authorization: Bearer header holding a valid session token +// - the session cookie holding a valid token, plus the X-Rotom-Session header +// +// Returns 401 Unauthorized otherwise. When no secret is configured, every +// request is allowed through. func NewMiddleware(expectedSecret string) *Middleware { mw := &Middleware{} mw.secret.Store(&expectedSecret) return mw } -// Handler is a gin middleware that checks the X-Rotom-Secret header. +// Handler is a gin middleware that authenticates the request. func (mw *Middleware) Handler(ginContext *gin.Context) { - expectedSecret := mw.secret.Load() - if expectedSecret != nil && *expectedSecret != "" { - providedSecret := ginContext.GetHeader("X-Rotom-Secret") - if subtle.ConstantTimeCompare([]byte(providedSecret), []byte(*expectedSecret)) != 1 { - ginContext.Status(http.StatusUnauthorized) - ginContext.Abort() - return - } + secret := mw.currentSecret() + if secret == "" { + ginContext.Next() + return + } + if !mw.authenticate(ginContext, secret) { + ginContext.Status(http.StatusUnauthorized) + ginContext.Abort() + return } ginContext.Next() } -// SetSecret updates the expected secret value atomically. +// SetSessionTTL sets how long newly minted UI sessions stay valid. Values <= 0 +// select DefaultSessionTTL. +// +// Changing this affects only sessions minted afterwards: a token's expiry is +// baked into its signed claims, so shortening the TTL does not retroactively +// cut short sessions already issued. Rotating the secret is what ends those. +func (mw *Middleware) SetSessionTTL(ttl time.Duration) { + if ttl < 0 { + ttl = 0 + } + mw.sessionTTLNanos.Store(int64(ttl)) +} + +// SessionTTL returns the lifetime applied to newly minted sessions. +func (mw *Middleware) SessionTTL() time.Duration { + if ttl := time.Duration(mw.sessionTTLNanos.Load()); ttl > 0 { + return ttl + } + return DefaultSessionTTL +} + +// SetSecret updates the expected secret value atomically. Rotating the secret +// also invalidates every outstanding session token, since the token signing +// key is derived from it. func (mw *Middleware) SetSecret(secret string) { mw.secret.Store(&secret) } + +// Enabled reports whether a secret is configured, and therefore whether +// clients need to authenticate at all. +func (mw *Middleware) Enabled() bool { + return mw.currentSecret() != "" +} + +// CheckSecret reports whether provided matches the configured secret. It +// returns false when no secret is configured, so a login attempt cannot mint a +// token on an unauthenticated instance. +func (mw *Middleware) CheckSecret(provided string) bool { + secret := mw.currentSecret() + if secret == "" { + return false + } + return secretsEqual(provided, secret) +} + +// MintSessionToken issues a session token bound to the current secret. +func (mw *Middleware) MintSessionToken(ttl time.Duration) (string, error) { + return MintToken(mw.currentSecret(), time.Now(), ttl) +} + +// VerifySessionToken checks a token against the current secret. +func (mw *Middleware) VerifySessionToken(token string) error { + _, err := VerifyToken(mw.currentSecret(), token, time.Now()) + return err +} + +// authenticate reports whether the request carries any acceptable credential. +func (mw *Middleware) authenticate(ginContext *gin.Context, secret string) bool { + if secretsEqual(ginContext.GetHeader("X-Rotom-Secret"), secret) { + return true + } + + authorization := ginContext.GetHeader("Authorization") + if token, found := strings.CutPrefix(authorization, bearerPrefix); found { + if _, err := VerifyToken(secret, token, time.Now()); err == nil { + return true + } + } + + // Cookie credentials are only honoured on requests carrying the session + // header. The browser attaches the cookie automatically, so without this + // check a cross-site form post would ride an operator's live session; a + // custom header cannot be set cross-origin without a preflight we never + // answer. + if ginContext.GetHeader(SessionRequestHeader) == "" { + return false + } + cookie, err := ginContext.Cookie(SessionCookieName) + if err != nil || cookie == "" { + return false + } + _, err = VerifyToken(secret, cookie, time.Now()) + return err == nil +} + +func (mw *Middleware) currentSecret() string { + secret := mw.secret.Load() + if secret == nil { + return "" + } + return *secret +} + +func secretsEqual(provided, expected string) bool { + return subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1 +} diff --git a/libs/auth/session_handlers.go b/libs/auth/session_handlers.go new file mode 100644 index 0000000..6cf4899 --- /dev/null +++ b/libs/auth/session_handlers.go @@ -0,0 +1,132 @@ +package auth + +import ( + "log/slog" + "net/http" + + "github.com/gin-gonic/gin" +) + +// Log field key constants. +const ( + fieldStatus = "status" + fieldError = "error" + fieldAuthRequired = "auth_required" + fieldAuthenticated = "authenticated" + statusOK = "ok" + statusError = "error" +) + +// loginRequest is the body of a login attempt. +type loginRequest struct { + Secret string `json:"secret"` +} + +// SetupSessionRoutes registers the unauthenticated session endpoints on group. +// +// These deliberately sit outside the authenticated route group: they are how a +// browser obtains a credential in the first place, so requiring one to reach +// them would lock the UI out permanently. +func (mw *Middleware) SetupSessionRoutes(group *gin.RouterGroup, logger *slog.Logger) { + group.GET("/auth/me", mw.handleMe) + group.POST("/auth/login", func(c *gin.Context) { mw.handleLogin(c, logger) }) + group.POST("/auth/logout", mw.handleLogout) +} + +// handleMe reports whether authentication is required, and whether this +// request already carries a valid credential. The UI polls this on load to +// decide between rendering the app and rendering the login form. +func (mw *Middleware) handleMe(c *gin.Context) { + secret := mw.currentSecret() + if secret == "" { + c.JSON(http.StatusOK, gin.H{ + fieldStatus: statusOK, + fieldAuthRequired: false, + fieldAuthenticated: true, + }) + return + } + c.JSON(http.StatusOK, gin.H{ + fieldStatus: statusOK, + fieldAuthRequired: true, + fieldAuthenticated: mw.authenticate(c, secret), + }) +} + +// handleLogin exchanges the configured secret for a session cookie. +func (mw *Middleware) handleLogin(c *gin.Context, logger *slog.Logger) { + if !mw.Enabled() { + c.JSON(http.StatusBadRequest, gin.H{ + fieldStatus: statusError, + fieldError: "authentication is not enabled", + }) + return + } + + var req loginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + fieldStatus: statusError, + fieldError: "invalid request body", + }) + return + } + + if !mw.CheckSecret(req.Secret) { + // Logged so operators can spot brute-force attempts against an + // endpoint that is, by necessity, unauthenticated. + logger.LogAttrs(c.Request.Context(), slog.LevelWarn, "failed UI login attempt", + slog.String("remote_addr", c.ClientIP())) + c.JSON(http.StatusUnauthorized, gin.H{ + fieldStatus: statusError, + fieldError: "invalid secret", + }) + return + } + + ttl := mw.SessionTTL() + token, err := mw.MintSessionToken(ttl) + if err != nil { + logger.LogAttrs(c.Request.Context(), slog.LevelError, "failed to mint session token", + slog.String(fieldError, err.Error())) + c.JSON(http.StatusInternalServerError, gin.H{ + fieldStatus: statusError, + fieldError: "failed to create session", + }) + return + } + + // The cookie's Max-Age mirrors the token's exp so the browser drops it at + // the same moment the server stops honouring it. + setSessionCookie(c, token, int(ttl.Seconds())) + logger.LogAttrs(c.Request.Context(), slog.LevelInfo, "UI login succeeded", + slog.String("remote_addr", c.ClientIP()), + slog.Duration("session_ttl", ttl)) + c.JSON(http.StatusOK, gin.H{ + fieldStatus: statusOK, + fieldAuthRequired: true, + fieldAuthenticated: true, + }) +} + +// handleLogout clears the session cookie. +func (mw *Middleware) handleLogout(c *gin.Context) { + setSessionCookie(c, "", -1) + c.JSON(http.StatusOK, gin.H{ + fieldStatus: statusOK, + fieldAuthRequired: mw.Enabled(), + fieldAuthenticated: false, + }) +} + +// setSessionCookie writes the session cookie with the hardening flags the +// token's security depends on: HttpOnly so JavaScript cannot read it, and +// SameSite=Strict so a cross-site navigation never carries it. +func setSessionCookie(c *gin.Context, token string, maxAge int) { + c.SetSameSite(http.SameSiteStrictMode) + // Secure would make the cookie undeliverable over plain HTTP, which is a + // supported way to run this, so it is set only when the connection really + // is TLS -- either directly or via a proxy that says so. + secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" + c.SetCookie(SessionCookieName, token, maxAge, "/", "", secure, true) +} diff --git a/libs/auth/session_test.go b/libs/auth/session_test.go new file mode 100644 index 0000000..8bf9f3b --- /dev/null +++ b/libs/auth/session_test.go @@ -0,0 +1,361 @@ +package auth + +import ( + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" +) + +func discardLogger() *slog.Logger { + return slog.New(slog.DiscardHandler) +} + +// newTestRouter wires a middleware the same way the web server does: session +// endpoints unauthenticated, everything else behind the middleware. +func newTestRouter(mw *Middleware) *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + + mw.SetupSessionRoutes(router.Group("/api"), discardLogger()) + + api := router.Group("/api") + api.Use(mw.Handler) + api.GET("/status", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + return router +} + +func doLogin(t *testing.T, router *gin.Engine, secret string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", + strings.NewReader(`{"secret":"`+secret+`"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w +} + +func sessionCookie(t *testing.T, w *httptest.ResponseRecorder) *http.Cookie { + t.Helper() + for _, cookie := range w.Result().Cookies() { + if cookie.Name == SessionCookieName { + return cookie + } + } + t.Fatalf("no %s cookie in response", SessionCookieName) + return nil +} + +// TestSessionCookieAuthenticatesRequests is the regression test for the bug +// this whole feature exists to fix: with a secret configured, the UI could not +// reach any API endpoint. +func TestSessionCookieAuthenticatesRequests(t *testing.T) { + mw := NewMiddleware("a-secret") + router := newTestRouter(mw) + + loginResponse := doLogin(t, router, "a-secret") + if loginResponse.Code != http.StatusOK { + t.Fatalf("expected login to succeed, got %d: %s", loginResponse.Code, loginResponse.Body) + } + cookie := sessionCookie(t, loginResponse) + + req := httptest.NewRequest(http.MethodGet, "/api/status", nil) + req.AddCookie(cookie) + req.Header.Set(SessionRequestHeader, "1") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected the session cookie to authenticate the request, got %d", w.Code) + } +} + +func TestSessionCookieHardening(t *testing.T) { + mw := NewMiddleware("a-secret") + router := newTestRouter(mw) + + cookie := sessionCookie(t, doLogin(t, router, "a-secret")) + + if !cookie.HttpOnly { + t.Error("session cookie must be HttpOnly so page JavaScript cannot read the token") + } + if cookie.SameSite != http.SameSiteStrictMode { + t.Errorf("expected SameSite=Strict, got %v", cookie.SameSite) + } + if cookie.Path != "/" { + t.Errorf("expected path /, got %q", cookie.Path) + } + if cookie.Value == "a-secret" { + t.Error("the cookie must carry a token, never the secret itself") + } +} + +// TestCookieRequiresSessionHeader covers the CSRF defence: the browser attaches the +// cookie to cross-site requests under some conditions, but a cross-site caller +// cannot set a custom header without a preflight. +func TestCookieRequiresSessionHeader(t *testing.T) { + mw := NewMiddleware("a-secret") + router := newTestRouter(mw) + + cookie := sessionCookie(t, doLogin(t, router, "a-secret")) + + req := httptest.NewRequest(http.MethodGet, "/api/status", nil) + req.AddCookie(cookie) + // Deliberately no SessionRequestHeader. + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401 for a cookie request without %s, got %d", SessionRequestHeader, w.Code) + } +} + +func TestSessionTTLDefaultsAndOverrides(t *testing.T) { + tests := []struct { + name string + set func(*Middleware) + want time.Duration + }{ + {"unset falls back to the default", func(*Middleware) {}, DefaultSessionTTL}, + {"explicit ttl is used", func(mw *Middleware) { mw.SetSessionTTL(30 * time.Minute) }, 30 * time.Minute}, + {"zero falls back to the default", func(mw *Middleware) { mw.SetSessionTTL(0) }, DefaultSessionTTL}, + {"negative falls back to the default", func(mw *Middleware) { mw.SetSessionTTL(-5 * time.Minute) }, DefaultSessionTTL}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mw := NewMiddleware("a-secret") + tt.set(mw) + if got := mw.SessionTTL(); got != tt.want { + t.Errorf("expected %v, got %v", tt.want, got) + } + }) + } +} + +// TestSessionTTLAppliesToCookieAndToken checks the configured lifetime reaches +// both halves of the session: the cookie the browser holds, and the expiry +// signed into the token the server verifies. If they disagreed, a session +// would either die early or outlive its cookie. +func TestSessionTTLAppliesToCookieAndToken(t *testing.T) { + const ttl = 15 * time.Minute + + mw := NewMiddleware("a-secret") + mw.SetSessionTTL(ttl) + router := newTestRouter(mw) + + cookie := sessionCookie(t, doLogin(t, router, "a-secret")) + + if want := int(ttl.Seconds()); cookie.MaxAge != want { + t.Errorf("expected cookie Max-Age %d, got %d", want, cookie.MaxAge) + } + + now := time.Now() + expiry, err := VerifyToken("a-secret", cookie.Value, now) + if err != nil { + t.Fatalf("VerifyToken rejected the session token: %v", err) + } + // Second-granularity claims plus clock movement between mint and check. + if delta := expiry.Sub(now.Add(ttl)); delta > 2*time.Second || delta < -2*time.Second { + t.Errorf("expected the token to expire ~%v out, got %v", ttl, expiry.Sub(now)) + } +} + +// TestShortSessionTTLExpires proves a configured TTL actually ends the session +// rather than only being advertised on the cookie. +func TestShortSessionTTLExpires(t *testing.T) { + mw := NewMiddleware("a-secret") + mw.SetSessionTTL(time.Second) + + token, err := mw.MintSessionToken(mw.SessionTTL()) + if err != nil { + t.Fatalf("MintSessionToken returned error: %v", err) + } + + if _, err := VerifyToken("a-secret", token, time.Now()); err != nil { + t.Fatalf("token should be valid immediately after minting: %v", err) + } + if _, err := VerifyToken("a-secret", token, time.Now().Add(2*time.Second)); !IsErrTokenExpired(err) { + t.Errorf("expected the token to be expired after its ttl, got %v", err) + } +} + +func TestBearerTokenAuthenticatesRequests(t *testing.T) { + mw := NewMiddleware("a-secret") + router := newTestRouter(mw) + + token, err := mw.MintSessionToken(time.Hour) + if err != nil { + t.Fatalf("MintSessionToken returned error: %v", err) + } + + tests := []struct { + name string + authHeader string + wantStatus int + }{ + {"valid bearer token", "Bearer " + token, http.StatusOK}, + {"garbage bearer token", "Bearer not-a-token", http.StatusUnauthorized}, + {"missing bearer prefix", token, http.StatusUnauthorized}, + {"empty bearer token", "Bearer ", http.StatusUnauthorized}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/status", nil) + req.Header.Set("Authorization", tt.authHeader) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("expected %d, got %d", tt.wantStatus, w.Code) + } + }) + } +} + +// TestSecretRotationInvalidatesSessions documents the only revocation path a +// stateless token has: changing the secret changes the derived signing key. +func TestSecretRotationInvalidatesSessions(t *testing.T) { + mw := NewMiddleware("a-secret") + router := newTestRouter(mw) + + cookie := sessionCookie(t, doLogin(t, router, "a-secret")) + + mw.SetSecret("rotated-secret") + + req := httptest.NewRequest(http.MethodGet, "/api/status", nil) + req.AddCookie(cookie) + req.Header.Set(SessionRequestHeader, "1") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected the pre-rotation session to be rejected, got %d", w.Code) + } +} + +func TestLoginRejectsWrongSecret(t *testing.T) { + mw := NewMiddleware("a-secret") + router := newTestRouter(mw) + + w := doLogin(t, router, "wrong-secret") + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401 for a wrong secret, got %d", w.Code) + } + for _, cookie := range w.Result().Cookies() { + if cookie.Name == SessionCookieName && cookie.Value != "" { + t.Error("a failed login must not set a session cookie") + } + } +} + +// TestLoginDisabledWithoutSecret ensures an unauthenticated instance cannot +// have a session minted against an empty secret. +func TestLoginDisabledWithoutSecret(t *testing.T) { + router := newTestRouter(NewMiddleware("")) + + w := doLogin(t, router, "") + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 when no secret is configured, got %d", w.Code) + } +} + +func TestLogoutClearsCookie(t *testing.T) { + mw := NewMiddleware("a-secret") + router := newTestRouter(mw) + + cookie := sessionCookie(t, doLogin(t, router, "a-secret")) + + req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil) + req.AddCookie(cookie) + req.Header.Set(SessionRequestHeader, "1") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected logout to succeed, got %d", w.Code) + } + cleared := sessionCookie(t, w) + if cleared.Value != "" || cleared.MaxAge >= 0 { + t.Errorf("expected logout to clear the cookie, got value=%q maxAge=%d", + cleared.Value, cleared.MaxAge) + } +} + +func TestAuthMeReportsState(t *testing.T) { + type meResponse struct { + AuthRequired bool `json:"auth_required"` + Authenticated bool `json:"authenticated"` + } + + t.Run("no secret configured", func(t *testing.T) { + router := newTestRouter(NewMiddleware("")) + + req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + var body meResponse + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if body.AuthRequired { + t.Error("expected auth_required=false when no secret is configured") + } + if !body.Authenticated { + t.Error("expected authenticated=true when no secret is configured") + } + }) + + t.Run("secret configured, no credential", func(t *testing.T) { + router := newTestRouter(NewMiddleware("a-secret")) + + req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // The probe itself must stay reachable while logged out, otherwise the + // UI has no way to discover that it needs to log in. + if w.Code != http.StatusOK { + t.Fatalf("expected /auth/me to be reachable unauthenticated, got %d", w.Code) + } + var body meResponse + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if !body.AuthRequired { + t.Error("expected auth_required=true") + } + if body.Authenticated { + t.Error("expected authenticated=false without a credential") + } + }) + + t.Run("secret configured, logged in", func(t *testing.T) { + mw := NewMiddleware("a-secret") + router := newTestRouter(mw) + cookie := sessionCookie(t, doLogin(t, router, "a-secret")) + + req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) + req.AddCookie(cookie) + req.Header.Set(SessionRequestHeader, "1") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + var body meResponse + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if !body.AuthRequired || !body.Authenticated { + t.Errorf("expected auth_required=true and authenticated=true, got %+v", body) + } + }) +} diff --git a/libs/auth/token.go b/libs/auth/token.go new file mode 100644 index 0000000..951d5f2 --- /dev/null +++ b/libs/auth/token.go @@ -0,0 +1,161 @@ +package auth + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "time" +) + +// SessionCookieName is the cookie the UI stores its session token in. The +// cookie is set HttpOnly so browser JavaScript can never read the token, which +// keeps an XSS bug in the UI from turning into a stolen credential. +const SessionCookieName = "rotom_session" + +// SessionRequestHeader must be present on any request authenticated by cookie. +// A cross-site form or image tag cannot set a custom header without triggering +// a CORS preflight the server never answers, so requiring it blocks CSRF even +// if a browser ignores SameSite. +const SessionRequestHeader = "X-Rotom-Session" + +// DefaultSessionTTL is how long a UI session stays valid before the operator +// has to log in again. Callers can override it per-middleware with +// SetSessionTTL; this is the fallback when they do not. +const DefaultSessionTTL = 24 * time.Hour + +// signingKeyLabel domain-separates the token signing key from the raw config +// secret, so a token signature can never be confused with, or used to probe, +// the secret itself. +const signingKeyLabel = "rotom-ng ui session v1" + +// tokenSubject identifies the single-operator session these tokens represent. +// It exists so a future multi-user scheme can tell old tokens apart. +const tokenSubject = "rotom-ui" + +var ( + errTokenInvalid = errors.New("session token is invalid") + errTokenExpired = errors.New("session token has expired") +) + +// NewErrTokenInvalid returns the sentinel error for a malformed or badly +// signed token. +func NewErrTokenInvalid() error { return errTokenInvalid } + +// IsErrTokenInvalid reports whether err is the invalid-token sentinel. +func IsErrTokenInvalid(err error) bool { return errors.Is(err, errTokenInvalid) } + +// NewErrTokenExpired returns the sentinel error for a well-formed token whose +// expiry has passed. +func NewErrTokenExpired() error { return errTokenExpired } + +// IsErrTokenExpired reports whether err is the expired-token sentinel. +func IsErrTokenExpired(err error) bool { return errors.Is(err, errTokenExpired) } + +// tokenHeader is the JWT header. Only HS256 is ever minted, and VerifyToken +// requires exactly this value rather than dispatching on whatever the token +// claims -- that check is what makes algorithm-confusion attacks (alg: none, +// or an RS256 public key replayed as an HMAC secret) impossible here. +type tokenHeader struct { + Alg string `json:"alg"` + Typ string `json:"typ"` +} + +// tokenClaims is the JWT payload. +type tokenClaims struct { + Sub string `json:"sub"` + Iat int64 `json:"iat"` + Exp int64 `json:"exp"` +} + +var b64 = base64.RawURLEncoding + +// signingKey derives the HMAC key for tokens from the configured secret. +// +// Deriving rather than generating a random key at startup gives two properties +// worth having: sessions survive a restart, and rotating the secret (including +// via config reload) invalidates every outstanding token for free. The latter +// is the only revocation mechanism a stateless token has. +func signingKey(secret string) []byte { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(signingKeyLabel)) + return mac.Sum(nil) +} + +func sign(key []byte, signingInput string) string { + mac := hmac.New(sha256.New, key) + mac.Write([]byte(signingInput)) + return b64.EncodeToString(mac.Sum(nil)) +} + +// MintToken creates a signed HS256 token valid for ttl, bound to secret. +func MintToken(secret string, now time.Time, ttl time.Duration) (string, error) { + headerJSON, err := json.Marshal(tokenHeader{Alg: "HS256", Typ: "JWT"}) + if err != nil { + return "", fmt.Errorf("failed to encode token header: %w", err) + } + claimsJSON, err := json.Marshal(tokenClaims{ + Sub: tokenSubject, + Iat: now.Unix(), + Exp: now.Add(ttl).Unix(), + }) + if err != nil { + return "", fmt.Errorf("failed to encode token claims: %w", err) + } + + signingInput := b64.EncodeToString(headerJSON) + "." + b64.EncodeToString(claimsJSON) + return signingInput + "." + sign(signingKey(secret), signingInput), nil +} + +// VerifyToken checks a token's signature and expiry against secret. It returns +// the expiry time on success. +func VerifyToken(secret, token string, now time.Time) (time.Time, error) { + headerB64, rest, found := strings.Cut(token, ".") + if !found { + return time.Time{}, errTokenInvalid + } + claimsB64, signatureB64, found := strings.Cut(rest, ".") + if !found { + return time.Time{}, errTokenInvalid + } + + // Compare the signature before decoding any claims, so malformed or + // hostile payloads are never parsed on an unauthenticated path. + expected := sign(signingKey(secret), headerB64+"."+claimsB64) + if !hmac.Equal([]byte(signatureB64), []byte(expected)) { + return time.Time{}, errTokenInvalid + } + + headerJSON, err := b64.DecodeString(headerB64) + if err != nil { + return time.Time{}, errTokenInvalid + } + var header tokenHeader + if err := json.Unmarshal(headerJSON, &header); err != nil { + return time.Time{}, errTokenInvalid + } + if header.Alg != "HS256" || header.Typ != "JWT" { + return time.Time{}, errTokenInvalid + } + + claimsJSON, err := b64.DecodeString(claimsB64) + if err != nil { + return time.Time{}, errTokenInvalid + } + var claims tokenClaims + if err := json.Unmarshal(claimsJSON, &claims); err != nil { + return time.Time{}, errTokenInvalid + } + if claims.Sub != tokenSubject { + return time.Time{}, errTokenInvalid + } + + expiry := time.Unix(claims.Exp, 0) + if !now.Before(expiry) { + return time.Time{}, errTokenExpired + } + return expiry, nil +} diff --git a/libs/auth/token_test.go b/libs/auth/token_test.go new file mode 100644 index 0000000..04e00a6 --- /dev/null +++ b/libs/auth/token_test.go @@ -0,0 +1,175 @@ +package auth + +import ( + "encoding/base64" + "encoding/json" + "strings" + "testing" + "time" +) + +func TestMintAndVerifyToken(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + + token, err := MintToken("a-secret", now, time.Hour) + if err != nil { + t.Fatalf("MintToken returned error: %v", err) + } + + expiry, err := VerifyToken("a-secret", token, now.Add(time.Minute)) + if err != nil { + t.Fatalf("VerifyToken rejected a freshly minted token: %v", err) + } + if want := now.Add(time.Hour).Unix(); expiry.Unix() != want { + t.Errorf("expected expiry %d, got %d", want, expiry.Unix()) + } +} + +func TestVerifyTokenRejections(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + + valid, err := MintToken("a-secret", now, time.Hour) + if err != nil { + t.Fatalf("MintToken returned error: %v", err) + } + + // A token signed with a different secret, used to prove that rotating the + // secret invalidates outstanding sessions. + otherSecret, err := MintToken("other-secret", now, time.Hour) + if err != nil { + t.Fatalf("MintToken returned error: %v", err) + } + + tests := []struct { + name string + secret string + token string + now time.Time + wantErr func(error) bool + }{ + { + name: "expired token", + secret: "a-secret", + token: valid, + now: now.Add(2 * time.Hour), + wantErr: IsErrTokenExpired, + }, + { + name: "exactly at expiry", + secret: "a-secret", + token: valid, + now: now.Add(time.Hour), + wantErr: IsErrTokenExpired, + }, + { + name: "token minted with a different secret", + secret: "a-secret", + token: otherSecret, + now: now, + wantErr: IsErrTokenInvalid, + }, + { + name: "empty token", + secret: "a-secret", + token: "", + now: now, + wantErr: IsErrTokenInvalid, + }, + { + name: "not a jwt", + secret: "a-secret", + token: "garbage", + now: now, + wantErr: IsErrTokenInvalid, + }, + { + name: "missing signature segment", + secret: "a-secret", + token: strings.Join(strings.Split(valid, ".")[:2], "."), + now: now, + wantErr: IsErrTokenInvalid, + }, + { + name: "tampered signature", + secret: "a-secret", + token: valid[:len(valid)-1] + "X", + now: now, + wantErr: IsErrTokenInvalid, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := VerifyToken(tt.secret, tt.token, tt.now) + if err == nil { + t.Fatalf("expected an error, got nil") + } + if !tt.wantErr(err) { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +// TestVerifyTokenRejectsTamperedClaims covers the case that matters most: a +// caller extending their own expiry. The signature covers the claims, so any +// edit invalidates it. +func TestVerifyTokenRejectsTamperedClaims(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + + token, err := MintToken("a-secret", now, time.Minute) + if err != nil { + t.Fatalf("MintToken returned error: %v", err) + } + + parts := strings.Split(token, ".") + if len(parts) != 3 { + t.Fatalf("expected 3 token segments, got %d", len(parts)) + } + + claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + t.Fatalf("failed to decode claims: %v", err) + } + var claims tokenClaims + if err := json.Unmarshal(claimsJSON, &claims); err != nil { + t.Fatalf("failed to unmarshal claims: %v", err) + } + claims.Exp = now.Add(100 * time.Hour).Unix() + forged, err := json.Marshal(claims) + if err != nil { + t.Fatalf("failed to marshal forged claims: %v", err) + } + + tampered := parts[0] + "." + base64.RawURLEncoding.EncodeToString(forged) + "." + parts[2] + if _, err := VerifyToken("a-secret", tampered, now); !IsErrTokenInvalid(err) { + t.Errorf("expected an invalid-token error for tampered claims, got %v", err) + } +} + +// TestVerifyTokenRejectsAlgNone guards the classic JWT footgun: a token that +// declares no signature algorithm must never be accepted. +func TestVerifyTokenRejectsAlgNone(t *testing.T) { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) + claims := base64.RawURLEncoding.EncodeToString( + []byte(`{"sub":"rotom-ui","iat":1700000000,"exp":9999999999}`), + ) + + for _, signature := range []string{"", "anything"} { + token := header + "." + claims + "." + signature + if _, err := VerifyToken("a-secret", token, time.Unix(1_700_000_000, 0)); !IsErrTokenInvalid(err) { + t.Errorf("expected an invalid-token error for alg=none, got %v", err) + } + } +} + +// TestSigningKeyIsDerived asserts the signing key is not the raw secret, so a +// leaked signature can never be replayed as the secret itself. +func TestSigningKeyIsDerived(t *testing.T) { + if got := string(signingKey("a-secret")); got == "a-secret" { + t.Error("signing key must not equal the raw secret") + } + if a, b := signingKey("secret-one"), signingKey("secret-two"); string(a) == string(b) { + t.Error("different secrets must derive different signing keys") + } +} diff --git a/libs/base-ui/src/auth/auth-context.ts b/libs/base-ui/src/auth/auth-context.ts new file mode 100644 index 0000000..be4cc39 --- /dev/null +++ b/libs/base-ui/src/auth/auth-context.ts @@ -0,0 +1,25 @@ +import { createContext, useContext } from "react"; + +export interface AuthContextValue { + /** Whether the server has an api secret configured at all. */ + authRequired: boolean; + /** Ends the session and returns the gate to the login form. */ + logout: () => void; + /** True while a logout request is in flight. */ + loggingOut: boolean; +} + +const AuthContext = createContext(null); + +export const AuthContextProvider = AuthContext.Provider; + +/** + * Auth state for components rendered inside `AuthGate`. + * + * Returns `null` outside a provider rather than throwing, so shared chrome + * like `Layout` can offer a logout control when it happens to be wrapped and + * render normally when it isn't. Consuming apps adopt `AuthGate` on their own + * schedule. + */ +export const useAuthOptional = (): AuthContextValue | null => + useContext(AuthContext); diff --git a/libs/base-ui/src/auth/auth-gate.tsx b/libs/base-ui/src/auth/auth-gate.tsx new file mode 100644 index 0000000..63a9545 --- /dev/null +++ b/libs/base-ui/src/auth/auth-gate.tsx @@ -0,0 +1,159 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { + type FC, + type ReactNode, + useCallback, + useEffect, + useMemo, +} from "react"; +import { Button } from "../components/ui/button"; +import { + type AuthState, + fetchAuthState, + isAuthError, + logout as logoutRequest, +} from "../lib/api"; +import { AuthContextProvider } from "./auth-context"; +import { LoginCard } from "./login-card"; + +const AUTH_QUERY_KEY = ["auth", "me"] as const; + +export interface AuthGateProps { + children: ReactNode; + appName: string; + appIcon?: string; +} + +/** + * Gates the app behind the API secret when one is configured. + * + * Rather than a `/login` route, this swaps the rendered tree in place: the URL + * never changes, so an expired session mid-poll leaves the operator exactly + * where they were and a deep link survives logging in without any + * return-to-path plumbing. + * + * When no secret is configured the server reports `auth_required: false` and + * this renders `children` straight through, so unauthenticated deployments + * behave exactly as they did before. + */ +export const AuthGate: FC = ({ children, appName, appIcon }) => { + const queryClient = useQueryClient(); + + const { + data: authState, + isPending, + isError, + refetch, + } = useQuery({ + queryKey: AUTH_QUERY_KEY, + queryFn: fetchAuthState, + // The probe is not a polling endpoint; session loss is detected from the + // 401s that real queries throw (see the cache subscription below), which + // is both faster and one fewer request every poll interval. + staleTime: Number.POSITIVE_INFINITY, + refetchInterval: false, + retry: 1, + }); + + // Any query or mutation that 401s means the session is gone. Flipping the + // cached auth state here is what turns an expired cookie into a login form + // without every page needing to handle 401 itself. + useEffect(() => { + // A 401 can only happen when a secret is configured, so auth_required is + // necessarily true here regardless of what the last probe said. + const markUnauthenticated = () => { + queryClient.setQueryData(AUTH_QUERY_KEY, { + auth_required: true, + authenticated: false, + }); + }; + + const unsubscribeQueries = queryClient + .getQueryCache() + .subscribe((event) => { + if (isAuthError(event.query.state.error)) { + markUnauthenticated(); + } + }); + + const unsubscribeMutations = queryClient + .getMutationCache() + .subscribe((event) => { + if (isAuthError(event.mutation?.state.error)) { + markUnauthenticated(); + } + }); + + return () => { + unsubscribeQueries(); + unsubscribeMutations(); + }; + }, [queryClient]); + + const logoutMutation = useMutation({ + mutationFn: logoutRequest, + onSuccess: (state) => { + queryClient.setQueryData(AUTH_QUERY_KEY, state); + // Drop every cached page so the next operator to sign in never sees the + // previous session's data flash before the first poll lands. + queryClient.removeQueries({ predicate: (q) => q.queryKey[0] !== "auth" }); + }, + }); + + const handleLoginSuccess = useCallback(() => { + queryClient.setQueryData(AUTH_QUERY_KEY, { + auth_required: true, + authenticated: true, + }); + // Queries that failed while logged out are in an error state and would sit + // there until their next poll; refetch now so the app paints immediately. + queryClient.invalidateQueries(); + }, [queryClient]); + + const contextValue = useMemo( + () => ({ + authRequired: authState?.auth_required ?? false, + logout: () => logoutMutation.mutate(), + loggingOut: logoutMutation.isPending, + }), + [authState?.auth_required, logoutMutation], + ); + + if (isPending) { + return ( +
+ +
+ ); + } + + // A failed probe means the server is unreachable or erroring — distinct from + // being logged out, and showing a login form here would just be misleading. + if (isError) { + return ( +
+

+ Could not reach the {appName} API. +

+ +
+ ); + } + + if (authState.auth_required && !authState.authenticated) { + return ( + + ); + } + + return ( + {children} + ); +}; diff --git a/libs/base-ui/src/auth/index.ts b/libs/base-ui/src/auth/index.ts new file mode 100644 index 0000000..4a9ec4c --- /dev/null +++ b/libs/base-ui/src/auth/index.ts @@ -0,0 +1,3 @@ +export { type AuthContextValue, useAuthOptional } from "./auth-context"; +export { AuthGate, type AuthGateProps } from "./auth-gate"; +export { LoginCard, type LoginCardProps } from "./login-card"; diff --git a/libs/base-ui/src/auth/login-card.tsx b/libs/base-ui/src/auth/login-card.tsx new file mode 100644 index 0000000..4811378 --- /dev/null +++ b/libs/base-ui/src/auth/login-card.tsx @@ -0,0 +1,110 @@ +import { useMutation } from "@tanstack/react-query"; +import { KeyRound, Loader2 } from "lucide-react"; +import { type FC, type FormEvent, useState } from "react"; +import { Button } from "../components/ui/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "../components/ui/card"; +import { Input } from "../components/ui/input"; +import { AESTHETIC_CARD, AESTHETIC_CARD_HEADER } from "../lib/aesthetic"; +import { login } from "../lib/api"; +import { cn } from "../lib/utils"; + +export interface LoginCardProps { + appName: string; + appIcon?: string; + /** Called once the server has accepted the secret and set the cookie. */ + onSuccess: () => void; +} + +/** + * Secret entry form shown by `AuthGate` when the API requires a credential. + * + * The secret is posted to `/api/auth/login` and exchanged for an HttpOnly + * cookie, so it is never persisted anywhere JavaScript can read it — it lives + * in component state for the duration of the submit and nowhere else. + */ +export const LoginCard: FC = ({ + appName, + appIcon, + onSuccess, +}) => { + const [secret, setSecret] = useState(""); + + const loginMutation = useMutation({ + mutationFn: () => login(secret), + onSuccess: () => { + setSecret(""); + onSuccess(); + }, + }); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!secret || loginMutation.isPending) return; + loginMutation.mutate(); + }; + + return ( +
+ + + + {appIcon ? ( + + ) : ( + + )} + Sign in to {appName} + + + +
+ + setSecret(event.target.value)} + disabled={loginMutation.isPending} + /> + + {loginMutation.isError ? ( +

+ {loginMutation.error instanceof Error + ? loginMutation.error.message + : "Sign in failed"} +

+ ) : null} + + +
+
+
+
+ ); +}; diff --git a/libs/base-ui/src/devices/device-page.tsx b/libs/base-ui/src/devices/device-page.tsx index bce40f0..6aa3cbd 100644 --- a/libs/base-ui/src/devices/device-page.tsx +++ b/libs/base-ui/src/devices/device-page.tsx @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { useCallback } from "react"; +import { apiFetch } from "../lib/api"; import { statusQuery } from "../lib/query-options"; import type { Device, Status } from "../types"; import { DeviceGrids } from "./device-grids"; @@ -49,7 +50,7 @@ export const DevicePage = () => { const cancel = new AbortController(); const timer = setTimeout(() => cancel.abort(), 5000); try { - const res = await fetch("/api/device/_/action/delete", { + const res = await apiFetch("/api/device/_/action/delete", { method: "PUT", signal: cancel.signal, }); diff --git a/libs/base-ui/src/devices/devices-table.tsx b/libs/base-ui/src/devices/devices-table.tsx index fc62429..e42703f 100644 --- a/libs/base-ui/src/devices/devices-table.tsx +++ b/libs/base-ui/src/devices/devices-table.tsx @@ -30,6 +30,7 @@ import { TABLE_HEADER_ROW, TABLE_WRAPPER, } from "../lib/aesthetic"; +import { apiFetch } from "../lib/api"; import { cn } from "../lib/utils"; import { Search } from "../search"; import { @@ -450,7 +451,7 @@ const DevicesTable = ({ deviceId: string; action: "reboot" | "restart" | "logcat" | "delete" | "disconnect"; }) => { - const promise = fetch(`/api/device/${deviceId}/action/${action}`, { + const promise = apiFetch(`/api/device/${deviceId}/action/${action}`, { method: "PUT", }).then(async (response) => { if (response.status !== 200) { @@ -517,7 +518,7 @@ const DevicesTable = ({ const action = currentEnabled ? "disable" : "enable"; try { - const response = await fetch( + const response = await apiFetch( `/api/device/${deviceId}/action/${action}`, { method: "PUT" }, ); diff --git a/libs/base-ui/src/index.ts b/libs/base-ui/src/index.ts index e9484e7..214f49b 100644 --- a/libs/base-ui/src/index.ts +++ b/libs/base-ui/src/index.ts @@ -1,4 +1,5 @@ export * from "./anim"; +export * from "./auth"; export { ConfirmationDialog } from "./components/confirmation-dialog"; export { CustomTablePagination } from "./components/custom-table-pagination"; export { StatusGrid } from "./components/status-grid"; @@ -55,10 +56,17 @@ export { TABLE_WRAPPER, } from "./lib/aesthetic"; export { + AuthError, + type AuthState, + apiFetch, + fetchAuthState, fetchConfig, fetchJobInstances, fetchJobs, fetchStatus, + isAuthError, + login, + logout, } from "./lib/api"; export { formatMemory } from "./lib/format-memory"; export { createAppQueryClient } from "./lib/query-client"; diff --git a/libs/base-ui/src/layout/layout.tsx b/libs/base-ui/src/layout/layout.tsx index 5d3dbdf..e94170b 100644 --- a/libs/base-ui/src/layout/layout.tsx +++ b/libs/base-ui/src/layout/layout.tsx @@ -1,8 +1,14 @@ -import { Menu } from "lucide-react"; +import { LogOut, Menu } from "lucide-react"; import { motion } from "motion/react"; import { type FC, type ReactNode, useState } from "react"; +import { useAuthOptional } from "@/auth/auth-context"; import { Button } from "@/components/ui/button"; import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { NavLink } from "./nav-link"; @@ -27,6 +33,10 @@ export const Layout: FC = ({ navItems, }) => { const [drawerOpen, setDrawerOpen] = useState(false); + // Null when the app has not adopted AuthGate; false when no api secret is + // configured. Either way there is no session to end, so no button. + const auth = useAuthOptional(); + const showLogout = auth?.authRequired ?? false; return (
= ({ {item.label} ))} + {showLogout ? ( + + + + + Sign out + + ) : null} @@ -77,6 +103,20 @@ export const Layout: FC = ({ {item.label} ))} + {showLogout ? ( + + ) : null} diff --git a/libs/base-ui/src/lib/api.ts b/libs/base-ui/src/lib/api.ts index 8ec6764..7acf03c 100644 --- a/libs/base-ui/src/lib/api.ts +++ b/libs/base-ui/src/lib/api.ts @@ -7,10 +7,67 @@ * Throwing on non-2xx means TanStack Query sees the failure, hits its retry * logic, and surfaces an `error` to the consumer. Without the check, a 500 * + html body would silently parse to `null` and the UI would mis-render. + * + * Every request goes through `apiFetch`, which attaches the header the server + * requires on cookie-authenticated requests. Calling `fetch` directly against + * `/api` will 401 whenever an api secret is configured — the cookie alone is + * not enough. */ import type { ConfigResponse, JobInstances, Jobs, Status } from "../types"; +/** + * Header marking a request as intending to authenticate with the session + * cookie. The server only honours the cookie when this is present, which is + * what stops a cross-site form post from riding a logged-in operator's + * session — a cross-origin caller cannot set it without a preflight. + */ +const SESSION_REQUEST_HEADER = "X-Rotom-Session"; + +/** + * Thrown when the API rejects a request for want of a valid credential. + * `AuthGate` watches for this to send the operator back to the login form, + * and `query-client.ts` skips retries on it — retrying a 401 just burns + * requests, the credential will not appear on its own. + */ +export class AuthError extends Error { + constructor(message = "Unauthorized") { + super(message); + this.name = "AuthError"; + } +} + +export const isAuthError = (error: unknown): error is AuthError => + error instanceof AuthError; + +/** + * Core request: adds the UI header and keeps same-origin cookies on the + * request. Returns the raw response, 401s included, so callers that need to + * read an error body off a 401 (login) can do so. + */ +const requestApi = (input: string, init?: RequestInit): Promise => { + const headers = new Headers(init?.headers); + headers.set(SESSION_REQUEST_HEADER, "1"); + + return fetch(input, { ...init, headers, credentials: "same-origin" }); +}; + +/** + * `fetch` for the rotom API. As `requestApi`, but converts a 401 into an + * `AuthError` so an expired session surfaces as a login prompt rather than a + * generic failure toast. + */ +export const apiFetch = async ( + input: string, + init?: RequestInit, +): Promise => { + const res = await requestApi(input, init); + if (res.status === 401) { + throw new AuthError(); + } + return res; +}; + const okJson = async (res: Response): Promise => { if (!res.ok) { throw new Error(`${res.status} ${res.statusText}`); @@ -18,14 +75,49 @@ const okJson = async (res: Response): Promise => { return res.json() as Promise; }; +/** Shape of the `/api/auth/me` probe used to decide whether to show login. */ +export interface AuthState { + auth_required: boolean; + authenticated: boolean; +} + +export const fetchAuthState = (): Promise => + apiFetch("/api/auth/me").then(okJson); + +export const login = (secret: string): Promise => + requestApi("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ secret }), + }).then(async (res) => { + if (!res.ok) { + // The server distinguishes a wrong secret from a malformed request; + // surface its message so the form can say which. + let message = `${res.status} ${res.statusText}`; + try { + const body = await res.json(); + if (typeof body?.error === "string" && body.error.trim()) { + message = body.error; + } + } catch { + // Keep the status-line fallback. + } + throw new Error(message); + } + return res.json() as Promise; + }); + +export const logout = (): Promise => + apiFetch("/api/auth/logout", { method: "POST" }).then(okJson); + export const fetchStatus = (): Promise => - fetch("/api/status").then(okJson); + apiFetch("/api/status").then(okJson); export const fetchConfig = (): Promise => - fetch("/api/config").then(okJson); + apiFetch("/api/config").then(okJson); export const fetchJobs = (): Promise<{ jobs: Jobs }> => - fetch("/api/job").then(okJson<{ jobs: Jobs }>); + apiFetch("/api/job").then(okJson<{ jobs: Jobs }>); export const fetchJobInstances = (): Promise<{ instances: JobInstances }> => - fetch("/api/job-instance").then(okJson<{ instances: JobInstances }>); + apiFetch("/api/job-instance").then(okJson<{ instances: JobInstances }>); diff --git a/libs/base-ui/src/lib/query-client.ts b/libs/base-ui/src/lib/query-client.ts index 1463771..33319fe 100644 --- a/libs/base-ui/src/lib/query-client.ts +++ b/libs/base-ui/src/lib/query-client.ts @@ -8,12 +8,15 @@ * - refetchOnWindowFocus: false — polling already covers freshness; the * refocus-triggered refetch only burned bandwidth on tab switches. * - retry: 1 — the next poll IS the retry. Three retries with exponential - * backoff just delays the user seeing the error UI. + * backoff just delays the user seeing the error UI. A 401 is never + * retried: the credential will not materialise on its own, and retrying + * only delays `AuthGate` showing the login form. * - gcTime: 5 min — keeps recently-unmounted page data warm during * navigation, dropped soon enough not to leak. */ import { QueryClient } from "@tanstack/react-query"; +import { isAuthError } from "./api"; import { POLL_INTERVAL_MS } from "./query-options"; export const createAppQueryClient = () => @@ -23,7 +26,7 @@ export const createAppQueryClient = () => staleTime: POLL_INTERVAL_MS - 1000, gcTime: 5 * 60_000, refetchOnWindowFocus: false, - retry: 1, + retry: (failureCount, error) => !isAuthError(error) && failureCount < 1, }, }, }); diff --git a/libs/services/types.go b/libs/services/types.go index 31bd708..9866d64 100644 --- a/libs/services/types.go +++ b/libs/services/types.go @@ -1,12 +1,24 @@ package services -import "github.com/gin-gonic/gin" +import ( + "log/slog" + + "github.com/gin-gonic/gin" +) // AuthMiddleware is an interface for authentication middleware. type AuthMiddleware interface { Handler(ginContext *gin.Context) } +// SessionAuthMiddleware is an AuthMiddleware that can also issue browser +// session credentials. Middleware implementing it gets its session endpoints +// registered on the unauthenticated side of the API group. +type SessionAuthMiddleware interface { + AuthMiddleware + SetupSessionRoutes(group *gin.RouterGroup, logger *slog.Logger) +} + // RoutesInstaller is an interface for setting up routes on a gin engine. type RoutesInstaller interface { SetupRoutes(r *gin.Engine) error diff --git a/libs/services/web_server.go b/libs/services/web_server.go index 2e4cc9d..73c3860 100644 --- a/libs/services/web_server.go +++ b/libs/services/web_server.go @@ -63,6 +63,15 @@ func NewWebServer(ctx context.Context, logger *slog.Logger, config WebServerConf func (s *WebServer) SetupRoutes(r *gin.Engine) error { // API routes { + // Session endpoints live on their own group with no auth middleware: + // they are how the UI obtains a credential, so gating them would make + // logging in impossible. Registering them as a separate group rather + // than relying on ordering keeps that independent of gin's + // middleware-capture-at-registration behaviour. + if sessionMiddleware, ok := s.config.AuthMiddleware.(SessionAuthMiddleware); ok { + sessionMiddleware.SetupSessionRoutes(r.Group("/api"), s.logger) + } + api := r.Group("/api") if authMiddleware := s.config.AuthMiddleware; authMiddleware != nil {