From 83809bb380eea00474c8e022fa5841ec9c758ded Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 16:12:31 +0100 Subject: [PATCH] feat: add token-to-cookie endpoint for embedded chat WebSocket auth (#23280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The VS Code extension embeds the Coder agent chat UI in an iframe, passing the session token via `postMessage`. HTTP requests use the `Coder-Session-Token` header, but browser WebSocket connections **cannot carry custom headers** — they rely on cookies. This causes all WebSocket requests (e.g. streaming chat messages) to fail with authorization errors in the embedded iframe. ## Solution Add `POST /api/v2/users/me/session/token-to-cookie` — a lightweight endpoint that converts the current (already-validated) session token into a `Set-Cookie` response. The frontend embed bootstrap flow calls this immediately after `API.setSessionToken(token)`, before any WebSocket connections are opened. ### Backend (`coderd/userauth.go`, `coderd/coderd.go`) - New handler `postSessionTokenCookie` behind `apiKeyMiddleware`. - Reads the validated token via `httpmw.APITokenFromRequest(r)`. - Sets an `HttpOnly` cookie with the API key's expiry, applying site-wide cookie config (Secure, SameSite, host prefix) via `HTTPCookies.Apply`. - Returns `204 No Content`. ### Frontend (`site/src/pages/AgentsPage/EmbedContext.tsx`) - `bootstrapChatEmbedSessionFn` now calls the new endpoint after setting the header token and before fetching user/permissions. - The cookie is in place before any WebSocket connections are opened. ## Security - **No privilege escalation**: The token is already valid — this just moves it from a header credential to a cookie credential. - **POST only**: Avoids CSRF-via-navigation. - **Same origin**: The iframe loads from the Coder server, so the cookie applies to the correct domain. - **HttpOnly**: The cookie is not accessible to JavaScript. > Built with [Coder Agents](https://coder.com/agents) 🤖 --- coderd/apidoc/docs.go | 23 ++++++++++++++ coderd/apidoc/swagger.json | 21 ++++++++++++ coderd/coderd.go | 1 + coderd/userauth.go | 37 ++++++++++++++++++++++ site/src/pages/AgentsPage/EmbedContext.tsx | 7 ++++ 5 files changed, 89 insertions(+) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 1231da75c7..2ccb23b84c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7826,6 +7826,29 @@ const docTemplate = `{ ] } }, + "/users/me/session/token-to-cookie": { + "post": { + "description": "Converts the current session token into a Set-Cookie response.\nThis is used by embedded iframes (e.g. VS Code chat) that\nreceive a session token out-of-band via postMessage but need\ncookie-based auth for WebSocket connections.", + "tags": [ + "Authorization" + ], + "summary": "Set session token cookie", + "operationId": "set-session-token-cookie", + "responses": { + "204": { + "description": "No Content" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + } + }, "/users/oauth2/github/callback": { "get": { "tags": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 2313633d51..8d9c7934c0 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6927,6 +6927,27 @@ ] } }, + "/users/me/session/token-to-cookie": { + "post": { + "description": "Converts the current session token into a Set-Cookie response.\nThis is used by embedded iframes (e.g. VS Code chat) that\nreceive a session token out-of-band via postMessage but need\ncookie-based auth for WebSocket connections.", + "tags": ["Authorization"], + "summary": "Set session token cookie", + "operationId": "set-session-token-cookie", + "responses": { + "204": { + "description": "No Content" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + } + }, "/users/oauth2/github/callback": { "get": { "tags": ["Users"], diff --git a/coderd/coderd.go b/coderd/coderd.go index 84144614e7..0518dbb6d8 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1515,6 +1515,7 @@ func New(options *Options) *API { r.Post("/", api.postUser) r.Get("/", api.users) r.Post("/logout", api.postLogout) + r.Post("/me/session/token-to-cookie", api.postSessionTokenCookie) r.Get("/oidc-claims", api.userOIDCClaims) // These routes query information about site wide roles. r.Route("/roles", func(r chi.Router) { diff --git a/coderd/userauth.go b/coderd/userauth.go index 6b2aab6c53..980130393b 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -744,6 +744,43 @@ func (api *API) postLogout(rw http.ResponseWriter, r *http.Request) { }) } +// @Summary Set session token cookie +// @Description Converts the current session token into a Set-Cookie response. +// @Description This is used by embedded iframes (e.g. VS Code chat) that +// @Description receive a session token out-of-band via postMessage but need +// @Description cookie-based auth for WebSocket connections. +// @ID set-session-token-cookie +// @Security CoderSessionToken +// @Tags Authorization +// @Success 204 +// @Router /users/me/session/token-to-cookie [post] +// @x-apidocgen {"skip": true} +func (api *API) postSessionTokenCookie(rw http.ResponseWriter, r *http.Request) { + // Only accept the token from the Coder-Session-Token header. + // Other sources (query params, cookies) should not be allowed + // to bootstrap a new cookie. + token := r.Header.Get(codersdk.SessionTokenHeader) + if token == "" { + httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{ + Message: "Session token must be provided via the Coder-Session-Token header.", + }) + return + } + + apiKey := httpmw.APIKey(r) + + cookie := api.DeploymentValues.HTTPCookies.Apply(&http.Cookie{ + Name: codersdk.SessionTokenCookie, + Value: token, + Path: "/", + HttpOnly: true, + // Expire the cookie when the underlying API key expires. + Expires: apiKey.ExpiresAt, + }) + http.SetCookie(rw, cookie) + rw.WriteHeader(http.StatusNoContent) +} + // GithubOAuth2Team represents a team scoped to an organization. type GithubOAuth2Team struct { Organization string diff --git a/site/src/pages/AgentsPage/EmbedContext.tsx b/site/src/pages/AgentsPage/EmbedContext.tsx index e0ae15c819..a49d7f4e4a 100644 --- a/site/src/pages/AgentsPage/EmbedContext.tsx +++ b/site/src/pages/AgentsPage/EmbedContext.tsx @@ -49,7 +49,14 @@ const bootstrapChatEmbedSessionFn = async ({ authorization: AuthorizationRequest; queryClient: QueryClient; }) => { + // This is the token forwarded by the VS Code extension's + // ChatPanelProvider via the coder:vscode-auth-bootstrap + // postMessage handshake. See chatPanelProvider.ts in + // coder/vscode-coder for the sending side. API.setSessionToken(token); + // Exchange the token for a cookie so that WebSocket connections + // (which cannot carry custom headers) are also authenticated. + await API.getAxiosInstance().post("/api/v2/users/me/session/token-to-cookie"); // Fetch user and permissions first, then set them in the cache // atomically. This avoids a race where invalidating the "me" // query causes isSignedIn to flip before permissions are ready.