Hmbown/CodeWhale · error

OAuth failed permanently ( ). Sign in again with ` `.

Error message

{name} OAuth {operation} failed permanently ({err}). Sign in again with `{}`.

What it means

Thrown when an OAuth refresh/exchange operation fails with a permanently unrecoverable condition: the token endpoint returned `invalid_grant` (or a provider-specific refresh-token-reused/expired/invalidated error) or HTTP 401. The message includes the provider name, operation, underlying error, and the exact command to sign in again.

Solutions

  1. Sign in again using the printed relogin command/hint to obtain fresh tokens
  2. Stop retrying with the old refresh token — reuse attempts trigger further invalidation
  3. If this happens repeatedly across machines, ensure only one client refreshes at a time (token rotation conflicts)
  4. Check provider session/consent revocation in the provider's admin/user dashboard

Example fix

// before
let token = refresh_access_token(provider, stored_refresh)?; // permanent failure
// after
let token = match refresh_access_token(provider, stored_refresh) {
    Ok(t) => t,
    Err(e) if e.to_string().contains("failed permanently") => {
        eprintln!("credentials stale; running interactive re-login");
        run_login(provider).await?;
        refresh_access_token(provider, freshly_stored_refresh(provider))?
    }
    Err(e) => return Err(e),
};
Defensive patterns

Strategy: try-catch

Validate before calling

// detect a stale refresh token before it poisons the flow
if (refreshTokenAge(provider) > maxRefreshTokenLifetime) {
  triggerReLogin(provider);
}

Try / catch

try {
  await refreshAccessToken(provider);
} catch (e) {
  if (String(e).includes('failed permanently')) {
    await interactiveReLogin(provider); // tokens are unrecoverable
  } else {
    retryWithBackoff(e);
  }
}

Prevention

When it happens

Trigger: Calling the refresh/exchange path when the stored refresh token is expired, revoked, already used (reuse detection), or otherwise rejected — detected via error code match or HTTP status 401 in the token response.

Common situations: Refresh token rotated elsewhere (second client consumed it); long-idle session exceeded the provider's refresh-token lifetime; admin revoked the session; provider reuse-detection invalidated the token after a duplicate refresh.

Related errors


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

Appendix: source

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

    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})");
    }
    anyhow::ensure!(
        parsed
            .access_token
            .as_deref()
            .is_some_and(|token| !token.trim().is_empty()),
        "{name} OAuth {operation} returned an empty access token"
    );
    Ok(parsed)
}

fn compact_form_error(body: &str) -> String {
    body.chars().filter(|c| !c.is_control()).take(80).collect()

View on GitHub (pinned to 73e0f67d83)