feat: add VS Code iframe embed auth bootstrap (#23060)

## VS Code iframe embed auth via postMessage + setSessionToken

Adds embed auth for VS Code iframe integration, allowing the Coder agent
chat UI to be embedded in VS Code webviews without manual login — using
direct header auth instead of cookies.

### How it works

1. **Parent frame** (VS Code webview) loads an iframe pointing to
`/agents/:agentId/embed`
2. **Embed page** detects the user is signed out and posts
`coder:vscode-ready` to the parent
3. **Parent** responds with `coder:vscode-auth-bootstrap` containing the
user's Coder API token
4. **Embed page** calls `API.setSessionToken(token)` to set the
`Coder-Session-Token` header on all subsequent axios requests
5. **Embed page** fetches user + permissions, sets them in the React
Query cache atomically, and renders the authenticated agent chat UI

No cookies, no CSRF, no backend endpoint needed. The token is passed via
postMessage and used as a header on every API request.

### What changed

**Frontend** (`site/src/pages/AgentsPage/`):
- `AgentEmbedPage.tsx` — added postMessage bootstrap directly in the
embed page: listens for `coder:vscode-auth-bootstrap`, calls
`API.setSessionToken(token)`, fetches user/permissions atomically to
avoid race conditions
- `EmbedContext.tsx` — React context signaling embed mode (from previous
commit, unchanged)
- `AgentDetail/TopBar.tsx` — conditionally hides navigation elements in
embed mode (from previous commit, unchanged)
- Both `/agents/:agentId/embed` and `/agents/:agentId/embed/session`
routes live outside `RequireAuth`

**Auth bootstrap** (`site/src/api/queries/users.ts`):
- `bootstrapChatEmbedSessionFn` now calls `API.setSessionToken(token)`
instead of posting to a backend endpoint
- Fetches user and permissions directly via `API.getAuthenticatedUser()`
and `API.checkAuthorization()`, then sets both in the query cache
atomically — this avoids a race where `isSignedIn` flips before
permissions are loaded

**Removed** (no longer needed):
- `coderd/embedauth.go` — the `POST
/api/experimental/chats/embed-session` handler
- `coderd/embedauth_test.go` — backend tests for the endpoint
- `codersdk/embedauth.go` — `EmbedSessionTokenRequest` SDK type
- `site/src/api/api.ts` — `postChatEmbedSession` method
- `docs/user-guides/workspace-access/vscode-embed-auth.md` — doc page
for the old cookie flow
- Swagger/API doc entries for the endpoint

### Why not cookies?

The initial implementation used a backend endpoint to set an HttpOnly
session cookie. This required `SameSite=None; Secure` for cross-origin
iframes, which doesn't work over HTTP in development (Chrome requires
HTTPS for `Secure` cookies). The `setSessionToken` approach bypasses
cookies entirely — the token is set as an axios default header, and
header-based auth also naturally bypasses CSRF protection.

### Dogfooding

Tested end-to-end with a VS Code extension that:
1. Registers a `/openChat` deep link handler
(`vscode://coder.coder-remote/openChat?url=...&token=...&agentId=...`)
2. Starts a local HTTP reverse proxy (to work around VS Code webview
iframe sandboxing)
3. Loads `/agents/:agentId/embed` in an iframe through the proxy
4. Relays the postMessage handshake between the iframe and the extension
host
5. The embed page receives the token, calls `setSessionToken`, and
renders the chat

Verified: chat title, messages, and input field all display correctly in
VS Code's secondary sidebar panel.
This commit is contained in:
Thomas Kosiewski
2026-03-16 17:45:01 +01:00
committed by GitHub
parent 741af057dc
commit c1884148f0
5 changed files with 439 additions and 105 deletions
+1 -1
View File
@@ -179,7 +179,7 @@ export const login = (
mutationFn: async (credentials: { email: string; password: string }) =>
loginFn({ ...credentials, authorization }),
onSuccess: async (data: Awaited<ReturnType<typeof loginFn>>) => {
queryClient.setQueryData(["me"], data.user);
queryClient.setQueryData(meKey, data.user);
queryClient.setQueryData(
getAuthorizationKey(authorization),
data.permissions,
+112 -104
View File
@@ -25,6 +25,7 @@ import {
import type { FC } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { useEmbedContext } from "../EmbedContext";
interface SidebarPanelState {
showSidebarPanel: boolean;
@@ -70,19 +71,22 @@ export const AgentDetailTopBar: FC<AgentDetailTopBarProps> = ({
onToggleSidebarCollapsed,
}) => {
const navigate = useNavigate();
const { isEmbedded } = useEmbedContext();
return (
<div className="flex shrink-0 items-center gap-2 px-4 py-1.5">
{/* Mobile back button */}
<Button
variant="subtle"
size="icon"
onClick={() => navigate("/agents")}
aria-label="Back"
className="inline-flex h-7 w-7 min-w-0 shrink-0 md:hidden"
>
<ArrowLeftIcon />
</Button>
{!isEmbedded && (
<Button
variant="subtle"
size="icon"
onClick={() => navigate("/agents")}
aria-label="Back"
className="inline-flex h-7 w-7 min-w-0 shrink-0 md:hidden"
>
<ArrowLeftIcon />
</Button>
)}
{/* Desktop expand button: visible when sidebar is manually collapsed. */}
{isSidebarCollapsed && (
<Button
@@ -120,108 +124,112 @@ export const AgentDetailTopBar: FC<AgentDetailTopBarProps> = ({
</div>
{/* Actions area */}
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="subtle"
className="h-7 w-7 text-content-secondary hover:text-content-primary"
aria-label="Open agent actions"
>
<EllipsisIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
disabled={!workspace.canOpenEditors}
onSelect={() => {
workspace.onOpenInEditor("cursor");
}}
>
<ExternalLinkIcon className="h-3.5 w-3.5" />
Open in Cursor
</DropdownMenuItem>
<DropdownMenuItem
disabled={!workspace.canOpenEditors}
onSelect={() => {
workspace.onOpenInEditor("vscode");
}}
>
<ExternalLinkIcon className="h-3.5 w-3.5" />
Open in VS Code
</DropdownMenuItem>
<DropdownMenuItem
// You can think of the web terminal as an editor if you squint.
disabled={!workspace.canOpenEditors}
onSelect={workspace.onOpenTerminal}
>
<TerminalIcon className="h-3.5 w-3.5" />
Open Terminal
</DropdownMenuItem>
<DropdownMenuItem
disabled={!workspace.sshCommand}
onSelect={async () => {
if (!workspace.sshCommand) return;
try {
await navigator.clipboard.writeText(workspace.sshCommand);
toast.success("SSH command copied to clipboard");
} catch {
toast.error("Failed to copy SSH command");
}
}}
>
<CopyIcon className="h-3.5 w-3.5" />
Copy SSH Command
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={!workspace.canOpenWorkspace}
onSelect={workspace.onViewWorkspace}
>
<MonitorIcon className="h-3.5 w-3.5" />
View Workspace
</DropdownMenuItem>
<DropdownMenuSeparator />
{isArchived ? (
<DropdownMenuItem onSelect={onUnarchiveAgent}>
<ArchiveRestoreIcon className="h-3.5 w-3.5" />
Unarchive Agent
{!isEmbedded && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="subtle"
className="h-7 w-7 text-content-secondary hover:text-content-primary"
aria-label="Open agent actions"
>
<EllipsisIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
disabled={!workspace.canOpenEditors}
onSelect={() => {
workspace.onOpenInEditor("cursor");
}}
>
<ExternalLinkIcon className="h-3.5 w-3.5" />
Open in Cursor
</DropdownMenuItem>
) : (
<>
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onSelect={onArchiveAgent}
>
<ArchiveIcon className="h-3.5 w-3.5" />
Archive Agent
<DropdownMenuItem
disabled={!workspace.canOpenEditors}
onSelect={() => {
workspace.onOpenInEditor("vscode");
}}
>
<ExternalLinkIcon className="h-3.5 w-3.5" />
Open in VS Code
</DropdownMenuItem>
<DropdownMenuItem
// You can think of the web terminal as an editor if you squint.
disabled={!workspace.canOpenEditors}
onSelect={workspace.onOpenTerminal}
>
<TerminalIcon className="h-3.5 w-3.5" />
Open Terminal
</DropdownMenuItem>
<DropdownMenuItem
disabled={!workspace.sshCommand}
onSelect={async () => {
if (!workspace.sshCommand) return;
try {
await navigator.clipboard.writeText(workspace.sshCommand);
toast.success("SSH command copied to clipboard");
} catch {
toast.error("Failed to copy SSH command");
}
}}
>
<CopyIcon className="h-3.5 w-3.5" />
Copy SSH Command
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={!workspace.canOpenWorkspace}
onSelect={workspace.onViewWorkspace}
>
<MonitorIcon className="h-3.5 w-3.5" />
View Workspace
</DropdownMenuItem>
<DropdownMenuSeparator />
{isArchived ? (
<DropdownMenuItem onSelect={onUnarchiveAgent}>
<ArchiveRestoreIcon className="h-3.5 w-3.5" />
Unarchive Agent
</DropdownMenuItem>
{hasWorkspace && (
) : (
<>
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onSelect={onArchiveAndDeleteWorkspace}
onSelect={onArchiveAgent}
>
<Trash2Icon className="h-3.5 w-3.5" />
Archive & Delete Workspace
<ArchiveIcon className="h-3.5 w-3.5" />
Archive Agent
</DropdownMenuItem>
)}
</>
{hasWorkspace && (
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onSelect={onArchiveAndDeleteWorkspace}
>
<Trash2Icon className="h-3.5 w-3.5" />
Archive & Delete Workspace
</DropdownMenuItem>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
{!isEmbedded && (
<Button
variant="subtle"
size="icon"
onClick={panel.onToggleSidebar}
className="h-7 w-7 text-content-secondary hover:text-content-primary"
aria-label="Toggle panel"
>
{panel.showSidebarPanel ? (
<PanelRightCloseIcon className="h-4 w-4" />
) : (
<PanelRightOpenIcon className="h-4 w-4" />
)}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="subtle"
size="icon"
onClick={panel.onToggleSidebar}
className="h-7 w-7 text-content-secondary hover:text-content-primary"
aria-label="Toggle panel"
>
{panel.showSidebarPanel ? (
<PanelRightCloseIcon className="h-4 w-4" />
) : (
<PanelRightOpenIcon className="h-4 w-4" />
)}
</Button>
</Button>
)}
</div>
</div>
);
@@ -0,0 +1,241 @@
import { getErrorMessage } from "api/errors";
import { Button } from "components/Button/Button";
import { Loader } from "components/Loader/Loader";
import { useAuthContext } from "contexts/auth/AuthProvider";
import { ProxyProvider } from "contexts/ProxyContext";
import { DashboardProvider } from "modules/dashboard/DashboardProvider";
import { permissionChecks } from "modules/permissions";
import {
type FC,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useMutation, useQueryClient } from "react-query";
import { Outlet, useParams } from "react-router";
import type { AgentsOutletContext } from "./AgentsPage";
import { bootstrapChatEmbedSession, EmbedProvider } from "./EmbedContext";
type BootstrapMessage = {
type: "coder:vscode-auth-bootstrap";
payload: {
token: string;
};
};
const getBootstrapToken = (data: unknown): string | undefined => {
if (typeof data !== "object" || data === null) {
return undefined;
}
const message = data as Partial<BootstrapMessage>;
if (message.type !== "coder:vscode-auth-bootstrap") {
return undefined;
}
if (typeof message.payload !== "object" || message.payload === null) {
return undefined;
}
const payload = message.payload as { token?: unknown };
if (typeof payload.token !== "string") {
return undefined;
}
const token = payload.token.trim();
return token.length > 0 ? token : undefined;
};
const AgentEmbedPage: FC = () => {
const { agentId } = useParams<{ agentId: string }>();
if (!agentId) {
throw new Error("AgentEmbedPage requires an agentId route parameter.");
}
const auth = useAuthContext();
const queryClient = useQueryClient();
const embedSessionMutation = useMutation(
bootstrapChatEmbedSession({ checks: permissionChecks }, queryClient),
);
const latestEmbedSessionMutationRef = useRef(embedSessionMutation);
latestEmbedSessionMutationRef.current = embedSessionMutation;
const inFlightBootstrapRef = useRef<Promise<unknown> | null>(null);
const [chatErrorReasons, setChatErrorReasons] = useState<
Record<string, string>
>({});
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const setChatErrorReason = useCallback((chatId: string, reason: string) => {
const trimmedReason = reason.trim();
if (!chatId || !trimmedReason) {
return;
}
setChatErrorReasons((current) => {
if (current[chatId] === trimmedReason) {
return current;
}
return {
...current,
[chatId]: trimmedReason,
};
});
}, []);
const clearChatErrorReason = useCallback((chatId: string) => {
if (!chatId) {
return;
}
setChatErrorReasons((current) => {
if (!(chatId in current)) {
return current;
}
const next = { ...current };
delete next[chatId];
return next;
});
}, []);
const requestArchiveAgent = useCallback((_chatId: string) => {}, []);
const requestUnarchiveAgent = useCallback((_chatId: string) => {}, []);
const requestArchiveAndDeleteWorkspace = useCallback(
(_chatId: string, _workspaceId: string) => {},
[],
);
const onToggleSidebarCollapsed = useCallback(() => {
setIsSidebarCollapsed((current) => !current);
}, []);
const outletContext = useMemo<AgentsOutletContext>(
() => ({
chatErrorReasons,
setChatErrorReason,
clearChatErrorReason,
requestArchiveAgent,
requestUnarchiveAgent,
requestArchiveAndDeleteWorkspace,
isSidebarCollapsed,
onToggleSidebarCollapsed,
}),
[
chatErrorReasons,
setChatErrorReason,
clearChatErrorReason,
requestArchiveAgent,
requestUnarchiveAgent,
requestArchiveAndDeleteWorkspace,
isSidebarCollapsed,
onToggleSidebarCollapsed,
],
);
// When signed out and not already bootstrapping, listen for the
// postMessage from the parent frame carrying the session token.
const isAwaitingBootstrapMessage =
auth.isSignedOut &&
!embedSessionMutation.isPending &&
!embedSessionMutation.isError;
useEffect(() => {
if (!isAwaitingBootstrapMessage) {
return;
}
const parentWindow = window.parent;
const handleMessage = (event: MessageEvent) => {
if (event.source !== parentWindow) {
return;
}
const token = getBootstrapToken(event.data);
if (!token || inFlightBootstrapRef.current) {
return;
}
const bootstrapPromise = latestEmbedSessionMutationRef.current
.mutateAsync(token)
.catch(() => undefined)
.finally(() => {
inFlightBootstrapRef.current = null;
});
inFlightBootstrapRef.current = bootstrapPromise;
};
// Register the listener before notifying the parent so an
// immediate bootstrap response is never missed.
window.addEventListener("message", handleMessage);
parentWindow.postMessage(
{ type: "coder:vscode-ready", payload: { agentId } },
"*",
);
return () => {
window.removeEventListener("message", handleMessage);
};
}, [agentId, isAwaitingBootstrapMessage]);
const handleBootstrapRetry = useCallback(() => {
inFlightBootstrapRef.current = null;
embedSessionMutation.reset();
}, [embedSessionMutation]);
if (auth.isSignedIn) {
return (
<EmbedProvider value={{ isEmbedded: true }}>
<DashboardProvider>
<ProxyProvider>
<Outlet context={outletContext} />
</ProxyProvider>
</DashboardProvider>
</EmbedProvider>
);
}
if (embedSessionMutation.isError) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-surface-primary px-6 text-center">
<div className="space-y-2">
<h1 className="text-xl font-semibold text-content-primary">
Unable to start embedded agent.
</h1>
<p className="max-w-md text-sm text-content-secondary">
{getErrorMessage(
embedSessionMutation.error,
"We couldn't exchange the VS Code bootstrap token for a session.",
)}
</p>
</div>
<Button onClick={handleBootstrapRetry}>Try again</Button>
</div>
);
}
if (embedSessionMutation.isPending) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-surface-primary px-6 text-center">
<Loader label="Signing in to embedded agent" />
<p className="max-w-md text-sm text-content-secondary">
Signing in to the embedded agent
</p>
</div>
);
}
// Either auth is loading or we're waiting for the bootstrap
// postMessage from the parent frame.
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-surface-primary px-6 text-center">
<Loader label="Waiting for VS Code authentication" />
<p className="max-w-md text-sm text-content-secondary">
{auth.isLoading ? "Loading…" : "Waiting for VS Code authentication…"}
</p>
</div>
);
};
export default AgentEmbedPage;
@@ -0,0 +1,66 @@
import { API } from "api/api";
import { getAuthorizationKey } from "api/queries/authCheck";
import { meKey } from "api/queries/users";
import type { AuthorizationRequest } from "api/typesGenerated";
import { createContext, useContext } from "react";
import type { QueryClient } from "react-query";
interface EmbedContextValue {
isEmbedded: boolean;
}
const EmbedContext = createContext<EmbedContextValue>({
isEmbedded: false,
});
export const EmbedProvider = EmbedContext.Provider;
export const useEmbedContext = () => useContext(EmbedContext);
export const bootstrapChatEmbedSession = (
authorization: AuthorizationRequest,
queryClient: QueryClient,
) => {
return {
mutationFn: async (token: string) =>
bootstrapChatEmbedSessionFn({
token,
authorization,
queryClient,
}),
onSuccess: (
data: Awaited<ReturnType<typeof bootstrapChatEmbedSessionFn>>,
) => {
queryClient.setQueryData(meKey, data.user);
queryClient.setQueryData(
getAuthorizationKey(authorization),
data.permissions,
);
},
};
};
const bootstrapChatEmbedSessionFn = async ({
token,
authorization,
queryClient,
}: {
token: string;
authorization: AuthorizationRequest;
queryClient: QueryClient;
}) => {
API.setSessionToken(token);
// 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.
const [user, permissions] = await Promise.all([
API.getAuthenticatedUser(),
API.checkAuthorization(authorization),
]);
queryClient.setQueryData(meKey, user);
queryClient.setQueryData(getAuthorizationKey(authorization), permissions);
return {
user,
permissions,
};
};
+19
View File
@@ -348,6 +348,7 @@ const ProvisionerJobsPage = lazy(
);
const AgentsPage = lazy(() => import("./pages/AgentsPage/AgentsPage"));
const AgentDetail = lazy(() => import("./pages/AgentsPage/AgentDetail"));
const AgentEmbedPage = lazy(() => import("./pages/AgentsPage/AgentEmbedPage"));
import {
AgentDetailSkeleton,
@@ -654,6 +655,24 @@ export const router = createBrowserRouter(
/>
</Route>
</Route>
<Route
path="/agents/:agentId/embed"
element={
<Suspense fallback={<AgentDetailSkeleton />}>
<AgentEmbedPage />
</Suspense>
}
>
<Route
index
element={
<Suspense fallback={<AgentDetailSkeleton />}>
<AgentDetail />
</Suspense>
}
/>
</Route>
</Route>,
),
);