Hmbown/CodeWhale · error

OAuth returned HTTP that was not token JSON

Error message

{name} OAuth {operation} returned HTTP {status} that was not token JSON

What it means

This path parses an OAuth token-endpoint response body into `OAuthTokenMaterial` (name and operation are interpolated from the provider params for a clear message). If the body is not valid JSON, or lacks the token structure, the error states the provider name, operation, and HTTP status so the developer knows which request went wrong. It distinguishes 'not token JSON at all' from the structured token-error handling that follows.

Solutions

  1. Verify the token URL in the provider params points at the real token endpoint (usually .../token), not the authorize or documentation URL
  2. curl the token endpoint with a test request and confirm it returns application/json token data
  3. Check the HTTP status in the message: an HTML 200 from the wrong URL is a config error; 5xx is a server-side problem

Example fix

// before: token_url points at authorize page
"token_url": "https://idp.example.com/authorize"
// after
"token_url": "https://idp.example.com/oauth/token"
Defensive patterns

Strategy: try-catch

Validate before calling

async fn token_endpoint_returns_json(url: &str) -> anyhow::Result<()> {
    let body = reqwest::get(url).await?.text().await?;
    if serde_json::from_str::<serde_json::Value>(&body).is_err() {
        anyhow::bail!("token endpoint at {url} does not return JSON");
    }
    Ok("")
}

Try / catch

match exchange_code_for_token(params, code) {
    Ok(material) => material,
    Err(e) if e.to_string().contains("not token JSON") => {
        // provider name + operation + status are in the message:
        // usually a wrong token_url or an HTML error page
        log_raw_response_and_fix_token_url(params)?;
        Err(e)
    }
    Err(e) => Err(e),
}

Prevention

When it happens

Trigger: A custom OAuth provider's token endpoint returns a non-JSON body (HTML login page, empty body, XML) for a token or refresh operation; a device-flow token poll receives an intermediate non-JSON response.

Common situations: Provider params misconfigured so the token URL hits a human-facing sign-in page; an OAuth server that responds to authorization_code exchange with a redirect/HTML; a captive portal replacing the response.

Understand the failure class

Background: "Invalid JSON response" and "Failed to parse response" errors: when an API answers 200 but the body isn't the JSON your library expected — this error's family across 28 libraries.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@73e0f67d83 (2026-09-22). Data as JSON: /api/errors/4860adcca7836e5e. Report an issue: GitHub.

Appendix: source

Thrown at crates/tui/src/oauth.rs:974

/// Pinned remote revoke URL; `None` when the provider revokes locally only.
pub(crate) fn remote_revoke_url(params: &OAuthProviderParams, issuer: &str) -> Option<String> {
    params
        .revoke_path
        .map(|path| format!("{}/{}", issuer.trim_end_matches('/'), path))
}

/// Parse a form-post token response. Error bodies are never echoed: the
/// detail names the error code only, so a hostile issuer cannot smuggle
/// secret-bearing text back through diagnostics.
pub(crate) fn parse_oauth_form_response(
    status: u16,
    body: &str,
    operation: &str,
    params: &OAuthProviderParams,
) -> Result<OAuthTokenMaterial> {
    let name = params.display_name;
    let parsed: OAuthTokenMaterial = serde_json::from_str(body).map_err(|_| {
        anyhow::anyhow!("{name} OAuth {operation} returned HTTP {status} that was not token JSON")
    })?;
    if !(200..300).contains(&status) || parsed.error.is_some() {
        let err = parsed.error.as_deref().unwrap_or("token_error");
        if matches!(
            err,
            "invalid_grant"
                | "refresh_token_reused"
                | "refresh_token_expired"
                | "refresh_token_invalidated"
        ) || status == 401
        {
            bail!(
                "{name} OAuth {operation} failed permanently ({err}). Sign in again with `{}`.",
                params.relogin_hint
            );
        }
        bail!("{name} OAuth {operation} failed ({err})");
    }

View on GitHub (pinned to 73e0f67d83)