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

OAuth state mismatch

Error message

OAuth state mismatch

What it means

Thrown by `parse_code_from_redirect` when an OpenAI OAuth callback carries a `state` query parameter that differs from the `expected_state` the caller passed. The state parameter is the CSRF defense of the authorization-code flow: it ties the redirect back to the authorize request that started it. A mismatch means the incoming callback belongs to a different or stale login attempt, so the code is rejected before it can be exchanged.

Source

Thrown at crates/zeroclaw-providers/src/auth/openai_oauth.rs:322

    let params = parse_query_params(query);
    let is_callback_payload = trimmed.contains('?')
        || params.contains_key("code")
        || params.contains_key("state")
        || params.contains_key("error");

    if let Some(err) = params.get("error") {
        let desc = params
            .get("error_description")
            .cloned()
            .unwrap_or_else(|| "OAuth authorization failed".to_string());
        anyhow::bail!("OpenAI OAuth error: {err} ({desc})");
    }

    if let Some(expected_state) = expected_state {
        if let Some(got) = params.get("state") {
            if got != expected_state {
                anyhow::bail!("OAuth state mismatch");
            }
        } else if is_callback_payload {
            anyhow::bail!("Missing OAuth state in callback");
        }
    }

    if let Some(code) = params.get("code").cloned() {
        return Ok(code);
    }

    if !is_callback_payload {
        return Ok(trimmed.to_string());
    }

    anyhow::bail!("Missing OAuth code in callback")
}

pub fn extract_account_id_from_jwt(token: &str) -> Option<String> {

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Restart the login flow so `build_authorize_url` and `parse_code_from_redirect` share one fresh `PkceState` (new state + PKCE verifier)
  2. Close stale browser tabs on auth.openai.com or localhost:1455 before retrying
  3. Run only one login attempt at a time per machine — the loopback listener is a fixed port (1455)
  4. If you call the parser directly, pass the exact `pkce.state` you embedded in the authorize URL

Example fix

// before: URL built with an old PkceState, callback checked against a new one
let url = build_authorize_url(&old_pkce);
// later, in the callback handler:
let code = parse_code_from_redirect(path, Some(&new_pkce.state))?; // "OAuth state mismatch"

// after: one PkceState drives the whole flow
let pkce = generate_pkce_state();
let url = build_authorize_url(&pkce);
// later:
let code = parse_code_from_redirect(path, Some(&pkce.state))?;
Defensive patterns

Strategy: validation

Validate before calling

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

// only hand the path to the parser when the state round-trips
if 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("state mismatch") => restart_login_flow().await, // stale attempt: discard
    Err(e) => return Err(e),
}

Prevention

When it happens

Trigger: Calling `parse_code_from_redirect(input, Some(expected))` directly, or via `receive_loopback_code` on 127.0.0.1:1455, where the callback query has `state=<other>` and `<other> != expected` — e.g. input `/auth/callback?code=x&state=a` when the flow started with state `b`. The state check only runs when an expected_state is supplied.

Common situations: Two login flows racing on the same fixed loopback port (an old browser tab's redirect lands after a new flow started); reusing an authorize URL built from an earlier run with a fresh PkceState; re-running the login command while a previous callback is still in flight.

Related errors


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