e9579a9896
* feat(relay): ambient token endpoint mode for gateway.idp.token_url
When gateway.idp.token_url is configured WITHOUT client_id/client_secret,
treat the URL as a metadata-server-style ambient credential endpoint:
plain GET, response body is the token (raw JWT or {"access_token": ...}
JSON envelope). Covers workload-identity proxies such as Domino's
$DOMINO_API_PROXY/access-token, which mint short-lived user-scoped OIDC
tokens with no client registration.
Previously this configuration was a hard error (client_id/client_secret
missing), so no working deployment changes behaviour: creds present keeps
the OAuth2 client_credentials POST, no token_url keeps Nous Portal. The
misconfig error now self-diagnoses (names the ambient fallback and how to
select the client_credentials grant instead).
* fix(relay): reject short plain-text bodies in ambient token shape gate
Review finding: the shape gate accepted any base64url-alphabet word, so an
IdP answering the ambient GET with a terse error body ('unauthorized',
'error', 'null') had that word returned as a bearer token instead of the
fail-closed misconfiguration error. Tighten the gate to JWT-like dotted
tokens (3+ segments) or long opaque tokens (>= 32 chars); short bare words
now raise the self-diagnosing ambient error.
* fix(relay): partial IdP client credentials keep the loud error, never select ambient GET
The ambient-endpoint dispatch used 'not client_id or not client_secret',
so configuring exactly one credential (a mistyped client_credentials
setup) silently issued a GET at the IdP token endpoint and then raised
'no client_id/client_secret configured' — factually wrong for that
operator, and a stray request the old hard error never made.
Ambient mode now requires NEITHER credential; a partial pair raises
immediately, names the missing key, and issues no HTTP request (tests
assert urlopen is never called). Docstring and relay.md now say
'neither' instead of 'without'.
* fix(relay): ambient JSON envelope requires a string access_token, no coercion
Review finding (P2): the JSON-envelope branch accepted any truthy
access_token via str() coercion — a number became '12345…', a boolean
became 'True', an object became its Python repr — bypassing the fail-
closed contract and deferring the failure to the connector, where it
hides the real endpoint problem.
The envelope value must now be a non-empty string, the same contract the
client_credentials path enforces on its token response. Deliberately NO
shape gate on envelope values: an envelope is an intentional token
response (mode-1 symmetry), and opaque tokens may use the standard-base64
alphabet the raw-body gate rejects. Mutation check: reverting the branch
to str() coercion sends the 3 coercion tests red (3 failed, 15 passed).
---------
Co-authored-by: Ben Barclay <ben@nousresearch.com>
258 lines
10 KiB
Python
258 lines
10 KiB
Python
"""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.
|
|
|
|
Three modes:
|
|
1. Generic OAuth2 client_credentials when gateway.idp.token_url (or
|
|
GATEWAY_RELAY_IDP_TOKEN_URL) is configured WITH client credentials
|
|
(air-gapped / self-hosted-IdP).
|
|
1b. Ambient token endpoint when token_url is configured WITHOUT client
|
|
credentials: plain GET, body is the token (raw JWT or JSON envelope).
|
|
The metadata-server pattern (e.g. Domino's $DOMINO_API_PROXY/access-token).
|
|
2. Nous Portal (resolve_nous_access_token) otherwise — the default.
|
|
|
|
The HTTP calls and the Nous resolver are monkeypatched; these prove the mode
|
|
SELECTION, the request shapes, 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_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_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()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mode 1b — ambient token endpoint (token_url set, NO client credentials).
|
|
# The metadata-server pattern: plain GET, response body IS the token, either
|
|
# a raw JWT string (Domino's $DOMINO_API_PROXY/access-token) or a JSON
|
|
# envelope ({"access_token": ...}).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_FAKE_JWT = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.c2ln"
|
|
|
|
|
|
def test_ambient_get_when_no_client_credentials(monkeypatch):
|
|
"""token_url without client_id/secret selects a plain GET, not a raise."""
|
|
monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://proxy.local/access-token")
|
|
|
|
captured = {}
|
|
|
|
def fake_urlopen(req, timeout=None):
|
|
captured["url"] = req.full_url
|
|
captured["method"] = req.get_method()
|
|
captured["body"] = req.data
|
|
return io.BytesIO((_FAKE_JWT + "\n").encode())
|
|
|
|
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
|
|
|
token = relay._resolve_relay_identity_token()
|
|
assert token == _FAKE_JWT # raw body, whitespace-trimmed
|
|
assert captured["url"] == "https://proxy.local/access-token"
|
|
assert captured["method"] == "GET"
|
|
assert captured["body"] is None # no form payload on the ambient path
|
|
|
|
|
|
def test_ambient_accepts_json_envelope(monkeypatch):
|
|
"""Ambient endpoints that return {"access_token": ...} JSON also work."""
|
|
monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://proxy.local/access-token")
|
|
|
|
def fake_urlopen(req, timeout=None):
|
|
return io.BytesIO(json.dumps({"access_token": _FAKE_JWT}).encode())
|
|
|
|
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
|
assert relay._resolve_relay_identity_token() == _FAKE_JWT
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"envelope",
|
|
[
|
|
{"access_token": 12345678901234567890123456789012}, # number, not string
|
|
{"access_token": True}, # boolean: str() would coerce to 'True'
|
|
{"access_token": {"nested": "x"}}, # object
|
|
{"access_token": ""}, # empty string
|
|
{"access_token": None},
|
|
{"token": "wrong-field-name"},
|
|
],
|
|
)
|
|
def test_ambient_rejects_non_string_envelope_values(monkeypatch, envelope):
|
|
"""access_token in a JSON envelope must be a non-empty STRING — the same
|
|
contract as the client_credentials path. No str() coercion of numbers,
|
|
booleans, or objects into 'tokens'."""
|
|
monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://proxy.local/access-token")
|
|
|
|
def fake_urlopen(req, timeout=None):
|
|
return io.BytesIO(json.dumps(envelope).encode())
|
|
|
|
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
|
with pytest.raises(RuntimeError, match="ambient"):
|
|
relay._resolve_relay_identity_token()
|
|
|
|
|
|
def test_ambient_rejects_non_token_body(monkeypatch):
|
|
"""A body that is neither a JWT-ish string nor a token envelope fails loudly."""
|
|
monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://proxy.local/access-token")
|
|
|
|
def fake_urlopen(req, timeout=None):
|
|
return io.BytesIO(b"<html>404 not found</html>")
|
|
|
|
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
|
with pytest.raises(RuntimeError, match="ambient"):
|
|
relay._resolve_relay_identity_token()
|
|
|
|
|
|
def test_ambient_rejects_empty_body(monkeypatch):
|
|
monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://proxy.local/access-token")
|
|
|
|
def fake_urlopen(req, timeout=None):
|
|
return io.BytesIO(b" \n")
|
|
|
|
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
|
with pytest.raises(RuntimeError, match="ambient"):
|
|
relay._resolve_relay_identity_token()
|
|
|
|
|
|
def test_ambient_rejects_short_plaintext_error_words(monkeypatch):
|
|
"""A terse plain-text error body (e.g. 'unauthorized') must not be
|
|
returned as a credential — it matches the base64url alphabet but is
|
|
neither a JWT nor plausibly an opaque token."""
|
|
monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://proxy.local/access-token")
|
|
|
|
for body in ("unauthorized", "error", "access_denied", "null", "forbidden"):
|
|
def fake_urlopen(req, timeout=None, _b=body):
|
|
return io.BytesIO(_b.encode())
|
|
|
|
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
|
with pytest.raises(RuntimeError, match="ambient"):
|
|
relay._resolve_relay_identity_token()
|
|
|
|
|
|
def test_ambient_accepts_long_opaque_token(monkeypatch):
|
|
"""Non-JWT opaque bearer tokens (long random strings) still work."""
|
|
monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://proxy.local/access-token")
|
|
opaque = "v2_9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b"
|
|
|
|
def fake_urlopen(req, timeout=None):
|
|
return io.BytesIO(opaque.encode())
|
|
|
|
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
|
assert relay._resolve_relay_identity_token() == opaque
|
|
|
|
|
|
def test_client_credentials_still_selected_when_creds_present(monkeypatch):
|
|
"""Presence of client creds keeps the POST grant — ambient never hijacks it."""
|
|
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")
|
|
|
|
captured = {}
|
|
|
|
def fake_urlopen(req, timeout=None):
|
|
captured["method"] = req.get_method()
|
|
return io.BytesIO(json.dumps({"access_token": "cc-token"}).encode())
|
|
|
|
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
|
assert relay._resolve_relay_identity_token() == "cc-token"
|
|
assert captured["method"] == "POST"
|
|
|
|
|
|
def test_partial_credentials_client_id_only_raises_without_get(monkeypatch):
|
|
"""client_id without client_secret is a misconfig: loud error, no ambient GET."""
|
|
monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://idp.test/token")
|
|
monkeypatch.setenv("GATEWAY_RELAY_IDP_CLIENT_ID", "agent-client")
|
|
|
|
def fake_urlopen(req, timeout=None):
|
|
raise AssertionError("no HTTP request may be issued on partial credentials")
|
|
|
|
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
|
with pytest.raises(RuntimeError, match="client_secret missing"):
|
|
relay._resolve_relay_identity_token()
|
|
|
|
|
|
def test_partial_credentials_client_secret_only_raises_without_get(monkeypatch):
|
|
"""client_secret without client_id is a misconfig: loud error, no ambient GET."""
|
|
monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://idp.test/token")
|
|
monkeypatch.setenv("GATEWAY_RELAY_IDP_CLIENT_SECRET", "shh")
|
|
|
|
def fake_urlopen(req, timeout=None):
|
|
raise AssertionError("no HTTP request may be issued on partial credentials")
|
|
|
|
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
|
with pytest.raises(RuntimeError, match="client_id missing"):
|
|
relay._resolve_relay_identity_token()
|
|
|
|
|
|
def test_ambient_via_config_yaml(monkeypatch):
|
|
"""Ambient mode also engages when token_url comes from config.yaml, not env."""
|
|
monkeypatch.setattr(
|
|
"gateway.run._load_gateway_config",
|
|
lambda: {"gateway": {"idp": {"token_url": "https://proxy.local/access-token"}}},
|
|
raising=False,
|
|
)
|
|
|
|
def fake_urlopen(req, timeout=None):
|
|
assert req.get_method() == "GET"
|
|
return io.BytesIO(_FAKE_JWT.encode())
|
|
|
|
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
|
assert relay._resolve_relay_identity_token() == _FAKE_JWT
|