Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 78 additions & 6 deletions gateway/relay/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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()
Expand Down
23 changes: 20 additions & 3 deletions hermes_cli/gateway_enroll.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import socket
import sys
import urllib.error
import urllib.parse
import urllib.request
from typing import Optional

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.")
Expand All @@ -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.
Expand Down
143 changes: 143 additions & 0 deletions tests/gateway/relay/test_identity_token_resolver.py
Original file line number Diff line number Diff line change
@@ -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()
Loading