feat(desktop): make xAI Grok a first-class OAuth provider in the launcher

xAI Grok was only reachable via the "I have an API key" form. xAI's
OAuth (SuperGrok / Premium+) flow already exists in the backend
(`hermes auth add xai-oauth`) but was never surfaced in the desktop
onboarding launcher.

Add a loopback PKCE flow: the local backend binds the 127.0.0.1
callback listener, the client opens the browser, and the redirect lands
back automatically — no code to copy/paste. Reuses the existing xAI
OAuth helpers (discovery, callback server, token exchange, persist)
rather than duplicating them.

- web_server: catalog entry (flow: loopback) + status dispatch +
  _start_xai_loopback_flow + background worker + route branch
- desktop: 'loopback' flow type, awaiting_browser status, xAI Grok card
  (PROVIDER_DISPLAY / FLOW_SUBTITLES / FlowPanel waiting render)
- tests: catalog listing, start authorize-url, worker persist, state
  mismatch rejection
This commit is contained in:
Brooklyn Nicholson 2026-06-02 17:34:00 -05:00
parent c47b9d126f
commit dd5e97bd7f
5 changed files with 434 additions and 6 deletions

View file

@ -2922,6 +2922,17 @@ _OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = (
"docs_url": "https://github.com/QwenLM/qwen-code",
"status_fn": None, # dispatched via auth.get_qwen_auth_status
},
{
"id": "xai-oauth",
"name": "xAI Grok OAuth (SuperGrok / Premium+)",
# Loopback PKCE: the desktop's local backend binds a 127.0.0.1
# callback server, the client opens the browser, and the redirect
# lands back on the loopback listener — no code to copy/paste.
"flow": "loopback",
"cli_command": "hermes auth add xai-oauth",
"docs_url": "https://hermes-agent.nousresearch.com/docs/guides/xai-grok-oauth",
"status_fn": None, # dispatched via auth.get_xai_oauth_auth_status
},
{
"id": "minimax-oauth",
"name": "MiniMax (OAuth)",
@ -2988,6 +2999,17 @@ def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]:
"expires_at": raw.get("expires_at"),
"has_refresh_token": True,
}
if provider_id == "xai-oauth":
raw = hauth.get_xai_oauth_auth_status()
return {
"logged_in": bool(raw.get("logged_in")),
"source": raw.get("source") or "xai_oauth",
"source_label": raw.get("auth_mode") or "xAI Grok OAuth",
"token_preview": _truncate_token(raw.get("api_key")),
"expires_at": None,
"has_refresh_token": True,
"last_refresh": raw.get("last_refresh"),
}
except Exception as e:
return {"logged_in": False, "error": str(e)}
return {"logged_in": False}
@ -3483,6 +3505,202 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
raise HTTPException(status_code=400, detail=f"Provider {provider_id} does not support device-code flow")
# xAI Grok OAuth uses a loopback-redirect PKCE flow (RFC 8252). Unlike the
# device-code providers there is no user_code to display: the local backend
# binds a 127.0.0.1 callback server, the client opens the authorize URL in
# the browser, and the redirect lands back on the loopback listener. The
# background worker waits for that callback, exchanges the code, and persists
# the tokens exactly like `hermes auth add xai-oauth`.
_XAI_LOOPBACK_TIMEOUT_SECONDS = 300.0
def _start_xai_loopback_flow() -> Dict[str, Any]:
"""Begin the xAI loopback PKCE flow.
Binds the local callback server, builds the authorize URL, and spawns a
background worker that waits for the redirect and finishes the exchange.
Returns the authorize URL for the client to open in the browser.
"""
from hermes_cli import auth as hauth
discovery = hauth._xai_oauth_discovery()
server, thread, callback_result, redirect_uri = hauth._xai_start_callback_server()
try:
hauth._xai_validate_loopback_redirect_uri(redirect_uri)
verifier = hauth._oauth_pkce_code_verifier()
challenge = hauth._oauth_pkce_code_challenge(verifier)
state = secrets.token_hex(16)
nonce = secrets.token_hex(16)
authorize_url = hauth._xai_oauth_build_authorize_url(
authorization_endpoint=discovery["authorization_endpoint"],
redirect_uri=redirect_uri,
code_challenge=challenge,
state=state,
nonce=nonce,
)
except Exception:
# Binding succeeded but URL construction failed — release the socket
# so we don't leak a listener on the loopback port.
try:
server.shutdown()
server.server_close()
except Exception:
pass
raise
sid, sess = _new_oauth_session("xai-oauth", "loopback")
sess["server"] = server
sess["thread"] = thread
sess["callback_result"] = callback_result
sess["redirect_uri"] = redirect_uri
sess["verifier"] = verifier
sess["challenge"] = challenge
sess["state"] = state
sess["token_endpoint"] = discovery["token_endpoint"]
sess["discovery"] = discovery
sess["expires_at"] = time.time() + _XAI_LOOPBACK_TIMEOUT_SECONDS
threading.Thread(
target=_xai_loopback_worker, args=(sid,), daemon=True,
name=f"oauth-xai-{sid[:6]}",
).start()
return {
"session_id": sid,
"flow": "loopback",
"auth_url": authorize_url,
"expires_in": int(_XAI_LOOPBACK_TIMEOUT_SECONDS),
}
def _xai_loopback_worker(session_id: str) -> None:
"""Wait for the xAI loopback callback, exchange the code, persist tokens."""
from datetime import datetime, timezone
from hermes_cli import auth as hauth
with _oauth_sessions_lock:
sess = _oauth_sessions.get(session_id)
if not sess:
return
def _fail(message: str) -> None:
with _oauth_sessions_lock:
s = _oauth_sessions.get(session_id)
if s is not None:
s["status"] = "error"
s["error_message"] = message
try:
callback = hauth._xai_wait_for_callback(
sess["server"],
sess["thread"],
sess["callback_result"],
timeout_seconds=_XAI_LOOPBACK_TIMEOUT_SECONDS,
)
except Exception as exc:
_fail(f"xAI authorization timed out: {exc}")
return
if callback.get("error"):
detail = callback.get("error_description") or callback["error"]
_fail(f"xAI authorization failed: {detail}")
return
if callback.get("state") != sess["state"]:
_fail("xAI authorization failed: state mismatch.")
return
code = str(callback.get("code") or "").strip()
if not code:
_fail("xAI authorization failed: missing authorization code.")
return
try:
payload = hauth._xai_oauth_exchange_code_for_tokens(
token_endpoint=sess["token_endpoint"],
code=code,
redirect_uri=sess["redirect_uri"],
code_verifier=sess["verifier"],
code_challenge=sess["challenge"],
)
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:
_fail("xAI token exchange did not return the expected tokens.")
return
base_url = hauth._xai_validate_inference_base_url(
os.getenv("HERMES_XAI_BASE_URL", "").strip().rstrip("/")
or os.getenv("XAI_BASE_URL", "").strip().rstrip("/"),
fallback=hauth.DEFAULT_XAI_OAUTH_BASE_URL,
)
last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
tokens = {
"access_token": access_token,
"refresh_token": refresh_token,
"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",
}
hauth._save_xai_oauth_tokens(
tokens,
discovery=sess.get("discovery"),
redirect_uri=sess["redirect_uri"],
last_refresh=last_refresh,
)
_add_xai_oauth_pool_entry(access_token, refresh_token, base_url, last_refresh)
except Exception as exc:
_fail(f"xAI token exchange failed: {exc}")
return
with _oauth_sessions_lock:
s = _oauth_sessions.get(session_id)
if s is not None:
s["status"] = "approved"
_log.info("oauth/loopback: xai-oauth login completed (session=%s)", session_id)
def _add_xai_oauth_pool_entry(
access_token: str, refresh_token: str, base_url: str, last_refresh: str
) -> None:
"""Mirror `hermes auth add xai-oauth`'s credential-pool insert.
Best-effort: the auth-store write in _save_xai_oauth_tokens is the source
of truth for runtime resolution; the pool entry only matters for the
rotation strategy.
"""
try:
import uuid
from agent.credential_pool import (
PooledCredential,
load_pool,
AUTH_TYPE_OAUTH,
SOURCE_MANUAL,
)
pool = load_pool("xai-oauth")
existing = [
e for e in pool.entries()
if getattr(e, "source", "").startswith(f"{SOURCE_MANUAL}:dashboard_xai_pkce")
]
for e in existing:
try:
pool.remove_entry(getattr(e, "id", ""))
except Exception:
pass
entry = PooledCredential(
provider="xai-oauth",
id=uuid.uuid4().hex[:6],
label="dashboard PKCE",
auth_type=AUTH_TYPE_OAUTH,
priority=0,
source=f"{SOURCE_MANUAL}:dashboard_xai_pkce",
access_token=access_token,
refresh_token=refresh_token,
base_url=base_url,
last_refresh=last_refresh,
)
pool.add_entry(entry)
except Exception as e:
_log.warning("xai-oauth pool add (dashboard) failed: %s", e)
def _nous_poller(session_id: str) -> None:
"""Background poller that drives a Nous device-code flow to completion."""
from hermes_cli.auth import (
@ -3772,6 +3990,10 @@ async def start_oauth_login(provider_id: str, request: Request):
return _start_anthropic_pkce()
if catalog_entry["flow"] == "device_code":
return await _start_device_code_flow(provider_id)
if catalog_entry["flow"] == "loopback" and provider_id == "xai-oauth":
return await asyncio.get_running_loop().run_in_executor(
None, _start_xai_loopback_flow
)
except HTTPException:
raise
except Exception as e: