Hmbown/CodeWhale · error
OAuth callback must be GET
Error message
OAuth callback must be GET
What it means
During local-loopback OAuth sign-in, the TUI runs a tiny HTTP server on the callback port and parses each incoming request line via parse_http_request_target. It rejects any request whose HTTP method is not GET, because the OAuth authorization-code flow delivers the code via a browser GET redirect. Anything else (POST, HEAD, scanner probes) cannot carry a valid authorization response.
Solutions
- Let the real browser complete the sign-in; do not POST or script the callback URL manually.
- Retry `codewhale auth` / sign-in to start a fresh listener on a free port.
- Check for local software (antivirus, scanners, other dev servers) hitting the loopback port and exclude the port range.
- Ensure the provider's redirect URI uses a plain GET redirect (response_type=code), not form_post.
Example fix
// before (manual probe) curl -X POST 'http://127.0.0.1:1455/callback?code=abc' // after open 'http://127.0.0.1:1455/callback?code=abc' # or let the browser redirect do a GET
Defensive patterns
Strategy: try-catch
Validate before calling
const isGet = (line) => /^GET\s+\S+\s+HTTP\//.test(line);
Type guard
const isGetRequest = (r) => typeof r.method === 'string' && r.method.toUpperCase() === 'GET';
Try / catch
try { await signIn(); } catch (e) { if (String(e).includes('OAuth callback must be GET')) { /* abort: only browser GET reaches this port */ } } Prevention
- Never script POST/HEAD requests at the loopback callback port
- Exclude the callback port range from local port scanners and probes
- Test the callback by opening the URL in a browser, not curl -X POST
When it happens
Trigger: A TCP client connects to the loopback callback port and sends a non-GET request line (e.g. "POST /callback?code=... HTTP/1.1", "HEAD /", or a raw non-HTTP probe) before the real browser redirect arrives.
Common situations: Port scanners or security software probing open loopback ports; another local app that claimed the port and speaks its own protocol; a curl POST used to test the callback; a misconfigured redirect that issues POST instead of GET.
Understand the failure class
Background: "Must be a positive integer", "Invalid value", "Unsupported": the invalid-argument-value error family, when a library rejects the value you pass — this error's family across 35 libraries.
Related errors
- OIDC discovery failed with HTTP
- OAuth device-code request failed
- returned HTTP with content type ; expected JSON
- building bundle fetch client failed
- bundle fetch failed with HTTP status
AI-assisted analysis of Hmbown/CodeWhale@73e0f67d83 (2026-09-22).
Data as JSON: /api/errors/d2a2227a2e47e40c.
Report an issue: GitHub.
Appendix: source
Thrown at crates/tui/src/oauth.rs:1261
}
}
fn parse_http_request_target(request_line: &str) -> Result<String> {
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or_default();
anyhow::ensure!(
method.eq_ignore_ascii_case("GET"),
"OAuth callback must be GET"
);
let target = parts
.next()
.context("OAuth callback missing request target")?;
Ok(target.to_string())
}
fn query_from_target<'a>(params: &OAuthProviderParams, target: &'a str) -> Result<&'a str> {
let path = target.split('?').next().unwrap_or(target);
anyhow::ensure!(
path == params.callback_path,
"OAuth callback path was not {}",
params.callback_path
);
Ok(target.split_once('?').map(|(_, q)| q).unwrap_or(""))
}
/// Bind the loopback callback on both IP stacks for the first free port.
///
/// The redirect URI has to say `localhost` — that is what is registered with
/// the authorization server, and redirect matching is exact — but `localhost`
/// resolves to `::1` before `127.0.0.1` on IPv6-first hosts. Binding only
/// IPv4 left the browser connecting to a closed port, which browsers paper
/// over with Happy Eyeballs fallback: a working sign-in becomes a slow one,
/// and a broken one wherever that fallback is disabled. Binding both is the
/// fix that keeps the registered redirect URI intact.
///
/// A host with only one stack available binds only that one and still works.View on GitHub (pinned to 73e0f67d83)