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:
- Non-localhost bind → mint token
T(crypto/rand, 32 bytes, base64url). - Print
http://host:port/?token=T. - A small studio session bootstrap middleware runs first: on a request bearing a
valid
?token=T, itSet-Cookies a session cookie (keryx_studio,HttpOnly,SameSite=Strict,Path=/) — establishing the cookie session on the first shell load. - GTB's
AuthMiddleware(withWithBearerVerifier(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. - With the cookie set on the first (shell) load, the browser sends it automatically on
every subsequent request —
fetchand<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, iflocation.searchhastoken, strip it from the URL (history.replaceState) so it isn't bookmarked/kept in history (the cookie now carries the session). On a401from 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).
HttpOnlycookie (JS can't read it → no XSS exfil),SameSite=Strict(no cross-site CSRF). NotSecure— the studio ishttpon a LAN; the token is visible on the wire to a LAN MITM. This is acceptable for a trusted-LAN dev tool; thehttpspath 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¶
- The gate transport (§3) — RESOLVED: add cookie auth to GTB. Rather than
studio-local glue, GTB's
http.AuthMiddlewaregainsWithCookieVerifier(name, v)(≥ v0.24.0); the studio wires bearer + cookie + a tiny?token→Set-Cookiebootstrap. 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. - Gate trigger — RESOLVED: non-loopback bind → gate on; loopback → open (per D5).
Loopback set = {
"",localhost,127.0.0.1,::1}. - Gate scope — RESOLVED:
/api/*gated; SPA shell +/healthzpublic (skipper) so the page loads to capture the token. - Cookie attributes — RESOLVED:
HttpOnly+SameSite=Strict+ notSecure(http LAN), a documented known limitation pending https ([[auth-trusted-ca-future]]).