fix(auth): improve claude subscription refresh handling
This commit is contained in:
+29
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user