fix(mcp): reliable OAuth across runtimes and honest pre-restart UI

MCP authorization was broken in several stacked ways. The browser return
leg landed on the SPA behind the auth gate, so the system browser saw a
login page instead of finishing; the pending-context store silently
saved nothing because its route had no JSON body parser; and the
callback-URL config write started deferring behind Apply & Restart, so
authorization ran against a runtime without the URL and dead-ended on
OpenCode's loopback listener.

The return leg is now completed entirely server-side by an
unauthenticated GET /mcp/oauth/callback that only forwards a code whose
state matches a parked context. Desktop with the local server and VS
Code switch to OpenCode's native flow over its fixed loopback port —
no config writes or restarts at all, with a one-time cleanup of the
previously written callback URL — and its completion signal drives the
page instead of blind status polling. Remote, hosted-web, and mobile
keep the server-callback flow, applying a queued callback-URL write
immediately since authorization cannot wait for a manual restart.

Also: a server queued behind Apply & Restart now shows an Awaiting
restart badge and explanation instead of connect/reauthorize buttons
that can only fail, and Reauthorize is offered only while the server is
actually connected.
This commit is contained in:
Bohdan Triapitsyn
2026-08-10 20:23:45 +03:00
parent 3feee346da
commit 75978cf188
19 changed files with 466 additions and 22 deletions
+128 -1
View File
@@ -1,3 +1,4 @@
import express from 'express';
import { createProjectIdFromPath } from '../projects/project-id.js';
import fs from 'fs';
import os from 'os';
@@ -46,6 +47,46 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
return trimmed || null;
};
const escapeHtml = (value) => String(value)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
// Self-contained page for the OAuth return leg: the system browser has no UI
// session, so it cannot load the SPA behind the auth gate — everything it
// needs ships inline. `openchamber://focus/mcp-auth` raises the desktop app;
// the link stays visible because some browsers only follow custom-protocol
// URLs from a user gesture.
const renderMcpOAuthCallbackPage = ({ title, message, desktopReturn }) => `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)} — OpenChamber</title>
<style>
:root { color-scheme: light dark; }
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: Canvas; color: CanvasText; }
main { max-width: 34rem; padding: 2.5rem 2rem; text-align: center; }
h1 { font-size: 1.25rem; margin: 0 0 0.75rem; }
p { margin: 0; line-height: 1.5; opacity: 0.85; }
a.return { display: inline-block; margin-top: 1.5rem; padding: 0.5rem 1.25rem; border-radius: 0.5rem;
border: 1px solid color-mix(in srgb, CanvasText 25%, transparent); color: inherit; text-decoration: none; }
</style>
</head>
<body>
<main>
<h1>${escapeHtml(title)}</h1>
<p>${escapeHtml(message)}</p>
${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return to OpenChamber</a>
<script>window.location.href = 'openchamber://focus/mcp-auth';</script>` : ''}
</main>
</body>
</html>`;
const readOpenCodeCurrentVersion = async () => {
const healthResponse = await fetch(buildOpenCodeUrl('/global/health', ''), {
method: 'GET',
@@ -341,7 +382,10 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
}
});
app.post('/api/mcp/auth/pending', async (req, res) => {
// The body parser is per-route on this server; without it req.body is
// undefined here, the state read as absent, and the "parked" context was
// silently never stored — the callback then always failed as unknown.
app.post('/api/mcp/auth/pending', express.json({ limit: '16kb' }), async (req, res) => {
try {
pruneExpiredPendingMcpAuthContexts();
@@ -417,6 +461,89 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
}
});
// Browser return leg of the MCP OAuth flow, completed entirely server-side.
//
// The provider redirects the SYSTEM browser here, and that browser has no
// OpenChamber UI session — the SPA route this path used to land on sits
// behind the client-side auth gate, so the user saw a login page instead of
// a finished authorization. No session can be required on this path.
//
// Safe without auth because it acts only on a code+state pair whose `state`
// matches a context parked by an authenticated start call: `state` is the
// OAuth CSRF secret, generated per flow and known only to the initiating
// client and the provider. Without a match the code is NOT forwarded, so an
// unauthenticated caller cannot bind this server's MCP entry to a foreign
// account by fabricating a callback. The endpoint reads nothing and mutates
// nothing else.
app.get('/mcp/oauth/callback', async (req, res) => {
const queryValue = (key) => normalizePendingString(Array.isArray(req.query?.[key]) ? req.query[key][0] : req.query?.[key]);
const state = queryValue('state');
const code = queryValue('code');
const providerError = queryValue('error');
const providerErrorDescription = queryValue('error_description');
pruneExpiredPendingMcpAuthContexts();
const context = state ? pendingMcpAuthContextByState.get(state) ?? null : null;
const startedFromDesktop = context?.origin === 'desktop';
const finish = (status, { title, message }) => {
if (state) pendingMcpAuthContextByState.delete(state);
res.status(status).type('html').send(renderMcpOAuthCallbackPage({
title,
message,
// Browsers only follow custom-protocol links from a user gesture in
// some configurations, so the page both tries the jump and keeps a
// visible link as the fallback.
desktopReturn: startedFromDesktop,
}));
};
if (providerError) {
return finish(400, {
title: 'Authorization Failed',
message: providerErrorDescription || providerError,
});
}
if (!code) {
return finish(400, {
title: 'Authorization Failed',
message: 'The provider did not return an authorization code. Start authorization again from MCP Settings.',
});
}
if (!context?.name) {
return finish(400, {
title: 'Authorization Failed',
message: 'This authorization session has expired or is unknown to the running app. Return to OpenChamber and click Authorize again.',
});
}
try {
const callbackUrl = new URL(buildOpenCodeUrl(`/mcp/${encodeURIComponent(context.name)}/auth/callback`, ''));
if (context.directory) callbackUrl.searchParams.set('directory', context.directory);
const upstream = await fetch(callbackUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...getOpenCodeAuthHeaders() },
body: JSON.stringify({ code }),
});
if (!upstream.ok) {
const payload = await upstream.json().catch(() => null);
return finish(502, {
title: 'Authorization Failed',
message: payload?.error || payload?.message || `OpenCode rejected the authorization code (${upstream.status}). Start authorization again from MCP Settings.`,
});
}
return finish(200, {
title: 'Authorization Complete',
message: 'You can close this tab and return to OpenChamber.',
});
} catch (error) {
return finish(502, {
title: 'Authorization Failed',
message: error?.message || 'Failed to complete MCP authorization.',
});
}
});
app.get('/api/provider/:providerId/source', async (req, res) => {
try {
const { providerId } = req.params;