Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ All notable changes to CodexBuddyMac are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [1.1.1] — 2026-06-26

### Fixed

- **Actionable message when the Claude session must be re-authenticated.** When
the stored Claude refresh token is permanently invalid (revoked, rotated away,
or expired — the OAuth endpoint returns `invalid_grant`), **Refresh Claude
Login** now reports *Claude session expired. Run `claude auth login` to sign in
again* instead of a generic "backing off after a recent failure". The bridge no
longer starts a five-minute retry backoff for this permanent failure and skips
further refresh calls against the dead token, so a fresh `claude auth login` is
picked up immediately. The re-auth state self-heals once a new credential is
stored, and `GET /claude/status` exposes a `reauth_required` flag.

## [1.1] — 2026-06-19

### Added
Expand Down
8 changes: 4 additions & 4 deletions CodexBuddyMac/CodexBuddyMac.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
ENABLE_APP_SANDBOX = NO;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
Expand All @@ -270,7 +270,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.1;
MARKETING_VERSION = 1.1.1;
PRODUCT_BUNDLE_IDENTIFIER = com.example.CodexBuddyMac;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
Expand All @@ -290,7 +290,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
ENABLE_APP_SANDBOX = NO;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
Expand All @@ -300,7 +300,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.1;
MARKETING_VERSION = 1.1.1;
PRODUCT_BUNDLE_IDENTIFIER = com.example.CodexBuddyMac;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
Expand Down
40 changes: 38 additions & 2 deletions CodexBuddyMac/CodexBuddyMac/Bridge/codex_usage_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@
CLAUDE_REFRESH_BACKOFF_SECONDS = 300.0
_claude_refresh_lock = threading.Lock()
_claude_refresh_retry_after: float = 0.0
# A refresh token rejected with `invalid_grant` is permanently dead (revoked,
# rotated away, or expired); waiting cannot revive it, so the user must sign in
# again. Remember the dead token so we stop calling the endpoint with it and can
# tell the UI to prompt for re-authentication until a different credential
# appears. State self-heals once a new credential is stored.
_claude_reauth_required = False
_claude_dead_refresh_token: str | None = None

# The /claude/refresh endpoint changes state (rotates the token, writes the
# Keychain). Gate it behind a loopback token so a stray local process or a
Expand Down Expand Up @@ -409,6 +416,7 @@ def _keychain_account() -> str | None:

def claude_token_status() -> dict[str, Any]:
"""Report Claude token validity for the Settings UI (no secrets returned)."""
global _claude_reauth_required, _claude_dead_refresh_token
creds = _read_keychain_credentials()
if creds is None:
return {"present": False, "valid": False, "can_refresh": False,
Expand All @@ -417,16 +425,27 @@ def claude_token_status() -> dict[str, Any]:
if not isinstance(oauth, dict) or not oauth.get("accessToken"):
return {"present": False, "valid": False, "can_refresh": False,
"message": "No Claude access token in credential"}
# A newly stored credential (different refresh token) clears a prior
# invalid_grant, so re-auth state self-heals after `claude auth login`.
if (_claude_dead_refresh_token is not None
and oauth.get("refreshToken") != _claude_dead_refresh_token):
_claude_reauth_required = False
_claude_dead_refresh_token = None
can_refresh = bool(oauth.get("refreshToken"))
expires_at = oauth.get("expiresAt")
if isinstance(expires_at, (int, float)):
seconds = (expires_at - time.time() * 1000) / 1000
valid = seconds > 0
if valid:
_claude_reauth_required = False
_claude_dead_refresh_token = None
return {"present": True, "valid": valid, "can_refresh": can_refresh,
"reauth_required": _claude_reauth_required,
"expires_in_seconds": int(seconds),
"refresh_in_seconds": int(seconds - CLAUDE_TOKEN_REFRESH_SKEW_SECONDS),
"message": "Valid" if valid else "Expired"}
return {"present": True, "valid": True, "can_refresh": can_refresh,
"reauth_required": False,
"message": "Valid (no expiry recorded)"}


Expand All @@ -438,7 +457,7 @@ def refresh_claude_token(expected_access_token: str | None = None) -> dict[str,
post-write read-back doesn't verify, so a bad write can't corrupt the shared
item (which also holds the mcpOAuth plugin tokens).
"""
global _claude_refresh_retry_after
global _claude_refresh_retry_after, _claude_reauth_required, _claude_dead_refresh_token

with _claude_refresh_lock:
now = time.monotonic()
Expand All @@ -452,6 +471,13 @@ def refresh_claude_token(expected_access_token: str | None = None) -> dict[str,
oauth = data.get("claudeAiOauth")
if not isinstance(oauth, dict) or not oauth.get("refreshToken"):
return {"ok": False, "error": "No refresh token available"}
# A token already rejected with invalid_grant won't come back to life;
# skip the network call and ask for re-auth until a new credential is
# stored (detected by a different refresh token).
if (_claude_dead_refresh_token is not None
and oauth["refreshToken"] == _claude_dead_refresh_token):
return {"ok": False, "reauth_required": True,
"error": "Claude session expired. Run `claude auth login` to sign in again."}
if expected_access_token is not None and oauth.get("accessToken") != expected_access_token:
return {"ok": True, "message": "Claude token already refreshed by another request",
"expires_in_seconds": None, "rotated_refresh_token": False}
Expand All @@ -475,12 +501,20 @@ def refresh_claude_token(expected_access_token: str | None = None) -> dict[str,
with urlopen(request, timeout=15, context=trusted_ssl_context()) as response:
token_data = json.loads(response.read().decode("utf-8"))
except HTTPError as exc:
_claude_refresh_retry_after = now + CLAUDE_REFRESH_BACKOFF_SECONDS
detail = ""
try:
detail = exc.read().decode("utf-8")[:200]
except OSError:
pass
if exc.code == 400 and "invalid_grant" in detail:
# Refresh token is permanently dead. Don't start a transient
# backoff (waiting can't fix it); remember the dead token and
# flag re-auth so the next refresh is skipped until sign-in.
_claude_reauth_required = True
_claude_dead_refresh_token = oauth["refreshToken"]
return {"ok": False, "reauth_required": True,
"error": "Claude session expired. Run `claude auth login` to sign in again."}
_claude_refresh_retry_after = now + CLAUDE_REFRESH_BACKOFF_SECONDS
return {"ok": False, "error": f"Refresh failed: HTTP {exc.code} {detail}".strip()}
except (URLError, TimeoutError, json.JSONDecodeError, OSError, ValueError) as exc:
_claude_refresh_retry_after = now + CLAUDE_REFRESH_BACKOFF_SECONDS
Expand Down Expand Up @@ -527,6 +561,8 @@ def refresh_claude_token(expected_access_token: str | None = None) -> dict[str,
return {"ok": False, "error": "Write verification failed; restored original credential"}

_claude_refresh_retry_after = 0.0
_claude_reauth_required = False
_claude_dead_refresh_token = None

return {"ok": True, "message": "Claude token refreshed",
"expires_in_seconds": int(expires_in) if isinstance(expires_in, (int, float)) else None,
Expand Down
10 changes: 10 additions & 0 deletions CodexBuddyMac/CodexBuddyMac/BridgeService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,16 @@ enum BridgeService {
restartRecommended: true
)
}
// A permanently dead refresh token (OAuth invalid_grant) cannot be
// renewed by the app or a bridge restart; the user must sign in again.
if let reauth = obj?["reauth_required"] as? Bool, reauth {
return ClaudeLoginRefreshResult(
succeeded: false,
message: (obj?["error"] as? String)
?? "Claude session expired. Run `claude auth login` in Terminal to sign in again.",
restartRecommended: false
)
}
return ClaudeLoginRefreshResult(
succeeded: false,
message: (obj?["error"] as? String) ?? "Claude login refresh failed (HTTP \(code)).",
Expand Down
49 changes: 49 additions & 0 deletions tests/test_claude_auth_recovery.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import importlib.util
import copy
import io
import json
import os
import tempfile
Expand Down Expand Up @@ -199,6 +200,54 @@ def test_bridge_auth_token_file_is_private(self):
self.assertTrue(token)
self.assertEqual(os.stat(token_file).st_mode & 0o777, 0o600)

def test_invalid_grant_flags_reauth_and_skips_backoff(self):
bridge = load_bridge("bridge_invalid_grant_test")
credentials = {
"claudeAiOauth": {
"accessToken": "old-access",
"refreshToken": "dead-refresh",
"expiresAt": 0,
}
}
bridge._read_keychain_credentials = lambda: (json.dumps(credentials), credentials)
bridge._keychain_account = lambda: "test-account"
bridge.trusted_ssl_context = lambda: None

calls = []

def fail(*args, **kwargs):
calls.append(1)
body = io.BytesIO(
b'{"error":"invalid_grant",'
b'"error_description":"Refresh token not found or invalid"}'
)
raise HTTPError(bridge.CLAUDE_OAUTH_TOKEN_URL, 400, "Bad Request", Message(), body)

bridge.urlopen = fail

first = bridge.refresh_claude_token()
self.assertFalse(first["ok"])
self.assertTrue(first.get("reauth_required"))
# A permanent failure must not start the transient retry backoff.
self.assertEqual(bridge._claude_refresh_retry_after, 0.0)

# The dead token is remembered: a second attempt short-circuits without
# hitting the endpoint again.
second = bridge.refresh_claude_token()
self.assertTrue(second.get("reauth_required"))
self.assertEqual(len(calls), 1)

# Status reports re-auth required while the dead token is still stored.
status = bridge.claude_token_status()
self.assertTrue(status.get("reauth_required"))

# Storing a fresh credential clears the re-auth state.
credentials["claudeAiOauth"]["refreshToken"] = "new-refresh"
credentials["claudeAiOauth"]["expiresAt"] = (time.time() + 3600) * 1000
healed = bridge.claude_token_status()
self.assertTrue(healed["valid"])
self.assertFalse(healed.get("reauth_required"))

def test_token_status_reports_proactive_refresh_timing(self):
bridge = load_bridge("bridge_token_refresh_timing_test")
expires_at = (time.time() + 3600) * 1000
Expand Down
Loading