Accept Claude CLI-format credentials files

A CLI .credentials.json placed at a provider's credentials_file loaded fine and
then reported "credentials are invalid or missing": _load_credentials() did a
bare json.load, but every reader looks for `access_token`, while the CLI schema
nests `claudeAiOauth.accessToken`. The tokens are equivalent — only the spelling
differs — so normalize on load instead of rejecting them.

ClaudeAuth.normalize_tokens() is the inverse of
ClaudeProviderHandler._oauth_tokens_to_cli_credentials(); keep the two in step.
It also carries subscriptionType/rateLimitTier across, so converting back to CLI
shape reproduces the real values rather than falling back to that function's
'pro'/'default_claude_ai' defaults. Anything already in AISBF shape is untouched.

Applied to the DB path too, which assigns auth.tokens directly and hits the
identical mismatch if a CLI blob was stored verbatim.
Co-Authored-By: 's avatarClaude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 2c3e2d8e
...@@ -100,17 +100,68 @@ class ClaudeAuth: ...@@ -100,17 +100,68 @@ class ClaudeAuth:
logger.warning(f"ClaudeAuth initialized without TLS fingerprinting (curl_cffi not available) - credentials: {self.credentials_file}") logger.warning(f"ClaudeAuth initialized without TLS fingerprinting (curl_cffi not available) - credentials: {self.credentials_file}")
logger.warning("Install curl_cffi for better Cloudflare bypass: pip install curl_cffi") logger.warning("Install curl_cffi for better Cloudflare bypass: pip install curl_cffi")
@staticmethod
def normalize_tokens(tokens: Optional[Dict]) -> Optional[Dict]:
"""
Accept the Claude CLI's .credentials.json schema as well as our own.
A CLI credentials file dropped in as the provider's credentials file is
a reasonable thing to do — the tokens are equivalent, only the spelling
differs — but every reader here looks for `access_token`, so an
unconverted file loads fine and then reports "invalid or missing".
CLI schema: claudeAiOauth.accessToken, .refreshToken,
.expiresAt (ms int), .scopes (list), .subscriptionType,
.rateLimitTier
AISBF schema: access_token, refresh_token, expires_at (seconds float),
scope (space-separated str)
This is the inverse of
ClaudeProviderHandler._oauth_tokens_to_cli_credentials(); keep the two
in step. Anything already in AISBF shape is returned untouched.
"""
if not isinstance(tokens, dict):
return tokens
oauth = tokens.get('claudeAiOauth')
if not isinstance(oauth, dict):
return tokens
expires_at_ms = oauth.get('expiresAt') or 0
scopes = oauth.get('scopes') or []
normalized = {
'access_token': oauth.get('accessToken', ''),
'refresh_token': oauth.get('refreshToken', ''),
# CLI stores milliseconds; every expiry comparison here is in seconds
'expires_at': (expires_at_ms / 1000.0) if expires_at_ms else 0,
'scope': ' '.join(scopes) if isinstance(scopes, list) else (scopes or ''),
}
# Carried so a later conversion back to CLI shape keeps the real values
# instead of falling back to its defaults.
if oauth.get('subscriptionType'):
normalized['subscription_type'] = oauth['subscriptionType']
if oauth.get('rateLimitTier'):
normalized['rate_limit_tier'] = oauth['rateLimitTier']
if tokens.get('trustedDeviceToken'):
normalized['trusted_device_token'] = tokens['trustedDeviceToken']
return normalized
def _load_credentials(self) -> Optional[Dict]: def _load_credentials(self) -> Optional[Dict]:
"""Load credentials from file if they exist.""" """Load credentials from file if they exist."""
if self.credentials_file.exists(): if self.credentials_file.exists():
try: try:
with open(self.credentials_file, 'r') as f: with open(self.credentials_file, 'r') as f:
tokens = json.load(f) tokens = json.load(f)
logger.info("Loaded existing Claude credentials")
return tokens
except Exception as e: except Exception as e:
logger.warning(f"Failed to load credentials: {e}") logger.warning(f"Failed to load credentials: {e}")
return None return None
if isinstance(tokens, dict) and 'claudeAiOauth' in tokens:
tokens = self.normalize_tokens(tokens)
logger.info(
"Loaded Claude CLI-format credentials, normalized to AISBF schema"
)
else:
logger.info("Loaded existing Claude credentials")
return tokens
return None return None
def _save_credentials(self, data: Dict): def _save_credentials(self, data: Dict):
......
...@@ -273,8 +273,11 @@ class ClaudeProviderHandler(BaseProviderHandler): ...@@ -273,8 +273,11 @@ class ClaudeProviderHandler(BaseProviderHandler):
skip_initial_load=True, skip_initial_load=True,
save_callback=lambda creds: self._save_auth_to_db(creds) save_callback=lambda creds: self._save_auth_to_db(creds)
) )
# Set tokens directly from database # Set tokens directly from database, accepting either our
auth.tokens = db_creds['credentials'].get('tokens', {}) # own schema or a CLI credentials blob stored verbatim.
auth.tokens = ClaudeAuth.normalize_tokens(
db_creds['credentials'].get('tokens', {})
)
# Add expires_at if missing (for existing credentials saved before fix) # Add expires_at if missing (for existing credentials saved before fix)
if auth.tokens and 'expires_at' not in auth.tokens and 'expires_in' in auth.tokens: if auth.tokens and 'expires_at' not in auth.tokens and 'expires_in' in auth.tokens:
import time import time
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment