feat(auth): OpenRouter OAuth PKCE login via hermes auth add openrouter --type oauth

Browser login against openrouter.ai/auth (S256 PKCE, bind-first OS-assigned loopback port,
POST /api/v1/auth/keys code exchange) that stores the minted key as a plain api_key pool entry
with source manual:openrouter_pkce, so it rotates and resolves exactly like a pasted key.

Salvaged from #102639 (nyx573) onto the facade+siblings layout: the flow lives in the new
auth_openrouter sibling and rides the shared loopback helpers instead of appending to the
auth.py facade; auth_commands gains a table row rather than a provider branch. OpenRouter echoes
no `state`, so the CSRF nonce rides in the callback path (a redirect that guesses the port but not
the nonce is a 404 and never reaches the exchange); remote/SSH sessions use OpenRouter's
documented headless paste-the-code mode instead of an unreachable loopback listener. OpenRouter
keeps its API-key default when --type is omitted so the documented `--api-key` form is unchanged.
This commit is contained in:
nyx573
2026-09-03 22:14:40 -05:00
committed by Teknium
parent 3310298a37
commit 0007a4c2f9
4 changed files with 135 additions and 4 deletions
+2 -1
View File
@@ -6,7 +6,7 @@
only I/O primitives (cross-process flock, atomic 0o600 writes).
- ``resolve_provider()`` picks the active provider via the documented priority chain.
- ``OAUTH_PROVIDER_FLOWS`` maps each OAuth provider to its resolver/status builder; the flows live in
``auth_nous``/``auth_codex``/``auth_xai``/``auth_qwen``/``auth_minimax``/``auth_spotify`` and are
``auth_nous``/``auth_codex``/``auth_xai``/``auth_qwen``/``auth_minimax``/``auth_spotify``/``auth_openrouter`` and are
re-imported here so ``hermes_cli.auth.<name>`` stays the public/patchable surface."""
from __future__ import annotations
@@ -85,6 +85,7 @@ from hermes_cli.auth_codex import ( # noqa: F401 re-exported
from hermes_cli.auth_spotify import ( # noqa: F401 re-exported
_refresh_spotify_oauth_state, get_spotify_auth_status, login_spotify_command,
resolve_spotify_runtime_credentials)
from hermes_cli.auth_openrouter import _openrouter_pkce_login # noqa: F401 re-exported
from hermes_cli.auth_qwen import ( # noqa: F401 re-exported
_qwen_access_token_is_expiring, _qwen_cli_auth_path, _read_qwen_cli_tokens,
_refresh_qwen_cli_tokens, _save_qwen_cli_tokens, get_qwen_auth_status,
+17 -3
View File
@@ -24,7 +24,10 @@ from hermes_cli.secret_prompt import masked_secret_prompt
# Providers that support OAuth login in addition to API keys.
_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "xai-oauth", "qwen-oauth", "minimax-oauth"}
_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "xai-oauth", "qwen-oauth", "minimax-oauth", "openrouter"}
# ...and default to it when ``--type`` is omitted. OpenRouter stays API-key-first: the documented
# ``hermes auth add openrouter --api-key sk-or-...`` must keep working with no ``--type``.
_OAUTH_DEFAULT_PROVIDERS = _OAUTH_CAPABLE_PROVIDERS - {"openrouter"}
def _get_custom_provider_entries() -> list[dict]:
@@ -203,6 +206,9 @@ class _OAuthAddSpec:
source: str
fields: Callable[[dict, str], dict]
activate_first: bool = False
# OpenRouter's PKCE exchange mints a plain API key (no refresh pair), so its pool entry is an
# ``api_key`` row that happens to come from a browser login.
auth_type: str = AUTH_TYPE_OAUTH
_OAUTH_ADD_SPECS: dict[str, _OAuthAddSpec] = {
@@ -247,6 +253,14 @@ _OAUTH_ADD_SPECS: dict[str, _OAuthAddSpec] = {
source=f"{SOURCE_MANUAL}:minimax_oauth",
fields=lambda creds, provider: {
"refresh_token": creds.get("refresh_token"), "base_url": creds.get("inference_base_url")}),
"openrouter": _OAuthAddSpec(
login=lambda args: auth_mod._openrouter_pkce_login(
open_browser=not getattr(args, "no_browser", False),
timeout_seconds=float(getattr(args, "timeout", None) or 300.0)),
token=lambda creds: creds["api_key"],
source=f"{SOURCE_MANUAL}:openrouter_pkce",
fields=lambda creds, provider: {"base_url": _provider_base_url(provider)},
auth_type=AUTH_TYPE_API_KEY),
}
@@ -343,7 +357,7 @@ def auth_add_command(args) -> None:
if requested_type == "api-key":
requested_type = AUTH_TYPE_API_KEY
elif not requested_type:
oauth_default = provider in _OAUTH_CAPABLE_PROVIDERS and not is_custom
oauth_default = provider in _OAUTH_DEFAULT_PROVIDERS and not is_custom
requested_type = AUTH_TYPE_OAUTH if oauth_default else AUTH_TYPE_API_KEY
pool = load_pool(provider)
@@ -376,7 +390,7 @@ def _add_credential(args, provider: str, pool, requested_type: str) -> PooledCre
# singleton save path (which collapsed every added account into the latest login).
# ``manual:*`` entries refresh from their own token pair, so they need no singleton shadow.
entry = PooledCredential(
provider=provider, id=uuid.uuid4().hex[:6], label=label, auth_type=AUTH_TYPE_OAUTH, priority=0,
provider=provider, id=uuid.uuid4().hex[:6], label=label, auth_type=spec.auth_type, priority=0,
source=spec.source, access_token=token, **spec.fields(creds, provider))
first_credential = not pool.entries()
entry = pool.add_entry(entry)
+6
View File
@@ -111,6 +111,11 @@ DEFAULT_SPOTIFY_REDIRECT_URI = "http://127.0.0.1:43827/spotify/callback"
SPOTIFY_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/features/spotify"
SPOTIFY_DASHBOARD_URL = "https://developer.spotify.com/dashboard"
SPOTIFY_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
# OpenRouter PKCE (https://openrouter.ai/docs/guides/overview/auth/oauth): the "token" endpoint
# mints a plain user-controlled API key; there is no refresh token.
OPENROUTER_AUTH_URL = "https://openrouter.ai/auth"
OPENROUTER_AUTH_KEYS_URL = "https://openrouter.ai/api/v1/auth/keys"
OPENROUTER_OAUTH_DOCS_URL = "https://openrouter.ai/docs/guides/overview/auth/oauth"
OAUTH_OVER_SSH_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/guides/oauth-over-ssh"
DEFAULT_SPOTIFY_SCOPE = " ".join((
@@ -156,6 +161,7 @@ _codex_err = _provider_error_factory("openai-codex")
_spotify_err = _provider_error_factory("spotify")
_qwen_err = _provider_error_factory("qwen-oauth")
_minimax_err = _provider_error_factory("minimax-oauth")
_openrouter_err = _provider_error_factory("openrouter")
def _decode_jwt_claims(token: Any) -> Dict[str, Any]:
+110
View File
@@ -0,0 +1,110 @@
"""OpenRouter OAuth PKCE login (``hermes auth add openrouter --type oauth``).
Contract: https://openrouter.ai/docs/guides/overview/auth/oauth. The browser is sent to
``/auth?callback_url=...&code_challenge=...&code_challenge_method=S256``; the redirect carries
``?code=``; ``POST /api/v1/auth/keys`` swaps ``{code, code_verifier, code_challenge_method}`` for
``{"key": "sk-or-v1-..."}`` — a plain user-controlled API key, no refresh token. OpenRouter echoes no
``state`` parameter, so the CSRF nonce rides in the loopback callback PATH: a redirect to any other
path is a 404 and never reaches the exchange.
"""
from __future__ import annotations
import secrets
import webbrowser
from typing import Any, Dict
from urllib.parse import urlencode
from hermes_cli.auth_constants import (
OPENROUTER_AUTH_KEYS_URL, OPENROUTER_AUTH_URL, OPENROUTER_OAUTH_DOCS_URL, _openrouter_err, httpx)
from hermes_cli.auth_device_flow import (
_bind_loopback_callback_server, _can_open_graphical_browser, _is_remote_session,
_make_loopback_callback_handler, _pkce_code_challenge, _pkce_code_verifier, _serve_loopback_callback)
_ERROR_BODY_LIMIT = 2048
def _openrouter_exchange_code(code: str, code_verifier: str, *, timeout_seconds: float = 20.0) -> str:
"""Exchange the authorization code for an API key; the key never enters a log or error message."""
try:
response = httpx.post(
OPENROUTER_AUTH_KEYS_URL, json={
"code": code, "code_verifier": code_verifier, "code_challenge_method": "S256"},
headers={"Content-Type": "application/json"}, timeout=timeout_seconds)
except Exception as exc:
raise _openrouter_err(f"OpenRouter code exchange failed: {exc}", "openrouter_token_exchange_failed") from exc
if response.status_code == 403:
raise _openrouter_err(
"OpenRouter rejected the authorization code (invalid, already used, or older than 10 minutes). "
"Run the login again.", "openrouter_token_exchange_denied", relogin=True)
if response.status_code >= 400:
detail = response.text.strip()[:_ERROR_BODY_LIMIT]
raise _openrouter_err(
f"OpenRouter code exchange failed (HTTP {response.status_code})." + (f" Response: {detail}" if detail else ""),
"openrouter_token_exchange_failed")
try:
payload = response.json()
except ValueError as exc:
raise _openrouter_err(
"OpenRouter code exchange returned a non-JSON body.", "openrouter_token_exchange_invalid") from exc
key = str(payload.get("key") or "").strip() if isinstance(payload, dict) else ""
if not key:
raise _openrouter_err(
"OpenRouter code exchange response did not include a 'key'.", "openrouter_token_exchange_invalid")
return key
def _openrouter_headless_code(auth_url: str) -> str:
"""Remote/SSH: OpenRouter shows the code on screen when ``callback_url`` is omitted; the user pastes it."""
from hermes_cli.secret_prompt import masked_secret_prompt
print(
"Remote session detected — using OpenRouter's headless flow.\n"
f"Open this URL in a browser on any machine, authorize, then paste the code shown:\n {auth_url}\n")
code = masked_secret_prompt("Authorization code: ").strip()
if not code:
raise _openrouter_err("No authorization code entered.", "openrouter_auth_no_code")
return code
def _openrouter_loopback_code(auth_url_params: Dict[str, str], *, open_browser: bool, timeout_seconds: float) -> str:
nonce = secrets.token_urlsafe(16)
path = f"/callback/{nonce}"
handler_cls, result = _make_loopback_callback_handler(path, display_name="OpenRouter")
server = _bind_loopback_callback_server(
"127.0.0.1", 0, handler_cls, err=_openrouter_err, bind_failed_code="openrouter_callback_bind_failed")
redirect_uri = f"http://127.0.0.1:{server.server_address[1]}{path}"
auth_url = f"{OPENROUTER_AUTH_URL}?{urlencode({'callback_url': redirect_uri, **auth_url_params})}"
print(f"Open this URL to authorize Hermes with OpenRouter:\n {auth_url}\n\nDocs: {OPENROUTER_OAUTH_DOCS_URL}")
if open_browser and _can_open_graphical_browser():
try:
opened = webbrowser.open(auth_url)
except Exception:
opened = False
print("Browser opened for OpenRouter authorization." if opened
else "Could not open the browser automatically; use the URL above.")
print("Waiting for the OpenRouter callback...")
callback = _serve_loopback_callback(
server, result, timeout_seconds=timeout_seconds, err=_openrouter_err,
timeout_code="openrouter_callback_timeout")
if callback.get("error"):
raise _openrouter_err(
f"OpenRouter authorization failed: {callback.get('error_description') or callback['error']}",
"openrouter_auth_denied")
code = str(callback.get("code") or "").strip()
if not code:
raise _openrouter_err("OpenRouter callback did not carry an authorization code.", "openrouter_auth_no_code")
return code
def _openrouter_pkce_login(*, open_browser: bool = True, timeout_seconds: float = 300.0) -> Dict[str, Any]:
"""Run the PKCE flow and return ``{"api_key": ...}`` for the credential-pool add path."""
code_verifier = _pkce_code_verifier()
params = {"code_challenge": _pkce_code_challenge(code_verifier), "code_challenge_method": "S256"}
if _is_remote_session():
code = _openrouter_headless_code(f"{OPENROUTER_AUTH_URL}?{urlencode({**params, 'key_label': 'hermes-agent'})}")
else:
code = _openrouter_loopback_code(params, open_browser=open_browser, timeout_seconds=timeout_seconds)
print("Exchanging the authorization code for an OpenRouter API key...")
return {"api_key": _openrouter_exchange_code(code, code_verifier)}