tinyhumansai/openhuman · error · Error

OpenRouter OAuth returned an invalid callback URL.

Error message

OpenRouter OAuth returned an invalid callback URL.

What it means

extractOAuthCode runs new URL(callbackUrl) inside try/catch; anything the WHATWG URL constructor cannot parse (empty string, relative path, fragment-only garbage captured from the loopback listener) throws and is rethrown as this message.

Source

Thrown at app/src/utils/openrouterOAuth.ts:50

function base64UrlEncode(bytes: Uint8Array): string {
  let binary = '';
  for (const value of bytes) {
    binary += String.fromCharCode(value);
  }
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}

async function createCodeChallenge(verifier: string): Promise<string> {
  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
  return base64UrlEncode(new Uint8Array(digest));
}

function extractOAuthCode(callbackUrl: string, expectedState: string): string {
  let parsed: URL;
  try {
    parsed = new URL(callbackUrl);
  } catch {
    throw new Error('OpenRouter OAuth returned an invalid callback URL.');
  }

  const actualState = parsed.searchParams.get('state');
  if (actualState !== expectedState) {
    throw new Error('OpenRouter OAuth callback state did not match the request.');
  }

  const code = parsed.searchParams.get('code');
  if (!code) {
    throw new Error('OpenRouter OAuth did not return an authorization code.');
  }
  return code;
}

async function exchangeCodeForKey(
  code: string,
  verifier: string,
  fetchImpl: typeof fetch

View on GitHub (pinned to a221052e0d)

Solutions

  1. Log the raw callbackUrl — it is usually empty or a fragment
  2. Make the listener skip requests whose path does not match the expected callback path
  3. If empty captures recur, keep listening for the next request instead of failing on the first

Example fix

// before — first captured request decides
const url = await listener.next();
const code = extractOAuthCode(url, state);

// after — only requests that parse and match the path count
for await (const url of listener) {
  if (!url.startsWith('/callback')) continue;
  const code = extractOAuthCode(url, state);
  break;
}
Defensive patterns

Strategy: validation

Validate before calling

function isParsableUrl(u: string): boolean {
  try {
    new URL(u);
    return true;
  } catch {
    return false;
  }
}

Try / catch

Catch the parse failure, keep the listener open, and wait for the next captured request — the first request is often a favicon or probe, not the OAuth callback.

Prevention

When it happens

Trigger: The loopback OAuth listener hands back an empty or malformed capture — the socket closed before the request line was read, or the first non-OAuth request (favicon, probe) was mistaken for the callback.

Common situations: Browser firing an extra request first; listener race where the connection reset mid-read; port-fallback logic returning an incomplete string.

Related errors


AI-assisted analysis of tinyhumansai/openhuman@a221052e0d (2026-08-16). Data as JSON: /api/errors/10449c37bb07681b. Report an issue: GitHub.