fix: surface OAuth denial error immediately instead of waiting 120s (#2039)

When a user denies OAuth access on OpenRouter or DigitalOcean, the CLI
now immediately shows a clear error message and falls back to manual
key entry, instead of silently waiting the full 120s poll timeout.

Changes:
- OpenRouter OAuth: check for `error` query param on callback, set
  `oauthDenied` flag, show denial-specific HTML page in browser, break
  polling loop early, and log a clear terminal error
- DigitalOcean OAuth: add `oauthDenied` flag (error detection already
  existed but the polling loop still waited 120s), break loop early

Fixes #2038

Agent: code-health

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
A 2026-02-28 14:42:32 -08:00 committed by GitHub
parent 44f67462ed
commit 0904e53c85
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 37 additions and 2 deletions

View file

@ -330,6 +330,7 @@ async function tryDoOAuth(): Promise<string | null> {
const csrfState = generateCsrfState();
let oauthCode: string | null = null;
let oauthDenied = false;
let server: ReturnType<typeof Bun.serve> | null = null;
// Try ports in range
@ -347,6 +348,7 @@ async function tryDoOAuth(): Promise<string | null> {
if (error) {
const desc = url.searchParams.get("error_description") || error;
logError(`DigitalOcean authorization denied: ${desc}`);
oauthDenied = true;
return new Response(OAUTH_ERROR_HTML, {
status: 403,
headers: {
@ -434,12 +436,19 @@ async function tryDoOAuth(): Promise<string | null> {
// Wait up to 120 seconds
logStep("Waiting for authorization in browser (timeout: 120s)...");
const deadline = Date.now() + 120_000;
while (!oauthCode && Date.now() < deadline) {
while (!oauthCode && !oauthDenied && Date.now() < deadline) {
await sleep(500);
}
server.stop(true);
if (oauthDenied) {
logError("OAuth authorization was denied by the user");
logError("Alternative: Use a manual API token instead");
logError(" export DO_API_TOKEN=dop_v1_...");
return null;
}
if (!oauthCode) {
logError("OAuth authentication timed out after 120 seconds");
logError("Alternative: Use a manual API token instead");

View file

@ -56,6 +56,8 @@ const SUCCESS_HTML = `<html><head><meta name="viewport" content="width=device-wi
const ERROR_HTML = `<html><head><meta name="viewport" content="width=device-width,initial-scale=1"><style>${OAUTH_CSS}h1{color:#dc2626}@media(prefers-color-scheme:dark){h1{color:#ef4444}}</style></head><body><div class="card"><div class="icon">&#10007;</div><h1>Authentication Failed</h1><p>Invalid or missing state parameter (CSRF protection). Please try again.</p></div></body></html>`;
const DENIAL_HTML = `<html><head><meta name="viewport" content="width=device-width,initial-scale=1"><style>${OAUTH_CSS}h1{color:#dc2626}@media(prefers-color-scheme:dark){h1{color:#ef4444}}</style></head><body><div class="card"><div class="icon">&#10007;</div><h1>Authorization Denied</h1><p>You denied access to OpenRouter. You can close this tab and return to your terminal.</p></div></body></html>`;
async function tryOauthFlow(callbackPort = 5180, agentSlug?: string, cloudSlug?: string): Promise<string | null> {
logStep("Attempting OAuth authentication...");
@ -72,6 +74,7 @@ async function tryOauthFlow(callbackPort = 5180, agentSlug?: string, cloudSlug?:
const csrfState = generateCsrfState();
let oauthCode: string | null = null;
let oauthDenied = false;
let server: ReturnType<typeof Bun.serve> | null = null;
// Try ports in range
@ -83,6 +86,22 @@ async function tryOauthFlow(callbackPort = 5180, agentSlug?: string, cloudSlug?:
hostname: "127.0.0.1",
fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/callback") {
// Check for OAuth denial / error
const error = url.searchParams.get("error");
if (error) {
const desc = url.searchParams.get("error_description") || error;
logError(`OpenRouter authorization denied: ${desc}`);
oauthDenied = true;
return new Response(DENIAL_HTML, {
status: 403,
headers: {
"Content-Type": "text/html",
Connection: "close",
},
});
}
}
const code = url.searchParams.get("code");
if (url.pathname === "/callback" && code) {
// CSRF check
@ -145,12 +164,19 @@ async function tryOauthFlow(callbackPort = 5180, agentSlug?: string, cloudSlug?:
// Wait up to 120 seconds
logStep("Waiting for authentication in browser (timeout: 120s)...");
const deadline = Date.now() + 120_000;
while (!oauthCode && Date.now() < deadline) {
while (!oauthCode && !oauthDenied && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 500));
}
server.stop(true);
if (oauthDenied) {
logError("OAuth authorization was denied by the user");
logError("Alternative: Use a manual API key instead");
logError(" export OPENROUTER_API_KEY=sk-or-v1-...");
return null;
}
if (!oauthCode) {
logError("OAuth authentication timed out after 120 seconds");
logError("Alternative: Use a manual API key instead");