Hmbown/CodeWhale · error

OAuth error callback state did not match the pending login

Error message

OAuth error callback state did not match the pending login

What it means

When the OAuth provider redirects back with an error outcome, `accept_callback` still verifies the `state` parameter when the provider included one; if it does not match the pending login, this error is thrown before the error detail is surfaced. This ensures an attacker cannot fabricate error callbacks against an unrelated pending login, and distinguishes mismatched-state errors from genuine provider errors.

Solutions

  1. Restart the login flow and complete (or fail) it in the tab the flow opened
  2. Verify the provider preserves the state parameter verbatim on error redirects; URL-encoding differences must be decoded before comparison
  3. Discard the stale error callback and retry with a fresh state
Defensive patterns

Strategy: try-catch

Validate before calling

fn error_callback_state_ok(expected: &str, url: &Url) -> Option<bool> {
    url.query_pairs().find(|(k, _)| k == "state")
        .map(|(_, v)| v == expected) // None => provider omitted state, allowed
}

Try / catch

match accept_callback(expected_state, outcome) {
    Ok(_) => unreachable!("error outcome cannot succeed"),
    Err(e) if e.to_string().contains("error callback state did not match") => {
        // denial from a stale/different login: restart the flow
        cancel_pending_login();
        start_new_login()
    }
    Err(e) => return Err(e),  // genuine provider error: read its detail
}

Prevention

When it happens

Trigger: `accept_callback` receives `CallbackOutcome::Error { error, description, state: Some(other_state) }` where `state` differs from `expected_state` — an error redirect carrying a state from a different login attempt.

Common situations: A user denies consent in a stale tab from a previous login while a newer one is pending; a provider that mangles/re-encodes the state on its error redirect; replaying an old denial URL.

Related errors


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

Appendix: source

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

    Ok(CallbackOutcome::Success { code, state })
}

pub fn accept_callback(expected_state: &str, outcome: CallbackOutcome) -> Result<String> {
    match outcome {
        CallbackOutcome::Success { code, state } => {
            anyhow::ensure!(
                state == expected_state,
                "OAuth callback state did not match the pending login"
            );
            Ok(code)
        }
        CallbackOutcome::Error {
            error,
            description,
            state,
        } => {
            if let Some(state) = state {
                anyhow::ensure!(
                    state == expected_state,
                    "OAuth error callback state did not match the pending login"
                );
            }
            let detail = description
                .filter(|text| !text.trim().is_empty())
                .unwrap_or(error);
            bail!("sign-in was not completed: {detail}")
        }
    }
}

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"

View on GitHub (pinned to 73e0f67d83)