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:
+2
-1
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)}
|
||||
Reference in New Issue
Block a user