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 {