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

xAI OAuth state mismatch

Error message

xAI OAuth state mismatch

What it means

CSRF guard of the xAI flow: `parse_code_from_redirect` compared the callback's `state` to the `expected_state` and they differ. The xAI parser hard-requires state whenever an expectation is passed — a missing state is its own separate error — so this specifically means a present-but-different value. Used by `receive_loopback_code` (loopback 127.0.0.1:56121) and direct callers.

Source

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

    let trimmed = input.trim();
    if trimmed.is_empty() {
        anyhow::bail!("No xAI OAuth code provided");
    }
    let query = trimmed.split_once('?').map_or(trimmed, |(_, query)| query);
    let params = parse_query_params(query);
    if let Some(err) = params.get("error") {
        let desc = params
            .get("error_description")
            .cloned()
            .unwrap_or_else(|| "xAI OAuth authorization failed".to_string());
        anyhow::bail!("{err}: {desc}");
    }
    if let Some(expected) = expected_state {
        let actual = params
            .get("state")
            .ok_or_else(|| anyhow::Error::msg("xAI OAuth callback missing state parameter"))?;
        if actual != expected {
            anyhow::bail!("xAI OAuth state mismatch");
        }
    }
    if let Some(code) = params.get("code")
        && !code.trim().is_empty()
    {
        return Ok(code.trim().to_string());
    }
    if expected_state.is_none() && !trimmed.contains('=') && !trimmed.contains('?') {
        return Ok(trimmed.to_string());
    }
    anyhow::bail!("xAI OAuth callback missing code parameter")
}

pub fn extract_account_id_from_jwt(token: &str) -> Option<String> {
    let payload = decode_jwt_payload(token)?;
    payload
        .get("email")
        .or_else(|| payload.get("sub"))

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Restart the flow so one PkceState builds the authorize URL and validates the callback
  2. Close old tabs on auth.x.ai that will redirect with the previous state
  3. When restoring across process restarts, persist and reload the exact state used in the authorize URL
  4. Serialize logins so only one flow owns the loopback port at a time

Example fix

// before: restored state does not match what was sent
let pkce = restore_pkce_state(code_verifier, "guessed-state".into());
// callback state=actual -> "xAI OAuth state mismatch"

// after: persist the state you embedded and restore it verbatim
let pkce = restore_pkce_state(saved_code_verifier, saved_state);
Defensive patterns

Strategy: validation

Validate before calling

fn xai_state_matches(input: &str, expected: &str) -> bool {
    input.split_once('?').map_or(false, |(_, q)| {
        q.split('&').any(|pair| pair == format!("state={}", expected))
    })
}

if xai_state_matches(path, &pkce.state) {
    let code = parse_code_from_redirect(path, Some(&pkce.state))?;
}

Try / catch

match parse_code_from_redirect(path, Some(&pkce.state)) {
    Ok(code) => exchange(code),
    Err(e) if e.to_string().contains("xAI OAuth state mismatch") => restart_login_flow().await,
    Err(e) => return Err(e),
}

Prevention

When it happens

Trigger: `parse_code_from_redirect("/callback?code=abc&state=bad", Some("xyz"))`; via the loopback listener, a stale browser redirect from an earlier login attempt lands after a new flow started on the same fixed port.

Common situations: Concurrent or restarted logins sharing port 56121; `restore_pkce_state` called with a guessed or wrong saved `state` value across a process restart; stale auth.x.ai tabs.

Related errors


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