feat: pass session token as query param on agent chat WebSockets (#23405)

## Problem

When the Coder chat UI is embedded in a VS Code webview, the session
token is set via the Coder-Session-Token header for HTTP requests.
However, browsers cannot attach custom headers to WebSocket connections,
and VS Code Electron webview environment does not support cookies set
via Set-Cookie from iframe origins. This causes all chat WebSocket
connections to fail with authorization errors.

## Solution

Pass the session token as a coder_session_token query parameter on all
chat-related WebSocket connections. The backend already accepts this
parameter (see APITokenFromRequest in coderd/httpmw/apikey.go).

The token is only included when API.getSessionToken() returns a value,
which only happens in the embed bootstrap flow. Normal browser sessions
use cookies and are unaffected.

> Built with [Coder Agents](https://coder.com/agents)
This commit is contained in:
Thomas Kosiewski
2026-03-23 15:27:55 +01:00
committed by GitHub
parent c49170b6b3
commit ea37f1ff86
7 changed files with 32 additions and 86 deletions
-23
View File
@@ -7971,29 +7971,6 @@ 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": [
-21
View File
@@ -7064,27 +7064,6 @@
]
}
},
"/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"],
-1
View File
@@ -1517,7 +1517,6 @@ 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) {
-37
View File
@@ -744,43 +744,6 @@ 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
+25
View File
@@ -147,6 +147,10 @@ export const watchChat = (
if (afterMessageId !== undefined && afterMessageId > 0) {
params.set("after_id", afterMessageId.toString());
}
const token = API.getSessionToken();
if (token) {
params.set(SessionTokenCookie, token);
}
const query = params.toString();
const route = `/api/experimental/chats/${chatId}/stream${query ? `?${query}` : ""}`;
return new OneWayWebSocket({
@@ -155,8 +159,14 @@ export const watchChat = (
};
export const watchChats = (): OneWayWebSocket<TypesGen.ServerSentEvent> => {
const searchParams: Record<string, string> = {};
const token = API.getSessionToken();
if (token) {
searchParams[SessionTokenCookie] = token;
}
return new OneWayWebSocket({
apiRoute: "/api/experimental/chats/watch",
searchParams,
});
};
@@ -3473,6 +3483,14 @@ function createWebSocket(
path: string,
params: URLSearchParams = new URLSearchParams(),
) {
// When running in an embedded context (e.g. VS Code webview),
// the session token is set via the API header but browsers
// cannot attach custom headers to WebSocket connections.
// Pass it as a query parameter instead.
const token = API.getSessionToken();
if (token) {
params.set(SessionTokenCookie, token);
}
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(
`${protocol}//${location.host}${path}?${params}`,
@@ -3485,6 +3503,7 @@ function createWebSocket(
interface ClientApi extends ApiMethods {
getCsrfToken: () => string;
setSessionToken: (token: string) => void;
getSessionToken: () => string | undefined;
setHost: (host: string | undefined) => void;
getAxiosInstance: () => AxiosInstance;
}
@@ -3508,6 +3527,12 @@ export class Api extends ApiMethods implements ClientApi {
this.axios.defaults.headers.common["Coder-Session-Token"] = token;
};
getSessionToken = (): string | undefined => {
return this.axios.defaults.headers.common["Coder-Session-Token"] as
| string
| undefined;
};
setHost = (host: string | undefined): void => {
this.axios.defaults.baseURL = host;
};
+7 -1
View File
@@ -748,6 +748,12 @@ const AgentDetail: FC = () => {
return;
}
// Prefer the active git repo root so VS Code opens to the
// actual project directory, falling back to the agent's
// configured directory.
const repoRoots = Array.from(gitWatcher.repositories.keys()).sort();
const folder = repoRoots[0] ?? workspaceAgent.expanded_directory;
generateKeyMutation.mutate(undefined, {
onSuccess: ({ key }) => {
location.href = getVSCodeHref(editor, {
@@ -755,7 +761,7 @@ const AgentDetail: FC = () => {
workspace: workspace.name,
token: key,
agent: workspaceAgent.name,
folder: workspaceAgent.expanded_directory,
folder,
chatId: agentId,
});
},
@@ -54,9 +54,6 @@ const bootstrapChatEmbedSessionFn = async ({
// 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.