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:
Bohdan Triapitsyn
2026-08-04 19:14:58 +03:00
parent 8c37061886
commit 687681c83b
19 changed files with 1239 additions and 290 deletions
+21 -3
View File
@@ -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);
};