Skip to content

0018 — studio networked-exposure auth: the server gate (R-API-3)

Status: IMPLEMENTED (studio Phase 2D, 2026-06-27). The §3 amendment to D5 (cookie auth) was signed off + landed as GTB WithCookieVerifier (go-tool-base v0.24.0); the studio gate consumes it. §7 decisions all resolved (see below). Date: 2026-06-26 Depends on: GTB v0.23.0 (pkg/authn — the credential verifier primitives), the studio server (pkg/studio/server.go, mux.go). Parent: spec 0015 (§5 build order: 2D before 2C; D5 resolved — startup bearer token; D6 — platform-auth health is 2C, not here).

1. Goal & scope

R-API-3 (MUST): gate the studio when it is not bound to localhost. Today the studio binds localhost (single-user dev, open). The moment a user runs studio --host 0.0.0.0 to reach it from a phone/another machine (the mobile-responsive UI is built for exactly this), the whole surface — workspaces, generation, render, files — is open to the LAN. 2D adds an auth gate that triggers only on a non-localhost bind: a random token minted at startup, printed in the listen URL, required for the data surface.

In scope: the token mint, the gate middleware, the listen-URL print, the SPA's session handling, localhost-stays-open. Out of scope: platform-auth/token-health (the Connections view — that's 2C, D6), TLS/https (future — the trusted-CA work [[auth-trusted-ca-future]]), multi-user/accounts (single-user tool).

2. The decision already made (0015 D5)

A startup bearer token: on a non-localhost bind, mint a random token, print it in the listen URL (http://host:port/?token=…, Jupyter-style); a middleware requires it for the data surface; localhost stays open; no secret in config; rotates per start; the studio never persists it. D5 named GTB's server authn middleware as the mechanism. 2D revisits only how that middleware is wired — see §3, which is where D5 needs a small amendment.

3. The wrinkle: the studio serves media via <img>/<video>, which can't send headers

GTB's http.AuthMiddleware (v0.23.0) extracts the credential from the Authorization header (or a named API-key header) only. But the studio loads takes + previews with <img src> / <audio src> / <video src> pointing at GET …/file/{path} — the browser issues those with no custom headers. A header-only gate over /api/* would 401 every image, audio clip, and video preview the moment the studio is exposed — the gate would break the cockpit, not just protect it. (A fetch-only API would be fine with the header; media src attributes are the problem.)

Resolution (the amendment to D5, signed off 2026-06-26): add cookie auth to GTB's http.AuthMiddleware as a first-class credential source — don't write studio-local glue. GTB gains WithCookieVerifier(cookieName, v) (read the credential from a named cookie, verified by a GTB authn.Verifier — mirrors WithAPIKeyHeader), slotted into the precedence below an explicit header scheme (the ambient cookie never overrides an explicit Authorization). The studio then wires GTB's middleware with both WithBearerVerifier (for API/MCP clients) and WithCookieVerifier (for the browser), and adds only a tiny session bootstrap (?token= valid → Set-Cookie) — the one app-specific flow step. This keeps the security primitive and the gate in GTB (reusable, tested there); the studio owns only the token mint + the bootstrap. The flow is the Jupyter model D5 already cited:

  1. Non-localhost bind → mint token T (crypto/rand, 32 bytes, base64url).
  2. Print http://host:port/?token=T.
  3. A small studio session bootstrap middleware runs first: on a request bearing a valid ?token=T, it Set-Cookies a session cookie (keryx_studio, HttpOnly, SameSite=Strict, Path=/) — establishing the cookie session on the first shell load.
  4. GTB's AuthMiddleware (with WithBearerVerifier(v) + WithCookieVerifier( "keryx_studio", v) + WithAuthSkipper(non-/api)) is the gate: /api/* requires a valid cookie or bearer; non-/api (SPA shell, /healthz) is skipped so the page loads to capture the token.
  5. With the cookie set on the first (shell) load, the browser sends it automatically on every subsequent request — fetch and <img>/<audio>/<video> src — so media just works and the SPA needs no per-request token juggling.

authn.NewAPIKeyVerifier(authn.KeyEntry{Key: T, Subject: "studio"}) gives the one verifier v used for both bearer + cookie. GTB dependency: this needs the new WithCookieVerifier in go-tool-base ≥ v0.24.0 (a separate GTB feature + release; 2D consumes it). On localhost bind neither middleware is installed (open).

4. Surface / behaviour

  • pkg/studio/auth.go (new): mintToken(), newGate(token, log) Middleware (extract → verify → set-cookie-on-token → gate /api/*), isLoopback(host).
  • server.go: when the resolved bind host is non-loopback, mint a token, wrap the mux with the gate, and print the ?token= URL (and a "exposed — token required" line); on loopback, bind as today (open) + a one-line "localhost — open" note.
  • The SPA (main.js): on load, if location.search has token, strip it from the URL (history.replaceState) so it isn't bookmarked/kept in history (the cookie now carries the session). On a 401 from the API, surface "session expired — re-open the studio from its token URL" rather than a generic error.

5. Security posture (single-user dev tool on a trusted LAN)

  • No secret in config / never persisted — the token lives in memory for the process lifetime, rotates per start (R-CFG-2 holds; the studio still never touches platform secrets).
  • HttpOnly cookie (JS can't read it → no XSS exfil), SameSite=Strict (no cross-site CSRF). Not Secure — the studio is http on a LAN; the token is visible on the wire to a LAN MITM. This is acceptable for a trusted-LAN dev tool; the https path is future work ([[auth-trusted-ca-future]]). Documented as a known limitation, not hidden.
  • Fail-closed: any non-loopback bind with no valid credential → 401; the verifier rejects empty/mismatched tokens in constant time; the gate is all-or-nothing on /api.
  • Generic 401, reason logged server-side only (mirrors GTB's posture).

6. Testing / DoD

Failing test → code → green just ci. Unit: mintToken (length/charset/uniqueness), isLoopback (localhost/127.0.0.1/::1/0.0.0.0/LAN IP table), and the gate middleware — table-driven over {loopback open · /api no-cred→401 · valid ?token→200 + Set-Cookie · valid cookie→200 · /healthz + SPA shell always open · bad token→401}. A godog scenario binds the studio non-localhost and asserts /api is 401 without the token and 200 with it (the e2e already starts the real binary — it can pass --host). -race. Docs: the studio component page (an "Exposing the studio" section) + the security limitation. /simplify + /code-review.

7. Decisions — RESOLVED 2026-06-26

  1. The gate transport (§3) — RESOLVED: add cookie auth to GTB. Rather than studio-local glue, GTB's http.AuthMiddleware gains WithCookieVerifier(name, v) (≥ v0.24.0); the studio wires bearer + cookie + a tiny ?tokenSet-Cookie bootstrap. The security primitive + gate live in GTB (reusable, tested); the studio owns only the mint + bootstrap. Two-repo work: the GTB feature ships + releases first, then 2D consumes v0.24.0.
  2. Gate trigger — RESOLVED: non-loopback bind → gate on; loopback → open (per D5). Loopback set = {"", localhost, 127.0.0.1, ::1}.
  3. Gate scope — RESOLVED: /api/* gated; SPA shell + /healthz public (skipper) so the page loads to capture the token.
  4. Cookie attributes — RESOLVED: HttpOnly + SameSite=Strict + not Secure (http LAN), a documented known limitation pending https ([[auth-trusted-ca-future]]).