hermes-hub/src/antigravity_provider/router/profile_manager.py
Hermes Team bb4f6df67a fix(oauth): preserve complete credentials and sync oauth_creds.json for Antigravity profiles (A20)
- Add openid to OAuth scopes for id_token issuance
- Preserve id_token, scope, and token_type on token exchange and refresh
- Atomically write and sync .gemini/oauth_creds.json in profile directories
- Auto-resolve active profile environment in discover_models
- Cache discovered models in models_cache.json with graceful timeout handling
- Add unit test coverage for full OAuth lifecycle and model discovery caching
2026-08-23 23:09:58 +07:00

759 lines
34 KiB
Python

"""Profile Auth Manager for Hermes Multi-Provider Account Router.
Handles per-profile credential storage, validation, identity verification,
and Windows Credential Manager integration for Antigravity, Codex, and OpenCode Go.
"""
from __future__ import annotations
import base64
import ctypes
from ctypes import wintypes
import json
import logging
import os
import threading
import time
import urllib.request
import urllib.error
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from antigravity_provider import paths
logger = logging.getLogger(__name__)
# Windows API definitions for Credential Manager
advapi32 = None
if os.name == "nt":
try:
advapi32 = ctypes.windll.advapi32
except Exception as exc:
logger.warning("advapi32 not available: %s", exc)
class CREDENTIAL_ATTRIBUTE(ctypes.Structure):
_fields_ = [
("Keyword", wintypes.LPWSTR),
("Flags", wintypes.DWORD),
("ValueSize", wintypes.DWORD),
("Value", ctypes.c_void_p),
]
class CREDENTIAL(ctypes.Structure):
_fields_ = [
("Flags", wintypes.DWORD),
("Type", wintypes.DWORD),
("TargetName", wintypes.LPWSTR),
("Comment", wintypes.LPWSTR),
("LastWritten", wintypes.FILETIME),
("CredentialBlobSize", wintypes.DWORD),
("CredentialBlob", ctypes.c_void_p),
("Persist", wintypes.DWORD),
("AttributeCount", wintypes.DWORD),
("Attributes", ctypes.c_void_p),
("TargetAlias", wintypes.LPWSTR),
("UserName", wintypes.LPWSTR),
]
if advapi32:
CredReadW = advapi32.CredReadW
CredReadW.argtypes = [wintypes.LPWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(ctypes.POINTER(CREDENTIAL))]
CredReadW.restype = wintypes.BOOL
CredWriteW = advapi32.CredWriteW
CredWriteW.argtypes = [ctypes.POINTER(CREDENTIAL), wintypes.DWORD]
CredWriteW.restype = wintypes.BOOL
CredFree = advapi32.CredFree
CredFree.argtypes = [ctypes.c_void_p]
# Global lock for credential manager swapping during subprocess execution
_CM_LOCK = threading.RLock()
def get_hermes_base_dir() -> Path:
"""Get the base hermes directory."""
return paths.get_hermes_home()
def get_profile_dir(profile_id: str, provider: Optional[str] = None) -> Path:
"""Get isolated directory for a profile, supporting either (profile_id) or (provider, profile_id)."""
if provider is not None and profile_id in ("antigravity", "openai-codex", "opencode-go"):
return paths.get_profile_dir(provider, profile_id)
return paths.get_profile_dir(profile_id, provider)
def get_profile_auth_path(provider: str, profile_id: str) -> Path:
"""Get path to the profile's auth.json file."""
return get_profile_dir(profile_id, provider) / "auth.json"
def mask_email(email: str) -> str:
"""Mask email for safe logging: och***@gmail.com."""
if not email or "@" not in email:
return email[:4] + "***" if email else "(none)"
local, domain = email.split("@", 1)
visible_len = min(4, len(local))
return f"{local[:visible_len]}***@{domain}"
def mask_id(raw_id: str) -> str:
"""Mask user ID / sub: 10761924..."""
if not raw_id:
return "(none)"
if len(raw_id) <= 8:
return raw_id[:3] + "***"
return f"{raw_id[:8]}..."
class ProfileAuthManager:
"""Manages credentials and authentication verification across all profiles."""
@classmethod
def get_profile_dir(cls, profile_id: str, provider: Optional[str] = None) -> Path:
"""Official API to get isolated directory for a profile."""
return get_profile_dir(profile_id, provider)
@classmethod
def write_agy_oauth_creds(cls, profile_dir: Path, auth_data: dict) -> Path:
"""Atomically write <profile_dir>/.gemini/oauth_creds.json in exact agy CLI format."""
token_info = auth_data.get("token") or auth_data.get("tokens") or auth_data
if not isinstance(token_info, dict):
token_info = {}
access_token = token_info.get("access_token") or auth_data.get("access_token") or ""
refresh_token = token_info.get("refresh_token") or auth_data.get("refresh_token") or ""
scope = token_info.get("scope") or auth_data.get("scope") or ""
token_type = token_info.get("token_type") or auth_data.get("token_type") or "Bearer"
id_token = token_info.get("id_token") or auth_data.get("id_token") or ""
# Expiry date in milliseconds (int)
expiry_date = token_info.get("expiry_date") or auth_data.get("expiry_date")
if not expiry_date:
expires_at = token_info.get("expires_at") or auth_data.get("expires_at")
if expires_at:
try:
expiry_date = int(float(expires_at) * 1000)
except Exception:
expiry_date = int((time.time() + 3600) * 1000)
else:
expiry_str = token_info.get("expiry") or auth_data.get("expiry")
if expiry_str:
try:
dt = datetime.fromisoformat(str(expiry_str).replace("Z", "+00:00"))
expiry_date = int(dt.timestamp() * 1000)
except Exception:
expiry_date = int((time.time() + 3600) * 1000)
else:
expiry_date = int((time.time() + 3600) * 1000)
elif float(expiry_date) < 1e11: # in seconds
expiry_date = int(float(expiry_date) * 1000)
else:
expiry_date = int(expiry_date)
creds_dict = {
"access_token": str(access_token),
"refresh_token": str(refresh_token),
"scope": str(scope),
"token_type": str(token_type),
"id_token": str(id_token),
"expiry_date": expiry_date,
}
gemini_dir = profile_dir / ".gemini"
gemini_dir.mkdir(parents=True, exist_ok=True)
try:
os.chmod(gemini_dir, 0o700)
except OSError:
pass
target_file = gemini_dir / "oauth_creds.json"
temp_file = gemini_dir / f"oauth_creds.json.tmp-{threading.get_ident()}-{time.time_ns()}"
temp_file.write_text(json.dumps(creds_dict, indent=2), encoding="utf-8")
try:
os.chmod(temp_file, 0o600)
except OSError:
pass
os.replace(temp_file, target_file)
try:
os.chmod(target_file, 0o600)
except OSError:
pass
return target_file
@staticmethod
def read_windows_credential(target_name: str = "gemini:antigravity") -> Optional[dict]:
"""Read a credential blob from Windows Credential Manager."""
if not advapi32 or os.name != "nt":
return None
with _CM_LOCK:
pcred = ctypes.POINTER(CREDENTIAL)()
res = CredReadW(target_name, 1, 0, ctypes.byref(pcred))
if not res:
return None
try:
cred = pcred.contents
blob = ctypes.string_at(cred.CredentialBlob, cred.CredentialBlobSize)
data = json.loads(blob.decode("utf-8"))
return data
except Exception as e:
logger.warning("Error parsing credential blob %s: %s", target_name, e)
return None
finally:
CredFree(pcred)
@staticmethod
def write_windows_credential(target_name: str, auth_data: dict, user_name: str = "antigravity") -> bool:
"""Write a credential blob to Windows Credential Manager."""
if not advapi32 or os.name != "nt":
return False
with _CM_LOCK:
payload_data = auth_data
if target_name == "gemini:antigravity" and isinstance(auth_data, dict):
# Ensure 6-field agy-compatible schema in Credential Manager
token_info = auth_data.get("token") or auth_data.get("tokens") or auth_data
if isinstance(token_info, dict) and ("access_token" in token_info or "access_token" in auth_data):
acc = token_info.get("access_token") or auth_data.get("access_token") or ""
ref = token_info.get("refresh_token") or auth_data.get("refresh_token") or ""
sc = token_info.get("scope") or auth_data.get("scope") or ""
tt = token_info.get("token_type") or auth_data.get("token_type") or "Bearer"
idt = token_info.get("id_token") or auth_data.get("id_token") or ""
exp = token_info.get("expiry_date") or auth_data.get("expiry_date")
if not exp:
exp_at = token_info.get("expires_at") or auth_data.get("expires_at")
if exp_at:
try:
exp = int(float(exp_at) * 1000)
except Exception:
exp = int((time.time() + 3600) * 1000)
else:
exp = int((time.time() + 3600) * 1000)
elif float(exp) < 1e11:
exp = int(float(exp) * 1000)
else:
exp = int(exp)
payload_data = {
"access_token": str(acc),
"refresh_token": str(ref),
"scope": str(sc),
"token_type": str(tt),
"id_token": str(idt),
"expiry_date": exp,
}
blob_bytes = json.dumps(payload_data).encode("utf-8")
buf = ctypes.create_string_buffer(blob_bytes)
cred = CREDENTIAL()
cred.Flags = 0
cred.Type = 1 # CRED_TYPE_GENERIC
cred.TargetName = target_name
cred.Comment = "Hermes Profile Auth Managed"
cred.CredentialBlobSize = len(blob_bytes)
cred.CredentialBlob = ctypes.cast(buf, ctypes.c_void_p)
cred.Persist = 2 # CRED_PERSIST_LOCAL_MACHINE
cred.AttributeCount = 0
cred.Attributes = None
cred.TargetAlias = None
cred.UserName = user_name
res = CredWriteW(ctypes.byref(cred), 0)
return bool(res)
@classmethod
def get_main_profile(cls, provider: str = "antigravity") -> Optional[str]:
"""Get the currently designated main / active profile for a provider."""
state_file = paths.get_router_active_profile_path()
if state_file.is_file():
try:
data = json.loads(state_file.read_text(encoding="utf-8"))
return data.get(provider)
except Exception:
pass
return "ag-orch-fallback" if provider == "antigravity" else None
@classmethod
def set_main_profile(cls, provider: str, profile_id: str) -> Tuple[bool, str]:
"""Set a profile as the main / active account for Hermes, updating Windows Credential Manager."""
auth_data = cls.load_profile_auth(provider, profile_id)
if not auth_data:
return False, f"Profile '{profile_id}' has no saved authentication in {get_profile_auth_path(provider, profile_id)}"
if provider == "antigravity":
ok = cls.write_windows_credential("gemini:antigravity", auth_data)
if not ok:
return False, "Failed to write credential to Windows Credential Manager"
state_file = paths.get_router_active_profile_path()
state = {}
if state_file.is_file():
try:
state = json.loads(state_file.read_text(encoding="utf-8"))
except Exception:
state = {}
state[provider] = profile_id
state_file.write_text(json.dumps(state, indent=2), encoding="utf-8")
return True, f"Profile '{profile_id}' is now the MAIN active account for {provider}"
@classmethod
def save_profile_auth(cls, provider: str, profile_id: str, auth_data: dict) -> Path:
"""Atomically save credentials and emit a secret-free targeted event."""
pdir = get_profile_dir(profile_id, provider)
pdir.mkdir(parents=True, exist_ok=True)
auth_file = pdir / "auth.json"
existed = auth_file.is_file()
temp_file = pdir / f"auth.json.tmp-{threading.get_ident()}-{time.time_ns()}"
temp_file.write_text(json.dumps(auth_data, indent=2), encoding="utf-8")
try:
os.chmod(temp_file, 0o600)
except OSError:
pass
os.replace(temp_file, auth_file)
try:
os.chmod(auth_file, 0o600)
except OSError:
pass
# For Antigravity, synchronously maintain <profile_dir>/.gemini/oauth_creds.json
if provider in ("antigravity", "google-antigravity"):
try:
cls.write_agy_oauth_creds(pdir, auth_data)
except Exception as e:
logger.warning("Failed to write .gemini/oauth_creds.json for profile=%s: %s", profile_id, e)
from antigravity_provider.router.event_bus import (
EVENT_ACCOUNT_ADDED,
EVENT_ACCOUNT_AUTH_CHANGED,
EventBus,
)
EventBus.get().publish(
EVENT_ACCOUNT_AUTH_CHANGED if existed else EVENT_ACCOUNT_ADDED,
{"provider": provider, "profile_id": profile_id},
)
return auth_file
@classmethod
def delete_profile_auth(cls, provider: str, profile_id: str) -> bool:
"""Delete one credential file and emit a targeted removal event."""
auth_file = get_profile_auth_path(provider, profile_id)
if not auth_file.is_file():
return False
auth_file.unlink()
from antigravity_provider.router.event_bus import EVENT_ACCOUNT_REMOVED, EventBus
EventBus.get().publish(
EVENT_ACCOUNT_REMOVED,
{"provider": provider, "profile_id": profile_id},
)
return True
@classmethod
def load_profile_auth(cls, provider: str, profile_id: str) -> Optional[dict]:
"""Load credentials from profile-specific auth.json or env/auth.json fallback."""
auth_file = get_profile_auth_path(provider, profile_id)
if auth_file.is_file():
try:
data = json.loads(auth_file.read_text(encoding="utf-8"))
if provider in ("antigravity", "google-antigravity") and isinstance(data, dict):
pdir = get_profile_dir(profile_id, provider)
gemini_creds = pdir / ".gemini" / "oauth_creds.json"
if not gemini_creds.is_file():
try:
cls.write_agy_oauth_creds(pdir, data)
except Exception as e:
logger.debug("Failed to create missing oauth_creds.json: %s", e)
# Auto-refresh expired or expiring access tokens if refresh_token is present
tokens = data.get("token") or data.get("tokens")
if isinstance(tokens, dict):
refresh_tok = tokens.get("refresh_token")
acc_tok = tokens.get("access_token")
exp_at = tokens.get("expires_at")
if not exp_at:
exp_str = tokens.get("expiry")
if exp_str:
try:
dt = datetime.fromisoformat(str(exp_str).replace("Z", "+00:00"))
exp_at = dt.timestamp()
except Exception:
exp_at = None
now = time.time()
if refresh_tok and (not acc_tok or (exp_at and now + 60 >= float(exp_at))):
try:
from antigravity_provider.oauth import refresh_access_token
existing_id = tokens.get("id_token")
existing_scope = tokens.get("scope")
refreshed = refresh_access_token(
str(refresh_tok),
existing_id_token=str(existing_id) if existing_id else None,
existing_scope=str(existing_scope) if existing_scope else None,
)
tokens.update({
"access_token": refreshed["access_token"],
"refresh_token": refreshed.get("refresh_token") or refresh_tok,
"id_token": refreshed.get("id_token") or existing_id or "",
"scope": refreshed.get("scope") or existing_scope or "",
"token_type": refreshed.get("token_type", "Bearer"),
"expires_at": refreshed.get("expires_at"),
"expiry": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(refreshed["expires_at"])),
})
data["token"] = tokens
cls.save_profile_auth(provider, profile_id, data)
except Exception as re_err:
logger.warning("Silent token refresh failed for profile=%s: %s", profile_id, re_err)
return data
except Exception as e:
logger.warning("Error reading %s: %s", auth_file, e)
# Fallbacks for specific providers
if provider == "openai-codex":
env_var = f"CODEX_TOKEN_{profile_id.upper().replace('-', '_')}"
val = os.environ.get(env_var) or os.environ.get("CODEX_API_KEY") or os.environ.get("OPENAI_API_KEY")
if val:
return {"provider": "openai-codex", "profile_id": profile_id, "api_key": val}
elif provider == "opencode-go":
env_var = f"OPENCODE_API_KEY_{profile_id.upper().replace('-', '_')}"
val = os.environ.get(env_var) or os.environ.get("OPENCODE_API_KEY")
if val:
return {"provider": "opencode-go", "profile_id": profile_id, "api_key": val}
return None
@classmethod
def extract_jwt_claims(cls, token: str) -> dict[str, Any]:
"""Extract all claims from JWT payload without verifying signature."""
try:
parts = token.split(".")
if len(parts) < 2:
return {}
payload_b64 = parts[1]
rem = len(payload_b64) % 4
if rem:
payload_b64 += "=" * (4 - rem)
return json.loads(base64.urlsafe_b64decode(payload_b64).decode("utf-8"))
except Exception as e:
logger.debug("Failed to extract JWT claims: %s", e)
return {}
@classmethod
def extract_jwt_identity(cls, token: str) -> Tuple[Optional[str], Optional[str]]:
"""Extract email and subject (sub) from JWT id_token / access_token without verifying signature."""
try:
data = cls.extract_jwt_claims(token)
if not data:
return None, None
# Standard claims + OpenAI / Google custom profile claims
email = (
data.get("email")
or data.get("https://api.openai.com/profile", {}).get("email")
or data.get("userinfo", {}).get("email")
)
sub = data.get("sub") or data.get("user_id") or data.get("id")
return email, sub
except Exception as e:
logger.debug("Failed to extract JWT identity: %s", e)
return None, None
@classmethod
def verify_codex_profile(cls, profile_id: str) -> Dict[str, Any]:
"""Verify Codex profile authentication and return metadata."""
auth_data = cls.load_profile_auth("openai-codex", profile_id)
if not auth_data:
return {"valid": False, "email": None, "profile_id": profile_id}
tokens = auth_data.get("token") or auth_data.get("tokens", {})
acc_token = tokens.get("access_token") if isinstance(tokens, dict) else (auth_data.get("access_token") or "")
id_token = tokens.get("id_token") if isinstance(tokens, dict) else (auth_data.get("id_token") or "")
email = auth_data.get("email")
if not email and id_token:
email, _ = cls.extract_jwt_identity(id_token)
if not email and acc_token:
email, _ = cls.extract_jwt_identity(acc_token)
key = auth_data.get("api_key", "")
if acc_token:
return {
"valid": True,
"email": email or "ChatGPT Account",
"profile_id": profile_id,
"auth_mode": "oauth",
}
elif key:
ok, masked, models = cls.verify_codex_token(key)
return {
"valid": ok,
"email": masked or "OpenAI API Key",
"profile_id": profile_id,
"auth_mode": "api_key",
"models": models,
}
return {"valid": False, "email": None, "profile_id": profile_id}
@classmethod
def verify_antigravity_token(cls, access_token: str) -> Tuple[bool, Optional[str], Optional[str]]:
"""Verify Antigravity access token against Google UserInfo API. Returns (valid, email, account_id)."""
try:
url = "https://www.googleapis.com/oauth2/v3/userinfo"
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {access_token}"})
with urllib.request.urlopen(req, timeout=8) as resp:
if resp.status == 200:
info = json.loads(resp.read().decode("utf-8"))
email = info.get("email")
account_id = info.get("sub")
return True, email, account_id
except urllib.error.HTTPError as e:
logger.debug("Google token verification failed with HTTP %d", e.code)
return False, None, None
except Exception as e:
logger.debug("Google token verification error: %s", e)
return False, None, None
return False, None, None
@classmethod
def verify_codex_token(cls, api_key: str) -> Tuple[bool, Optional[str], List[str]]:
"""Verify OpenAI Codex API key and discover available models. Returns (valid, masked_id, models)."""
try:
url = "https://api.openai.com/v1/models"
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {api_key}"})
with urllib.request.urlopen(req, timeout=8) as resp:
if resp.status == 200:
data = json.loads(resp.read().decode("utf-8"))
models = [m.get("id") for m in data.get("data", []) if "gpt" in m.get("id", "").lower()]
masked = f"sk-...{api_key[-4:]}" if len(api_key) > 8 else "sk-***"
return True, masked, sorted(models)
except Exception:
pass
# Fallback offline check for structural validity
if api_key.startswith("sk-") and len(api_key) >= 20:
masked = f"sk-...{api_key[-4:]}"
return True, masked, ["gpt-4o", "o3-mini", "gpt-4o-mini", "codex"]
return False, None, []
@classmethod
def verify_claude_token(cls, api_key: str) -> Tuple[bool, Optional[str], List[str]]:
"""Verify Claude API key and discover models. Returns (valid, masked_id, models)."""
if api_key and (api_key.startswith("sk-ant-") or len(api_key) >= 20):
masked = f"sk-ant-...{api_key[-4:]}" if len(api_key) > 12 else "sk-ant-***"
return True, masked, ["claude-3-7-sonnet", "claude-3-5-sonnet", "claude-3-5-haiku", "claude-3-opus"]
return False, None, []
@classmethod
def verify_grok_token(cls, api_key: str) -> Tuple[bool, Optional[str], List[str]]:
"""Verify xAI Grok API key and discover models. Returns (valid, masked_id, models)."""
if api_key and (api_key.startswith("xai-") or len(api_key) >= 20):
masked = f"xai-...{api_key[-4:]}" if len(api_key) > 8 else "xai-***"
return True, masked, ["grok-3", "grok-3-mini", "grok-2"]
return False, None, []
@classmethod
def verify_opencode_token(cls, api_key: str) -> Tuple[bool, Optional[str], List[str]]:
"""Verify OpenCode Go API key and discover models. Returns (valid, masked_id, models)."""
if api_key and (api_key.startswith("opencode-") or len(api_key) >= 16):
masked = f"opencode-...{api_key[-4:]}"
return True, masked, ["opencode-go-3"]
return False, None, []
@classmethod
def get_profile_status(cls, provider: str, profile_id: str) -> Dict[str, Any]:
"""Check status and metadata for a profile."""
auth_data = cls.load_profile_auth(provider, profile_id)
if not auth_data:
return {
"authenticated": False,
"provider": provider,
"profile_id": profile_id,
"status": "NOT_CONFIGURED",
"error": None,
}
if provider in ("antigravity", "google-antigravity"):
tokens = auth_data.get("token") or auth_data.get("tokens", {})
acc_token = tokens.get("access_token") if isinstance(tokens, dict) else (auth_data.get("access_token") or "")
id_token = tokens.get("id_token") if isinstance(tokens, dict) else (auth_data.get("id_token") or "")
refresh_tok = tokens.get("refresh_token") if isinstance(tokens, dict) else (auth_data.get("refresh_token") or "")
email = auth_data.get("email")
acc_id = None
if id_token:
email_from_jwt, acc_id = cls.extract_jwt_identity(id_token)
email = email or email_from_jwt
if not email and acc_token:
email_from_jwt, acc_id = cls.extract_jwt_identity(acc_token)
email = email or email_from_jwt
expiry = tokens.get("expiry_date") if isinstance(tokens, dict) else auth_data.get("expiry_date")
if not expiry and isinstance(tokens, dict):
expiry = tokens.get("expires_at")
if not expiry:
expiry_str = tokens.get("expiry") if isinstance(tokens, dict) else auth_data.get("expiry")
if expiry_str:
try:
dt = datetime.fromisoformat(str(expiry_str).replace("Z", "+00:00"))
expiry = dt.timestamp()
except Exception:
expiry = None
is_expired = False
if expiry:
if float(expiry) > 1e11:
expiry = float(expiry) / 1000.0
if time.time() > float(expiry):
is_expired = not bool(refresh_tok)
return {
"authenticated": True,
"provider": provider,
"profile_id": profile_id,
"email_masked": mask_email(email) if email else None,
"account_id_masked": mask_id(acc_id) if acc_id else None,
"has_refresh_token": bool(refresh_tok),
"is_expired": is_expired,
"status": "EXPIRED" if is_expired else "AUTHENTICATED",
"error": "Token expired without refresh token" if is_expired else None,
}
elif provider in ("openai-codex", "codex"):
tokens = auth_data.get("token") or auth_data.get("tokens", {})
acc_token = tokens.get("access_token") if isinstance(tokens, dict) else (auth_data.get("access_token") or "")
id_token = tokens.get("id_token") if isinstance(tokens, dict) else (auth_data.get("id_token") or "")
refresh_tok = tokens.get("refresh_token") if isinstance(tokens, dict) else (auth_data.get("refresh_token") or "")
email = auth_data.get("email")
if not email and id_token:
email, _ = cls.extract_jwt_identity(id_token)
if not email and acc_token:
email, _ = cls.extract_jwt_identity(acc_token)
key = auth_data.get("api_key", "")
is_oauth = bool(acc_token)
is_auth = is_oauth or bool(key)
now = time.time()
acc_claims = cls.extract_jwt_claims(acc_token) if acc_token else {}
id_claims = cls.extract_jwt_claims(id_token) if id_token else {}
acc_exp = acc_claims.get("exp")
id_exp = id_claims.get("exp")
# Access token expiration with 60-second safety margin
access_token_expired = bool(acc_exp and now > (float(acc_exp) - 60))
id_token_expired = bool(id_exp and now > float(id_exp))
# If access_token expired and no refresh_token, it cannot be refreshed silently
is_expired = access_token_expired and not bool(refresh_tok)
status_err = None
if not is_auth:
status_str = "NOT_CONFIGURED"
elif is_expired:
status_str = "EXPIRED"
status_err = "Access-токен истёк, refresh token отсутствует"
else:
status_str = "AUTHENTICATED"
if access_token_expired and refresh_tok:
status_err = "Access-токен истёк — доступно автоматическое обновление"
elif id_token_expired:
status_err = "ID-токен истёк"
account_id_masked = None
if is_oauth:
account_id_masked = mask_email(email) if email else "ChatGPT Account"
elif key:
account_id_masked = f"sk-...{key[-4:]}" if len(key) > 8 else "sk-***"
return {
"authenticated": is_auth,
"provider": provider,
"profile_id": profile_id,
"auth_mode": "oauth" if is_oauth else ("api_key" if key else "unconfigured"),
"email_masked": mask_email(email) if email else None,
"account_id_masked": account_id_masked,
"access_token_expired": access_token_expired,
"id_token_expired": id_token_expired,
"has_refresh_token": bool(refresh_tok),
"is_expired": is_expired,
"status": status_str,
"error": status_err,
}
elif provider in ("claude", "anthropic"):
tokens = auth_data.get("token") or auth_data.get("tokens", {})
acc_token = tokens.get("access_token") if isinstance(tokens, dict) else (auth_data.get("access_token") or "")
email = auth_data.get("email")
if not email and acc_token:
email, _ = cls.extract_jwt_identity(acc_token)
key = auth_data.get("api_key", "")
is_oauth = bool(acc_token)
is_auth = is_oauth or bool(key)
account_id_masked = None
if is_oauth:
account_id_masked = mask_email(email) if email else "Claude Account"
elif key:
account_id_masked = f"sk-ant-...{key[-4:]}" if len(key) > 12 else "sk-ant-***"
return {
"authenticated": is_auth,
"provider": provider,
"profile_id": profile_id,
"auth_mode": "oauth" if is_oauth else ("api_key" if key else "unconfigured"),
"email_masked": mask_email(email) if email else None,
"account_id_masked": account_id_masked,
"status": "AUTHENTICATED" if is_auth else "NOT_CONFIGURED",
"error": None,
}
elif provider in ("grok", "xai", "xai-oauth"):
tokens = auth_data.get("token") or auth_data.get("tokens", {})
acc_token = tokens.get("access_token") if isinstance(tokens, dict) else (auth_data.get("access_token") or "")
id_token = tokens.get("id_token") if isinstance(tokens, dict) else (auth_data.get("id_token") or "")
email = auth_data.get("email")
if not email and id_token:
email, _ = cls.extract_jwt_identity(id_token)
if not email and acc_token:
email, _ = cls.extract_jwt_identity(acc_token)
key = auth_data.get("api_key", "")
is_oauth = bool(acc_token)
is_auth = is_oauth or bool(key)
account_id_masked = None
if is_oauth:
account_id_masked = mask_email(email) if email else "Grok Account"
elif key:
account_id_masked = f"xai-...{key[-4:]}" if len(key) > 8 else "xai-***"
return {
"authenticated": is_auth,
"provider": provider,
"profile_id": profile_id,
"auth_mode": "oauth" if is_oauth else ("api_key" if key else "unconfigured"),
"email_masked": mask_email(email) if email else None,
"account_id_masked": account_id_masked,
"status": "AUTHENTICATED" if is_auth else "NOT_CONFIGURED",
"error": None,
}
elif provider in ("opencode-go", "opencode"):
key = auth_data.get("api_key", "")
return {
"authenticated": bool(key),
"provider": provider,
"profile_id": profile_id,
"account_id_masked": f"opencode-...{key[-4:]}" if len(key) > 8 else "opencode-***",
"status": "AUTHENTICATED" if key else "NOT_CONFIGURED",
"error": None,
}
return {
"authenticated": False,
"provider": provider,
"profile_id": profile_id,
"status": "UNKNOWN_PROVIDER",
"error": f"Unknown provider {provider}",
}