tinyhumansai/openhuman · error
OpenRouter OAuth returned an invalid callback URL.
Error message
OpenRouter OAuth returned an invalid callback URL.
What it means
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.
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)
Example fix
// before
const code = extractOAuthCode(rawFromOs, state);
// after
const raw = rawFromOs.trim();
const callbackUrl = raw.startsWith('http') ? raw : `http://localhost:${OPENROUTER_LOOPBACK_PORT}/${raw.replace(/^\/?/, '')}`;
const code = extractOAuthCode(callbackUrl, state); Defensive patterns
Strategy: validation
Validate before calling
function isAbsoluteHttpUrl(s: string): boolean {
try { const u = new URL(s.trim()); return u.protocol === 'http:' || u.protocol === 'https:'; }
catch { return false; }
}
// gate before extraction:
if (!isAbsoluteHttpUrl(callbackUrl)) throw new Error('callback was not an absolute URL'); Type guard
function isParsableUrl(s: string): s is string {
try { new URL(s); return true; } catch { return false; }
} Try / catch
try { const code = extractOAuthCode(callbackUrl, state); }
catch (err) {
if (err instanceof Error && err.message.includes('invalid callback URL')) {
// log raw payload; fix the listener/deep-link shape before retrying
} else throw err;
} Prevention
- 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
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- OPENAI_CODEX_OAUTH_MISSING_CALLBACK_URL
- OpenRouter key exchange failed
- OpenRouter key exchange succeeded but no API key was…
- OpenRouter OAuth callback state did not match the request.
- OpenRouter OAuth did not return an authorization code.
AI-assisted analysis of tinyhumansai/openhuman@7491200858 (2026-08-17).
Data as JSON: /api/errors/10449c37bb07681b.
Report an issue: GitHub.
Appendix: 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 fetchView on GitHub (pinned to 7491200858)