zeroclaw-labs/zeroclaw · error · anyhow::Error

xAI OAuth token request failed ({status}): {}

Error message

xAI OAuth token request failed ({status}): {}

What it means

xAI's `parse_token_response` — shared by `exchange_code_for_tokens`, `refresh_access_token`, and device polling — got a non-2xx whose body parsed as a standard OAuth error document; the provider's `error_description` (falling back to `error`) is embedded with the status. This is the informative variant: the provider told you exactly what failed.

Source

Thrown at crates/zeroclaw-providers/src/auth/xai_oauth.rs:326

                    anyhow::bail!("xAI device-code authorization was denied")
                }
                "expired_token" => anyhow::bail!("xAI device-code expired"),
                _ => anyhow::bail!(
                    "xAI device-code polling failed ({status}): {}",
                    err.error_description.unwrap_or(err.error)
                ),
            }
        }
        anyhow::bail!("xAI device-code polling failed ({status}): {text}");
    }
}

async fn parse_token_response(response: reqwest::Response) -> Result<TokenSet> {
    let status = response.status();
    let body = response.text().await.unwrap_or_default();
    if !status.is_success() {
        if let Ok(err) = serde_json::from_str::<OAuthErrorResponse>(&body) {
            anyhow::bail!(
                "xAI OAuth token request failed ({status}): {}",
                err.error_description.unwrap_or(err.error)
            );
        }
        anyhow::bail!("xAI OAuth token request failed ({status}): {body}");
    }

    let parsed: TokenResponse =
        serde_json::from_str(&body).context("Failed to parse xAI OAuth token response")?;
    let expires_at = parsed
        .expires_in
        .map(|secs| Utc::now() + chrono::Duration::seconds(secs))
        .or_else(|| derive_expires_at_from_jwt(&parsed.access_token));

    Ok(TokenSet {
        access_token: parsed.access_token,
        refresh_token: parsed.refresh_token,
        id_token: parsed.id_token,

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Map the embedded code: `invalid_grant` → restart login for a fresh code or refresh token; check the PKCE pair when exchanging
  2. Persist the PkceState before opening the browser and restore it verbatim with `restore_pkce_state`
  3. Ensure `redirect_uri` is exactly `http://127.0.0.1:56121/callback` (XAI_OAUTH_REDIRECT_URI)
  4. Write the new refresh token to the store immediately after every refresh (rotation invalidates the old one)

Example fix

// before: new random PKCE at exchange time — challenge unknown to xAI
let pkce = generate_pkce_state();
let tokens = exchange_code_for_tokens(&client, &token_ep, &code, &pkce).await?; // invalid_grant

// after: persist the original verifier+state and restore them exactly
let pkce = restore_pkce_state(saved_code_verifier, saved_state);
let tokens = exchange_code_for_tokens(&client, &token_ep, &code, &pkce).await?;
Defensive patterns

Strategy: try-catch

Validate before calling

// before opening the browser, make sure the PKCE pair is restorable
let pkce = generate_pkce_state();
save_pkce_to_state(&pkce).await?; // verifier + state survive a crash
let url = build_authorize_url(&disc.authorization_endpoint, &pkce);

Type guard

fn is_xai_invalid_grant(e: &anyhow::Error) -> bool {
    let s = e.to_string();
    s.contains("xAI OAuth token request failed") && s.contains("invalid_grant")
}

Try / catch

let tokens = match refresh_access_token(&client, &refresh).await {
    Ok(t) => t,
    Err(e) if is_xai_invalid_grant(&e) => run_interactive_login().await?,
    Err(e) => return Err(e),
};

Prevention

When it happens

Trigger: `invalid_grant` when the authorization code expired or was replayed, or when the PKCE pair mismatches (xAI revalidates `code_challenge` and `code_challenge_method` at the token endpoint); a refresh token that was revoked or rotated; `invalid_client`.

Common situations: Exchanging a code twice after a crash; a verifier from a different PkceState than the challenge sent in the authorize URL; `restore_pkce_state` rebuilt with the wrong saved verifier; old refresh token superseded by a login elsewhere.

Related errors


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/c6307f535b3b24f0. Report an issue: GitHub.