{"record":{"id":"10449c37bb07681b","repo":"tinyhumansai/openhuman","slug":"openrouter-oauth-returned-an-invalid-callback-url","errorCode":null,"errorMessage":"OpenRouter OAuth returned an invalid callback URL.","messagePattern":"OpenRouter OAuth returned an invalid callback URL\\.","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"app/src/utils/openrouterOAuth.ts","lineNumber":50,"sourceCode":"function base64UrlEncode(bytes: Uint8Array): string {\n  let binary = '';\n  for (const value of bytes) {\n    binary += String.fromCharCode(value);\n  }\n  return btoa(binary).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/g, '');\n}\n\nasync function createCodeChallenge(verifier: string): Promise<string> {\n  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));\n  return base64UrlEncode(new Uint8Array(digest));\n}\n\nfunction extractOAuthCode(callbackUrl: string, expectedState: string): string {\n  let parsed: URL;\n  try {\n    parsed = new URL(callbackUrl);\n  } catch {\n    throw new Error('OpenRouter OAuth returned an invalid callback URL.');\n  }\n\n  const actualState = parsed.searchParams.get('state');\n  if (actualState !== expectedState) {\n    throw new Error('OpenRouter OAuth callback state did not match the request.');\n  }\n\n  const code = parsed.searchParams.get('code');\n  if (!code) {\n    throw new Error('OpenRouter OAuth did not return an authorization code.');\n  }\n  return code;\n}\n\nasync function exchangeCodeForKey(\n  code: string,\n  verifier: string,\n  fetchImpl: typeof fetch","sourceCodeStart":32,"sourceCodeEnd":68,"githubUrl":"https://github.com/tinyhumansai/openhuman/blob/749120085864ce16e0f273c7b86fac7740b39c5b/app/src/utils/openrouterOAuth.ts#L32-L68","documentation":"extractOAuthCode() wraps the OAuth callback string in new URL(callbackUrl); the constructor throws on anything that is not an absolute, parseable URL (no scheme/host), and this catch converts that into a domain error. The callback should be the loopback redirect (http://localhost:3000/...) or the OS deep-link URL string.","triggerScenarios":"The listener/deep-link layer delivering a relative or truncated string ('/callback?code=...', empty string, just the query '?code=...'); a custom-scheme URL on a runtime where URL rejects the scheme; whitespace/newline contamination of the URL.","commonSituations":"Loopback listener on port 3000 returning only the search part; Tauri deep-link event payload shaped differently than expected across OSes; tests passing path-only fixtures.","solutions":["Log the raw callback string at the boundary and ensure it is the full absolute URL including scheme","If you receive only a query string, prepend the loopback origin before calling extractOAuthCode","Trim whitespace from OS-provided URL payloads (Windows deep links sometimes include trailing NULs/newlines)"],"exampleFix":"// before\nconst code = extractOAuthCode(rawFromOs, state);\n// after\nconst raw = rawFromOs.trim();\nconst callbackUrl = raw.startsWith('http') ? raw : `http://localhost:${OPENROUTER_LOOPBACK_PORT}/${raw.replace(/^\\/?/, '')}`;\nconst code = extractOAuthCode(callbackUrl, state);","handlingStrategy":"validation","validationCode":"function isAbsoluteHttpUrl(s: string): boolean {\n  try { const u = new URL(s.trim()); return u.protocol === 'http:' || u.protocol === 'https:'; }\n  catch { return false; }\n}\n// gate before extraction:\nif (!isAbsoluteHttpUrl(callbackUrl)) throw new Error('callback was not an absolute URL');","typeGuard":"function isParsableUrl(s: string): s is string {\n  try { new URL(s); return true; } catch { return false; }\n}","tryCatchPattern":"try { const code = extractOAuthCode(callbackUrl, state); }\ncatch (err) {\n  if (err instanceof Error && err.message.includes('invalid callback URL')) {\n    // log raw payload; fix the listener/deep-link shape before retrying\n  } else throw err;\n}","preventionTips":["Trim OS deep-link payloads before parsing — they arrive with stray whitespace/newlines","Log the raw callback string once at the boundary; malformed inputs are otherwise invisible","Keep the loopback origin constant (port 3000) so partial payloads can be reconstructed"],"tags":["oauth","openrouter","url-parsing","deep-link"],"backgroundTag":"oauth-callback-invalid","analyzedSha":"749120085864ce16e0f273c7b86fac7740b39c5b","analyzedAt":"2026-08-17T21:21:45.363Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}