fix(providers): complete OAuth logins that finish in the browser
OpenCode's authorize response reports how the client must finish: `code` expects a pasted code, while `auto` requires the client to call oauth/callback immediately and hold it open — upstream blocks in there polling for the device code or waiting on its loopback redirect, and only that call persists the credential. Every auth plugin OpenCode ships uses `auto`; none use `code`. The page implemented only `code`. It opened the browser, showed a paste field no provider can fill, and never called back, so a successful sign-in stored nothing and the app sat unchanged. Authorization now drives the UI: `auto` chains straight into the callback behind a waiting state with a cancel, and the paste field appears only when a provider actually asks for a code. Two smaller failures shared that surface. Prompts were never collected, which put GitHub Copilot Enterprise out of reach entirely, so a method that declares them now asks first and passes the answers to authorize. Device codes are also recovered from the instructions text, where they actually live — the old code read fields the API does not return, so the copy button never appeared. The callback is exempt from the ordinary proxy deadline and gets a 15-minute budget, bounded by the shortest upstream expiry we know of. A human sign-in with 2FA does not fit in four minutes, and expiring it turned a completed login into a 504.
This commit is contained in:
@@ -375,6 +375,8 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`.
|
||||
- SSE forwarders: `GET /api/global/event`, `GET /api/event`
|
||||
- Downstream heartbeats keep clients and intermediaries alive, while a separate upstream-only stall watchdog closes the downstream response when OpenCode stops producing bytes so clients reconnect instead of trusting synthetic heartbeats indefinitely. Each watchdog reset uses the current load-aware timeout, matching the shared event transport.
|
||||
- Session message forwarder: `POST /api/session/:sessionId/message`
|
||||
- Interactive OAuth forwarder: `POST /api/provider/:providerID/oauth/callback`
|
||||
- Upstream blocks inside this call for the whole browser sign-in (device-code polling or a loopback redirect), so it is exempt from the ordinary request deadline and uses a 15-minute proxy timeout instead of `LONG_REQUEST_TIMEOUT_MS`. All other `/api/provider/*` routes, including `oauth/authorize`, keep the ordinary deadline.
|
||||
- Generic `/api/*` forwarding with hop-by-hop header filtering
|
||||
- Windows `/session` merge fallback path behavior
|
||||
- OpenCode readiness gate for proxied `/api` requests
|
||||
|
||||
@@ -309,6 +309,16 @@ export const registerOpenCodeProxy = (app, deps) => {
|
||||
const PROXY_REQUEST_TIMEOUT_MS = normalizeProxyTimeout(LONG_REQUEST_TIMEOUT_MS);
|
||||
const PROXY_TIMEOUT_MARKER = Symbol('openchamberProxyTimedOut');
|
||||
|
||||
// A provider OAuth callback blocks upstream for as long as the user takes to
|
||||
// sign in in their browser (device-code polling, or a loopback redirect), so
|
||||
// it cannot share the ordinary request deadline. Bounded by the shortest
|
||||
// upstream expiry we know of — GitHub device codes last ~15 minutes.
|
||||
const INTERACTIVE_OAUTH_TIMEOUT_MS = 15 * 60 * 1000;
|
||||
const INTERACTIVE_OAUTH_PATH = /^\/provider\/[^/]+\/oauth\/callback\/?$/;
|
||||
|
||||
const isInteractiveOAuthCallback = (req) =>
|
||||
req.method === 'POST' && INTERACTIVE_OAUTH_PATH.test(req.path);
|
||||
|
||||
const isProxyTimeoutError = (error) => {
|
||||
const code = typeof error?.code === 'string' ? error.code : '';
|
||||
const message = typeof error?.message === 'string' ? error.message.toLowerCase() : '';
|
||||
@@ -327,6 +337,10 @@ export const registerOpenCodeProxy = (app, deps) => {
|
||||
};
|
||||
|
||||
const applyProxyResponseDeadline = (req, res, next) => {
|
||||
if (isInteractiveOAuthCallback(req)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
req[PROXY_TIMEOUT_MARKER] = true;
|
||||
if (sendProxyErrorResponse(res, 504)) {
|
||||
@@ -753,12 +767,12 @@ export const registerOpenCodeProxy = (app, deps) => {
|
||||
});
|
||||
|
||||
// Generic proxy for non-SSE OpenCode API routes.
|
||||
const apiProxy = createProxyMiddleware({
|
||||
const createApiProxy = (timeoutMs) => createProxyMiddleware({
|
||||
target: resolveProxyTarget(),
|
||||
changeOrigin: true,
|
||||
pathRewrite: { '^/api': '' },
|
||||
timeout: PROXY_REQUEST_TIMEOUT_MS,
|
||||
proxyTimeout: PROXY_REQUEST_TIMEOUT_MS,
|
||||
timeout: timeoutMs,
|
||||
proxyTimeout: timeoutMs,
|
||||
// Dynamic target — port can change after restart
|
||||
router: () => resolveProxyTarget(),
|
||||
on: {
|
||||
@@ -805,6 +819,9 @@ export const registerOpenCodeProxy = (app, deps) => {
|
||||
},
|
||||
});
|
||||
|
||||
const apiProxy = createApiProxy(PROXY_REQUEST_TIMEOUT_MS);
|
||||
const interactiveOAuthProxy = createApiProxy(INTERACTIVE_OAUTH_TIMEOUT_MS);
|
||||
|
||||
// Best-effort fallback for stale clients still sending symlink paths.
|
||||
// Settings and project selection normalize at source; this cached async path
|
||||
// avoids blocking the proxy hot path on every directory-scoped request.
|
||||
@@ -821,5 +838,6 @@ export const registerOpenCodeProxy = (app, deps) => {
|
||||
});
|
||||
|
||||
app.use('/api', applyProxyResponseDeadline);
|
||||
app.post('/api/provider/:providerID/oauth/callback', interactiveOAuthProxy);
|
||||
app.use('/api', apiProxy);
|
||||
};
|
||||
|
||||
@@ -623,4 +623,87 @@ describe('OpenCode proxy SSE forwarding', () => {
|
||||
expect(response.status).toBe(504);
|
||||
await expect(response.json()).resolves.toMatchObject({ error: 'OpenCode upstream timed out' });
|
||||
});
|
||||
|
||||
it('exempts interactive provider OAuth callbacks from the request deadline', async () => {
|
||||
const upstream = express();
|
||||
// Stands in for upstream blocking until the user finishes signing in.
|
||||
upstream.post('/provider/:providerID/oauth/callback', async (_req, res) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
res.json(true);
|
||||
});
|
||||
upstreamServer = await listen(upstream);
|
||||
const upstreamPort = upstreamServer.address().port;
|
||||
const externalBaseUrl = `http://127.0.0.1:${upstreamPort}`;
|
||||
|
||||
const app = express();
|
||||
registerOpenCodeProxy(app, {
|
||||
fs: {},
|
||||
os: {},
|
||||
path,
|
||||
OPEN_CODE_READY_GRACE_MS: 0,
|
||||
LONG_REQUEST_TIMEOUT_MS: 50,
|
||||
getRuntime: () => ({
|
||||
openCodePort: upstreamPort,
|
||||
openCodeBaseUrl: externalBaseUrl,
|
||||
isOpenCodeReady: true,
|
||||
openCodeNotReadySince: 0,
|
||||
isRestartingOpenCode: false,
|
||||
}),
|
||||
getOpenCodeAuthHeaders: () => ({}),
|
||||
buildOpenCodeUrl: (requestPath) => `${externalBaseUrl}${requestPath}`,
|
||||
ensureOpenCodeApiPrefix: () => {},
|
||||
});
|
||||
proxyServer = await listen(app);
|
||||
const proxyPort = proxyServer.address().port;
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${proxyPort}/api/provider/github-copilot/oauth/callback`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ method: 0 }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('still applies the request deadline to the OAuth authorize call', async () => {
|
||||
const upstream = express();
|
||||
upstream.post('/provider/:providerID/oauth/authorize', (_req, _res) => {
|
||||
// Leave the response open so the proxy timeout path is exercised.
|
||||
});
|
||||
upstreamServer = await listen(upstream);
|
||||
const upstreamPort = upstreamServer.address().port;
|
||||
const externalBaseUrl = `http://127.0.0.1:${upstreamPort}`;
|
||||
|
||||
const app = express();
|
||||
registerOpenCodeProxy(app, {
|
||||
fs: {},
|
||||
os: {},
|
||||
path,
|
||||
OPEN_CODE_READY_GRACE_MS: 0,
|
||||
LONG_REQUEST_TIMEOUT_MS: 50,
|
||||
getRuntime: () => ({
|
||||
openCodePort: upstreamPort,
|
||||
openCodeBaseUrl: externalBaseUrl,
|
||||
isOpenCodeReady: true,
|
||||
openCodeNotReadySince: 0,
|
||||
isRestartingOpenCode: false,
|
||||
}),
|
||||
getOpenCodeAuthHeaders: () => ({}),
|
||||
buildOpenCodeUrl: (requestPath) => `${externalBaseUrl}${requestPath}`,
|
||||
ensureOpenCodeApiPrefix: () => {},
|
||||
});
|
||||
proxyServer = await listen(app);
|
||||
const proxyPort = proxyServer.address().port;
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${proxyPort}/api/provider/github-copilot/oauth/authorize`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ method: 0 }),
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(504);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user