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 (
+
+ );
+};
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 {