From f64e4f4f5768c18a53f44890747653bafcab2796 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Wed, 8 Jul 2026 16:55:32 +1000 Subject: [PATCH] feat(gateway): generic OIDC client-credentials relay provisioning (NAS-free) (#60730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For air-gapped / self-hosted-IdP deploys with NO Nous Portal, let the gateway obtain its caller-identity bearer from a generic OAuth2 client_credentials grant against the operator's own IdP (e.g. Microsoft Entra ID) instead of only resolve_nous_access_token(). The connector's OIDC tenant resolver reads a claim (default tid) off that token as the tenant. - gateway/relay: new canonical _resolve_relay_identity_token() — client_credentials when gateway.idp.token_url (or GATEWAY_RELAY_IDP_* env) is set, else Nous Portal (unchanged default). Wired into self_provision_relay(). - hermes_cli/gateway_enroll: _resolve_identity_token() delegates to the canonical resolver so the enroll CLI and the runtime self-provision path share ONE impl. Config via gateway.idp.{token_url,client_id,client_secret,scope} in config.yaml (env override GATEWAY_RELAY_IDP_*). No behaviour change when unset. Tests: tests/gateway/relay/test_identity_token_resolver.py (6 — mode selection, request shape, config/env precedence, fail-closed). Relay suite 162 pass. Validated via the cross-repo gateway<->connector live E2E (provision, managed self-provision, inbound round-trip, /link) against a connector running the OIDC tenant resolver with zero NAS config. --- gateway/relay/__init__.py | 84 +++++++++- hermes_cli/gateway_enroll.py | 23 ++- .../relay/test_identity_token_resolver.py | 143 ++++++++++++++++++ 3 files changed, 241 insertions(+), 9 deletions(-) create mode 100644 tests/gateway/relay/test_identity_token_resolver.py diff --git a/gateway/relay/__init__.py b/gateway/relay/__init__.py index 74f8858ad02..0c64aaedeb5 100644 --- a/gateway/relay/__init__.py +++ b/gateway/relay/__init__.py @@ -436,6 +436,80 @@ def _post_provision( return payload +def _resolve_relay_identity_token() -> str: + """Resolve the caller-identity bearer token the connector introspects to a tenant. + + Canonical resolver shared by the runtime self-provision path and the + ``hermes gateway enroll`` CLI. Two modes, in precedence order: + + 1. **Generic OIDC client-credentials** (air-gapped / self-hosted-IdP, NO + Nous Portal): when ``gateway.idp.token_url`` (or + ``GATEWAY_RELAY_IDP_TOKEN_URL``) is configured, obtain a workload access + token via the OAuth2 ``client_credentials`` grant against the operator's + own IdP (Entra; Authentik in the sandbox). The connector's Seam-A OIDC + verifier reads a claim (default ``tid``) off it as the tenant. + 2. **Nous Portal** (default): ``resolve_nous_access_token()`` — existing + managed/hosted behaviour. + + Raises on failure; callers decide whether that's fatal (enroll CLI) or a + graceful boot no-op (self-provision). + """ + token_url = os.environ.get("GATEWAY_RELAY_IDP_TOKEN_URL", "").strip() + client_id = os.environ.get("GATEWAY_RELAY_IDP_CLIENT_ID", "").strip() + client_secret = os.environ.get("GATEWAY_RELAY_IDP_CLIENT_SECRET", "").strip() + scope = os.environ.get("GATEWAY_RELAY_IDP_SCOPE", "").strip() + if not token_url: + try: + from gateway.run import _load_gateway_config # late import to avoid cycle + + idp = ((_load_gateway_config().get("gateway") or {}).get("idp") or {}) + token_url = str(idp.get("token_url", "") or "").strip() + client_id = client_id or str(idp.get("client_id", "") or "").strip() + client_secret = client_secret or str(idp.get("client_secret", "") or "").strip() + scope = scope or str(idp.get("scope", "") or "").strip() + except Exception: # noqa: BLE001 - config absence must not crash + token_url = token_url or "" + + if not token_url: + # Mode 2 — Nous Portal (default, unchanged behaviour). + from hermes_cli.auth import resolve_nous_access_token + + return resolve_nous_access_token() + + # Mode 1 — generic OAuth2 client_credentials grant. + import json + import urllib.error + import urllib.parse + import urllib.request + + if not client_id or not client_secret: + raise RuntimeError( + "gateway.idp.token_url configured but client_id/client_secret missing" + ) + form = { + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + } + if scope: + form["scope"] = scope + req = urllib.request.Request( + token_url, + data=urllib.parse.urlencode(form).encode("utf-8"), + method="POST", + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + }, + ) + with urllib.request.urlopen(req, timeout=15.0) as resp: + payload = json.loads(resp.read().decode()) + access_token = (payload or {}).get("access_token") + if not isinstance(access_token, str) or not access_token.strip(): + raise RuntimeError("IdP client_credentials response had no access_token") + return access_token.strip() + + def self_provision_relay() -> bool: """Boot-time relay self-provision: mint relay creds in-process, no human, no disk. @@ -489,13 +563,11 @@ def self_provision_relay() -> bool: return False try: - from hermes_cli.auth import resolve_nous_access_token - - access_token = resolve_nous_access_token() + access_token = _resolve_relay_identity_token() except Exception as exc: # noqa: BLE001 - boot must survive a token failure - # No resolvable NAS identity (e.g. a self-hosted box that hasn't enrolled) - # -> nothing to provision with; skip quietly and let the gateway boot. - logger.warning("relay self-provision skipped: could not resolve Nous token (%s)", exc) + # No resolvable identity (e.g. a self-hosted box that hasn't enrolled and + # configured no IdP) -> nothing to provision with; skip quietly and boot. + logger.warning("relay self-provision skipped: could not resolve identity token (%s)", exc) return False identities = relay_platform_identities() diff --git a/hermes_cli/gateway_enroll.py b/hermes_cli/gateway_enroll.py index 88c7dfde566..975db9ff80a 100644 --- a/hermes_cli/gateway_enroll.py +++ b/hermes_cli/gateway_enroll.py @@ -35,6 +35,7 @@ import os import socket import sys import urllib.error +import urllib.parse import urllib.request from typing import Optional @@ -85,6 +86,20 @@ def _resolve_connector_url(override: Optional[str]) -> Optional[str]: return raw +def _resolve_identity_token() -> str: + """Resolve the caller-identity bearer token (generic-OIDC or Nous Portal). + + Delegates to the canonical resolver in ``gateway.relay`` so the enroll CLI and + the runtime self-provision path share ONE implementation (generic OAuth2 + client-credentials when ``gateway.idp.token_url`` is set — the air-gapped / + self-hosted-IdP path; otherwise Nous Portal). Raises RuntimeError on failure. + """ + from gateway.relay import _resolve_relay_identity_token + + return _resolve_relay_identity_token() + + + def _post_enroll( *, connector_base_url: str, @@ -179,9 +194,11 @@ def cmd_gateway_enroll(args) -> None: gateway_id = (getattr(args, "gateway_id", None) or _default_gateway_id()).strip() - # 1. Resolve a fresh Nous access token (the tenant-proving identity). + # 1. Resolve the caller-identity token (the tenant-proving identity). Generic + # OIDC client-credentials when an IdP token endpoint is configured (air- + # gapped / self-hosted-IdP, NO Nous Portal); otherwise the Nous Portal token. try: - access_token = resolve_nous_access_token() + access_token = _resolve_identity_token() except AuthError as exc: if getattr(exc, "relogin_required", False): print("✗ You're not logged into Nous Portal.") @@ -190,7 +207,7 @@ def cmd_gateway_enroll(args) -> None: print(f"✗ Could not resolve a Nous Portal access token: {exc}") sys.exit(1) except Exception as exc: - print(f"✗ Could not resolve a Nous Portal access token: {exc}") + print(f"✗ Could not resolve a caller-identity token: {exc}") sys.exit(1) # 2-3. Redeem the enrollment token at the connector. diff --git a/tests/gateway/relay/test_identity_token_resolver.py b/tests/gateway/relay/test_identity_token_resolver.py new file mode 100644 index 00000000000..d803be1c814 --- /dev/null +++ b/tests/gateway/relay/test_identity_token_resolver.py @@ -0,0 +1,143 @@ +"""Unit tests for the generic-OIDC / Nous-Portal caller-identity token resolver. + +Covers gateway.relay._resolve_relay_identity_token() — the canonical resolver +shared by the runtime self-provision path and the `hermes gateway enroll` CLI. + +Two modes: + 1. Generic OAuth2 client_credentials when gateway.idp.token_url (or + GATEWAY_RELAY_IDP_TOKEN_URL) is configured (air-gapped / self-hosted-IdP). + 2. Nous Portal (resolve_nous_access_token) otherwise — the default. + +The HTTP POST and the Nous resolver are monkeypatched; these prove the mode +SELECTION, the client_credentials request shape, and the fail-closed paths. +""" + +from __future__ import annotations + +import io +import json + +import pytest + +import gateway.relay as relay + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + for k in ( + "GATEWAY_RELAY_IDP_TOKEN_URL", + "GATEWAY_RELAY_IDP_CLIENT_ID", + "GATEWAY_RELAY_IDP_CLIENT_SECRET", + "GATEWAY_RELAY_IDP_SCOPE", + ): + monkeypatch.delenv(k, raising=False) + # Never read config.yaml off disk by default. + monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {}, raising=False) + + +def test_defaults_to_nous_portal_when_no_idp_configured(monkeypatch): + called = {} + + def fake_resolve(): + called["yes"] = True + return "nous-portal-token" + + monkeypatch.setattr( + "hermes_cli.auth.resolve_nous_access_token", fake_resolve, raising=False + ) + assert relay._resolve_relay_identity_token() == "nous-portal-token" + assert called == {"yes": True} + + +def test_client_credentials_via_env(monkeypatch): + monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://idp.test/token") + monkeypatch.setenv("GATEWAY_RELAY_IDP_CLIENT_ID", "agent-client") + monkeypatch.setenv("GATEWAY_RELAY_IDP_CLIENT_SECRET", "shh") + monkeypatch.setenv("GATEWAY_RELAY_IDP_SCOPE", "connector.provision") + + captured = {} + + def fake_urlopen(req, timeout=None): + captured["url"] = req.full_url + captured["method"] = req.get_method() + captured["body"] = req.data.decode() + captured["headers"] = {k.lower(): v for k, v in req.headers.items()} + return io.BytesIO(json.dumps({"access_token": "idp-workload-token"}).encode()) + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + + token = relay._resolve_relay_identity_token() + assert token == "idp-workload-token" + assert captured["url"] == "https://idp.test/token" + assert captured["method"] == "POST" + # client_credentials grant, form-encoded, with all fields. + assert "grant_type=client_credentials" in captured["body"] + assert "client_id=agent-client" in captured["body"] + assert "client_secret=shh" in captured["body"] + assert "scope=connector.provision" in captured["body"] + assert captured["headers"]["content-type"] == "application/x-www-form-urlencoded" + + +def test_client_credentials_via_config_yaml(monkeypatch): + monkeypatch.setattr( + "gateway.run._load_gateway_config", + lambda: { + "gateway": { + "idp": { + "token_url": "https://idp.test/token", + "client_id": "cfg-client", + "client_secret": "cfg-secret", + } + } + }, + raising=False, + ) + + def fake_urlopen(req, timeout=None): + body = req.data.decode() + assert "client_id=cfg-client" in body + assert "client_secret=cfg-secret" in body + # No scope configured -> not sent. + assert "scope=" not in body + return io.BytesIO(json.dumps({"access_token": "cfg-token"}).encode()) + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + assert relay._resolve_relay_identity_token() == "cfg-token" + + +def test_env_token_url_takes_precedence_over_config(monkeypatch): + monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://env.test/token") + monkeypatch.setenv("GATEWAY_RELAY_IDP_CLIENT_ID", "env-client") + monkeypatch.setenv("GATEWAY_RELAY_IDP_CLIENT_SECRET", "env-secret") + monkeypatch.setattr( + "gateway.run._load_gateway_config", + lambda: {"gateway": {"idp": {"token_url": "https://cfg.test/token"}}}, + raising=False, + ) + + def fake_urlopen(req, timeout=None): + assert req.full_url == "https://env.test/token" + return io.BytesIO(json.dumps({"access_token": "t"}).encode()) + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + assert relay._resolve_relay_identity_token() == "t" + + +def test_raises_when_client_creds_missing(monkeypatch): + monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://idp.test/token") + # No client_id / client_secret. + with pytest.raises(RuntimeError, match="client_id/client_secret missing"): + relay._resolve_relay_identity_token() + + +def test_raises_when_no_access_token_in_response(monkeypatch): + monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://idp.test/token") + monkeypatch.setenv("GATEWAY_RELAY_IDP_CLIENT_ID", "c") + monkeypatch.setenv("GATEWAY_RELAY_IDP_CLIENT_SECRET", "s") + + def fake_urlopen(req, timeout=None): + return io.BytesIO(json.dumps({"token_type": "Bearer"}).encode()) + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + with pytest.raises(RuntimeError, match="no access_token"): + relay._resolve_relay_identity_token()