fix(auth): improve claude subscription refresh handling

This commit is contained in:
tjb-tech
2026-04-07 05:32:55 +00:00
parent aa2e024734
commit 2b0fddd32b
4 changed files with 115 additions and 11 deletions
+29 -3
View File
@@ -14,6 +14,33 @@ from ohmo.gateway.runtime import OhmoSessionRuntimePool
logger = logging.getLogger(__name__)
def _format_gateway_error(exc: Exception) -> str:
"""Return a short, user-facing gateway error message."""
message = str(exc).strip() or exc.__class__.__name__
lowered = message.lower()
if "claude oauth refresh failed" in lowered:
return (
"[ohmo gateway error] Claude subscription auth refresh failed. "
"Run `oh auth claude-login` again or switch the gateway profile."
)
if "claude oauth refresh token is invalid or expired" in lowered:
return (
"[ohmo gateway error] Claude subscription token is expired. "
"Run `claude auth login`, then `oh auth claude-login`, or switch the gateway profile."
)
if "auth source not found" in lowered or "access token" in lowered:
return (
"[ohmo gateway error] Authentication is not configured for the current "
"gateway profile. Run `oh setup` or `ohmo config`."
)
if "api key" in lowered or "auth" in lowered or "credential" in lowered:
return (
"[ohmo gateway error] Authentication failed for the current gateway "
"profile. Check `oh auth status` and `ohmo config`."
)
return f"[ohmo gateway error] {message}"
class OhmoGatewayBridge:
"""Consume inbound messages and publish assistant replies."""
@@ -35,9 +62,9 @@ class OhmoGatewayBridge:
session_key = session_key_for_message(message)
try:
reply = await self._runtime_pool.handle_message(message, session_key)
except Exception: # pragma: no cover - gateway failure path
except Exception as exc: # pragma: no cover - gateway failure path
logger.exception("ohmo gateway failed to process inbound message")
reply = "[ohmo gateway error]"
reply = _format_gateway_error(exc)
if not reply:
continue
await self._bus.publish_outbound(
@@ -51,4 +78,3 @@ class OhmoGatewayBridge:
def stop(self) -> None:
self._running = False
+35 -4
View File
@@ -7,7 +7,7 @@ import json
import os
import subprocess
import time
import urllib.parse
import urllib.error
import urllib.request
import uuid
from dataclasses import dataclass
@@ -28,6 +28,13 @@ CLAUDE_COMMON_BETAS = (
"interleaved-thinking-2025-05-14",
"fine-grained-tool-streaming-2025-05-14",
)
CLAUDE_AI_OAUTH_SCOPES = (
"user:profile",
"user:inference",
"user:sessions:claude_code",
"user:mcp_servers",
"user:file_upload",
)
CLAUDE_OAUTH_ONLY_BETAS = (
"claude-code-20250219",
"oauth-2025-04-20",
@@ -303,20 +310,26 @@ def claude_oauth_headers() -> dict[str, str]:
}
def refresh_claude_oauth_credential(refresh_token: str) -> dict[str, Any]:
def refresh_claude_oauth_credential(
refresh_token: str,
*,
scopes: list[str] | tuple[str, ...] | None = None,
) -> dict[str, Any]:
"""Refresh a Claude OAuth token without mutating local files."""
if not refresh_token:
raise ValueError("refresh_token is required")
payload = urllib.parse.urlencode(
requested_scopes = list(scopes or CLAUDE_AI_OAUTH_SCOPES)
payload = json.dumps(
{
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": CLAUDE_OAUTH_CLIENT_ID,
"scope": " ".join(requested_scopes),
}
).encode("utf-8")
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Content-Type": "application/json",
"User-Agent": f"claude-cli/{get_claude_code_version()} (external, cli)",
}
last_error: Exception | None = None
@@ -325,6 +338,24 @@ def refresh_claude_oauth_credential(refresh_token: str) -> dict[str, Any]:
try:
with urllib.request.urlopen(request, timeout=10) as response:
result = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
body = ""
try:
body = exc.read().decode("utf-8", errors="replace").strip()
except Exception:
body = ""
if "invalid_grant" in body:
last_error = ValueError(
"Claude OAuth refresh token is invalid or expired. "
"Run `claude auth login` to refresh the official Claude CLI "
"credentials, then run `oh auth claude-login` again."
)
continue
detail = f"{exc.code} {exc.reason}"
if body:
detail = f"{detail}: {body}"
last_error = ValueError(f"Claude OAuth refresh failed at {endpoint}: {detail}")
continue
except Exception as exc:
last_error = exc
continue
+40 -4
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import base64
import json
import urllib.error
from pathlib import Path
import pytest
@@ -392,6 +393,8 @@ def test_describe_external_binding_reports_refreshable_claude_token(tmp_path: Pa
def test_refresh_claude_oauth_credential(monkeypatch):
seen: dict[str, object] = {}
class _FakeResponse:
def __enter__(self):
return self
@@ -408,10 +411,13 @@ def test_refresh_claude_oauth_credential(monkeypatch):
}
).encode("utf-8")
monkeypatch.setattr(
"openharness.auth.external.urllib.request.urlopen",
lambda request, timeout=10: _FakeResponse(),
)
def _fake_urlopen(request, timeout=10):
seen["timeout"] = timeout
seen["headers"] = dict(request.header_items())
seen["body"] = json.loads(request.data.decode("utf-8"))
return _FakeResponse()
monkeypatch.setattr("openharness.auth.external.urllib.request.urlopen", _fake_urlopen)
monkeypatch.setattr("openharness.auth.external.time.time", lambda: 1000)
refreshed = refresh_claude_oauth_credential("refresh-token")
@@ -419,6 +425,36 @@ def test_refresh_claude_oauth_credential(monkeypatch):
assert refreshed["access_token"] == "fresh-token"
assert refreshed["refresh_token"] == "fresh-refresh"
assert refreshed["expires_at_ms"] == (1000 * 1000) + (7200 * 1000)
assert seen["timeout"] == 10
assert seen["headers"]["Content-type"] == "application/json"
assert seen["body"]["grant_type"] == "refresh_token"
assert seen["body"]["refresh_token"] == "refresh-token"
assert "user:inference" in seen["body"]["scope"]
def test_refresh_claude_oauth_credential_reports_invalid_grant(monkeypatch):
class _FakeResponse:
def read(self):
return b'{"error":"invalid_grant","error_description":"Refresh token not found or invalid"}'
def close(self):
return None
error = urllib.error.HTTPError(
"https://platform.claude.com/v1/oauth/token",
400,
"Bad Request",
hdrs=None,
fp=_FakeResponse(),
)
monkeypatch.setattr(
"openharness.auth.external.urllib.request.urlopen",
lambda request, timeout=10: (_ for _ in ()).throw(error),
)
with pytest.raises(ValueError, match="claude auth login"):
refresh_claude_oauth_credential("refresh-token")
def test_get_claude_code_version_uses_fallback(monkeypatch):
+11
View File
@@ -2,6 +2,7 @@ from datetime import datetime
from openharness.channels.bus.events import InboundMessage
from ohmo.gateway.bridge import _format_gateway_error
from ohmo.gateway.router import session_key_for_message
@@ -27,3 +28,13 @@ def test_gateway_router_falls_back_to_chat_scope():
)
assert session_key_for_message(message) == "telegram:chat-1"
def test_gateway_error_formats_claude_refresh_failure():
exc = ValueError("Claude OAuth refresh failed: HTTP Error 400: Bad Request")
assert "claude-login" in _format_gateway_error(exc)
assert "Claude subscription auth refresh failed" in _format_gateway_error(exc)
def test_gateway_error_formats_generic_auth_failure():
exc = ValueError("API key missing for current profile")
assert "Authentication failed" in _format_gateway_error(exc)