831 lines
34 KiB
Python
831 lines
34 KiB
Python
"""xAI Grok OAuth: token store, discovery, refresh, device-code login.
|
|
|
|
Split out of ``hermes_cli/auth.py``; every moved name is re-imported there, so
|
|
``hermes_cli.auth.<name>`` keeps resolving (and monkeypatching) as before. Origin-internal
|
|
helpers are imported lazily inside each function (no import cycle; patches on
|
|
``hermes_cli.auth.<helper>`` still intercept).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import base64
|
|
import json
|
|
import os
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Optional
|
|
from urllib.parse import urlparse
|
|
from hermes_cli.auth_codex import _load_auth_store_maybe_locked, _refresh_payload_access_token
|
|
from hermes_cli.auth_constants import (
|
|
AUTH_LOCK_TIMEOUT_SECONDS,
|
|
AuthError,
|
|
DEFAULT_XAI_OAUTH_BASE_URL,
|
|
DEVICE_CODE_GRANT_TYPE,
|
|
XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
|
|
XAI_OAUTH_CLIENT_ID,
|
|
XAI_OAUTH_DEVICE_CODE_URL,
|
|
XAI_OAUTH_DISCOVERY_URL,
|
|
XAI_OAUTH_SCOPE,
|
|
_FORM_JSON_HEADERS,
|
|
_xai_err,
|
|
httpx,
|
|
)
|
|
from utils import env_float
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING: # annotation-only; the runtime import would be a cycle
|
|
from hermes_cli.auth import ProviderConfig
|
|
|
|
# Log-record parity with the origin module (caplog tests pin "hermes_cli.auth").
|
|
logger = logging.getLogger("hermes_cli.auth")
|
|
|
|
|
|
def _xai_oauth_state_from_store(auth_store: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
"""Return usable xAI OAuth state from provider state or credential pool."""
|
|
from hermes_cli.auth import _load_provider_state
|
|
state = _load_provider_state(auth_store, "xai-oauth")
|
|
tokens = state.get("tokens") if isinstance(state, dict) else None
|
|
if isinstance(tokens, dict):
|
|
access_token = str(tokens.get("access_token", "") or "").strip()
|
|
refresh_token = str(tokens.get("refresh_token", "") or "").strip()
|
|
if access_token and refresh_token:
|
|
return state
|
|
|
|
credential_pool = auth_store.get("credential_pool")
|
|
entries = (
|
|
credential_pool.get("xai-oauth")
|
|
if isinstance(credential_pool, dict)
|
|
else None
|
|
)
|
|
if isinstance(entries, list):
|
|
for entry in entries:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
access_token = str(entry.get("access_token", "") or "").strip()
|
|
refresh_token = str(entry.get("refresh_token", "") or "").strip()
|
|
if not access_token or not refresh_token:
|
|
continue
|
|
merged = dict(state or {})
|
|
merged["tokens"] = {
|
|
"access_token": access_token,
|
|
"refresh_token": refresh_token,
|
|
"token_type": str(entry.get("token_type") or "Bearer"),
|
|
}
|
|
if entry.get("last_refresh"):
|
|
merged["last_refresh"] = entry.get("last_refresh")
|
|
merged.setdefault("auth_mode", "oauth_pkce")
|
|
return merged
|
|
|
|
return state if isinstance(state, dict) else None
|
|
|
|
|
|
def _xai_oauth_state_has_usable_tokens(state: Optional[Dict[str, Any]]) -> bool:
|
|
tokens = state.get("tokens") if isinstance(state, dict) else None
|
|
return (
|
|
isinstance(tokens, dict)
|
|
and bool(str(tokens.get("access_token", "") or "").strip())
|
|
and bool(str(tokens.get("refresh_token", "") or "").strip())
|
|
)
|
|
|
|
|
|
def _read_xai_oauth_tokens(*, _lock: bool = True) -> Dict[str, Any]:
|
|
from hermes_cli.auth import _load_global_auth_store
|
|
auth_store = _load_auth_store_maybe_locked(_lock)
|
|
state = _xai_oauth_state_from_store(auth_store)
|
|
if not _xai_oauth_state_has_usable_tokens(state):
|
|
global_state = _xai_oauth_state_from_store(_load_global_auth_store())
|
|
if _xai_oauth_state_has_usable_tokens(global_state):
|
|
state = global_state
|
|
if not state:
|
|
raise _xai_err(
|
|
"No xAI OAuth credentials stored. Select xAI Grok OAuth (SuperGrok / Premium+) in `hermes model`.",
|
|
"xai_auth_missing", relogin=True,
|
|
)
|
|
tokens = state.get("tokens")
|
|
if not isinstance(tokens, dict):
|
|
raise _xai_err(
|
|
"xAI OAuth state is missing tokens. Re-authenticate with `hermes model`.",
|
|
"xai_auth_invalid_shape", relogin=True,
|
|
)
|
|
access_token = str(tokens.get("access_token", "") or "").strip()
|
|
refresh_token = str(tokens.get("refresh_token", "") or "").strip()
|
|
if not access_token:
|
|
raise _xai_err(
|
|
"xAI OAuth state is missing access_token. Re-authenticate with `hermes model`.",
|
|
"xai_auth_missing_access_token", relogin=True,
|
|
)
|
|
if not refresh_token:
|
|
raise _xai_err(
|
|
"xAI OAuth state is missing refresh_token. Re-authenticate with `hermes model`.",
|
|
"xai_auth_missing_refresh_token", relogin=True,
|
|
)
|
|
return {
|
|
"tokens": tokens,
|
|
"last_refresh": state.get("last_refresh"),
|
|
"discovery": state.get("discovery") or {},
|
|
"redirect_uri": state.get("redirect_uri"),
|
|
}
|
|
|
|
|
|
def _write_through_xai_oauth_to_global_root(state: Dict[str, Any]) -> None:
|
|
"""Persist a rotated xAI OAuth ``state`` into the global-root auth.json.
|
|
|
|
Best-effort write-through for the multi-profile rotation hazard (#43589): xAI rotates the
|
|
refresh_token on every refresh, so when a profile session refreshes a grant it resolved from the
|
|
root fallback, the rotated chain must land back in root.
|
|
|
|
Only updates ``providers.xai-oauth`` in the root store; never touches the profile store (the
|
|
caller already saved that). Swallows all errors — a failed write-through degrades to the pre-
|
|
existing behavior (root stale), it must never break the profile's own successful save.
|
|
"""
|
|
from hermes_cli.auth import _global_auth_file_path, _persist_provider_state_to_store
|
|
global_path = _global_auth_file_path()
|
|
if global_path is None:
|
|
# Classic mode (profile == root); the profile save already hit root.
|
|
return
|
|
# Seat belt: under pytest, refuse to write the real user's
|
|
# ~/.hermes/auth.json even when HERMES_HOME points at a profile path
|
|
# (mirrors the read-side guard in _load_global_auth_store). Uses the
|
|
# unmodified HOME env, not Path.home() which fixtures may monkeypatch.
|
|
if os.environ.get("PYTEST_CURRENT_TEST"):
|
|
real_home_env = os.environ.get("HOME", "")
|
|
if real_home_env:
|
|
real_root = Path(real_home_env) / ".hermes" / "auth.json"
|
|
try:
|
|
if global_path.resolve(strict=False) == real_root.resolve(strict=False):
|
|
return
|
|
except Exception:
|
|
return
|
|
try:
|
|
_persist_provider_state_to_store(
|
|
"xai-oauth",
|
|
state,
|
|
global_path,
|
|
set_active=False,
|
|
)
|
|
except Exception as exc: # pragma: no cover - best effort
|
|
logger.debug("xAI OAuth: write-through to global root failed: %s", exc)
|
|
|
|
|
|
def _save_xai_oauth_tokens(
|
|
tokens: Dict[str, Any],
|
|
*,
|
|
discovery: Optional[Dict[str, Any]] = None,
|
|
redirect_uri: str = "",
|
|
last_refresh: Optional[str] = None,
|
|
auth_mode: str = "oauth_device_code",
|
|
set_active: bool = True,
|
|
) -> None:
|
|
"""Persist xAI OAuth tokens into the auth store.
|
|
|
|
When *set_active* is True (default), also promote ``xai-oauth`` to ``active_provider`` —
|
|
appropriate for intentional model/auth login. Pass ``set_active=False`` for side-tool credential
|
|
bootstrap (TTS/setup, tools config, dashboard token save, token refresh) so inference routing is
|
|
unchanged.
|
|
"""
|
|
from hermes_cli.auth import _auth_store_lock, _global_auth_file_path, _load_auth_store, _load_provider_state_with_source, _same_path, _save_auth_store, _store_provider_state, _utc_now_z, _write_through_xai_oauth_to_global_root
|
|
if last_refresh is None:
|
|
last_refresh = _utc_now_z()
|
|
with _auth_store_lock():
|
|
auth_store = _load_auth_store()
|
|
# A profile that lacks its own xai-oauth block is reading the root
|
|
# grant through _load_provider_state's fallback. When such a profile
|
|
# refreshes the (rotating) grant, we must write the rotated chain back
|
|
# to root too, or root is left holding a revoked refresh token (#43589).
|
|
# #74339: the old key-presence check (_profile_has_own_xai_oauth_state)
|
|
# decided write-through based on whether the profile had a
|
|
# providers.xai-oauth key BEFORE the save — but _store_provider_state
|
|
# unconditionally creates that key below. Use
|
|
# _load_provider_state_with_source to learn where the grant was
|
|
# resolved from and write back only to that source.
|
|
state, source_path = _load_provider_state_with_source(
|
|
auth_store, "xai-oauth"
|
|
)
|
|
if state is None:
|
|
state = {}
|
|
state["tokens"] = tokens
|
|
state["last_refresh"] = last_refresh
|
|
state["auth_mode"] = auth_mode
|
|
if discovery:
|
|
state["discovery"] = discovery
|
|
if redirect_uri:
|
|
state["redirect_uri"] = redirect_uri
|
|
global_root = _global_auth_file_path()
|
|
is_from_root = bool(
|
|
source_path is not None
|
|
and global_root is not None
|
|
and _same_path(source_path, global_root)
|
|
)
|
|
if is_from_root:
|
|
# Grant was resolved from root — write back to root only.
|
|
# Do NOT call _store_provider_state on the profile auth_store
|
|
# (it would create a shadowing providers.xai-oauth key that
|
|
# disables write-through on the next refresh — #74339).
|
|
_write_through_xai_oauth_to_global_root(state)
|
|
else:
|
|
# Profile genuinely owns this — write to profile store.
|
|
_store_provider_state(
|
|
auth_store, "xai-oauth", state, set_active=set_active
|
|
)
|
|
_save_auth_store(auth_store)
|
|
|
|
|
|
def _xai_access_token_is_expiring(access_token: str, skew_seconds: int = 0) -> bool:
|
|
if not isinstance(access_token, str) or "." not in access_token:
|
|
return False
|
|
try:
|
|
parts = access_token.split(".")
|
|
if len(parts) < 2:
|
|
return False
|
|
payload_b64 = parts[1]
|
|
payload_b64 += "=" * (-len(payload_b64) % 4)
|
|
payload = json.loads(base64.urlsafe_b64decode(payload_b64.encode("ascii")).decode("utf-8"))
|
|
exp = payload.get("exp")
|
|
if not isinstance(exp, (int, float)):
|
|
return False
|
|
return float(exp) <= (time.time() + max(0, int(skew_seconds)))
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _xai_proactive_refresh_skew_seconds(access_token: str) -> int:
|
|
"""How far before JWT ``exp`` to proactively refresh xAI OAuth tokens.
|
|
|
|
SuperGrok sessions ship multi-hour tokens where the gateway-oriented hour-long skew makes sense,
|
|
but device-code logins often return ~15-minute JWTs; the full skew would force a refresh on
|
|
every credential resolution, burning single-use refresh tokens and racing concurrent callers
|
|
into ``invalid_grant`` quarantine.
|
|
"""
|
|
max_skew = XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS
|
|
if not isinstance(access_token, str) or "." not in access_token:
|
|
return max_skew
|
|
try:
|
|
parts = access_token.split(".")
|
|
if len(parts) < 2:
|
|
return max_skew
|
|
payload_b64 = parts[1]
|
|
payload_b64 += "=" * (-len(payload_b64) % 4)
|
|
payload = json.loads(base64.urlsafe_b64decode(payload_b64.encode("ascii")).decode("utf-8"))
|
|
exp = payload.get("exp")
|
|
if not isinstance(exp, (int, float)):
|
|
return max_skew
|
|
remaining = float(exp) - time.time()
|
|
if remaining <= 0:
|
|
return max_skew
|
|
if remaining <= 45 * 60:
|
|
return min(120, max_skew)
|
|
return max_skew
|
|
except Exception:
|
|
return max_skew
|
|
|
|
|
|
def _is_xai_origin_host(host: str) -> bool:
|
|
"""``x.ai`` is the bare apex, so an exact match or any ``.x.ai`` suffix is accepted."""
|
|
return host == "x.ai" or host.endswith(".x.ai")
|
|
|
|
|
|
def _xai_validate_oauth_endpoint(url: str, *, field: str) -> str:
|
|
"""Refuse any OIDC discovery endpoint that isn't HTTPS on the xAI origin.
|
|
|
|
The discovery result is cached in auth.json, so a single MITM at login could plant a malicious
|
|
``token_endpoint`` that receives the refresh_token forever. Pinning scheme + host (RFC 8414 §2:
|
|
HTTPS issuer, same-origin token_endpoint) removes that persistence; ``x.ai`` is the bare apex,
|
|
so an exact match or any ``.x.ai`` suffix is accepted.
|
|
"""
|
|
parsed = urlparse(url)
|
|
if parsed.scheme != "https":
|
|
raise _xai_err(
|
|
f"xAI OIDC discovery returned a non-HTTPS {field}: {url!r}.",
|
|
"xai_discovery_invalid",
|
|
)
|
|
host = (parsed.hostname or "").lower()
|
|
if not host:
|
|
raise _xai_err(
|
|
f"xAI OIDC discovery {field} is missing a hostname: {url!r}.",
|
|
"xai_discovery_invalid",
|
|
)
|
|
if not _is_xai_origin_host(host):
|
|
raise _xai_err(
|
|
f"xAI OIDC discovery {field} host {host!r} is not on the xAI origin "
|
|
f"(expected x.ai or a *.x.ai subdomain). Refusing to use a cached "
|
|
f"endpoint that may have been substituted by a MITM during initial "
|
|
f"discovery; re-authenticate with `hermes model` to re-fetch.",
|
|
"xai_discovery_invalid",
|
|
)
|
|
return url
|
|
|
|
|
|
def _xai_validate_inference_base_url(value: str, *, fallback: str) -> str:
|
|
"""Refuse a non-xAI base_url for the OAuth-authenticated inference path.
|
|
|
|
Pin the inference origin to ``api.x.ai`` (or any ``*.x.ai`` subdomain xAI may add). On
|
|
rejection, fall back to the default and log a warning rather than raise — a bad env var should
|
|
not deadlock authentication, but it should also never leak the bearer.
|
|
|
|
``value`` is the already-stripped, trailing-slash-trimmed candidate from env. Empty input
|
|
returns ``fallback`` unchanged.
|
|
"""
|
|
candidate = (value or "").strip().rstrip("/")
|
|
if not candidate:
|
|
return fallback
|
|
try:
|
|
parsed = urlparse(candidate)
|
|
except Exception:
|
|
logger.warning(
|
|
"Ignoring malformed xAI base_url override %r; using %s instead.",
|
|
candidate, fallback,
|
|
)
|
|
return fallback
|
|
if parsed.scheme != "https":
|
|
logger.warning(
|
|
"Refusing non-HTTPS xAI base_url override %r (xai-oauth bearer would "
|
|
"be sent in cleartext); falling back to %s.",
|
|
candidate, fallback,
|
|
)
|
|
return fallback
|
|
host = (parsed.hostname or "").lower()
|
|
if not host:
|
|
logger.warning(
|
|
"Ignoring xAI base_url override %r with no hostname; using %s instead.",
|
|
candidate, fallback,
|
|
)
|
|
return fallback
|
|
if not _is_xai_origin_host(host):
|
|
logger.warning(
|
|
"Refusing xAI base_url override %r — host %r is not on the xAI origin "
|
|
"(expected x.ai or a *.x.ai subdomain). The xai-oauth bearer is only "
|
|
"valid against xAI's inference API; sending it elsewhere would leak "
|
|
"the credential. Falling back to %s.",
|
|
candidate, host, fallback,
|
|
)
|
|
return fallback
|
|
return candidate
|
|
|
|
|
|
def _xai_oauth_discovery(timeout_seconds: float = 15.0) -> Dict[str, str]:
|
|
try:
|
|
response = httpx.get(
|
|
XAI_OAUTH_DISCOVERY_URL,
|
|
headers={"Accept": "application/json"},
|
|
timeout=timeout_seconds,
|
|
)
|
|
except Exception as exc:
|
|
raise _xai_err(f"xAI OIDC discovery failed: {exc}", "xai_discovery_failed") from exc
|
|
if response.status_code != 200:
|
|
raise _xai_err(
|
|
f"xAI OIDC discovery returned status {response.status_code}.",
|
|
"xai_discovery_failed",
|
|
)
|
|
try:
|
|
payload = response.json()
|
|
except Exception as exc:
|
|
raise _xai_err(
|
|
f"xAI OIDC discovery returned invalid JSON: {exc}",
|
|
"xai_discovery_invalid_json",
|
|
) from exc
|
|
if not isinstance(payload, dict):
|
|
raise _xai_err(
|
|
"xAI OIDC discovery response was not a JSON object.",
|
|
"xai_discovery_incomplete",
|
|
)
|
|
authorization_endpoint = str(payload.get("authorization_endpoint", "") or "").strip()
|
|
token_endpoint = str(payload.get("token_endpoint", "") or "").strip()
|
|
if not authorization_endpoint or not token_endpoint:
|
|
raise _xai_err(
|
|
"xAI OIDC discovery response was missing required endpoints.",
|
|
"xai_discovery_incomplete",
|
|
)
|
|
_xai_validate_oauth_endpoint(authorization_endpoint, field="authorization_endpoint")
|
|
_xai_validate_oauth_endpoint(token_endpoint, field="token_endpoint")
|
|
return {
|
|
"authorization_endpoint": authorization_endpoint,
|
|
"token_endpoint": token_endpoint,
|
|
}
|
|
|
|
|
|
def _xai_tokens_from_payload(payload: Dict[str, Any], access_token: str, fallback_refresh: str) -> Dict[str, Any]:
|
|
"""Token block persisted for xAI OAuth; falls back to *fallback_refresh* when none is rotated in."""
|
|
return {
|
|
"access_token": access_token,
|
|
"refresh_token": str(payload.get("refresh_token") or fallback_refresh).strip(),
|
|
"id_token": str(payload.get("id_token") or "").strip(),
|
|
"expires_in": payload.get("expires_in"),
|
|
"token_type": str(payload.get("token_type") or "Bearer").strip() or "Bearer",
|
|
}
|
|
|
|
|
|
def refresh_xai_oauth_pure(
|
|
access_token: str,
|
|
refresh_token: str,
|
|
*,
|
|
token_endpoint: str = "",
|
|
timeout_seconds: float = 20.0,
|
|
) -> Dict[str, Any]:
|
|
from hermes_cli.auth import _nonempty_str, _utc_now_z, _xai_oauth_discovery
|
|
del access_token
|
|
if not _nonempty_str(refresh_token):
|
|
raise _xai_err(
|
|
"xAI OAuth is missing refresh_token. Re-authenticate with `hermes model`.",
|
|
"xai_auth_missing_refresh_token", relogin=True,
|
|
)
|
|
endpoint = token_endpoint.strip() or _xai_oauth_discovery(timeout_seconds)["token_endpoint"]
|
|
# Re-validate cached endpoints on the refresh hot path: an auth.json
|
|
# written by an older Hermes (or hand-edited) may carry a non-xAI
|
|
# token_endpoint that would receive every future refresh_token in
|
|
# plaintext if we trusted it blindly. Cheap suffix check; fast-fail
|
|
# with a clear error so the user can re-run `hermes model` to refetch.
|
|
_xai_validate_oauth_endpoint(endpoint, field="token_endpoint")
|
|
timeout = httpx.Timeout(max(5.0, float(timeout_seconds)))
|
|
with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}) as client:
|
|
response = client.post(
|
|
endpoint,
|
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
data={
|
|
"grant_type": "refresh_token",
|
|
"client_id": XAI_OAUTH_CLIENT_ID,
|
|
"refresh_token": refresh_token,
|
|
},
|
|
)
|
|
if response.status_code != 200:
|
|
detail = response.text.strip()
|
|
# ``403`` from xAI's token endpoint is almost always a tier /
|
|
# entitlement gate (the OAuth grant exists but the account isn't
|
|
# on the allowlist for API access). Re-running ``hermes model``
|
|
# won't fix that — surface a separate error code so
|
|
# ``format_auth_error`` doesn't append a misleading
|
|
# re-authenticate hint, and point users at the ``XAI_API_KEY``
|
|
# fallback. See #26847.
|
|
if response.status_code == 403:
|
|
raise _xai_err(
|
|
"xAI token refresh failed with HTTP 403."
|
|
+ (f" Response: {detail}" if detail else "")
|
|
+ " This OAuth account is not authorized for xAI API"
|
|
" access — xAI may be restricting API/OAuth use to"
|
|
" specific SuperGrok tiers despite the in-app"
|
|
" subscription being active. Re-logging in won't"
|
|
" change that; set ``XAI_API_KEY`` and switch to"
|
|
" ``provider: xai`` (API-key path) if available, or"
|
|
" upgrade your subscription at https://x.ai/grok.",
|
|
"xai_oauth_tier_denied", relogin=False,
|
|
)
|
|
raise _xai_err(
|
|
"xAI token refresh failed."
|
|
+ (f" Response: {detail}" if detail else ""),
|
|
"xai_refresh_failed", relogin=response.status_code in {400, 401},
|
|
)
|
|
payload, refreshed_access = _refresh_payload_access_token(
|
|
response,
|
|
provider="xai-oauth",
|
|
invalid_json=("xAI token refresh returned invalid JSON: {exc}", "xai_refresh_invalid_json"),
|
|
invalid_json_relogin=False,
|
|
strict_str=False,
|
|
invalid_response=(
|
|
"xAI token refresh response was not a JSON object.",
|
|
"xai_refresh_invalid_response",
|
|
),
|
|
missing_access=(
|
|
"xAI token refresh response was missing access_token.",
|
|
"xai_refresh_missing_access_token",
|
|
),
|
|
)
|
|
return {
|
|
**_xai_tokens_from_payload(payload, refreshed_access, refresh_token),
|
|
"last_refresh": _utc_now_z(),
|
|
}
|
|
|
|
|
|
def _refresh_xai_oauth_tokens(
|
|
tokens: Dict[str, Any],
|
|
*,
|
|
token_endpoint: str,
|
|
redirect_uri: str = "",
|
|
timeout_seconds: float,
|
|
) -> Dict[str, Any]:
|
|
# Re-persist whatever auth_mode is already stored (legacy pre-device-code
|
|
# logins may still carry ``oauth_pkce``): the refresh hot path must not
|
|
# relabel how the grant was originally obtained.
|
|
from hermes_cli.auth import _load_auth_store, _load_provider_state, refresh_xai_oauth_pure
|
|
try:
|
|
state = _load_provider_state(_load_auth_store(), "xai-oauth") or {}
|
|
auth_mode = str(state.get("auth_mode") or "oauth_device_code")
|
|
except Exception:
|
|
auth_mode = "oauth_device_code"
|
|
refreshed = refresh_xai_oauth_pure(
|
|
str(tokens.get("access_token", "") or ""),
|
|
str(tokens.get("refresh_token", "") or ""),
|
|
token_endpoint=token_endpoint,
|
|
timeout_seconds=timeout_seconds,
|
|
)
|
|
updated_tokens = dict(tokens)
|
|
updated_tokens["access_token"] = refreshed["access_token"]
|
|
updated_tokens["refresh_token"] = refreshed["refresh_token"]
|
|
if refreshed.get("id_token"):
|
|
updated_tokens["id_token"] = refreshed["id_token"]
|
|
if refreshed.get("expires_in") is not None:
|
|
updated_tokens["expires_in"] = refreshed["expires_in"]
|
|
if refreshed.get("token_type"):
|
|
updated_tokens["token_type"] = refreshed["token_type"]
|
|
_save_xai_oauth_tokens(
|
|
updated_tokens,
|
|
discovery={"token_endpoint": token_endpoint},
|
|
redirect_uri=redirect_uri,
|
|
last_refresh=refreshed["last_refresh"],
|
|
auth_mode=auth_mode,
|
|
# Refresh must not flip active_provider — TTS/side tools can refresh
|
|
# xAI tokens while chat still routes through another provider.
|
|
set_active=False,
|
|
)
|
|
return updated_tokens
|
|
|
|
|
|
def _quarantine_xai_oauth_tokens(exc: AuthError) -> None:
|
|
"""Clear dead xAI tokens from auth.json after a terminal refresh failure.
|
|
|
|
Terminal = HTTP 400/401/403 (invalid_grant, token revoked). Subsequent sessions then fail fast
|
|
without a network retry. Mirrors credential_pool.py quarantine. Best-effort: persistence
|
|
failures are logged and swallowed (caller re-raises the original error regardless).
|
|
"""
|
|
from hermes_cli.auth import _last_auth_error_marker, _load_auth_store, _load_provider_state, _save_auth_store, _store_provider_state
|
|
try:
|
|
_q_store = _load_auth_store()
|
|
_q_state = _load_provider_state(_q_store, "xai-oauth") or {}
|
|
_q_tokens = dict(_q_state.get("tokens") or {})
|
|
_q_tokens.pop("access_token", None)
|
|
_q_tokens.pop("refresh_token", None)
|
|
_q_state["tokens"] = _q_tokens
|
|
_q_state["last_auth_error"] = _last_auth_error_marker(
|
|
"xai-oauth", exc,
|
|
reason="runtime_refresh_failure", default_code="xai_refresh_failed",
|
|
)
|
|
_store_provider_state(_q_store, "xai-oauth", _q_state, set_active=False)
|
|
_save_auth_store(_q_store)
|
|
except Exception as _save_exc:
|
|
logger.debug(
|
|
"xAI OAuth: failed to persist quarantined state: %s", _save_exc,
|
|
)
|
|
|
|
|
|
def _xai_oauth_inference_base_url() -> str:
|
|
return _xai_validate_inference_base_url(
|
|
os.getenv("HERMES_XAI_BASE_URL", "").strip().rstrip("/")
|
|
or os.getenv("XAI_BASE_URL", "").strip().rstrip("/"),
|
|
fallback=DEFAULT_XAI_OAUTH_BASE_URL,
|
|
)
|
|
|
|
|
|
def resolve_xai_oauth_runtime_credentials(
|
|
*,
|
|
force_refresh: bool = False,
|
|
refresh_if_expiring: bool = True,
|
|
refresh_skew_seconds: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
from hermes_cli.auth import _auth_store_lock, _is_terminal_xai_oauth_refresh_error, _refresh_xai_oauth_tokens, _xai_oauth_discovery
|
|
def _view(data: Dict[str, Any]) -> tuple[Dict[str, Any], str, str, str, bool]:
|
|
tokens = dict(data["tokens"])
|
|
access_token = str(tokens.get("access_token", "") or "").strip()
|
|
discovery = dict(data.get("discovery") or {})
|
|
token_endpoint = str(discovery.get("token_endpoint", "") or "").strip()
|
|
redirect_uri = str(data.get("redirect_uri", "") or "").strip()
|
|
effective_skew = (
|
|
int(refresh_skew_seconds)
|
|
if refresh_skew_seconds is not None
|
|
else _xai_proactive_refresh_skew_seconds(access_token)
|
|
)
|
|
should_refresh = bool(force_refresh)
|
|
if (not should_refresh) and refresh_if_expiring:
|
|
should_refresh = _xai_access_token_is_expiring(access_token, effective_skew)
|
|
return tokens, access_token, token_endpoint, redirect_uri, should_refresh
|
|
|
|
data = _read_xai_oauth_tokens()
|
|
refresh_timeout_seconds = env_float("HERMES_XAI_REFRESH_TIMEOUT_SECONDS", 20)
|
|
tokens, access_token, token_endpoint, redirect_uri, should_refresh = _view(data)
|
|
if should_refresh:
|
|
with _auth_store_lock(timeout_seconds=max(float(AUTH_LOCK_TIMEOUT_SECONDS), refresh_timeout_seconds + 5.0)):
|
|
data = _read_xai_oauth_tokens(_lock=False)
|
|
tokens, access_token, token_endpoint, redirect_uri, should_refresh = _view(data)
|
|
if should_refresh:
|
|
if not token_endpoint:
|
|
token_endpoint = _xai_oauth_discovery(refresh_timeout_seconds)["token_endpoint"]
|
|
try:
|
|
tokens = _refresh_xai_oauth_tokens(
|
|
tokens,
|
|
token_endpoint=token_endpoint,
|
|
redirect_uri=redirect_uri,
|
|
timeout_seconds=refresh_timeout_seconds,
|
|
)
|
|
access_token = str(tokens.get("access_token", "") or "").strip()
|
|
except AuthError as exc:
|
|
if _is_terminal_xai_oauth_refresh_error(exc):
|
|
_quarantine_xai_oauth_tokens(exc)
|
|
raise
|
|
|
|
base_url = _xai_oauth_inference_base_url()
|
|
return {
|
|
"provider": "xai-oauth",
|
|
"base_url": base_url,
|
|
"api_key": access_token,
|
|
"source": "hermes-auth-store",
|
|
"last_refresh": data.get("last_refresh"),
|
|
# Display/telemetry only. Device-code is the only supported xAI OAuth
|
|
# flow, so report it unconditionally — auth.json may still carry a
|
|
# legacy ``oauth_pkce`` label, which the refresh path preserves as-is.
|
|
"auth_mode": "oauth_device_code",
|
|
}
|
|
|
|
|
|
def _login_xai_oauth(
|
|
args,
|
|
pconfig: ProviderConfig,
|
|
*,
|
|
force_new_login: bool = False,
|
|
) -> None:
|
|
from hermes_cli.auth import _is_remote_session, _offer_existing_oauth_credentials, _print_login_success, _update_config_for_provider, _xai_oauth_device_code_login, resolve_xai_oauth_runtime_credentials, unsuppress_credential_source
|
|
del pconfig
|
|
|
|
if not force_new_login and _offer_existing_oauth_credentials(
|
|
"xai-oauth",
|
|
resolve=resolve_xai_oauth_runtime_credentials,
|
|
is_expiring=_xai_access_token_is_expiring,
|
|
display_name="xAI OAuth",
|
|
default_base_url=DEFAULT_XAI_OAUTH_BASE_URL,
|
|
):
|
|
return
|
|
|
|
print()
|
|
print("Signing in to xAI Grok OAuth (SuperGrok / Premium+)...")
|
|
print("(Hermes creates its own local OAuth session)")
|
|
print()
|
|
|
|
timeout_seconds = float(getattr(args, "timeout", None) or 20.0)
|
|
open_browser = not getattr(args, "no_browser", False)
|
|
if _is_remote_session():
|
|
open_browser = False
|
|
|
|
creds = _xai_oauth_device_code_login(
|
|
timeout_seconds=timeout_seconds,
|
|
open_browser=open_browser,
|
|
)
|
|
_save_xai_oauth_tokens(
|
|
creds["tokens"],
|
|
discovery=creds.get("discovery"),
|
|
redirect_uri=creds.get("redirect_uri", ""),
|
|
last_refresh=creds.get("last_refresh"),
|
|
auth_mode="oauth_device_code",
|
|
)
|
|
# An explicit interactive re-login is a strong signal the user wants the
|
|
# xAI credential re-enabled. ``hermes auth remove xai-oauth`` leaves a
|
|
# ``device_code`` suppression marker that otherwise stops the singleton
|
|
# seed from re-creating the pool entry, so ``hermes auth list`` would show
|
|
# nothing even though the agent still works via the singleton fallback.
|
|
# Clear it here (same helper ``auth_add_command`` uses). This is kept OUT
|
|
# of ``_save_xai_oauth_tokens`` on purpose — that helper is shared with the
|
|
# refresh hot path, which must never mutate suppression state.
|
|
unsuppress_credential_source("xai-oauth", "device_code")
|
|
config_path = _update_config_for_provider("xai-oauth", creds.get("base_url", DEFAULT_XAI_OAUTH_BASE_URL))
|
|
_print_login_success("xai-oauth", config_path, show_auth_state=True)
|
|
|
|
|
|
def _xai_oauth_request_device_code(
|
|
client: httpx.Client,
|
|
*,
|
|
scope: str = XAI_OAUTH_SCOPE,
|
|
) -> Dict[str, Any]:
|
|
response = client.post(
|
|
XAI_OAUTH_DEVICE_CODE_URL,
|
|
headers=_FORM_JSON_HEADERS,
|
|
data={
|
|
"client_id": XAI_OAUTH_CLIENT_ID,
|
|
"scope": scope,
|
|
},
|
|
)
|
|
if response.status_code != 200:
|
|
raise _xai_err(
|
|
f"xAI device-code request failed (HTTP {response.status_code})."
|
|
+ (f" Response: {response.text.strip()}" if response.text else ""),
|
|
"device_code_request_failed",
|
|
)
|
|
payload = response.json()
|
|
required = (
|
|
"device_code",
|
|
"user_code",
|
|
"verification_uri",
|
|
"verification_uri_complete",
|
|
"expires_in",
|
|
"interval",
|
|
)
|
|
missing = [key for key in required if key not in payload]
|
|
if missing:
|
|
raise _xai_err(
|
|
f"xAI device-code response missing fields: {', '.join(missing)}",
|
|
"device_code_invalid",
|
|
)
|
|
return payload
|
|
|
|
|
|
def _xai_oauth_poll_device_token(
|
|
client: httpx.Client,
|
|
*,
|
|
token_endpoint: str,
|
|
device_code: str,
|
|
expires_in: int,
|
|
poll_interval: int,
|
|
) -> Dict[str, Any]:
|
|
from hermes_cli.auth import _poll_device_token_generic
|
|
def _validate(payload: Dict[str, Any]) -> None:
|
|
for field_name, article in (("access_token", "an"), ("refresh_token", "a")):
|
|
if not payload.get(field_name):
|
|
raise _xai_err(
|
|
f"xAI device-code token response did not include {article} {field_name}.",
|
|
"xai_device_token_invalid",
|
|
)
|
|
|
|
def _error(response, error_payload) -> Exception:
|
|
description = (
|
|
error_payload.get("error_description")
|
|
or error_payload.get("error")
|
|
or response.text
|
|
)
|
|
return _xai_err(
|
|
f"xAI device-code token polling failed: {description}",
|
|
"xai_device_token_failed",
|
|
)
|
|
|
|
return _poll_device_token_generic(
|
|
lambda: client.post(
|
|
token_endpoint,
|
|
headers=_FORM_JSON_HEADERS,
|
|
data={
|
|
"grant_type": DEVICE_CODE_GRANT_TYPE,
|
|
"client_id": XAI_OAUTH_CLIENT_ID,
|
|
"device_code": device_code,
|
|
},
|
|
),
|
|
expires_in=int(expires_in),
|
|
poll_interval=max(1, int(poll_interval)),
|
|
validate_success=_validate,
|
|
on_non_json_error=lambda _r: _xai_err(
|
|
"xAI device-code token polling returned a non-JSON error response.",
|
|
"xai_device_token_failed",
|
|
),
|
|
on_error=_error,
|
|
on_timeout=lambda: _xai_err(
|
|
"Timed out waiting for xAI device authorization.",
|
|
"device_code_timeout",
|
|
),
|
|
)
|
|
|
|
|
|
def _xai_oauth_device_code_login(
|
|
*,
|
|
timeout_seconds: float = 20.0,
|
|
open_browser: bool = True,
|
|
) -> Dict[str, Any]:
|
|
from hermes_cli.auth import _can_open_graphical_browser, _is_remote_session, _print_device_code_instructions, _utc_now_z, _xai_oauth_discovery, _xai_oauth_poll_device_token
|
|
discovery = _xai_oauth_discovery(timeout_seconds)
|
|
token_endpoint = discovery["token_endpoint"]
|
|
timeout = httpx.Timeout(max(20.0, timeout_seconds))
|
|
with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}) as client:
|
|
device_data = _xai_oauth_request_device_code(client)
|
|
verification_url = str(
|
|
device_data.get("verification_uri_complete")
|
|
or device_data["verification_uri"]
|
|
)
|
|
user_code = str(device_data["user_code"])
|
|
expires_in = int(device_data["expires_in"])
|
|
interval = int(device_data["interval"])
|
|
|
|
_print_device_code_instructions(
|
|
verification_url,
|
|
user_code,
|
|
open_browser=open_browser and not _is_remote_session() and _can_open_graphical_browser(),
|
|
swallow_open_errors=True,
|
|
)
|
|
print(f"Waiting for approval (polling every {max(1, interval)}s)...")
|
|
|
|
payload = _xai_oauth_poll_device_token(
|
|
client,
|
|
token_endpoint=token_endpoint,
|
|
device_code=str(device_data["device_code"]),
|
|
expires_in=expires_in,
|
|
poll_interval=interval,
|
|
)
|
|
|
|
access_token = str(payload.get("access_token", "") or "").strip()
|
|
refresh_token = str(payload.get("refresh_token", "") or "").strip()
|
|
if not access_token or not refresh_token:
|
|
raise _xai_err(
|
|
"xAI device-code token response was missing required tokens.",
|
|
"xai_device_token_invalid",
|
|
)
|
|
base_url = _xai_oauth_inference_base_url()
|
|
return {
|
|
"tokens": _xai_tokens_from_payload(payload, access_token, refresh_token),
|
|
"discovery": discovery,
|
|
"redirect_uri": "",
|
|
"base_url": base_url,
|
|
"last_refresh": _utc_now_z(),
|
|
"source": "oauth-device-code",
|
|
}
|