From 0c46c70297fa9161426f0c5f29e7544a774b474e Mon Sep 17 00:00:00 2001 From: jack Date: Mon, 29 Jun 2026 20:00:01 +0800 Subject: [PATCH 1/2] feat(web): require token auth when bound to a non-loopback host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web server had no authentication โ€” the only gate was an Origin check, which does not stop direct (non-browser) requests. Binding to a non-loopback host (`--host 0.0.0.0`) therefore exposed full agent control (shell/file tools, MCP) to anyone who could reach the port. When bound to a non-loopback host (or when a token is explicitly provided), the server now requires a bearer token: - internal/web/auth.go: authMiddleware enforces the token on all non-exempt requests (positive allowlist: SPA assets, /api/health, /api/auth/verify). Token is read from Authorization: Bearer, the `jcode-auth` WebSocket subprotocol, or a ?token= fallback; compared in constant time. - command/web.go: new --auth-token flag (also JCODE_WEB_TOKEN); otherwise a token is auto-generated, persisted to ~/.jcode/web_token (0600), and printed in the startup banner. Loopback binds keep the existing no-auth behaviour. - Frontend: a TokenGate login page (shown when /api/health reports auth_required), Authorization-header injection, and WS token via subprotocol. - pty.go: fix the PTY WebSocket CheckOrigin (was unconditionally true) to isAllowedWebOrigin as defence in depth. Loopback default deployments keep no-auth (unchanged); the separate simple-request CSRF hardening for that case is tracked independently. Tests: internal/web/auth_test.go covers loopback detection, token extraction, constant-time compare, the exemption allowlist, and middleware allow/deny. Co-Authored-By: Claude Opus 4.8 --- internal/command/web.go | 72 +++++++++- internal/web/auth.go | 113 ++++++++++++++++ internal/web/auth_test.go | 165 +++++++++++++++++++++++ internal/web/pty.go | 8 +- internal/web/server.go | 43 ++++-- web/src/App.vue | 44 +++++++ web/src/components/TerminalInstance.vue | 6 +- web/src/components/TokenGate.vue | 167 ++++++++++++++++++++++++ web/src/composables/api.ts | 27 +++- web/src/composables/authToken.ts | 54 ++++++++ web/src/composables/ws.ts | 9 +- web/src/i18n/locales/en.ts | 12 ++ web/src/i18n/locales/ja.ts | 12 ++ web/src/i18n/locales/ko.ts | 12 ++ web/src/i18n/locales/zh-Hans.ts | 12 ++ web/src/i18n/locales/zh-Hant.ts | 12 ++ 16 files changed, 750 insertions(+), 18 deletions(-) create mode 100644 internal/web/auth.go create mode 100644 internal/web/auth_test.go create mode 100644 web/src/components/TokenGate.vue create mode 100644 web/src/composables/authToken.ts diff --git a/internal/command/web.go b/internal/command/web.go index d9c14d6b..987eff8a 100644 --- a/internal/command/web.go +++ b/internal/command/web.go @@ -2,11 +2,14 @@ package command import ( "context" + "crypto/rand" + "encoding/base64" "encoding/json" "fmt" "os" "os/signal" "path/filepath" + "strings" "sync" "sync/atomic" "syscall" @@ -61,17 +64,19 @@ func NewWebCmd() *cobra.Command { var port int var host string var openBrowser bool + var authToken string cmd := &cobra.Command{ Use: "web", Short: "Start the web server", SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - return runWebServer(port, host, openBrowser) + return runWebServer(port, host, openBrowser, authToken) }, } cmd.Flags().IntVar(&port, "port", 8080, "HTTP server port") cmd.Flags().StringVar(&host, "host", "127.0.0.1", "HTTP server host") cmd.Flags().BoolVar(&openBrowser, "open", true, "Open browser after server starts") + cmd.Flags().StringVar(&authToken, "auth-token", "", "Bearer token required when bound to a non-loopback host (auto-generated if empty). Can also be set via JCODE_WEB_TOKEN.") return cmd } @@ -97,7 +102,56 @@ func dropInteractiveTools(tools []tool.BaseTool) []tool.BaseTool { return out } -func runWebServer(port int, host string, openBrowser bool) error { +// resolveWebToken decides the web auth token and whether auth must be enforced. +// +// Auth is required when the bind host is non-loopback (exposed to the network), +// or when a token was explicitly supplied. Token source priority: +// 1. --auth-token flag / JCODE_WEB_TOKEN env โ€” session-scoped, never written to disk +// 2. ~/.jcode/web_token โ€” persisted (0600), reused across restarts +// 3. auto-generated (32 random bytes, base64url) when exposed and none of the +// above; persisted to ~/.jcode/web_token so the token is stable across restarts +func resolveWebToken(host, flagToken string) (token string, requireAuth bool, err error) { + explicit := flagToken + if explicit == "" { + explicit = os.Getenv("JCODE_WEB_TOKEN") + } + // Explicit token (flag/env): enforce auth, never touch disk (session-scoped). + if explicit != "" { + return explicit, true, nil + } + // Loopback bind with no explicit token: keep the existing no-auth behaviour. + if web.IsLoopbackBind(host) { + return "", false, nil + } + // Exposed bind, no explicit token: reuse a persisted token or generate one. + path := filepath.Join(config.ConfigDir(), "web_token") + if b, rerr := os.ReadFile(path); rerr == nil { + if t := strings.TrimSpace(string(b)); t != "" { + return t, true, nil + } + } + gen, gerr := generateWebToken() + if gerr != nil { + return "", true, fmt.Errorf("generate web token: %w", gerr) + } + if werr := os.WriteFile(path, []byte(gen), 0o600); werr != nil { + // Non-fatal: fall back to a session-scoped token (auth still enforced). + config.Logger().Printf("[web] could not persist web token to %s: %v", path, werr) + } + return gen, true, nil +} + +// generateWebToken returns 32 cryptographically-random bytes as a URL-safe +// base64 string (no padding). +func generateWebToken() (string, error) { + var b [32]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b[:]), nil +} + +func runWebServer(port int, host string, openBrowser bool, authToken string) error { // Check if we need setup (no providers configured). needsSetup := config.NeedsSetup() @@ -540,6 +594,18 @@ func runWebServer(port int, host string, openBrowser bool) error { }, nil } + // Resolve the web auth token. Auth is enforced when bound to a non-loopback + // host (exposed to the network) or when a token was explicitly provided. + webToken, requireAuth, err := resolveWebToken(host, authToken) + if err != nil { + return err + } + if requireAuth { + fmt.Printf("\n๐Ÿ” Web access token (required when reaching %s):\n %s\n", host, webToken) + fmt.Printf(" Open http://%s:%d/ and paste this token to sign in.\n\n", host, port) + config.Logger().Printf("[web] token auth enabled for non-loopback bind %q", host) + } + // Bootstrap engine for the initial task. bootEC, err := buildWebTask("", pwd, startupMode.String(), nil, false) if err != nil { @@ -588,6 +654,8 @@ func runWebServer(port int, host string, openBrowser bool) error { TokenUsage: bootEC.TokenUsage, ContextBreakdownFn: bootEC.BreakdownFn, Automations: autoStore, + AuthToken: webToken, + RequireAuth: requireAuth, }) // Start the periodic automation scheduler. A single process owns periodic diff --git a/internal/web/auth.go b/internal/web/auth.go new file mode 100644 index 00000000..d0cf7b0f --- /dev/null +++ b/internal/web/auth.go @@ -0,0 +1,113 @@ +package web + +import ( + "crypto/subtle" + "net" + "net/http" + "strings" +) + +// wsAuthSubprotocol is the WebSocket subprotocol name under which the bearer +// token rides on handshakes. Browsers cannot set custom headers on WebSocket +// connections, so the frontend sends ["jcode-auth", ""] and the token is +// read from the second value. The server also advertises this subprotocol on +// the upgrader so gorilla echoes the protocol name back and the handshake +// completes cleanly. +const wsAuthSubprotocol = "jcode-auth" + +// IsLoopbackBind reports whether the given bind host is loopback-only. +// +// An empty host, "0.0.0.0", or "::" binds all interfaces and is treated as +// exposed (non-loopback). "localhost" and any IP whose IsLoopback() is true are +// loopback. A hostname we cannot resolve statically is conservatively treated as +// exposed, so we fail safe (require auth) rather than fail open. +func IsLoopbackBind(host string) bool { + switch host { + case "", "0.0.0.0", "::": + return false + case "localhost": + return true + } + if ip := net.ParseIP(host); ip != nil { + return ip.IsLoopback() + } + return false // unknown hostname โ†’ assume exposed, require auth +} + +// extractToken pulls the bearer token from a request, in priority order: +// 1. Authorization: Bearer +// 2. Sec-WebSocket-Protocol: jcode-auth, (browser WebSocket handshakes +// can't carry custom headers, so the token rides as the second subprotocol) +// 3. ?token= (fallback for non-browser ws clients; discouraged because +// it lands in access logs, proxy logs and browser history) +func extractToken(r *http.Request) string { + if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { + return strings.TrimSpace(strings.TrimPrefix(h, "Bearer ")) + } + if protos := r.Header.Get("Sec-WebSocket-Protocol"); protos != "" { + parts := strings.Split(protos, ",") + for i, p := range parts { + if strings.TrimSpace(p) == wsAuthSubprotocol && i+1 < len(parts) { + return strings.TrimSpace(parts[i+1]) + } + } + } + return r.URL.Query().Get("token") +} + +// validToken compares the provided token against the expected one in constant +// time. An empty expected or provided token never validates. +func validToken(provided, expected string) bool { + if expected == "" || provided == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1 +} + +// isAuthExempt reports whether a request may proceed without a token even when +// auth is required. It is a POSITIVE allowlist rather than a "/api/* needs auth" +// rule, so an unregistered /api path that falls through to the SPA handler can't +// sneak past: anything under /api/ requires a token unless explicitly listed. +func isAuthExempt(r *http.Request) bool { + if r.Method == http.MethodOptions { + return true // defensive: preflight is normally short-circuited by corsMiddleware + } + p := r.URL.Path + if r.Method == http.MethodGet && p == "/api/health" { + return true // the frontend probes this before it has a token + } + if r.Method == http.MethodPost && p == "/api/auth/verify" { + return true // the endpoint the login page calls to validate a typed token + } + // Everything outside /api/ is the SPA shell + embedded static assets: the + // login page itself must load before the user has a token. + return !strings.HasPrefix(p, "/api/") +} + +// authMiddleware enforces token auth when requireAuth is set. corsMiddleware +// MUST wrap this โ€” corsMiddleware(s.authMiddleware(mux)) โ€” so OPTIONS preflights +// are answered by cors and never reach here without an Authorization header. +func (s *Server) authMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !s.requireAuth || isAuthExempt(r) { + next.ServeHTTP(w, r) + return + } + if !validToken(extractToken(r), s.authToken) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return + } + next.ServeHTTP(w, r) + }) +} + +// handleAuthVerify lets the login page check a token the user typed in. It reads +// the token the same way the middleware does and returns 200 on a match, 401 +// otherwise. When auth is not required it always succeeds. +func (s *Server) handleAuthVerify(w http.ResponseWriter, r *http.Request) { + if !s.requireAuth || validToken(extractToken(r), s.authToken) { + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + return + } + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid token"}) +} diff --git a/internal/web/auth_test.go b/internal/web/auth_test.go new file mode 100644 index 00000000..07ae9184 --- /dev/null +++ b/internal/web/auth_test.go @@ -0,0 +1,165 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestIsLoopbackBind(t *testing.T) { + cases := []struct { + host string + want bool + }{ + {"127.0.0.1", true}, + {"::1", true}, + {"localhost", true}, + {"0.0.0.0", false}, + {"::", false}, + {"", false}, + {"192.168.1.10", false}, + {"10.0.0.5", false}, + {"example.com", false}, // unresolvable hostname โ†’ assume exposed, fail safe + } + for _, c := range cases { + if got := IsLoopbackBind(c.host); got != c.want { + t.Errorf("IsLoopbackBind(%q) = %v, want %v", c.host, got, c.want) + } + } +} + +func TestExtractToken(t *testing.T) { + t.Run("authorization header", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/api/x", nil) + r.Header.Set("Authorization", "Bearer abc123") + if got := extractToken(r); got != "abc123" { + t.Fatalf("got %q, want abc123", got) + } + }) + t.Run("websocket subprotocol", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/api/ws", nil) + r.Header.Set("Sec-WebSocket-Protocol", "jcode-auth, tok-xyz") + if got := extractToken(r); got != "tok-xyz" { + t.Fatalf("got %q, want tok-xyz", got) + } + }) + t.Run("query fallback", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/api/ws?token=qq", nil) + if got := extractToken(r); got != "qq" { + t.Fatalf("got %q, want qq", got) + } + }) + t.Run("header beats query", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/api/x?token=qq", nil) + r.Header.Set("Authorization", "Bearer hdr") + if got := extractToken(r); got != "hdr" { + t.Fatalf("got %q, want hdr", got) + } + }) + t.Run("none", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/api/x", nil) + if got := extractToken(r); got != "" { + t.Fatalf("got %q, want empty", got) + } + }) +} + +func TestValidToken(t *testing.T) { + if !validToken("s3cret", "s3cret") { + t.Error("matching tokens should validate") + } + if validToken("wrong", "s3cret") { + t.Error("mismatched tokens must not validate") + } + if validToken("", "s3cret") { + t.Error("empty provided token must not validate") + } + if validToken("x", "") { + t.Error("empty expected token must not validate") + } +} + +func TestIsAuthExempt(t *testing.T) { + cases := []struct { + method, path string + want bool + }{ + {http.MethodGet, "/api/health", true}, + {http.MethodPost, "/api/auth/verify", true}, + {http.MethodOptions, "/api/chat", true}, // preflight defensively exempt + {http.MethodGet, "/", true}, + {http.MethodGet, "/assets/app.js", true}, + {http.MethodGet, "/index.html", true}, + {http.MethodPost, "/api/chat", false}, + {http.MethodPost, "/api/mcp/servers", false}, + {http.MethodGet, "/api/ws", false}, + {http.MethodGet, "/api/pty/pty_1/ws", false}, + {http.MethodPost, "/api/health", false}, // wrong method โ†’ not exempt + {http.MethodGet, "/api/auth/verify", false}, // wrong method โ†’ not exempt + } + for _, c := range cases { + r := httptest.NewRequest(c.method, c.path, nil) + if got := isAuthExempt(r); got != c.want { + t.Errorf("isAuthExempt(%s %s) = %v, want %v", c.method, c.path, got, c.want) + } + } +} + +func TestAuthMiddleware(t *testing.T) { + // The inner handler returns 418 so we can tell "passed through" (418) apart + // from "blocked by middleware" (401). + const passed = http.StatusTeapot + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(passed) }) + + serve := func(s *Server, r *http.Request) int { + rec := httptest.NewRecorder() + s.authMiddleware(inner).ServeHTTP(rec, r) + return rec.Code + } + + t.Run("auth disabled passes through", func(t *testing.T) { + s := &Server{requireAuth: false} + if code := serve(s, httptest.NewRequest(http.MethodPost, "/api/chat", nil)); code != passed { + t.Fatalf("got %d, want passthrough %d", code, passed) + } + }) + + s := &Server{requireAuth: true, authToken: "s3cret"} + + t.Run("protected without token โ†’ 401", func(t *testing.T) { + if code := serve(s, httptest.NewRequest(http.MethodPost, "/api/chat", nil)); code != http.StatusUnauthorized { + t.Fatalf("got %d, want 401", code) + } + }) + t.Run("protected with wrong token โ†’ 401", func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/api/chat", nil) + r.Header.Set("Authorization", "Bearer nope") + if code := serve(s, r); code != http.StatusUnauthorized { + t.Fatalf("got %d, want 401", code) + } + }) + t.Run("protected with correct token โ†’ passes", func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/api/chat", nil) + r.Header.Set("Authorization", "Bearer s3cret") + if code := serve(s, r); code != passed { + t.Fatalf("got %d, want passthrough %d", code, passed) + } + }) + t.Run("websocket subprotocol token โ†’ passes", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/api/ws", nil) + r.Header.Set("Sec-WebSocket-Protocol", "jcode-auth, s3cret") + if code := serve(s, r); code != passed { + t.Fatalf("got %d, want passthrough %d", code, passed) + } + }) + t.Run("health exempt even with auth on", func(t *testing.T) { + if code := serve(s, httptest.NewRequest(http.MethodGet, "/api/health", nil)); code != passed { + t.Fatalf("got %d, want passthrough %d", code, passed) + } + }) + t.Run("SPA asset exempt", func(t *testing.T) { + if code := serve(s, httptest.NewRequest(http.MethodGet, "/assets/app.js", nil)); code != passed { + t.Fatalf("got %d, want passthrough %d", code, passed) + } + }) +} diff --git a/internal/web/pty.go b/internal/web/pty.go index f79de7d7..834170de 100644 --- a/internal/web/pty.go +++ b/internal/web/pty.go @@ -51,7 +51,13 @@ func newPTYManager() *ptyManager { } var upgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, + // Same cross-origin gate as the main event WebSocket. authMiddleware (auth.go) + // runs first and enforces the token before this upgrade is reached; this is + // the second line of defence. It previously returned true unconditionally, + // which let any web page open a PTY (interactive shell) over the loopback / + // exposed port โ€” a direct RCE on a non-loopback bind. + CheckOrigin: isAllowedWebOrigin, + Subprotocols: []string{wsAuthSubprotocol}, } // register stores a backend under a fresh session id owned by ownerID. diff --git a/internal/web/server.go b/internal/web/server.go index 3929b649..00eb1d0b 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -59,6 +59,12 @@ type Server struct { openBrowser bool wsBroker *WSBroker + // authToken, when requireAuth is set, must be presented as a bearer token on + // every non-exempt request (see authMiddleware in auth.go). requireAuth is + // enabled when the server binds to a non-loopback host. + authToken string + requireAuth bool + // mu guards the shared-server maps and, during the single-active transition, // the bootstrap Engine's run state (the role that moves to a per-Engine lock // once tasks truly run in parallel). @@ -172,6 +178,8 @@ type ServerConfig struct { TokenUsage *model.TokenUsage // optional: shared token tracker (created when nil) ContextBreakdownFn func() usage.ContextBreakdown // optional: live per-task context breakdown Automations *automation.Store // optional: automation store (nil in setup mode) + AuthToken string // bearer token required on non-exempt requests when RequireAuth is set + RequireAuth bool // enforce token auth (set when bound to a non-loopback host) } // NewServer creates a new web server. @@ -234,6 +242,8 @@ func NewServer(cfg *ServerConfig) *Server { needsSetup: cfg.NeedsSetup, automations: cfg.Automations, autoRunInflight: make(map[string]bool), + authToken: cfg.AuthToken, + requireAuth: cfg.RequireAuth, } // The bootstrap engine is registered (and its pump started) in Start, once // the root context exists. @@ -270,6 +280,7 @@ func (s *Server) Start(ctx context.Context) error { // API routes mux.HandleFunc("GET /api/health", s.handleHealth) + mux.HandleFunc("POST /api/auth/verify", s.handleAuthVerify) mux.HandleFunc("GET /api/ws", s.handleWebSocket) mux.HandleFunc("POST /api/chat", s.handleChat) mux.HandleFunc("POST /api/stop", s.handleStop) @@ -373,8 +384,11 @@ func (s *Server) Start(ctx context.Context) error { // Serve embedded frontend (SPA with fallback to index.html) mux.Handle("GET /", newSPAHandler()) - // CORS middleware - corsHandler := corsMiddleware(mux) + // Auth (token) then CORS. corsMiddleware MUST stay the outer wrapper so + // OPTIONS preflights are answered there and never reach authMiddleware + // without an Authorization header. authMiddleware is a no-op unless + // requireAuth is set (i.e. bound to a non-loopback host). + corsHandler := corsMiddleware(s.authMiddleware(mux)) addr := fmt.Sprintf("%s:%d", s.host, s.port) @@ -450,15 +464,16 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { pwd = eng.pwd } writeJSON(w, http.StatusOK, map[string]any{ - "status": "needs_setup", - "version": s.version, - "pwd": pwd, - "provider": "", - "model": "", - "mode": "build", - "session_id": "", - "running": false, - "needs_setup": true, + "status": "needs_setup", + "version": s.version, + "pwd": pwd, + "provider": "", + "model": "", + "mode": "build", + "session_id": "", + "running": false, + "needs_setup": true, + "auth_required": s.requireAuth, }) return } @@ -474,6 +489,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { "session_id": eng.recUUID(), "running": eng.running.Load(), "image_support": s.currentModelSupportsImage(eng), + "auth_required": s.requireAuth, }) } @@ -2543,6 +2559,11 @@ func (s *Server) handleSetApprovalMode(w http.ResponseWriter, r *http.Request) { // to the loopback server and read the agent's live event stream. var wsUpgrader = websocket.Upgrader{ CheckOrigin: isAllowedWebOrigin, + // Advertise the auth subprotocol so gorilla echoes it back on the handshake + // response; browsers send ["jcode-auth", ""] and expect the server to + // confirm a subprotocol, otherwise some reject the connection. The token (the + // second value) is never echoed. + Subprotocols: []string{wsAuthSubprotocol}, } func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { diff --git a/web/src/App.vue b/web/src/App.vue index 6ad1ffa3..8ddb6860 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -7,6 +7,8 @@ import type { RemoteMeta } from '@/types/api' import { useChatStore } from '@/stores/chat' import { useProjectStore } from '@/stores/project' import { useWebSocket } from '@/composables/ws' +import { api } from '@/composables/api' +import { useAuthToken, clearAuthToken, setAuthExpiredHandler } from '@/composables/authToken' import { useTheme } from '@/composables/useTheme' import { useBranch } from '@/composables/useBranch' import ChatMessageVue from '@/components/ChatMessage.vue' @@ -21,6 +23,7 @@ import RemoteConnectWizard from '@/components/RemoteConnectWizard.vue' import TerminalPanel from '@/components/TerminalPanel.vue' import RightPanel from '@/components/RightPanel.vue' import SetupView from '@/components/SetupView.vue' +import TokenGate from '@/components/TokenGate.vue' import TopBar from '@/components/TopBar.vue' import CommandPalette from '@/components/CommandPalette.vue' import AutomationsView from '@/components/AutomationsView.vue' @@ -87,6 +90,8 @@ function onSettingsClose() { settingsOpen.value = false } const needsSetup = ref(false) +const needsAuth = ref(false) +const authToken = useAuthToken() // Honor reduced-motion for the new-task โ†” conversation composer transition. const reduceMotion = ref( @@ -329,6 +334,16 @@ async function boot() { return } connectionError.value = false + // Auth gate must run BEFORE the setup gate: /api/setup/* is itself protected, + // so without a valid token the wizard's own calls would 401. + if (health.auth_required) { + const ok = authToken.value ? await verifyToken(authToken.value) : false + if (!ok) { + needsAuth.value = true + return + } + } + needsAuth.value = false if (health.needs_setup) { needsSetup.value = true return @@ -343,12 +358,20 @@ async function boot() { onMounted(async () => { document.addEventListener('keydown', handleGlobalKeydown) + // A 401 from any request (e.g. a token that expired mid-session) routes here. + // Idempotent: only clear when we actually hold a token, so boot-time parallel + // 401s (the fire-and-forget status() before the gate) don't loop the gate. + setAuthExpiredHandler(() => { + if (authToken.value) clearAuthToken() + needsAuth.value = true + }) ensurePermission() await boot() }) onUnmounted(() => { document.removeEventListener('keydown', handleGlobalKeydown) + setAuthExpiredHandler(null) if (runTimer) clearInterval(runTimer) }) @@ -434,6 +457,22 @@ async function startNewTaskInProject(path: string): Promise { return true } +async function verifyToken(candidate: string): Promise { + try { + await api.authVerify(candidate) + return true + } catch { + return false + } +} + +// TokenGate already persisted the token; re-run boot so health/setup/workspace +// all load with the token in place. +function onAuthed() { + needsAuth.value = false + boot() +} + function onSetupComplete() { needsSetup.value = false connectionError.value = false @@ -729,6 +768,11 @@ function startResize(e: MouseEvent) { /> + + + diff --git a/web/src/components/TerminalInstance.vue b/web/src/components/TerminalInstance.vue index 802a3a7b..7a27716d 100644 --- a/web/src/components/TerminalInstance.vue +++ b/web/src/components/TerminalInstance.vue @@ -6,6 +6,7 @@ import { WebLinksAddon } from '@xterm/addon-web-links' import { useI18n } from 'vue-i18n' import { api } from '@/composables/api' import { wsBase } from '@/composables/apiBase' +import { getAuthToken } from '@/composables/authToken' import '@xterm/xterm/css/xterm.css' const props = defineProps<{ @@ -126,7 +127,10 @@ async function init() { function connectWS(ptyId: string) { if (!term) return const url = getWsUrl(ptyId) - ws = new WebSocket(url) + // Token (when the server requires auth) rides as the second WS subprotocol โ€” + // browsers can't set headers on a WS handshake. See auth.go. + const token = getAuthToken() + ws = token ? new WebSocket(url, ['jcode-auth', token]) : new WebSocket(url) ws.binaryType = 'arraybuffer' ws.onopen = () => { diff --git a/web/src/components/TokenGate.vue b/web/src/components/TokenGate.vue new file mode 100644 index 00000000..de3ff711 --- /dev/null +++ b/web/src/components/TokenGate.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/web/src/composables/api.ts b/web/src/composables/api.ts index 0162fa52..8b3039c8 100644 --- a/web/src/composables/api.ts +++ b/web/src/composables/api.ts @@ -2,15 +2,30 @@ import type { ModelsResponse, AgentMode, ExecResponse, DiffResponse, WorkspaceInfo, GitBranchesResponse, GitCheckoutResponse, TaskItem, TaskMetaPatch, MCPListResponse, MCPServerRequest, MCPLoginStatus, BrowseResponse, SSHListResponse, SkillInfo, SlashCommandInfo, TodoItem, Goal, SessionItem, SessionEntry, FileItem, SetupProvider, SetupModel, ProviderDetail, ProviderAdvanced, CustomModelDetail, ValidateResult, CatalogModel, ModelStateResponse, ChatImage, AskUserAnswer, AskUserRequestData, ApprovalRequestData, RemoteConnectRequest, RemoteConnectResponse, RemoteListDirResponse, RemoteBindResponse, DockerContainersResponse, UsageStats, TaskStats, TokenUpdateData } from '@/types/api' import type { AutomationItem, AutomationRun, AutomationTemplate, AutomationCreate, Automation } from '@/types/automation' import { apiBase } from './apiBase' +import { getAuthToken, notifyAuthExpired } from './authToken' -async function request(path: string, options?: RequestInit): Promise { +interface RequestOptions extends RequestInit { + /** + * Skip auto Authorization injection AND the global 401 handler. Used by + * authVerify, where a 401 means "wrong token typed in the login page" and must + * surface as an error in that form โ€” not clear the token / re-trigger the gate. + */ + skipAuth?: boolean +} + +async function request(path: string, options?: RequestOptions): Promise { + const token = getAuthToken() const resp = await fetch(`${apiBase}${path}`, { ...options, headers: { 'Content-Type': 'application/json', + ...(token && !options?.skipAuth ? { Authorization: `Bearer ${token}` } : {}), ...options?.headers, }, }) + if (resp.status === 401 && !options?.skipAuth) { + notifyAuthExpired() + } if (!resp.ok) { const body = await resp.json().catch(() => ({ error: resp.statusText })) throw new Error(body.error || `HTTP ${resp.status}`) @@ -25,9 +40,17 @@ export const api = { body: JSON.stringify({ before_user_message: beforeUserMessage }), }), health: () => - request<{ status: string; version: string; pwd: string; provider: string; model: string; mode: string; session_id: string; running: boolean; image_support?: boolean; needs_setup?: boolean }>( + request<{ status: string; version: string; pwd: string; provider: string; model: string; mode: string; session_id: string; running: boolean; image_support?: boolean; needs_setup?: boolean; auth_required?: boolean }>( '/api/health', ), + // authVerify validates a token typed into the login gate. skipAuth keeps a 401 + // (wrong token) from tripping the global expiry handler โ€” the gate shows it. + authVerify: (token: string) => + request<{ ok: boolean }>('/api/auth/verify', { + method: 'POST', + skipAuth: true, + headers: { Authorization: `Bearer ${token}` }, + }), status: () => request<{ running: boolean diff --git a/web/src/composables/authToken.ts b/web/src/composables/authToken.ts new file mode 100644 index 00000000..8cdd83a4 --- /dev/null +++ b/web/src/composables/authToken.ts @@ -0,0 +1,54 @@ +// Web access token storage + accessors. +// +// Kept OUTSIDE the Pinia store on purpose: api.ts and ws.ts must read the token +// without importing a store, which would create a circular import (api โ†” store) +// and may run before createPinia(). The token is a plain module-level reactive +// ref, persisted to localStorage so it survives reloads. +// +// Only relevant when the server is bound to a non-loopback host (it reports +// `auth_required` from /api/health). On loopback / desktop the token stays empty +// and nothing here has any effect. +import { ref } from 'vue' + +const STORAGE_KEY = 'jcode_web_token' + +const token = ref(localStorage.getItem(STORAGE_KEY) || '') + +/** Current token (empty string when none). Read fresh on every request/WS connect. */ +export function getAuthToken(): string { + return token.value +} + +/** Persist (or clear, when empty) the token. */ +export function setAuthToken(t: string): void { + token.value = t + if (t) localStorage.setItem(STORAGE_KEY, t) + else localStorage.removeItem(STORAGE_KEY) +} + +export function clearAuthToken(): void { + setAuthToken('') +} + +/** Reactive ref for components (login gate) that need to watch the token. */ +export function useAuthToken() { + return token +} + +// --- expiry notification --------------------------------------------------- +// api.ts cannot import the App component, so on a 401 it calls notifyAuthExpired() +// and App registers a handler at mount (clears the token + shows the login gate). +type AuthExpiredHandler = () => void +let onExpired: AuthExpiredHandler | null = null + +export function setAuthExpiredHandler(fn: AuthExpiredHandler | null): void { + onExpired = fn +} + +export function notifyAuthExpired(): void { + onExpired?.() +} + +// NOTE: desktop (Tauri) token injection hook โ€” when the desktop sidecar later +// runs on a non-loopback bind, initApiBase() can `invoke('get_sidecar_token')` +// and call setAuthToken() here; api.ts/ws.ts need no further change. diff --git a/web/src/composables/ws.ts b/web/src/composables/ws.ts index 230cc150..7f9e5c29 100644 --- a/web/src/composables/ws.ts +++ b/web/src/composables/ws.ts @@ -1,6 +1,7 @@ // WebSocket client composable for jcode web import { ref, onUnmounted } from 'vue' import { wsBase } from './apiBase' +import { getAuthToken } from './authToken' import type { AgentTextData, ToolCallData, @@ -85,7 +86,13 @@ export function useWebSocket(handlers: WSHandler) { // Build the WS URL from the resolved base: relative in browser mode (the // page is served by the API server), absolute in desktop mode (the page is // cross-origin to the Go server). See composables/apiBase.ts. - ws = new WebSocket(`${wsBase()}/api/ws`) + // Token (when the server requires auth) rides as the second WebSocket + // subprotocol โ€” browsers can't set headers on a WS handshake. Read fresh on + // every (re)connect so a token entered after a drop is picked up. See auth.go. + const token = getAuthToken() + ws = token + ? new WebSocket(`${wsBase()}/api/ws`, ['jcode-auth', token]) + : new WebSocket(`${wsBase()}/api/ws`) ws.onopen = () => { connected.value = true diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 96dcabec..d8e2d3fb 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -87,6 +87,18 @@ export default { // Connection-error overlay (App.vue). Currently hardcoded Chinese in the // source โ€” English is the canonical source here; the zh-* files carry the // original Chinese wording. + auth: { + title: 'Access token required', + body: 'This server is reachable over the network, so it needs a token. Paste the token shown in the server startup banner.', + placeholder: 'Paste access token', + show: 'Show', + hide: 'Hide', + submit: 'Continue', + verifying: 'Verifyingโ€ฆ', + required: 'Please enter the token', + invalid: 'Invalid token', + }, + connection: { errorTitle: "Can't connect to the jcode service", errorBody: 'The local service may not be ready yet, or it has stopped. Please retry shortly, or restart the app.', diff --git a/web/src/i18n/locales/ja.ts b/web/src/i18n/locales/ja.ts index 209ab2b6..ab5bd1f7 100644 --- a/web/src/i18n/locales/ja.ts +++ b/web/src/i18n/locales/ja.ts @@ -76,6 +76,18 @@ export default { }, }, + auth: { + title: 'ใ‚ขใ‚ฏใ‚ปใ‚นใƒˆใƒผใ‚ฏใƒณใŒๅฟ…่ฆใงใ™', + body: 'ใ“ใฎใ‚ตใƒผใƒใƒผใฏใƒใƒƒใƒˆใƒฏใƒผใ‚ฏ็ตŒ็”ฑใงใ‚ขใ‚ฏใ‚ปใ‚นๅฏ่ƒฝใชใŸใ‚ใ€ใƒˆใƒผใ‚ฏใƒณใŒๅฟ…่ฆใงใ™ใ€‚่ตทๅ‹•ๆ™‚ใซ่กจ็คบใ•ใ‚ŒใŸใƒˆใƒผใ‚ฏใƒณใ‚’่ฒผใ‚Šไป˜ใ‘ใฆใใ ใ•ใ„ใ€‚', + placeholder: 'ใ‚ขใ‚ฏใ‚ปใ‚นใƒˆใƒผใ‚ฏใƒณใ‚’่ฒผใ‚Šไป˜ใ‘', + show: '่กจ็คบ', + hide: '้ž่กจ็คบ', + submit: '็ถš่กŒ', + verifying: 'ๆคœ่จผไธญโ€ฆ', + required: 'ใƒˆใƒผใ‚ฏใƒณใ‚’ๅ…ฅๅŠ›ใ—ใฆใใ ใ•ใ„', + invalid: 'ใƒˆใƒผใ‚ฏใƒณใŒ็„กๅŠนใงใ™', + }, + connection: { errorTitle: 'jcode ใ‚ตใƒผใƒ“ใ‚นใซๆŽฅ็ถšใงใใพใ›ใ‚“', errorBody: 'ใƒญใƒผใ‚ซใƒซใ‚ตใƒผใƒ“ใ‚นใฎๆบ–ๅ‚™ใŒใงใใฆใ„ใชใ„ใ‹ใ€ๅœๆญขใ—ใฆใ„ใ‚‹ๅฏ่ƒฝๆ€งใŒใ‚ใ‚Šใพใ™ใ€‚ใ—ใฐใ‚‰ใใ—ใฆใ‹ใ‚‰ๅ†่ฉฆ่กŒใ™ใ‚‹ใ‹ใ€ใ‚ขใƒ—ใƒชใ‚’ๅ†่ตทๅ‹•ใ—ใฆใใ ใ•ใ„ใ€‚', diff --git a/web/src/i18n/locales/ko.ts b/web/src/i18n/locales/ko.ts index e3f053e0..d418af19 100644 --- a/web/src/i18n/locales/ko.ts +++ b/web/src/i18n/locales/ko.ts @@ -76,6 +76,18 @@ export default { }, }, + auth: { + title: '์•ก์„ธ์Šค ํ† ํฐ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค', + body: '์ด ์„œ๋ฒ„๋Š” ๋„คํŠธ์›Œํฌ๋กœ ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ์–ด ํ† ํฐ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ์„œ๋ฒ„ ์‹œ์ž‘ ์‹œ ํ‘œ์‹œ๋œ ํ† ํฐ์„ ๋ถ™์—ฌ๋„ฃ์œผ์„ธ์š”.', + placeholder: '์•ก์„ธ์Šค ํ† ํฐ ๋ถ™์—ฌ๋„ฃ๊ธฐ', + show: 'ํ‘œ์‹œ', + hide: '์ˆจ๊ธฐ๊ธฐ', + submit: '๊ณ„์†', + verifying: 'ํ™•์ธ ์ค‘โ€ฆ', + required: 'ํ† ํฐ์„ ์ž…๋ ฅํ•˜์„ธ์š”', + invalid: '์œ ํšจํ•˜์ง€ ์•Š์€ ํ† ํฐ', + }, + connection: { errorTitle: 'jcode ์„œ๋น„์Šค์— ์—ฐ๊ฒฐํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค', errorBody: '๋กœ์ปฌ ์„œ๋น„์Šค๊ฐ€ ์•„์ง ์ค€๋น„๋˜์ง€ ์•Š์•˜๊ฑฐ๋‚˜ ์ค‘์ง€๋˜์—ˆ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•˜๊ฑฐ๋‚˜, ์•ฑ์„ ์ข…๋ฃŒํ•˜๊ณ  ๋‹ค์‹œ ์‹œ์ž‘ํ•˜์„ธ์š”.', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index d14bd469..5c06a050 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -76,6 +76,18 @@ export default { }, }, + auth: { + title: '้œ€่ฆ่ฎฟ้—ฎไปค็‰Œ', + body: 'ๆญคๆœๅŠกๅฏ้€š่ฟ‡็ฝ‘็ปœ่ฎฟ้—ฎ๏ผŒๅ› ๆญค้œ€่ฆไปค็‰Œใ€‚่ฏท็ฒ˜่ดดๆœๅŠกๅ™จๅฏๅŠจๆ—ถๆ‰“ๅฐ็š„ไปค็‰Œใ€‚', + placeholder: '็ฒ˜่ดด่ฎฟ้—ฎไปค็‰Œ', + show: 'ๆ˜พ็คบ', + hide: '้š่—', + submit: '่ฟ›ๅ…ฅ', + verifying: '้ชŒ่ฏไธญโ€ฆ', + required: '่ฏท่พ“ๅ…ฅไปค็‰Œ', + invalid: 'ไปค็‰Œๆ— ๆ•ˆ', + }, + connection: { errorTitle: 'ๆ— ๆณ•่ฟžๆŽฅๅˆฐ jcode ๆœๅŠก', errorBody: 'ๆœฌๅœฐๆœๅŠกๅฏ่ƒฝๅฐšๆœชๅฐฑ็ปชๆˆ–ๅทฒๅœๆญขใ€‚่ฏท็จๅ€™้‡่ฏ•๏ผŒๆˆ–้€€ๅ‡บๅŽ้‡ๆ–ฐๅฏๅŠจใ€‚', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 480c303a..3ed8b4bc 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -77,6 +77,18 @@ export default { }, }, + auth: { + title: '้œ€่ฆๅญ˜ๅ–ๆฌŠๆ–', + body: 'ๆญคๆœๅ‹™ๅฏ้€้Ž็ถฒ่ทฏๅญ˜ๅ–๏ผŒๅ› ๆญค้œ€่ฆๆฌŠๆ–ใ€‚่ซ‹่ฒผไธŠไผบๆœๅ™จๅ•Ÿๅ‹•ๆ™‚้กฏ็คบ็š„ๆฌŠๆ–ใ€‚', + placeholder: '่ฒผไธŠๅญ˜ๅ–ๆฌŠๆ–', + show: '้กฏ็คบ', + hide: '้šฑ่—', + submit: '้€ฒๅ…ฅ', + verifying: '้ฉ—่ญ‰ไธญโ€ฆ', + required: '่ซ‹่ผธๅ…ฅๆฌŠๆ–', + invalid: 'ๆฌŠๆ–็„กๆ•ˆ', + }, + connection: { errorTitle: '็„กๆณ•้€ฃ็ทšๅˆฐ jcode ๆœๅ‹™', errorBody: 'ๆœฌๆฉŸๆœๅ‹™ๅฏ่ƒฝๅฐšๆœชๅฐฑ็ท’ๆˆ–ๅทฒๅœๆญขใ€‚่ซ‹็จๅ€™้‡่ฉฆ๏ผŒๆˆ–็ตๆŸๅพŒ้‡ๆ–ฐๅ•Ÿๅ‹•ใ€‚', From c57faa0d208e37d9e53581e5ddc3e9b05d0ea49a Mon Sep 17 00:00:00 2001 From: jack Date: Mon, 29 Jun 2026 20:21:10 +0800 Subject: [PATCH 2/2] fix(web): address CodeRabbit review on token auth - auth.go: limit ?token= fallback to the WebSocket endpoints only (keep bearer tokens out of HTTP access/proxy logs and history); add IsValidWSSubprotocolToken. - command/web.go: reject explicit tokens that aren't valid WS subprotocol values; MkdirAll ~/.jcode before persisting the auto-generated token so a first remote start doesn't silently fall back to a session-only token. - api.ts: normalize HeadersInit via Headers; attach response status to thrown errors so callers can distinguish 401 from transport/5xx. - App.vue / TokenGate.vue: only treat 401 as an invalid token; transport/5xx now surface as connection/server errors instead of forcing the login gate. - authToken.ts: return a readonly token ref from useAuthToken. - TokenGate.vue: use the --color-error-fg design token (drop hardcoded hex). - i18n: add auth.serverError (en/zh-Hans/zh-Hant/ja/ko). - auth_test.go: cover ws-only query fallback and IsValidWSSubprotocolToken. Co-Authored-By: Claude Opus 4.8 --- internal/command/web.go | 12 +++++++++-- internal/web/auth.go | 36 +++++++++++++++++++++++++++++++- internal/web/auth_test.go | 27 ++++++++++++++++++++++++ web/src/App.vue | 24 +++++++++++++++------ web/src/components/TokenGate.vue | 8 ++++--- web/src/composables/api.ts | 22 +++++++++++-------- web/src/composables/authToken.ts | 10 ++++++--- web/src/i18n/locales/en.ts | 1 + web/src/i18n/locales/ja.ts | 1 + web/src/i18n/locales/ko.ts | 1 + web/src/i18n/locales/zh-Hans.ts | 1 + web/src/i18n/locales/zh-Hant.ts | 1 + 12 files changed, 120 insertions(+), 24 deletions(-) diff --git a/internal/command/web.go b/internal/command/web.go index 987eff8a..4ac1db75 100644 --- a/internal/command/web.go +++ b/internal/command/web.go @@ -117,6 +117,9 @@ func resolveWebToken(host, flagToken string) (token string, requireAuth bool, er } // Explicit token (flag/env): enforce auth, never touch disk (session-scoped). if explicit != "" { + if !web.IsValidWSSubprotocolToken(explicit) { + return "", true, fmt.Errorf("auth token must be printable ASCII with no spaces or separators (it is sent as a WebSocket subprotocol)") + } return explicit, true, nil } // Loopback bind with no explicit token: keep the existing no-auth behaviour. @@ -124,7 +127,8 @@ func resolveWebToken(host, flagToken string) (token string, requireAuth bool, er return "", false, nil } // Exposed bind, no explicit token: reuse a persisted token or generate one. - path := filepath.Join(config.ConfigDir(), "web_token") + dir := config.ConfigDir() + path := filepath.Join(dir, "web_token") if b, rerr := os.ReadFile(path); rerr == nil { if t := strings.TrimSpace(string(b)); t != "" { return t, true, nil @@ -134,7 +138,11 @@ func resolveWebToken(host, flagToken string) (token string, requireAuth bool, er if gerr != nil { return "", true, fmt.Errorf("generate web token: %w", gerr) } - if werr := os.WriteFile(path, []byte(gen), 0o600); werr != nil { + // Ensure ~/.jcode exists before writing, otherwise a first remote start would + // fail with ENOENT and silently fall back to a session-scoped token. + if merr := os.MkdirAll(dir, 0o700); merr != nil { + config.Logger().Printf("[web] could not create config dir %s: %v", dir, merr) + } else if werr := os.WriteFile(path, []byte(gen), 0o600); werr != nil { // Non-fatal: fall back to a session-scoped token (auth still enforced). config.Logger().Printf("[web] could not persist web token to %s: %v", path, werr) } diff --git a/internal/web/auth.go b/internal/web/auth.go index d0cf7b0f..b5ba98b1 100644 --- a/internal/web/auth.go +++ b/internal/web/auth.go @@ -52,7 +52,41 @@ func extractToken(r *http.Request) string { } } } - return r.URL.Query().Get("token") + // The ?token= fallback exists only for non-browser WebSocket clients that can + // set neither a header nor a subprotocol. Restrict it to the WS endpoints so + // bearer tokens for normal HTTP APIs never land in access/proxy logs, the + // Referer header, or browser history. + if acceptsQueryToken(r.URL.Path) { + return r.URL.Query().Get("token") + } + return "" +} + +// acceptsQueryToken reports whether the ?token= fallback is allowed for a path โ€” +// only the WebSocket endpoints (the main event stream and the PTY sockets). +func acceptsQueryToken(path string) bool { + return path == "/api/ws" || (strings.HasPrefix(path, "/api/pty/") && strings.HasSuffix(path, "/ws")) +} + +// IsValidWSSubprotocolToken reports whether s is usable as a WebSocket +// subprotocol value (RFC 6455 / RFC 7230 token): non-empty, printable ASCII with +// no spaces or separators. The frontend sends the token as the second WS +// subprotocol, so an explicit token containing such characters would make the +// browser's WebSocket constructor throw and break the connection at startup. +func IsValidWSSubprotocolToken(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < 0x21 || r > 0x7e { + return false // control chars, space, and non-ASCII + } + switch r { + case '(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', '?', '=', '{', '}': + return false // RFC 7230 separators + } + } + return true } // validToken compares the provided token against the expected one in constant diff --git a/internal/web/auth_test.go b/internal/web/auth_test.go index 07ae9184..9f598712 100644 --- a/internal/web/auth_test.go +++ b/internal/web/auth_test.go @@ -62,6 +62,18 @@ func TestExtractToken(t *testing.T) { t.Fatalf("got %q, want empty", got) } }) + t.Run("query ignored on non-ws endpoint", func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/api/chat?token=qq", nil) + if got := extractToken(r); got != "" { + t.Fatalf("got %q, want empty (query token only allowed on ws endpoints)", got) + } + }) + t.Run("query allowed on pty ws", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/api/pty/pty_1/ws?token=pp", nil) + if got := extractToken(r); got != "pp" { + t.Fatalf("got %q, want pp", got) + } + }) } func TestValidToken(t *testing.T) { @@ -79,6 +91,21 @@ func TestValidToken(t *testing.T) { } } +func TestIsValidWSSubprotocolToken(t *testing.T) { + valid := []string{"abc123", "aZ0-_", "Zm9vYmFy", "x"} // base64url-style tokens + for _, s := range valid { + if !IsValidWSSubprotocolToken(s) { + t.Errorf("IsValidWSSubprotocolToken(%q) = false, want true", s) + } + } + invalid := []string{"", "has space", "has,comma", "semi;colon", "a/b", "quote\"x", "ctrl\tx", "รผnรฏcode"} + for _, s := range invalid { + if IsValidWSSubprotocolToken(s) { + t.Errorf("IsValidWSSubprotocolToken(%q) = true, want false", s) + } + } +} + func TestIsAuthExempt(t *testing.T) { cases := []struct { method, path string diff --git a/web/src/App.vue b/web/src/App.vue index 8ddb6860..06ccf42a 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -337,11 +337,21 @@ async function boot() { // Auth gate must run BEFORE the setup gate: /api/setup/* is itself protected, // so without a valid token the wizard's own calls would 401. if (health.auth_required) { - const ok = authToken.value ? await verifyToken(authToken.value) : false - if (!ok) { + if (!authToken.value) { needsAuth.value = true return } + const res = await verifyToken(authToken.value) + if (res === 'invalid') { + needsAuth.value = true + return + } + if (res === 'error') { + // Transport/5xx: the stored token may still be valid โ€” surface as a + // connection error (retryable) rather than forcing the login gate. + connectionError.value = true + return + } } needsAuth.value = false if (health.needs_setup) { @@ -457,12 +467,14 @@ async function startNewTaskInProject(path: string): Promise { return true } -async function verifyToken(candidate: string): Promise { +// 'ok' = token valid; 'invalid' = server returned 401; 'error' = transport/5xx, +// so the stored token may still be good and we should not force the login gate. +async function verifyToken(candidate: string): Promise<'ok' | 'invalid' | 'error'> { try { await api.authVerify(candidate) - return true - } catch { - return false + return 'ok' + } catch (e) { + return (e as { status?: number })?.status === 401 ? 'invalid' : 'error' } } diff --git a/web/src/components/TokenGate.vue b/web/src/components/TokenGate.vue index de3ff711..93ea9290 100644 --- a/web/src/components/TokenGate.vue +++ b/web/src/components/TokenGate.vue @@ -27,8 +27,10 @@ async function submit() { await api.authVerify(candidate) // skipAuth: a 401 surfaces here, not as expiry setAuthToken(candidate) emit('authed') - } catch { - error.value = t('auth.invalid') + } catch (e) { + // Only a 401 means the token is wrong; transport/5xx is a server problem and + // should not be reported as bad credentials. + error.value = (e as { status?: number })?.status === 401 ? t('auth.invalid') : t('auth.serverError') } finally { submitting.value = false } @@ -147,7 +149,7 @@ async function submit() { } .auth-error { font-size: 12px; - color: var(--color-danger-fg, #dc2626); + color: var(--color-error-fg); text-align: left; } .auth-submit { diff --git a/web/src/composables/api.ts b/web/src/composables/api.ts index 8b3039c8..f60cb082 100644 --- a/web/src/composables/api.ts +++ b/web/src/composables/api.ts @@ -15,20 +15,24 @@ interface RequestOptions extends RequestInit { async function request(path: string, options?: RequestOptions): Promise { const token = getAuthToken() - const resp = await fetch(`${apiBase}${path}`, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...(token && !options?.skipAuth ? { Authorization: `Bearer ${token}` } : {}), - ...options?.headers, - }, - }) + // Normalize to a Headers instance so every HeadersInit form (plain object, + // Headers, tuple array) is preserved rather than silently dropped. + const headers = new Headers(options?.headers) + if (!headers.has('Content-Type')) headers.set('Content-Type', 'application/json') + if (token && !options?.skipAuth && !headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${token}`) + } + const resp = await fetch(`${apiBase}${path}`, { ...options, headers }) if (resp.status === 401 && !options?.skipAuth) { notifyAuthExpired() } if (!resp.ok) { const body = await resp.json().catch(() => ({ error: resp.statusText })) - throw new Error(body.error || `HTTP ${resp.status}`) + // Attach the status so callers can distinguish 401 (bad token) from + // transport/5xx failures and react differently. + const err = new Error(body.error || `HTTP ${resp.status}`) as Error & { status?: number } + err.status = resp.status + throw err } return resp.json() } diff --git a/web/src/composables/authToken.ts b/web/src/composables/authToken.ts index 8cdd83a4..cb248e62 100644 --- a/web/src/composables/authToken.ts +++ b/web/src/composables/authToken.ts @@ -8,7 +8,7 @@ // Only relevant when the server is bound to a non-loopback host (it reports // `auth_required` from /api/health). On loopback / desktop the token stays empty // and nothing here has any effect. -import { ref } from 'vue' +import { readonly, ref } from 'vue' const STORAGE_KEY = 'jcode_web_token' @@ -30,9 +30,13 @@ export function clearAuthToken(): void { setAuthToken('') } -/** Reactive ref for components (login gate) that need to watch the token. */ +/** + * Read-only reactive ref for components (login gate) that need to watch the + * token. Returned readonly so callers can't bypass setAuthToken() and leave + * localStorage out of sync with the in-memory value. + */ export function useAuthToken() { - return token + return readonly(token) } // --- expiry notification --------------------------------------------------- diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index d8e2d3fb..beb3b25c 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -97,6 +97,7 @@ export default { verifying: 'Verifyingโ€ฆ', required: 'Please enter the token', invalid: 'Invalid token', + serverError: 'Could not verify the token. Check the connection and try again.', }, connection: { diff --git a/web/src/i18n/locales/ja.ts b/web/src/i18n/locales/ja.ts index ab5bd1f7..0f6eb599 100644 --- a/web/src/i18n/locales/ja.ts +++ b/web/src/i18n/locales/ja.ts @@ -86,6 +86,7 @@ export default { verifying: 'ๆคœ่จผไธญโ€ฆ', required: 'ใƒˆใƒผใ‚ฏใƒณใ‚’ๅ…ฅๅŠ›ใ—ใฆใใ ใ•ใ„', invalid: 'ใƒˆใƒผใ‚ฏใƒณใŒ็„กๅŠนใงใ™', + serverError: 'ใƒˆใƒผใ‚ฏใƒณใ‚’ๆคœ่จผใงใใพใ›ใ‚“ใงใ—ใŸใ€‚ๆŽฅ็ถšใ‚’็ขบ่ชใ—ใฆๅ†่ฉฆ่กŒใ—ใฆใใ ใ•ใ„ใ€‚', }, connection: { diff --git a/web/src/i18n/locales/ko.ts b/web/src/i18n/locales/ko.ts index d418af19..32ec3694 100644 --- a/web/src/i18n/locales/ko.ts +++ b/web/src/i18n/locales/ko.ts @@ -86,6 +86,7 @@ export default { verifying: 'ํ™•์ธ ์ค‘โ€ฆ', required: 'ํ† ํฐ์„ ์ž…๋ ฅํ•˜์„ธ์š”', invalid: '์œ ํšจํ•˜์ง€ ์•Š์€ ํ† ํฐ', + serverError: 'ํ† ํฐ์„ ํ™•์ธํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์—ฐ๊ฒฐ์„ ํ™•์ธํ•œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•˜์„ธ์š”.', }, connection: { diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 5c06a050..4af2b40c 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -86,6 +86,7 @@ export default { verifying: '้ชŒ่ฏไธญโ€ฆ', required: '่ฏท่พ“ๅ…ฅไปค็‰Œ', invalid: 'ไปค็‰Œๆ— ๆ•ˆ', + serverError: 'ๆ— ๆณ•้ชŒ่ฏไปค็‰Œ๏ผŒ่ฏทๆฃ€ๆŸฅ่ฟžๆŽฅๅŽ้‡่ฏ•ใ€‚', }, connection: { diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 3ed8b4bc..063d5bb2 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -87,6 +87,7 @@ export default { verifying: '้ฉ—่ญ‰ไธญโ€ฆ', required: '่ซ‹่ผธๅ…ฅๆฌŠๆ–', invalid: 'ๆฌŠๆ–็„กๆ•ˆ', + serverError: '็„กๆณ•้ฉ—่ญ‰ๆฌŠๆ–๏ผŒ่ซ‹ๆชขๆŸฅ้€ฃ็ทšๅพŒ้‡่ฉฆใ€‚', }, connection: {