From 6d9eb23d39ccdb2f77e6d7ee061b7393c3a6d124 Mon Sep 17 00:00:00 2001 From: pufit Date: Fri, 30 Oct 2026 11:12:10 -0400 Subject: [PATCH] External auth: verify the gateway's actor context header With auth.external.gateway_jwks_file set, external mode checks the X-Nerve-Actor-Context JWS before it acts as the person: the gateway's Ed25519 signature with a key from that local JWK Set file, the tenant, agent, signer and audience, the method and target of the request, the issue and expiry times, and one use per assertion. An HTTP request is checked when it arrives, before its body is read. A failed check gives 401, a missing key set 503, and verification: log only logs the failure. Without the setting, requests are handled as before; nerve doctor warns. --- docs/accounts.md | 19 +- docs/config.md | 78 +- nerve/cli.py | 34 + nerve/config.py | 62 ++ nerve/config_reload.py | 20 + nerve/config_validate.py | 4 + nerve/gateway/actor_context.py | 355 +++++++ nerve/gateway/auth.py | 199 +++- nerve/gateway/server.py | 42 +- tests/fixtures/actor_context_vectors.json | 227 +++++ tests/test_actor_context_verify.py | 1130 +++++++++++++++++++++ 11 files changed, 2140 insertions(+), 30 deletions(-) create mode 100644 nerve/gateway/actor_context.py create mode 100644 tests/fixtures/actor_context_vectors.json create mode 100644 tests/test_actor_context_verify.py diff --git a/docs/accounts.md b/docs/accounts.md index 9ee7e3f60..a1f7c7b6f 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -136,8 +136,15 @@ and names the person behind each request. See | Backend and external MCP token | The system actor | The header is a compact JWS. Nerve reads `principal_id` and -`profile.display_name` from its payload. It does not check the signature or -any other claim. `principal_id` must be a UUID, and Nerve uses its lowercase +`profile.display_name` from its payload. With `auth.external.gateway_jwks_file` +set, Nerve first checks the gateway's signature with a key from that file, and +the claims: this tenant and agent, the method and target of this request, the +issue and expiry times, and that the assertion was not used before. It checks +a request when the request arrives, before it reads the body. A failed check +gives `401`, and a missing key set gives `503`. See +[Actor context verification](config.md#actor-context-verification). Without +that setting, Nerve does not check the signature or any other claim. +`principal_id` must be a UUID, and Nerve uses its lowercase form as the actor ID. `profile.display_name` must be a string, `null`, or absent. A header that Nerve cannot read, a header that names the system actor, and a request with more than one header give `401`. When the header is @@ -182,10 +189,10 @@ In external mode: actor for Nerve's own model calls. See [Inference principal header](config.md#inference-principal-header). -> **Warning:** Nerve trusts the header. Use external mode only when the -> gateway is the only caller that can reach Nerve. A caller that can send the -> header can act as any person, also as the actor of an account from an earlier -> local mode. +> **Warning:** Without `auth.external.gateway_jwks_file`, Nerve trusts the +> header. Then use external mode only when the gateway is the only caller that +> can reach Nerve. A caller that can send the header can act as any person, +> also as the actor of an account from an earlier local mode. ### The system actor in external mode diff --git a/docs/config.md b/docs/config.md index d39b98ad1..5a06f241f 100644 --- a/docs/config.md +++ b/docs/config.md @@ -248,7 +248,7 @@ A reload is always explicit. Two things cause one: | Skills (`skills/`, and the platform skills in `skills/` in the config directory) | ✅ re-scanned | | `lockdown` | ✅ the write guards and the layer stack both follow | | `setup.local_secrets` | ✅ read on every request of the setup page | -| Web gateway auth (`auth.*`) | partly. `auth.jwt_expiry_hours` follows a reload for newly issued or refreshed tokens. The deprecated `auth.password_hash` follows a reload only for accounts without a local password. `auth.jwt_secret` requires a restart (see the restart table) | +| Web gateway auth (`auth.*`) | partly. `auth.jwt_expiry_hours` follows a reload for newly issued or refreshed tokens. The deprecated `auth.password_hash` follows a reload only for accounts without a local password. `auth.external.*` follows a reload, which also reads the file that `gateway_jwks_file` names again. `auth.jwt_secret` requires a restart (see the restart table) | | `gateway.drain_timeout_seconds` | ✅ read when the shutdown signal arrives (see [Shutdown Sequence](architecture.md#shutdown-sequence)) | | `notifications.*` | ✅ read per notification | | `workspace_sync.*` | ✅ from the next sync cycle | @@ -2182,6 +2182,10 @@ a server or a tool that the organization removed. |-----|------|---------|-------------| | `auth.password_hash` | string | - | Deprecated compatibility setting. Manage passwords from the Accounts page. If neither this setting nor the sole account has a password, anyone who can reach the gateway can act as the owner. See [Accounts and identity](accounts.md) | | `auth.jwt_secret` | string | - | JWT signing secret. When unset, Nerve generates one and stores it in `nerve.db`. Changing it requires a restart and signs users out. See [Accounts and identity](accounts.md) | +| `auth.external.gateway_jwks_file` | path | - | External mode only. An absolute path to the gateway's public actor signing keys, a JWK Set file. When set, Nerve verifies the `X-Nerve-Actor-Context` header. See [Actor context verification](#actor-context-verification) | +| `auth.external.tenant_id` | string | - | The tenant of this instance, a lower-case UUID. Required with `gateway_jwks_file` | +| `auth.external.agent_id` | string | - | The agent of this instance, a lower-case UUID. Required with `gateway_jwks_file` | +| `auth.external.verification` | string | `require` | `require` refuses a header that fails the check. `log` logs the failure and uses the header anyway, for a first rollout | ### Authentication mode @@ -2201,10 +2205,74 @@ error. `nerve restart` checks the value before it stops the running daemon. runs `nerve doctor`. It does not ask the running server, so the mode of a daemon that started with a different environment can be different. -> **Warning:** In external mode, Nerve trusts the `X-Nerve-Actor-Context` -> header and does not check its signature. A caller that can send a request to -> Nerve can act as any person. Use external mode only when the gateway is the -> only caller that can reach Nerve. +#### Actor context verification + +The gateway signs the `X-Nerve-Actor-Context` header with an Ed25519 key. To +check it, give Nerve the gateway's public keys and the identity of this +instance in `auth.external`. The mode itself still comes only from +`NERVE_AUTH_MODE`. + +```yaml +auth: + external: + gateway_jwks_file: /etc/nerve/actor-keys.json # an absolute path + tenant_id: 00000000-0000-0000-0000-000000000000 + agent_id: 00000000-0000-0000-0000-000000000000 + verification: require # or log +``` + +The file is a JWK Set of Ed25519 public keys (`kty` `OKP`, `crv` `Ed25519`, +`alg` `EdDSA`, `use` `sig`, a unique `kid`, and `x`). Nerve reads only `keys` +and ignores the other members of the document. A file with a private key +member is refused. Nerve reads the file at startup and on each reload, and +again, at most once a minute, when a header names an unknown `kid`. When a +later read fails, the keys of the last good read stay in use. Nerve never +fetches keys over the network. + +With the file set, Nerve accepts the header of an HTTP request or a WebSocket +upgrade only when all of these are true: + +- The header is a compact JWS. Its protected header has `typ` + `nerve-actor-context+jws`, `alg` `EdDSA` and a `kid` from the file, and no + `jwk`, `jku`, `x5c`, `x5u` or `crit`. The signature verifies with that key. +- The payload has `version` `"1"`, the configured `tenant_id` and `agent_id`, + `signer_id` `urn:nerve:gateway:actor::`, `audience` + `urn:nerve:runtime::`, `actor_kind` `human`, and a UUID + in `principal_id`. +- `request_binding` names this request. `kind` is `http`, or `websocket` for an + upgrade, which also needs a `gateway_connection_id`. `method` is the request + method. `target` is the request-target that Nerve received: the raw path, and + `?` and the query when there is a query. Without a query, `/path?` also + matches. +- `issued_at` is at most 30 seconds in the future, `expires_at` is at most 30 + seconds in the past, and `expires_at` is 1 to 30 seconds after `issued_at`. +- Nerve has not accepted the same `assertion_id` before. Nerve keeps accepted + IDs in memory until they expire, at most 10,000; when it is full, it forgets + the oldest. + +Nerve checks the header of an HTTP request when the request arrives, before it +reads the body, and the header of a WebSocket upgrade when it accepts the +socket. + +| Result | Response | +|---|---| +| All checks pass | The request acts as `principal_id`, with `profile.display_name`, as without the check | +| A check fails | `401`. The reason, the `kid` and the request ID go to the log, never the header | +| No usable key set: the file cannot be read or used, or an `auth.external` setting is invalid | `503` | +| A check fails, or no usable key set, with `verification: log` | Nerve logs the failure and reads the header without the check | + +The server accepts a WebSocket before it authenticates it, so a failed check +and a missing key set both close the socket with `4001`. + +A reload applies changed `auth.external` settings and reads the file again. +In external mode, `nerve doctor` reads the file, reports invalid settings, and +warns when `verification` is `log`. Startup logs the key file and the +`verification` value, as a warning when it is `log`. + +> **Warning:** Without `auth.external.gateway_jwks_file`, Nerve trusts the +> `X-Nerve-Actor-Context` header and does not check its signature. A caller +> that can send a request to Nerve can act as any person. Then use external +> mode only when the gateway is the only caller that can reach Nerve. #### Inference principal header diff --git a/nerve/cli.py b/nerve/cli.py index 698163f09..10c8f9dff 100644 --- a/nerve/cli.py +++ b/nerve/cli.py @@ -1685,6 +1685,38 @@ def _hosted_github_lines(config, errors: list[str], warnings: list[str]) -> list return lines +def _actor_context_lines(config, errors: list[str], warnings: list[str]) -> list[str]: + """Doctor lines for the actor context check of external mode (``auth.external``).""" + from nerve.config import EXTERNAL_VERIFICATION_LOG + from nerve.gateway.actor_context import KeyFileError, read_key_file + + settings = config.auth.external + if settings.gateway_jwks_file is None: + warnings.append( + "[WARN] Actor context: the X-Nerve-Actor-Context header is not verified " + "(auth.external.gateway_jwks_file is not set), so only the gateway must " + "be able to reach Nerve" + ) + return [] + problems = settings.problems() + if not problems: + try: + keys = read_key_file(settings.gateway_jwks_file) + except KeyFileError as error: + problems = [f"auth.external.gateway_jwks_file: {error}"] + if problems: + errors.extend(f"[ERR] Actor context: {problem}" for problem in problems) + return [] + source = f"agent {settings.agent_id}, {len(keys)} gateway key(s) from {settings.gateway_jwks_file}" + if settings.verification == EXTERNAL_VERIFICATION_LOG: + warnings.append( + "[WARN] Actor context: auth.external.verification is log, so a header " + f"that fails the check is used anyway ({source})" + ) + return [] + return [f"[OK] Actor context verified (verification: {settings.verification}): {source}"] + + def _github_repos_lines(config, warnings: list[str]) -> list[str]: """The doctor line of the repository watch (``sync.github_repos``). @@ -2258,6 +2290,8 @@ def doctor_report(config, config_source: str = "", check_api: bool = False) -> s else: lines.append(f"[OK] Auth mode: {mode} ({mode_source})") external = mode == AUTH_MODE_EXTERNAL + if external: + lines.extend(_actor_context_lines(config, errors, warnings)) configured = bool(config.auth.password_hash) identity_state = inspect_bootstrap_state(paths.db_path()) diff --git a/nerve/config.py b/nerve/config.py index 261559659..88e5ded4c 100644 --- a/nerve/config.py +++ b/nerve/config.py @@ -2960,6 +2960,66 @@ def from_dict(cls, d: dict) -> RetentionConfig: ) +EXTERNAL_VERIFICATION_REQUIRE = "require" +EXTERNAL_VERIFICATION_LOG = "log" +EXTERNAL_VERIFICATION_MODES = (EXTERNAL_VERIFICATION_REQUIRE, EXTERNAL_VERIFICATION_LOG) + + +@dataclass +class ExternalAuthConfig: + """The check of the gateway's actor context in external mode. + + With ``gateway_jwks_file`` set, Nerve accepts an ``X-Nerve-Actor-Context`` + header only with a signature from a key in that local JWK Set file, and + with claims for exactly ``tenant_id``, ``agent_id`` and the request (see + :mod:`nerve.gateway.actor_context`). ``verification: log`` logs a failed + check and uses the header anyway, for a first rollout. Without + ``gateway_jwks_file``, Nerve reads the header without a check. The mode + itself comes only from ``NERVE_AUTH_MODE``. + """ + + gateway_jwks_file: Path | None = None + tenant_id: str = "" + agent_id: str = "" + verification: str = EXTERNAL_VERIFICATION_REQUIRE + + @classmethod + @_coerced + def from_dict(cls, d: dict) -> ExternalAuthConfig: + return cls( + gateway_jwks_file=_expand_path(d.get("gateway_jwks_file")), + tenant_id=str(d.get("tenant_id") or "").strip(), + agent_id=str(d.get("agent_id") or "").strip(), + verification=str( + d.get("verification") or EXTERNAL_VERIFICATION_REQUIRE, + ).strip().lower(), + ) + + def problems(self) -> list[str]: + """Why these settings cannot check the actor context. Empty when they + can, and when ``gateway_jwks_file`` is unset (no check). + + The key file itself is read by the verifier and by ``nerve doctor``. + """ + if self.gateway_jwks_file is None: + return [] + found: list[str] = [] + if not self.gateway_jwks_file.is_absolute(): + # The daemon and `nerve doctor` can start in different directories. + found.append("auth.external.gateway_jwks_file must be an absolute path") + for key in ("tenant_id", "agent_id"): + value = getattr(self, key) + try: + canonical = str(uuid.UUID(value)) == value + except ValueError: + canonical = False + if not canonical: + found.append(f"auth.external.{key} must be a lowercase UUID") + if self.verification not in EXTERNAL_VERIFICATION_MODES: + found.append("auth.external.verification must be require or log") + return found + + @dataclass class AuthConfig: password_hash: str = "" @@ -2973,6 +3033,7 @@ class AuthConfig: # logged out mid-work. Only a tab left untouched for the whole window # comes back to a password prompt. jwt_expiry_hours: int = 720 # 30 days + external: ExternalAuthConfig = field(default_factory=ExternalAuthConfig) @classmethod @_coerced @@ -2981,6 +3042,7 @@ def from_dict(cls, d: dict) -> AuthConfig: password_hash=d.get("password_hash", ""), jwt_secret=d.get("jwt_secret", ""), jwt_expiry_hours=max(1, _lenient_int(d.get("jwt_expiry_hours"), 720)), + external=ExternalAuthConfig.from_dict(d.get("external") or {}), ) diff --git a/nerve/config_reload.py b/nerve/config_reload.py index da2e5ac13..ce50c1380 100644 --- a/nerve/config_reload.py +++ b/nerve/config_reload.py @@ -355,6 +355,23 @@ def _reload_hosted_keys(engine) -> str | None: return f"{_ERROR_PREFIX}{detail}" +def _reload_actor_context_keys() -> str | None: + """Read the gateway key file of the actor context check again. + + ``None`` unless external mode checks the actor context + (``auth.external.gateway_jwks_file``). A changed key file, tenant or + agent takes effect here as well. + """ + try: + from nerve.gateway.auth import reload_actor_context_keys + + return reload_actor_context_keys() + except Exception as e: # noqa: BLE001 - report the subsystem and continue + detail = str(e) or type(e).__name__ + logger.warning("Actor context key reload failed: %s", detail) + return f"{_ERROR_PREFIX}{detail}" + + async def _republish_hosted_subscriptions(engine) -> str | None: """Send the hosted source subscriptions to the gateway again. @@ -426,6 +443,9 @@ async def reload_all(engine, cron_service, config_dir: Path) -> dict: hosted = _reload_hosted_keys(engine) if hosted is not None: summary["hosted"] = hosted + actor_keys = _reload_actor_context_keys() + if actor_keys is not None: + summary["actor_context"] = actor_keys # 2. Cron jobs + sources. if cron_service is not None: diff --git a/nerve/config_validate.py b/nerve/config_validate.py index c799711d0..d1962de52 100644 --- a/nerve/config_validate.py +++ b/nerve/config_validate.py @@ -472,6 +472,10 @@ def _is_unresolved(value: Any) -> bool: "hosted channels would try to read a directory as the gateway's public " "key file, so they do not start and every stream upgrade is refused" ), + ("auth", "external", "gateway_jwks_file"): ( + "external mode would have no key to check the actor context header with, " + "so every request that carries the header is refused" + ), ("workspace",): ( "the entire workspace (settings.yaml, the cron config, memory, " "everything nerve syncs) would be read from and written to whatever " diff --git a/nerve/gateway/actor_context.py b/nerve/gateway/actor_context.py new file mode 100644 index 000000000..6562eacd7 --- /dev/null +++ b/nerve/gateway/actor_context.py @@ -0,0 +1,355 @@ +"""Verification of the gateway's actor context on a request. + +In external mode the gateway names the person behind each request and each +WebSocket upgrade in the ``X-Nerve-Actor-Context`` header: a compact JWS that +the gateway signs with Ed25519 (``EdDSA``) and a ``kid``. With +``auth.external.gateway_jwks_file`` set, Nerve acts as that person only when +the signature verifies with a key from that file and the claims name this +tenant and agent, the method and target of this request, and a time within +the assertion's lifetime. Each assertion is accepted once. Each check here is +part of the trust boundary. + +The public keys come from a local JWK Set file. Nerve never fetches keys over +the network. The file is read at startup and on a config reload, and again, +at most once per minute, when a token names an unknown ``kid``. When a later +read fails, the keys from the last good read stay in use. +""" + +from __future__ import annotations + +import base64 +import binascii +import json +import logging +import re +import time +from collections import OrderedDict +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +logger = logging.getLogger(__name__) + +ACTOR_CONTEXT_TYPE = "nerve-actor-context+jws" +ACTOR_CONTEXT_VERSION = "1" +CLOCK_SKEW_SECONDS = 30 +MAXIMUM_LIFETIME_SECONDS = 30 +UNKNOWN_KID_REREAD_SECONDS = 60.0 +MAXIMUM_KEY_FILE_BYTES = 1024 * 1024 +MAXIMUM_TOKEN_BYTES = 8 * 1024 +MAXIMUM_KID_BYTES = 128 +# Assertion IDs remembered at one time. A full cache forgets the oldest. +MAXIMUM_REMEMBERED_ASSERTIONS = 10_000 + +# JWK members that hold private or symmetric key material (RFC 7518, RFC 8037). +PRIVATE_KEY_MEMBERS = frozenset({"d", "p", "q", "dp", "dq", "qi", "oth", "k"}) +# Header members that embed or reference a key. Keys come only from the file. +KEY_HEADER_MEMBERS = frozenset({"jwk", "jku", "x5c", "x5u"}) + +_BASE64URL = re.compile(r"[A-Za-z0-9_-]*") + + +class AssertionRejected(Exception): + """An actor context that fails verification. The message is for local logs only.""" + + +class KeysUnavailable(Exception): + """No usable key set is loaded, so no actor context can be verified.""" + + +class KeyFileError(ValueError): + """A gateway key file that Nerve cannot use. The message names no key material.""" + + +class ActorContextVerifier: + """Verify actor contexts for one tenant and agent against the gateway's keys. + + The constructor reads the key file. When the file cannot be used, the + verifier starts without keys and logs why, and :meth:`verify` raises + :class:`KeysUnavailable` until a read succeeds. ``now`` is the clock for + the claims, and ``monotonic`` drives the reread limit, for tests. + """ + + def __init__( + self, + key_file: Path, + tenant_id: str, + agent_id: str, + *, + now: Callable[[], float] = time.time, + monotonic: Callable[[], float] = time.monotonic, + ) -> None: + self.key_file = key_file + self.tenant_id = tenant_id + self.agent_id = agent_id + self._signer_id = f"urn:nerve:gateway:actor:{tenant_id}:{agent_id}" + self._audience = f"urn:nerve:runtime:{tenant_id}:{agent_id}" + self._now = now + self._monotonic = monotonic + self._keys: dict[str, Ed25519PublicKey] = {} + self._read_at = 0.0 + # Accepted assertion IDs, oldest first, each with the time after which + # the assertion can no longer pass the time check. + self._seen: OrderedDict[str, float] = OrderedDict() + try: + self.reload_keys() + except KeyFileError as error: + logger.warning("The gateway's actor key file cannot be used: %s", error) + + @property + def key_count(self) -> int: + """The number of keys in use.""" + return len(self._keys) + + def reload_keys(self) -> int: + """Read the key file again and return the number of keys. + + Raises :class:`KeyFileError` and keeps the current keys when the file + cannot be used. + """ + self._read_at = self._monotonic() + keys = read_key_file(self.key_file) + self._keys = keys + return len(keys) + + def verify(self, value: str, *, method: str, target: str, websocket: bool) -> None: + """Raise unless *value* is a valid actor context for this request. + + ``method`` and ``target`` are the method and the request-target of the + request as Nerve received it, and ``websocket`` says that the request + is a WebSocket upgrade. An accepted assertion is not accepted again. + Raises :class:`KeysUnavailable` when no key set is loaded, and + :class:`AssertionRejected` when a check fails. + """ + if not self._keys: + self._reread_if_due() + if not self._keys: + raise KeysUnavailable(f"no usable key set was read from {self.key_file}") + if not value or len(value) > MAXIMUM_TOKEN_BYTES: + raise AssertionRejected("assertion is empty or oversized") + parts = value.split(".") + if len(parts) != 3: + raise AssertionRejected("assertion is not a compact JWS") + header, claims = _json_object(parts[0]), _json_object(parts[1]) + if header is None or claims is None: + raise AssertionRejected("assertion header or payload is not base64url JSON") + try: + self._check_signature(header, parts) + self._check_claims(claims, method=method, target=target, websocket=websocket) + except AssertionRejected as error: + raise AssertionRejected( + f"{error} (kid {_shown(header.get('kid'))}, " + f"request {_shown(claims.get('request_id'))})" + ) from None + self._remember(claims["assertion_id"], claims["expires_at"]) + + def _check_signature(self, header: dict[str, Any], parts: list[str]) -> None: + if header.get("typ") != ACTOR_CONTEXT_TYPE: + raise AssertionRejected(f"assertion typ is not {ACTOR_CONTEXT_TYPE}") + if header.get("alg") != "EdDSA": + raise AssertionRejected("assertion algorithm is not EdDSA") + if "crit" in header: + raise AssertionRejected("assertion has critical header extensions") + if KEY_HEADER_MEMBERS & header.keys(): + raise AssertionRejected("assertion header carries or references a key") + kid = header.get("kid") + if not isinstance(kid, str) or not kid or len(kid) > MAXIMUM_KID_BYTES: + raise AssertionRejected("assertion has no usable kid") + key = self._key_for(kid) + if key is None: + raise AssertionRejected("assertion kid is not in the key set") + signature = _base64url(parts[2]) or b"" + try: + key.verify(signature, f"{parts[0]}.{parts[1]}".encode("ascii")) + except InvalidSignature: + raise AssertionRejected("assertion signature does not verify") from None + + def _check_claims( + self, claims: dict[str, Any], *, method: str, target: str, websocket: bool, + ) -> None: + if claims.get("version") != ACTOR_CONTEXT_VERSION: + raise AssertionRejected(f"assertion version is not {ACTOR_CONTEXT_VERSION}") + if claims.get("tenant_id") != self.tenant_id: + raise AssertionRejected("assertion tenant_id names another tenant") + if claims.get("agent_id") != self.agent_id: + raise AssertionRejected("assertion agent_id names another agent") + if claims.get("signer_id") != self._signer_id: + raise AssertionRejected("assertion signer_id is not this agent's") + if claims.get("audience") != self._audience: + raise AssertionRejected("assertion audience is not this agent's") + if claims.get("actor_kind") != "human": + raise AssertionRejected("assertion actor_kind is not human") + + binding = claims.get("request_binding") + if not isinstance(binding, dict): + raise AssertionRejected("assertion has no request_binding") + kind = "websocket" if websocket else "http" + if binding.get("kind") != kind: + raise AssertionRejected( + f"assertion binds kind {_shown(binding.get('kind'))}, request is {kind}" + ) + connection = claims.get("gateway_connection_id") + if websocket and (not isinstance(connection, str) or not connection): + raise AssertionRejected("assertion has no gateway_connection_id") + if binding.get("method") != method: + raise AssertionRejected( + f"assertion binds method {_shown(binding.get('method'))}, request is {method}" + ) + # The gateway keeps a bare trailing "?", and the server drops it. + # The target is not logged: its query can hold a secret. + bound = binding.get("target") + if bound != target and not ("?" not in target and bound == f"{target}?"): + raise AssertionRejected("assertion binds another request target") + + issued_at, expires_at = claims.get("issued_at"), claims.get("expires_at") + if not _is_integer(issued_at) or not _is_integer(expires_at): + raise AssertionRejected("assertion issued_at or expires_at is not an integer") + if not 0 < expires_at - issued_at <= MAXIMUM_LIFETIME_SECONDS: + raise AssertionRejected("assertion lifetime is out of range") + now = self._now() + if issued_at > now + CLOCK_SKEW_SECONDS: + raise AssertionRejected("assertion is issued in the future") + if expires_at < now - CLOCK_SKEW_SECONDS: + raise AssertionRejected("assertion has expired") + + assertion_id = claims.get("assertion_id") + if not isinstance(assertion_id, str) or not assertion_id: + raise AssertionRejected("assertion has no assertion_id") + if assertion_id in self._seen: + raise AssertionRejected("assertion was already used") + + def _remember(self, assertion_id: str, expires_at: int) -> None: + """Remember an accepted assertion until it can no longer pass the time check.""" + now = self._now() + while self._seen and next(iter(self._seen.values())) < now: + self._seen.popitem(last=False) + if len(self._seen) >= MAXIMUM_REMEMBERED_ASSERTIONS: + # IDs need not expire in the order they came: forget every expired + # one before a live one. + self._seen = OrderedDict( + (seen, until) for seen, until in self._seen.items() if until >= now + ) + if len(self._seen) >= MAXIMUM_REMEMBERED_ASSERTIONS: + self._seen.popitem(last=False) + self._seen[assertion_id] = expires_at + CLOCK_SKEW_SECONDS + + def _key_for(self, kid: str) -> Ed25519PublicKey | None: + """The key for *kid*, after a reread when it is unknown and one is due.""" + key = self._keys.get(kid) + if key is None: + self._reread_if_due() + key = self._keys.get(kid) + return key + + def _reread_if_due(self) -> None: + if self._monotonic() - self._read_at < UNKNOWN_KID_REREAD_SECONDS: + return + try: + self.reload_keys() + except KeyFileError as error: + logger.warning("The gateway's actor key file reread failed: %s", error) + + +def _base64url(segment: str) -> bytes | None: + """The bytes of an unpadded base64url segment, or ``None``.""" + if not _BASE64URL.fullmatch(segment): + return None + try: + return base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4)) + except (binascii.Error, ValueError): + return None + + +def _json_object(segment: str) -> dict[str, Any] | None: + """The JSON object in a base64url segment, or ``None``.""" + raw = _base64url(segment) + if raw is None: + return None + try: + value = json.loads(raw) + except (ValueError, RecursionError): + return None + return value if isinstance(value, dict) else None + + +def _is_integer(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) + + +def _shown(value: Any) -> str: + """A short header or claim value for a log line, or ``-``.""" + return repr(value) if isinstance(value, str) and len(value) <= MAXIMUM_KID_BYTES else "-" + + +def read_key_file(path: Path) -> dict[str, Ed25519PublicKey]: + """The public keys of the gateway key file at *path*, by ``kid``. + + Raises :class:`KeyFileError` when the file cannot be read or does not + hold only valid public Ed25519 signing keys. + """ + try: + with open(path, "rb") as file: + body = file.read(MAXIMUM_KEY_FILE_BYTES + 1) + except OSError as error: + raise KeyFileError(f"cannot read {path}: {error.strerror or type(error).__name__}") from None + if len(body) > MAXIMUM_KEY_FILE_BYTES: + raise KeyFileError(f"{path} is larger than {MAXIMUM_KEY_FILE_BYTES} bytes") + return parse_key_set(body, source=str(path)) + + +def parse_key_set(body: bytes, *, source: str = "key file") -> dict[str, Ed25519PublicKey]: + """The keys of a JWK Set of public Ed25519 signing keys, by ``kid``. + + Only ``keys`` is read; other members of the document are ignored. Every + entry must be an OKP Ed25519 public key with ``alg`` ``EdDSA``, ``use`` + ``sig`` and a unique ``kid``. A set with any private key member is + refused as a whole, because it shows that a signing key was copied to the + wrong place. + """ + try: + document = json.loads(body) + except (ValueError, RecursionError): + raise KeyFileError(f"{source} is not valid JSON") from None + entries = document.get("keys") if isinstance(document, dict) else None + if not isinstance(entries, list) or not entries: + raise KeyFileError(f"{source} has no keys") + if any(isinstance(entry, dict) and PRIVATE_KEY_MEMBERS & entry.keys() for entry in entries): + raise KeyFileError(f"{source} holds private key members; give Nerve the public keys only") + keys: dict[str, Ed25519PublicKey] = {} + for index, entry in enumerate(entries): + where = f"{source} key {index}" + if not isinstance(entry, dict): + raise KeyFileError(f"{where} is not an object") + kid = entry.get("kid") + if not isinstance(kid, str) or not kid or len(kid) > MAXIMUM_KID_BYTES: + raise KeyFileError(f"{where} has no usable kid") + where = f"{source} key {kid!r}" + if kid in keys: + raise KeyFileError(f"{where} appears more than once") + if entry.get("kty") != "OKP" or entry.get("crv") != "Ed25519": + raise KeyFileError(f"{where} is not an Ed25519 key") + if entry.get("alg") != "EdDSA" or entry.get("use") != "sig": + raise KeyFileError(f"{where} is not an EdDSA signing key") + x = entry.get("x") + raw = _base64url(x) if isinstance(x, str) else None + try: + keys[kid] = Ed25519PublicKey.from_public_bytes(raw or b"") + except ValueError: + raise KeyFileError(f"{where} is not a valid Ed25519 public key") from None + return keys + + +__all__ = [ + "ACTOR_CONTEXT_TYPE", + "CLOCK_SKEW_SECONDS", + "MAXIMUM_LIFETIME_SECONDS", + "ActorContextVerifier", + "AssertionRejected", + "KeyFileError", + "KeysUnavailable", + "parse_key_set", + "read_key_file", +] diff --git a/nerve/gateway/auth.py b/nerve/gateway/auth.py index 2759454b5..7781f7ed6 100644 --- a/nerve/gateway/auth.py +++ b/nerve/gateway/auth.py @@ -5,7 +5,7 @@ The authentication mode is pinned at startup from ``NERVE_AUTH_MODE``. In ``external`` mode, the gateway names the person behind a request in the -``X-Nerve-Actor-Context`` header (see :func:`decode_actor_context`), and local +``X-Nerve-Actor-Context`` header (see :func:`read_actor_context`), and local logins and session tokens are not accepted. """ @@ -25,8 +25,20 @@ import bcrypt import jwt from fastapi import Depends, HTTPException, Request, WebSocket - -from nerve.config import ConfigError, NerveConfig, get_config +from starlette.types import ASGIApp, Receive, Scope, Send + +from nerve.config import ( + EXTERNAL_VERIFICATION_LOG, + ConfigError, + ExternalAuthConfig, + NerveConfig, + get_config, +) +from nerve.gateway.actor_context import ( + ActorContextVerifier, + AssertionRejected, + KeysUnavailable, +) from nerve.identity import ( ACTOR_KIND_HUMAN, Actor, @@ -467,9 +479,11 @@ def decode_actor_context(value: str) -> tuple[str, str | None]: """Return the principal ID and display name in an actor context header. The value is a compact JWS. This function decodes the payload and does - not check the signature, so it trusts every caller that can reach Nerve. - External mode needs the gateway to be the only caller. Every reader of - the header calls this function. + not check the signature. Every reader of the header calls + :func:`read_actor_context`, which checks the signature and the claims + first when ``auth.external.gateway_jwks_file`` is set. Without it, Nerve + trusts every caller that can reach it, and external mode needs the + gateway to be the only caller. The principal ID is returned as a canonical lower-case UUID string. Raises :class:`ActorContextError` when the value cannot be read. @@ -514,11 +528,166 @@ def decode_actor_context(value: str) -> tuple[str, str | None]: return principal_id, display_name -async def resolve_external_actor(store: "Database", values: list[str]) -> Actor: +NO_ACTOR_KEYS_DETAIL = ( + "The gateway's actor signing keys are not available; see the server log" +) + + +class ActorKeysUnavailable(ActorResolutionError): + """No usable key set is loaded to check the actor context with.""" + + +# The verifier of auth.external, made on first use and again when its key +# file, tenant or agent changes. Its replay cache lives as long as it does. +_actor_verifier: ActorContextVerifier | None = None + + +def actor_context_verifier(settings: ExternalAuthConfig) -> ActorContextVerifier: + """Return the verifier for *settings*, which name a key file. + + Raises :class:`~nerve.gateway.actor_context.KeysUnavailable` when the + settings cannot be used. + """ + global _actor_verifier + problems = settings.problems() + if problems: + raise KeysUnavailable("; ".join(problems)) + wanted = (settings.gateway_jwks_file, settings.tenant_id, settings.agent_id) + verifier = _actor_verifier + if verifier is None or (verifier.key_file, verifier.tenant_id, verifier.agent_id) != wanted: + verifier = _actor_verifier = ActorContextVerifier(*wanted) + return verifier + + +def reload_actor_context_keys() -> str | None: + """Read the gateway key file of the actor context check again, for a reload. + + ``None`` in local mode or without ``auth.external.gateway_jwks_file``. + Raises and keeps the current keys when the file cannot be used. Changed + settings make a new verifier, which reads the file itself. + """ + settings = get_config().auth.external + if not is_external_mode() or settings.gateway_jwks_file is None: + return None + previous = _actor_verifier + verifier = actor_context_verifier(settings) + if verifier is previous: + return f"{verifier.reload_keys()} gateway key(s)" + if not verifier.key_count: + raise KeysUnavailable(f"no usable key set was read from {verifier.key_file}") + return f"{verifier.key_count} gateway key(s)" + + +def _request_target(scope: Scope) -> str: + """The request-target that Nerve received: the raw path, and ``?`` and + the query when the query is not empty.""" + raw_path = scope.get("raw_path") or scope["path"].encode("utf-8") + target = raw_path.decode("latin-1") + query = scope.get("query_string") or b"" + return f"{target}?{query.decode('latin-1')}" if query else target + + +def _check_actor_context( + value: str, scope: Scope, settings: ExternalAuthConfig, +) -> tuple[str, str | None]: + """Verify *value* against the request in *scope*, then decode it.""" + websocket = scope["type"] == "websocket" + try: + actor_context_verifier(settings).verify( + value, + method="GET" if websocket else scope["method"], + target=_request_target(scope), + websocket=websocket, + ) + except (AssertionRejected, KeysUnavailable) as e: + if settings.verification != EXTERNAL_VERIFICATION_LOG: + logger.warning("%s refused: %s", ACTOR_CONTEXT_HEADER, e) + if isinstance(e, KeysUnavailable): + raise ActorKeysUnavailable(NO_ACTOR_KEYS_DETAIL) from None + raise ActorContextError("The actor context failed verification") from None + logger.warning( + "%s check failed; it is used anyway, because " + "auth.external.verification is log: %s", ACTOR_CONTEXT_HEADER, e, + ) + return decode_actor_context(value) + + +# Where ActorContextMiddleware leaves the outcome of the check in the scope +# state: the principal ID and display name, or the error to raise. +ACTOR_CONTEXT_STATE = "nerve_actor_context" + + +class ActorContextMiddleware: + """Check the actor context of each HTTP request when the request arrives. + + FastAPI reads the request body before it runs :func:`require_auth`, so a + check there would see a slow upload only after its assertion expired. + This check runs before the body is read, once, and leaves its outcome in + the scope state for :func:`read_actor_context`. A request without + exactly one header is left to :func:`require_auth`. A WebSocket upgrade + has no body, and is checked when it is accepted. + """ + + _HEADER = ACTOR_CONTEXT_HEADER.lower().encode("latin-1") + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] == "http": + self._check(scope) + await self.app(scope, receive, send) + + def _check(self, scope: Scope) -> None: + settings = get_config().auth.external + if settings.gateway_jwks_file is None: + return + values = [value for key, value in scope["headers"] if key == self._HEADER] + if len(values) != 1: + return + try: + outcome = _check_actor_context(values[0].decode("latin-1"), scope, settings) + except (ActorContextError, ActorKeysUnavailable) as e: + outcome = e + scope.setdefault("state", {})[ACTOR_CONTEXT_STATE] = outcome + + +def read_actor_context( + value: str, connection: Request | WebSocket, +) -> tuple[str, str | None]: + """Return the principal ID and display name in a request's actor context. + + Without ``auth.external.gateway_jwks_file`` this is + :func:`decode_actor_context`. With it, the gateway's signature and the + claims are checked first, against *connection*, the request or the + WebSocket upgrade (see :mod:`nerve.gateway.actor_context`): + :class:`ActorContextMiddleware` checks an HTTP request when it arrives, + and this function takes that outcome, or checks now. A failed check + raises :class:`ActorContextError`, and a missing key set raises + :class:`ActorKeysUnavailable`; the reason goes to the log only. With + ``verification: log``, the failure is logged and the header is decoded + without the check. + """ + settings = get_config().auth.external + if settings.gateway_jwks_file is None: + return decode_actor_context(value) + state = connection.scope.get("state") or {} + if ACTOR_CONTEXT_STATE not in state: + return _check_actor_context(value, connection.scope, settings) + outcome = state[ACTOR_CONTEXT_STATE] + if isinstance(outcome, Exception): + raise outcome + return outcome + + +async def resolve_external_actor( + store: "Database", values: list[str], connection: Request | WebSocket, +) -> Actor: """Resolve the actor context header of a request to a human actor. - ``values`` holds each actor context header of the request. A request - must have exactly one, so that no reader can take a different value. + ``values`` holds each actor context header of ``connection``, the + request or the WebSocket upgrade. A request must have exactly one, so + that no reader can take a different value. Adds the ``actor_refs`` row on first sight and writes a changed display name, before the request can write anything that refers to the actor. @@ -530,7 +699,7 @@ async def resolve_external_actor(store: "Database", values: list[str]) -> Actor: f"not {len(values)}" ) try: - actor_id, display_name = decode_actor_context(values[0]) + actor_id, display_name = read_actor_context(values[0], connection) except ActorContextError as e: raise ActorResolutionError(str(e)) from e try: @@ -566,7 +735,9 @@ async def require_auth(request: Request) -> Actor: if store is None: raise HTTPException(status_code=503, detail=NO_IDENTITY_DETAIL) try: - return await resolve_external_actor(store, contexts) + return await resolve_external_actor(store, contexts, request) + except ActorKeysUnavailable as e: + raise HTTPException(status_code=503, detail=str(e)) from e except ActorResolutionError as e: raise HTTPException(status_code=401, detail=str(e)) from e @@ -599,7 +770,9 @@ async def authenticate_websocket(websocket: WebSocket) -> Actor | None: """Resolve a WebSocket actor when the connection is admitted. In external mode, the gateway signs the upgrade request, so the actor - context header names the person, as for HTTP requests. + context header names the person, as for HTTP requests. The socket is + accepted before this runs, so a failed check and a missing key set both + refuse it in the same way. """ secret = effective_jwt_secret(get_config()) if not secret: @@ -612,7 +785,7 @@ async def authenticate_websocket(websocket: WebSocket) -> Actor | None: if store is None: return None try: - return await resolve_external_actor(store, contexts) + return await resolve_external_actor(store, contexts, websocket) except ActorResolutionError as e: logger.info("WebSocket refused: %s", e) return None diff --git a/nerve/gateway/server.py b/nerve/gateway/server.py index fd5907f2c..e5b473555 100644 --- a/nerve/gateway/server.py +++ b/nerve/gateway/server.py @@ -33,13 +33,16 @@ from nerve.agent.agents.emitter import agent_channel from nerve.agent.engine import AgentEngine, NotAcceptingTurnsError from nerve.agent.streaming import broadcaster -from nerve.config import NerveConfig, get_config +from nerve.config import EXTERNAL_VERIFICATION_LOG, NerveConfig, get_config from nerve.db import Database, init_db, close_db from nerve.db.base import check_database_file +from nerve.gateway.actor_context import KeysUnavailable from nerve.gateway.auth import ( ACTOR_CONTEXT_HEADER, AUTH_MODE_EXTERNAL, SESSION_TOKEN_HEADER, + ActorContextMiddleware, + actor_context_verifier, auth_mode_from_env, authenticate_websocket, identity_store, @@ -1120,11 +1123,32 @@ def create_app() -> FastAPI: """ pin_auth_mode(auth_mode_from_env()) if is_external_mode(): - logger.warning( - "Authentication mode is %s: Nerve trusts the %s header without a " - "signature check. Only the gateway must be able to reach this server.", - AUTH_MODE_EXTERNAL, ACTOR_CONTEXT_HEADER, - ) + external = get_config().auth.external + if external.gateway_jwks_file is None: + logger.warning( + "Authentication mode is %s: Nerve trusts the %s header without a " + "signature check. Only the gateway must be able to reach this server.", + AUTH_MODE_EXTERNAL, ACTOR_CONTEXT_HEADER, + ) + elif external.verification == EXTERNAL_VERIFICATION_LOG: + logger.warning( + "Authentication mode is %s: Nerve checks the %s header but uses it " + "also when the check fails (verification=log, key file %s).", + AUTH_MODE_EXTERNAL, ACTOR_CONTEXT_HEADER, external.gateway_jwks_file, + ) + else: + logger.info( + "Authentication mode is %s: Nerve verifies the %s header " + "(verification=%s, key file %s).", + AUTH_MODE_EXTERNAL, ACTOR_CONTEXT_HEADER, + external.verification, external.gateway_jwks_file, + ) + if external.gateway_jwks_file is not None: + # Read the key file now, so that a problem shows in the startup log. + try: + actor_context_verifier(external) + except KeysUnavailable as e: + logger.warning("The %s header cannot be verified: %s", ACTOR_CONTEXT_HEADER, e) app = FastAPI( title="Nerve", @@ -1221,6 +1245,12 @@ async def _slide_session_token(request: Request, call_next): # noqa: ANN001 # about 2% larger. app.add_middleware(GZipMiddleware, minimum_size=1024, compresslevel=5) + # In external mode, check the actor context header when a request + # arrives. FastAPI reads the body before require_auth runs, so a slow + # upload would otherwise outlast its assertion. + if is_external_mode(): + app.add_middleware(ActorContextMiddleware) + # REST routes app.include_router(register_all_routes()) diff --git a/tests/fixtures/actor_context_vectors.json b/tests/fixtures/actor_context_vectors.json new file mode 100644 index 000000000..85a183505 --- /dev/null +++ b/tests/fixtures/actor_context_vectors.json @@ -0,0 +1,227 @@ +{ + "now": 1800000000, + "tenant_id": "11111111-1111-4111-8111-111111111111", + "agent_id": "22222222-2222-4222-8222-222222222222", + "key_set": { + "keys": [ + { + "use": "sig", + "kty": "OKP", + "kid": "test-2026-10", + "crv": "Ed25519", + "alg": "EdDSA", + "x": "vHy8tWNjdfodgkNNRmck2SN39TuYBpXdSdJtDOEiBaU" + }, + { + "use": "sig", + "kty": "OKP", + "kid": "test-2026-11", + "crv": "Ed25519", + "alg": "EdDSA", + "x": "VRVPQgZepaG-oFRjgmviaE65LfksEAAnqrquV8pVQgc" + } + ] + }, + "ring": { + "schema": "nerve.actor-verifier-ring.v1", + "generation": 1, + "tenant_id": "11111111-1111-4111-8111-111111111111", + "agent_id": "22222222-2222-4222-8222-222222222222", + "signer_id": "urn:nerve:gateway:actor:11111111-1111-4111-8111-111111111111:22222222-2222-4222-8222-222222222222", + "audience": "urn:nerve:runtime:11111111-1111-4111-8111-111111111111:22222222-2222-4222-8222-222222222222", + "keys": [ + { + "use": "sig", + "kty": "OKP", + "kid": "test-2026-10", + "crv": "Ed25519", + "alg": "EdDSA", + "x": "vHy8tWNjdfodgkNNRmck2SN39TuYBpXdSdJtDOEiBaU" + }, + { + "use": "sig", + "kty": "OKP", + "kid": "test-2026-11", + "crv": "Ed25519", + "alg": "EdDSA", + "x": "VRVPQgZepaG-oFRjgmviaE65LfksEAAnqrquV8pVQgc" + } + ] + }, + "vectors": [ + { + "name": "http_get_query_key_a", + "valid": true, + "now": 1800000005, + "method": "GET", + "target": "/api/sessions?limit=20", + "websocket": false, + "principal_id": "55555555-5555-4555-8555-555555555555", + "display_name": "Test Person", + "assertion": "eyJhbGciOiJFZERTQSIsImtpZCI6InRlc3QtMjAyNi0xMCIsInR5cCI6Im5lcnZlLWFjdG9yLWNvbnRleHQrandzIn0.eyJ2ZXJzaW9uIjoiMSIsInNpZ25lcl9pZCI6InVybjpuZXJ2ZTpnYXRld2F5OmFjdG9yOjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMToyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJhdWRpZW5jZSI6InVybjpuZXJ2ZTpydW50aW1lOjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMToyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJ0ZW5hbnRfaWQiOiIxMTExMTExMS0xMTExLTQxMTEtODExMS0xMTExMTExMTExMTEiLCJhZ2VudF9pZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiIsInByaW5jaXBhbF9pZCI6IjU1NTU1NTU1LTU1NTUtNDU1NS04NTU1LTU1NTU1NTU1NTU1NSIsImFjdG9yX2tpbmQiOiJodW1hbiIsInByb2ZpbGUiOnsiZGlzcGxheV9uYW1lIjoiVGVzdCBQZXJzb24iLCJwcmltYXJ5X2VtYWlsIjoicGVyc29uQGV4YW1wbGUuY29tIiwidmVyc2lvbiI6M30sInNvdXJjZSI6IndlYiIsImF1dGhlbnRpY2F0aW9uX21ldGhvZCI6ImdhdGV3YXlfc2Vzc2lvbiIsInJlcXVlc3RfaWQiOiJyZXEtR0VUIiwiYXNzZXJ0aW9uX2lkIjoiOF85TlJSNUNuaGdpRmFydUJxTFdTdyIsImFkbWl0dGVkX2F0IjoxODAwMDAwMDAwLCJpc3N1ZWRfYXQiOjE4MDAwMDAwMDAsImV4cGlyZXNfYXQiOjE4MDAwMDAwMzAsInJlcXVlc3RfYmluZGluZyI6eyJraW5kIjoiaHR0cCIsIm1ldGhvZCI6IkdFVCIsInRhcmdldCI6Ii9hcGkvc2Vzc2lvbnM_bGltaXQ9MjAifSwiYXV0aG9yaXphdGlvbiI6eyJraW5kIjoicHJvamVjdGlvbiIsInJlZmVyZW5jZSI6N319.4tEXBdHlIMgiAIkBfS77qjTC_1O49a5BFsfLozPxRM3F_Q07XsBemkois6v2OD7ksalCsvBv2I1Rw_wtueZjCw" + }, + { + "name": "http_post_key_b_rotation_overlap", + "valid": true, + "now": 1800000000, + "method": "POST", + "target": "/api/chat", + "websocket": false, + "principal_id": "55555555-5555-4555-8555-555555555555", + "display_name": "Test Person", + "assertion": "eyJhbGciOiJFZERTQSIsImtpZCI6InRlc3QtMjAyNi0xMSIsInR5cCI6Im5lcnZlLWFjdG9yLWNvbnRleHQrandzIn0.eyJ2ZXJzaW9uIjoiMSIsInNpZ25lcl9pZCI6InVybjpuZXJ2ZTpnYXRld2F5OmFjdG9yOjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMToyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJhdWRpZW5jZSI6InVybjpuZXJ2ZTpydW50aW1lOjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMToyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJ0ZW5hbnRfaWQiOiIxMTExMTExMS0xMTExLTQxMTEtODExMS0xMTExMTExMTExMTEiLCJhZ2VudF9pZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiIsInByaW5jaXBhbF9pZCI6IjU1NTU1NTU1LTU1NTUtNDU1NS04NTU1LTU1NTU1NTU1NTU1NSIsImFjdG9yX2tpbmQiOiJodW1hbiIsInByb2ZpbGUiOnsiZGlzcGxheV9uYW1lIjoiVGVzdCBQZXJzb24iLCJwcmltYXJ5X2VtYWlsIjoicGVyc29uQGV4YW1wbGUuY29tIiwidmVyc2lvbiI6M30sInNvdXJjZSI6IndlYiIsImF1dGhlbnRpY2F0aW9uX21ldGhvZCI6ImdhdGV3YXlfc2Vzc2lvbiIsInJlcXVlc3RfaWQiOiJyZXEtUE9TVCIsImFzc2VydGlvbl9pZCI6ImJScXR5ZVVESGt1WnZ4R3VDbmx1dkEiLCJhZG1pdHRlZF9hdCI6MTgwMDAwMDAwMCwiaXNzdWVkX2F0IjoxODAwMDAwMDAwLCJleHBpcmVzX2F0IjoxODAwMDAwMDMwLCJyZXF1ZXN0X2JpbmRpbmciOnsia2luZCI6Imh0dHAiLCJtZXRob2QiOiJQT1NUIiwidGFyZ2V0IjoiL2FwaS9jaGF0In0sImF1dGhvcml6YXRpb24iOnsia2luZCI6InByb2plY3Rpb24iLCJyZWZlcmVuY2UiOjd9fQ.q799baLoqS_2i2hUW2_YLmsAQXxR762mcCRyFDHOgpLvYJ8yMmgINFWzwqosOwhNE01Ttb-SOdNMsjAX4kxdBQ" + }, + { + "name": "websocket_upgrade", + "valid": true, + "now": 1800000001, + "method": "GET", + "target": "/ws", + "websocket": true, + "principal_id": "55555555-5555-4555-8555-555555555555", + "display_name": "Test Person", + "assertion": "eyJhbGciOiJFZERTQSIsImtpZCI6InRlc3QtMjAyNi0xMCIsInR5cCI6Im5lcnZlLWFjdG9yLWNvbnRleHQrandzIn0.eyJ2ZXJzaW9uIjoiMSIsInNpZ25lcl9pZCI6InVybjpuZXJ2ZTpnYXRld2F5OmFjdG9yOjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMToyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJhdWRpZW5jZSI6InVybjpuZXJ2ZTpydW50aW1lOjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMToyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJ0ZW5hbnRfaWQiOiIxMTExMTExMS0xMTExLTQxMTEtODExMS0xMTExMTExMTExMTEiLCJhZ2VudF9pZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiIsInByaW5jaXBhbF9pZCI6IjU1NTU1NTU1LTU1NTUtNDU1NS04NTU1LTU1NTU1NTU1NTU1NSIsImFjdG9yX2tpbmQiOiJodW1hbiIsInByb2ZpbGUiOnsiZGlzcGxheV9uYW1lIjoiVGVzdCBQZXJzb24iLCJwcmltYXJ5X2VtYWlsIjoicGVyc29uQGV4YW1wbGUuY29tIiwidmVyc2lvbiI6M30sInNvdXJjZSI6IndlYiIsImF1dGhlbnRpY2F0aW9uX21ldGhvZCI6ImdhdGV3YXlfc2Vzc2lvbiIsInJlcXVlc3RfaWQiOiJyZXEtR0VUIiwiYXNzZXJ0aW9uX2lkIjoiUk1oZjBYU196UFE4dGZWaHpRQkE2QSIsImdhdGV3YXlfY29ubmVjdGlvbl9pZCI6IlZtSUpPRnhtQWQyel9CUnl1SUhabkEiLCJhZG1pdHRlZF9hdCI6MTgwMDAwMDAwMCwiaXNzdWVkX2F0IjoxODAwMDAwMDAwLCJleHBpcmVzX2F0IjoxODAwMDAwMDMwLCJyZXF1ZXN0X2JpbmRpbmciOnsia2luZCI6IndlYnNvY2tldCIsIm1ldGhvZCI6IkdFVCIsInRhcmdldCI6Ii93cyJ9LCJhdXRob3JpemF0aW9uIjp7ImtpbmQiOiJwcm9qZWN0aW9uIiwicmVmZXJlbmNlIjo3fX0.goWsLas5df62N_u1w5TWOejdKPiiEy32lOUVhJ9m_RB_mmXRDkTkJfnvmTh_r9wRLHgTIp5G7oFlXwwMuZfsCA" + }, + { + "name": "http_bare_trailing_question_mark", + "valid": true, + "now": 1800000000, + "method": "GET", + "target": "/api/plans?", + "websocket": false, + "principal_id": "55555555-5555-4555-8555-555555555555", + "display_name": "Test Person", + "assertion": "eyJhbGciOiJFZERTQSIsImtpZCI6InRlc3QtMjAyNi0xMCIsInR5cCI6Im5lcnZlLWFjdG9yLWNvbnRleHQrandzIn0.eyJ2ZXJzaW9uIjoiMSIsInNpZ25lcl9pZCI6InVybjpuZXJ2ZTpnYXRld2F5OmFjdG9yOjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMToyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJhdWRpZW5jZSI6InVybjpuZXJ2ZTpydW50aW1lOjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMToyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJ0ZW5hbnRfaWQiOiIxMTExMTExMS0xMTExLTQxMTEtODExMS0xMTExMTExMTExMTEiLCJhZ2VudF9pZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiIsInByaW5jaXBhbF9pZCI6IjU1NTU1NTU1LTU1NTUtNDU1NS04NTU1LTU1NTU1NTU1NTU1NSIsImFjdG9yX2tpbmQiOiJodW1hbiIsInByb2ZpbGUiOnsiZGlzcGxheV9uYW1lIjoiVGVzdCBQZXJzb24iLCJwcmltYXJ5X2VtYWlsIjoicGVyc29uQGV4YW1wbGUuY29tIiwidmVyc2lvbiI6M30sInNvdXJjZSI6IndlYiIsImF1dGhlbnRpY2F0aW9uX21ldGhvZCI6ImdhdGV3YXlfc2Vzc2lvbiIsInJlcXVlc3RfaWQiOiJyZXEtR0VUIiwiYXNzZXJ0aW9uX2lkIjoiaENnWVBELXVjV2Jzdlh6RHVpYkZYZyIsImFkbWl0dGVkX2F0IjoxODAwMDAwMDAwLCJpc3N1ZWRfYXQiOjE4MDAwMDAwMDAsImV4cGlyZXNfYXQiOjE4MDAwMDAwMzAsInJlcXVlc3RfYmluZGluZyI6eyJraW5kIjoiaHR0cCIsIm1ldGhvZCI6IkdFVCIsInRhcmdldCI6Ii9hcGkvcGxhbnM_In0sImF1dGhvcml6YXRpb24iOnsia2luZCI6InByb2plY3Rpb24iLCJyZWZlcmVuY2UiOjd9fQ.1VhzYGjQEepbwFkFXigFhqEHRGFr4u0vrBVWtD6FQK0VK8esvKx5FMUnLHT8DT3qWpMkAdkFyr_bq1A7gKIFBw" + }, + { + "name": "api_token_source", + "valid": true, + "now": 1800000000, + "method": "GET", + "target": "/api/tasks", + "websocket": false, + "principal_id": "55555555-5555-4555-8555-555555555555", + "display_name": "Test Person", + "assertion": "eyJhbGciOiJFZERTQSIsImtpZCI6InRlc3QtMjAyNi0xMCIsInR5cCI6Im5lcnZlLWFjdG9yLWNvbnRleHQrandzIn0.eyJ2ZXJzaW9uIjoiMSIsInNpZ25lcl9pZCI6InVybjpuZXJ2ZTpnYXRld2F5OmFjdG9yOjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMToyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJhdWRpZW5jZSI6InVybjpuZXJ2ZTpydW50aW1lOjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMToyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJ0ZW5hbnRfaWQiOiIxMTExMTExMS0xMTExLTQxMTEtODExMS0xMTExMTExMTExMTEiLCJhZ2VudF9pZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiIsInByaW5jaXBhbF9pZCI6IjU1NTU1NTU1LTU1NTUtNDU1NS04NTU1LTU1NTU1NTU1NTU1NSIsImFjdG9yX2tpbmQiOiJodW1hbiIsInByb2ZpbGUiOnsiZGlzcGxheV9uYW1lIjoiVGVzdCBQZXJzb24iLCJwcmltYXJ5X2VtYWlsIjoicGVyc29uQGV4YW1wbGUuY29tIiwidmVyc2lvbiI6M30sInNvdXJjZSI6ImFwaSIsImF1dGhlbnRpY2F0aW9uX21ldGhvZCI6ImFwaV90b2tlbiIsInJlcXVlc3RfaWQiOiJyZXEtR0VUIiwiYXNzZXJ0aW9uX2lkIjoiTDFGcHlTOHZScEg2NG8wQTkwN0s4ZyIsImFkbWl0dGVkX2F0IjoxODAwMDAwMDAwLCJpc3N1ZWRfYXQiOjE4MDAwMDAwMDAsImV4cGlyZXNfYXQiOjE4MDAwMDAwMzAsInJlcXVlc3RfYmluZGluZyI6eyJraW5kIjoiaHR0cCIsIm1ldGhvZCI6IkdFVCIsInRhcmdldCI6Ii9hcGkvdGFza3MifSwiYXV0aG9yaXphdGlvbiI6eyJraW5kIjoicHJvamVjdGlvbiIsInJlZmVyZW5jZSI6N319.uJihjgKTRKWqW0AKA400ba506kbb8HJptmuCJ31MBZV-EFnp0uivjpVN78NmmqEvCK9796auE3WZgnGeuvJtBw" + }, + { + "name": "at_expiry_edge_within_skew", + "valid": true, + "now": 1800000059, + "method": "GET", + "target": "/api/sessions?limit=20", + "websocket": false, + "principal_id": "55555555-5555-4555-8555-555555555555", + "display_name": "Test Person", + "assertion": "eyJhbGciOiJFZERTQSIsImtpZCI6InRlc3QtMjAyNi0xMCIsInR5cCI6Im5lcnZlLWFjdG9yLWNvbnRleHQrandzIn0.eyJ2ZXJzaW9uIjoiMSIsInNpZ25lcl9pZCI6InVybjpuZXJ2ZTpnYXRld2F5OmFjdG9yOjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMToyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJhdWRpZW5jZSI6InVybjpuZXJ2ZTpydW50aW1lOjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMToyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJ0ZW5hbnRfaWQiOiIxMTExMTExMS0xMTExLTQxMTEtODExMS0xMTExMTExMTExMTEiLCJhZ2VudF9pZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiIsInByaW5jaXBhbF9pZCI6IjU1NTU1NTU1LTU1NTUtNDU1NS04NTU1LTU1NTU1NTU1NTU1NSIsImFjdG9yX2tpbmQiOiJodW1hbiIsInByb2ZpbGUiOnsiZGlzcGxheV9uYW1lIjoiVGVzdCBQZXJzb24iLCJwcmltYXJ5X2VtYWlsIjoicGVyc29uQGV4YW1wbGUuY29tIiwidmVyc2lvbiI6M30sInNvdXJjZSI6IndlYiIsImF1dGhlbnRpY2F0aW9uX21ldGhvZCI6ImdhdGV3YXlfc2Vzc2lvbiIsInJlcXVlc3RfaWQiOiJyZXEtR0VUIiwiYXNzZXJ0aW9uX2lkIjoiVEtRY0RWZDBkOFN5WVZwN0I5amRDZyIsImFkbWl0dGVkX2F0IjoxODAwMDAwMDAwLCJpc3N1ZWRfYXQiOjE4MDAwMDAwMDAsImV4cGlyZXNfYXQiOjE4MDAwMDAwMzAsInJlcXVlc3RfYmluZGluZyI6eyJraW5kIjoiaHR0cCIsIm1ldGhvZCI6IkdFVCIsInRhcmdldCI6Ii9hcGkvc2Vzc2lvbnM_bGltaXQ9MjAifSwiYXV0aG9yaXphdGlvbiI6eyJraW5kIjoicHJvamVjdGlvbiIsInJlZmVyZW5jZSI6N319.Ot3bHBtbT_A01MqsI_h-W-w1IwY2QDHMXyHtV7UlhgnAiBtbqNPGn6odVBktYXpkSCLHXzbqMxff7wbuq7_kCg" + }, + { + "name": "unknown_kid", + "valid": false, + "reason": "kid test-unpublished is not in the key set", + "now": 1800000000, + "method": "GET", + "target": "/api/sessions", + "websocket": false, + "principal_id": "55555555-5555-4555-8555-555555555555", + "display_name": "", + "assertion": "eyJhbGciOiJFZERTQSIsImtpZCI6InRlc3QtdW5wdWJsaXNoZWQiLCJ0eXAiOiJuZXJ2ZS1hY3Rvci1jb250ZXh0K2p3cyJ9.eyJ2ZXJzaW9uIjoiMSIsInNpZ25lcl9pZCI6InVybjpuZXJ2ZTpnYXRld2F5OmFjdG9yOjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMToyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJhdWRpZW5jZSI6InVybjpuZXJ2ZTpydW50aW1lOjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMToyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJ0ZW5hbnRfaWQiOiIxMTExMTExMS0xMTExLTQxMTEtODExMS0xMTExMTExMTExMTEiLCJhZ2VudF9pZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiIsInByaW5jaXBhbF9pZCI6IjU1NTU1NTU1LTU1NTUtNDU1NS04NTU1LTU1NTU1NTU1NTU1NSIsImFjdG9yX2tpbmQiOiJodW1hbiIsInByb2ZpbGUiOnsiZGlzcGxheV9uYW1lIjoiVGVzdCBQZXJzb24iLCJwcmltYXJ5X2VtYWlsIjoicGVyc29uQGV4YW1wbGUuY29tIiwidmVyc2lvbiI6M30sInNvdXJjZSI6IndlYiIsImF1dGhlbnRpY2F0aW9uX21ldGhvZCI6ImdhdGV3YXlfc2Vzc2lvbiIsInJlcXVlc3RfaWQiOiJyZXEtR0VUIiwiYXNzZXJ0aW9uX2lkIjoidTQ5Z0p2QldESXZuTlFrdWI5RXlKZyIsImFkbWl0dGVkX2F0IjoxODAwMDAwMDAwLCJpc3N1ZWRfYXQiOjE4MDAwMDAwMDAsImV4cGlyZXNfYXQiOjE4MDAwMDAwMzAsInJlcXVlc3RfYmluZGluZyI6eyJraW5kIjoiaHR0cCIsIm1ldGhvZCI6IkdFVCIsInRhcmdldCI6Ii9hcGkvc2Vzc2lvbnMifSwiYXV0aG9yaXphdGlvbiI6eyJraW5kIjoicHJvamVjdGlvbiIsInJlZmVyZW5jZSI6N319.8UOUEr-pR9IJX6QIEhDObIkrToVPIBksdl6fyE-n2Wm2-2sj7gzhdIDrM7xjkKhzeJ0RhrCN2GXroMuz8F-2DQ" + }, + { + "name": "other_agent_scope", + "valid": false, + "reason": "agent_id, signer_id and audience name another agent", + "now": 1800000000, + "method": "GET", + "target": "/api/sessions", + "websocket": false, + "principal_id": "55555555-5555-4555-8555-555555555555", + "display_name": "", + "assertion": "eyJhbGciOiJFZERTQSIsImtpZCI6InRlc3QtMjAyNi0xMCIsInR5cCI6Im5lcnZlLWFjdG9yLWNvbnRleHQrandzIn0.eyJ2ZXJzaW9uIjoiMSIsInNpZ25lcl9pZCI6InVybjpuZXJ2ZTpnYXRld2F5OmFjdG9yOjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMTozMzMzMzMzMy0zMzMzLTQzMzMtODMzMy0zMzMzMzMzMzMzMzMiLCJhdWRpZW5jZSI6InVybjpuZXJ2ZTpydW50aW1lOjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMTozMzMzMzMzMy0zMzMzLTQzMzMtODMzMy0zMzMzMzMzMzMzMzMiLCJ0ZW5hbnRfaWQiOiIxMTExMTExMS0xMTExLTQxMTEtODExMS0xMTExMTExMTExMTEiLCJhZ2VudF9pZCI6IjMzMzMzMzMzLTMzMzMtNDMzMy04MzMzLTMzMzMzMzMzMzMzMyIsInByaW5jaXBhbF9pZCI6IjU1NTU1NTU1LTU1NTUtNDU1NS04NTU1LTU1NTU1NTU1NTU1NSIsImFjdG9yX2tpbmQiOiJodW1hbiIsInByb2ZpbGUiOnsiZGlzcGxheV9uYW1lIjoiVGVzdCBQZXJzb24iLCJwcmltYXJ5X2VtYWlsIjoicGVyc29uQGV4YW1wbGUuY29tIiwidmVyc2lvbiI6M30sInNvdXJjZSI6IndlYiIsImF1dGhlbnRpY2F0aW9uX21ldGhvZCI6ImdhdGV3YXlfc2Vzc2lvbiIsInJlcXVlc3RfaWQiOiJyZXEtR0VUIiwiYXNzZXJ0aW9uX2lkIjoiYU45TmF4WEZKQ0pWVGl5blh5d3dVdyIsImFkbWl0dGVkX2F0IjoxODAwMDAwMDAwLCJpc3N1ZWRfYXQiOjE4MDAwMDAwMDAsImV4cGlyZXNfYXQiOjE4MDAwMDAwMzAsInJlcXVlc3RfYmluZGluZyI6eyJraW5kIjoiaHR0cCIsIm1ldGhvZCI6IkdFVCIsInRhcmdldCI6Ii9hcGkvc2Vzc2lvbnMifSwiYXV0aG9yaXphdGlvbiI6eyJraW5kIjoicHJvamVjdGlvbiIsInJlZmVyZW5jZSI6N319.Lh8YVj8E-nQHtY74yxRZf_SunEeiXvE5vVdUX_IfQF4DJCofBse93B3srKgJ7MZRmqxumD20aqfoUtFAoZCyAg" + }, + { + "name": "other_tenant_scope", + "valid": false, + "reason": "tenant_id, signer_id and audience name another tenant", + "now": 1800000000, + "method": "GET", + "target": "/api/sessions", + "websocket": false, + "principal_id": "55555555-5555-4555-8555-555555555555", + "display_name": "", + "assertion": "eyJhbGciOiJFZERTQSIsImtpZCI6InRlc3QtMjAyNi0xMCIsInR5cCI6Im5lcnZlLWFjdG9yLWNvbnRleHQrandzIn0.eyJ2ZXJzaW9uIjoiMSIsInNpZ25lcl9pZCI6InVybjpuZXJ2ZTpnYXRld2F5OmFjdG9yOjQ0NDQ0NDQ0LTQ0NDQtNDQ0NC04NDQ0LTQ0NDQ0NDQ0NDQ0NDoyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJhdWRpZW5jZSI6InVybjpuZXJ2ZTpydW50aW1lOjQ0NDQ0NDQ0LTQ0NDQtNDQ0NC04NDQ0LTQ0NDQ0NDQ0NDQ0NDoyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJ0ZW5hbnRfaWQiOiI0NDQ0NDQ0NC00NDQ0LTQ0NDQtODQ0NC00NDQ0NDQ0NDQ0NDQiLCJhZ2VudF9pZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiIsInByaW5jaXBhbF9pZCI6IjU1NTU1NTU1LTU1NTUtNDU1NS04NTU1LTU1NTU1NTU1NTU1NSIsImFjdG9yX2tpbmQiOiJodW1hbiIsInByb2ZpbGUiOnsiZGlzcGxheV9uYW1lIjoiVGVzdCBQZXJzb24iLCJwcmltYXJ5X2VtYWlsIjoicGVyc29uQGV4YW1wbGUuY29tIiwidmVyc2lvbiI6M30sInNvdXJjZSI6IndlYiIsImF1dGhlbnRpY2F0aW9uX21ldGhvZCI6ImdhdGV3YXlfc2Vzc2lvbiIsInJlcXVlc3RfaWQiOiJyZXEtR0VUIiwiYXNzZXJ0aW9uX2lkIjoiSEFEZGt0VG4xYkxFbE5GUXJRVVRjUSIsImFkbWl0dGVkX2F0IjoxODAwMDAwMDAwLCJpc3N1ZWRfYXQiOjE4MDAwMDAwMDAsImV4cGlyZXNfYXQiOjE4MDAwMDAwMzAsInJlcXVlc3RfYmluZGluZyI6eyJraW5kIjoiaHR0cCIsIm1ldGhvZCI6IkdFVCIsInRhcmdldCI6Ii9hcGkvc2Vzc2lvbnMifSwiYXV0aG9yaXphdGlvbiI6eyJraW5kIjoicHJvamVjdGlvbiIsInJlZmVyZW5jZSI6N319.Y3n19RuN06buBDE2YUW-NIEzS6yxweTnv1bPiBZUpKB6hgVW1ZoWPzmuC_sKCtI_ToBpdn54j-M_Do5OUegKDA" + }, + { + "name": "expired_beyond_skew", + "valid": false, + "reason": "expires_at + skew is before now", + "now": 1800000061, + "method": "GET", + "target": "/api/sessions", + "websocket": false, + "principal_id": "55555555-5555-4555-8555-555555555555", + "display_name": "", + "assertion": "eyJhbGciOiJFZERTQSIsImtpZCI6InRlc3QtMjAyNi0xMCIsInR5cCI6Im5lcnZlLWFjdG9yLWNvbnRleHQrandzIn0.eyJ2ZXJzaW9uIjoiMSIsInNpZ25lcl9pZCI6InVybjpuZXJ2ZTpnYXRld2F5OmFjdG9yOjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMToyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJhdWRpZW5jZSI6InVybjpuZXJ2ZTpydW50aW1lOjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMToyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJ0ZW5hbnRfaWQiOiIxMTExMTExMS0xMTExLTQxMTEtODExMS0xMTExMTExMTExMTEiLCJhZ2VudF9pZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiIsInByaW5jaXBhbF9pZCI6IjU1NTU1NTU1LTU1NTUtNDU1NS04NTU1LTU1NTU1NTU1NTU1NSIsImFjdG9yX2tpbmQiOiJodW1hbiIsInByb2ZpbGUiOnsiZGlzcGxheV9uYW1lIjoiVGVzdCBQZXJzb24iLCJwcmltYXJ5X2VtYWlsIjoicGVyc29uQGV4YW1wbGUuY29tIiwidmVyc2lvbiI6M30sInNvdXJjZSI6IndlYiIsImF1dGhlbnRpY2F0aW9uX21ldGhvZCI6ImdhdGV3YXlfc2Vzc2lvbiIsInJlcXVlc3RfaWQiOiJyZXEtR0VUIiwiYXNzZXJ0aW9uX2lkIjoicnRhdzZ1R25janhWTHlvVnRXLVJLUSIsImFkbWl0dGVkX2F0IjoxODAwMDAwMDAwLCJpc3N1ZWRfYXQiOjE4MDAwMDAwMDAsImV4cGlyZXNfYXQiOjE4MDAwMDAwMzAsInJlcXVlc3RfYmluZGluZyI6eyJraW5kIjoiaHR0cCIsIm1ldGhvZCI6IkdFVCIsInRhcmdldCI6Ii9hcGkvc2Vzc2lvbnMifSwiYXV0aG9yaXphdGlvbiI6eyJraW5kIjoicHJvamVjdGlvbiIsInJlZmVyZW5jZSI6N319.LUQ-nzUQmDNlzT-EWDdFxejcnjKFtYOwhjc8lCxidv6K_HGxnY-Eujocot9SU3EnBI5DUIaMuM6VLV8MI77BDA" + }, + { + "name": "issued_in_the_future_beyond_skew", + "valid": false, + "reason": "issued_at - skew is after now", + "now": 1799999969, + "method": "GET", + "target": "/api/sessions", + "websocket": false, + "principal_id": "55555555-5555-4555-8555-555555555555", + "display_name": "", + "assertion": "eyJhbGciOiJFZERTQSIsImtpZCI6InRlc3QtMjAyNi0xMCIsInR5cCI6Im5lcnZlLWFjdG9yLWNvbnRleHQrandzIn0.eyJ2ZXJzaW9uIjoiMSIsInNpZ25lcl9pZCI6InVybjpuZXJ2ZTpnYXRld2F5OmFjdG9yOjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMToyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJhdWRpZW5jZSI6InVybjpuZXJ2ZTpydW50aW1lOjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMToyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJ0ZW5hbnRfaWQiOiIxMTExMTExMS0xMTExLTQxMTEtODExMS0xMTExMTExMTExMTEiLCJhZ2VudF9pZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiIsInByaW5jaXBhbF9pZCI6IjU1NTU1NTU1LTU1NTUtNDU1NS04NTU1LTU1NTU1NTU1NTU1NSIsImFjdG9yX2tpbmQiOiJodW1hbiIsInByb2ZpbGUiOnsiZGlzcGxheV9uYW1lIjoiVGVzdCBQZXJzb24iLCJwcmltYXJ5X2VtYWlsIjoicGVyc29uQGV4YW1wbGUuY29tIiwidmVyc2lvbiI6M30sInNvdXJjZSI6IndlYiIsImF1dGhlbnRpY2F0aW9uX21ldGhvZCI6ImdhdGV3YXlfc2Vzc2lvbiIsInJlcXVlc3RfaWQiOiJyZXEtR0VUIiwiYXNzZXJ0aW9uX2lkIjoiMmVNUlhaQ2VCYXg0ajBfZTZNLUIzZyIsImFkbWl0dGVkX2F0IjoxODAwMDAwMDAwLCJpc3N1ZWRfYXQiOjE4MDAwMDAwMDAsImV4cGlyZXNfYXQiOjE4MDAwMDAwMzAsInJlcXVlc3RfYmluZGluZyI6eyJraW5kIjoiaHR0cCIsIm1ldGhvZCI6IkdFVCIsInRhcmdldCI6Ii9hcGkvc2Vzc2lvbnMifSwiYXV0aG9yaXphdGlvbiI6eyJraW5kIjoicHJvamVjdGlvbiIsInJlZmVyZW5jZSI6N319.m68xliBfH5fgg1BI2wmrvW96drXnCwi4NuQMxsrGxsMikDWo5RhGEjDFH4K-nM5Dm8rNGU81H8Q_jXH8vfmOAA" + }, + { + "name": "method_mismatch", + "valid": false, + "reason": "assertion binds POST, request is GET", + "now": 1800000000, + "method": "GET", + "target": "/api/chat", + "websocket": false, + "principal_id": "55555555-5555-4555-8555-555555555555", + "display_name": "", + "assertion": "eyJhbGciOiJFZERTQSIsImtpZCI6InRlc3QtMjAyNi0xMCIsInR5cCI6Im5lcnZlLWFjdG9yLWNvbnRleHQrandzIn0.eyJ2ZXJzaW9uIjoiMSIsInNpZ25lcl9pZCI6InVybjpuZXJ2ZTpnYXRld2F5OmFjdG9yOjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMToyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJhdWRpZW5jZSI6InVybjpuZXJ2ZTpydW50aW1lOjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMToyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJ0ZW5hbnRfaWQiOiIxMTExMTExMS0xMTExLTQxMTEtODExMS0xMTExMTExMTExMTEiLCJhZ2VudF9pZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiIsInByaW5jaXBhbF9pZCI6IjU1NTU1NTU1LTU1NTUtNDU1NS04NTU1LTU1NTU1NTU1NTU1NSIsImFjdG9yX2tpbmQiOiJodW1hbiIsInByb2ZpbGUiOnsiZGlzcGxheV9uYW1lIjoiVGVzdCBQZXJzb24iLCJwcmltYXJ5X2VtYWlsIjoicGVyc29uQGV4YW1wbGUuY29tIiwidmVyc2lvbiI6M30sInNvdXJjZSI6IndlYiIsImF1dGhlbnRpY2F0aW9uX21ldGhvZCI6ImdhdGV3YXlfc2Vzc2lvbiIsInJlcXVlc3RfaWQiOiJyZXEtUE9TVCIsImFzc2VydGlvbl9pZCI6Ii1PSDZrRHNUTmo1Ri1RM1hyNjNxM3ciLCJhZG1pdHRlZF9hdCI6MTgwMDAwMDAwMCwiaXNzdWVkX2F0IjoxODAwMDAwMDAwLCJleHBpcmVzX2F0IjoxODAwMDAwMDMwLCJyZXF1ZXN0X2JpbmRpbmciOnsia2luZCI6Imh0dHAiLCJtZXRob2QiOiJQT1NUIiwidGFyZ2V0IjoiL2FwaS9jaGF0In0sImF1dGhvcml6YXRpb24iOnsia2luZCI6InByb2plY3Rpb24iLCJyZWZlcmVuY2UiOjd9fQ.vkZPMFnI4lpBPn53nAdl4Nvy3qUuA_N3FdRcCbzmRlavvgi_BjGUzIiTW9bZQBA7zR8A03jFo9mqwoM8McVmBA" + }, + { + "name": "target_mismatch", + "valid": false, + "reason": "assertion binds /api/sessions?limit=20, request is /api/sessions?limit=21", + "now": 1800000000, + "method": "GET", + "target": "/api/sessions?limit=21", + "websocket": false, + "principal_id": "55555555-5555-4555-8555-555555555555", + "display_name": "", + "assertion": "eyJhbGciOiJFZERTQSIsImtpZCI6InRlc3QtMjAyNi0xMCIsInR5cCI6Im5lcnZlLWFjdG9yLWNvbnRleHQrandzIn0.eyJ2ZXJzaW9uIjoiMSIsInNpZ25lcl9pZCI6InVybjpuZXJ2ZTpnYXRld2F5OmFjdG9yOjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMToyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJhdWRpZW5jZSI6InVybjpuZXJ2ZTpydW50aW1lOjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMToyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJ0ZW5hbnRfaWQiOiIxMTExMTExMS0xMTExLTQxMTEtODExMS0xMTExMTExMTExMTEiLCJhZ2VudF9pZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiIsInByaW5jaXBhbF9pZCI6IjU1NTU1NTU1LTU1NTUtNDU1NS04NTU1LTU1NTU1NTU1NTU1NSIsImFjdG9yX2tpbmQiOiJodW1hbiIsInByb2ZpbGUiOnsiZGlzcGxheV9uYW1lIjoiVGVzdCBQZXJzb24iLCJwcmltYXJ5X2VtYWlsIjoicGVyc29uQGV4YW1wbGUuY29tIiwidmVyc2lvbiI6M30sInNvdXJjZSI6IndlYiIsImF1dGhlbnRpY2F0aW9uX21ldGhvZCI6ImdhdGV3YXlfc2Vzc2lvbiIsInJlcXVlc3RfaWQiOiJyZXEtR0VUIiwiYXNzZXJ0aW9uX2lkIjoiSURUMG05V005RzZmYnA2RmlUV3BoQSIsImFkbWl0dGVkX2F0IjoxODAwMDAwMDAwLCJpc3N1ZWRfYXQiOjE4MDAwMDAwMDAsImV4cGlyZXNfYXQiOjE4MDAwMDAwMzAsInJlcXVlc3RfYmluZGluZyI6eyJraW5kIjoiaHR0cCIsIm1ldGhvZCI6IkdFVCIsInRhcmdldCI6Ii9hcGkvc2Vzc2lvbnM_bGltaXQ9MjAifSwiYXV0aG9yaXphdGlvbiI6eyJraW5kIjoicHJvamVjdGlvbiIsInJlZmVyZW5jZSI6N319.6dgaLHzSbzh8XZODoRprqWg9CaP2mNe9KR7G2v07Zek8o-cTK0z4o0jxe2YzyE-4v4_jjotoxhZ7fquQ1c8qCA" + }, + { + "name": "kind_mismatch_websocket_assertion_on_http", + "valid": false, + "reason": "assertion kind websocket, request is http", + "now": 1800000000, + "method": "GET", + "target": "/ws", + "websocket": false, + "principal_id": "55555555-5555-4555-8555-555555555555", + "display_name": "", + "assertion": "eyJhbGciOiJFZERTQSIsImtpZCI6InRlc3QtMjAyNi0xMCIsInR5cCI6Im5lcnZlLWFjdG9yLWNvbnRleHQrandzIn0.eyJ2ZXJzaW9uIjoiMSIsInNpZ25lcl9pZCI6InVybjpuZXJ2ZTpnYXRld2F5OmFjdG9yOjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMToyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJhdWRpZW5jZSI6InVybjpuZXJ2ZTpydW50aW1lOjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMToyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJ0ZW5hbnRfaWQiOiIxMTExMTExMS0xMTExLTQxMTEtODExMS0xMTExMTExMTExMTEiLCJhZ2VudF9pZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiIsInByaW5jaXBhbF9pZCI6IjU1NTU1NTU1LTU1NTUtNDU1NS04NTU1LTU1NTU1NTU1NTU1NSIsImFjdG9yX2tpbmQiOiJodW1hbiIsInByb2ZpbGUiOnsiZGlzcGxheV9uYW1lIjoiVGVzdCBQZXJzb24iLCJwcmltYXJ5X2VtYWlsIjoicGVyc29uQGV4YW1wbGUuY29tIiwidmVyc2lvbiI6M30sInNvdXJjZSI6IndlYiIsImF1dGhlbnRpY2F0aW9uX21ldGhvZCI6ImdhdGV3YXlfc2Vzc2lvbiIsInJlcXVlc3RfaWQiOiJyZXEtR0VUIiwiYXNzZXJ0aW9uX2lkIjoiSTdsSWhOaEVZWkFCOG52N0VCOEhOdyIsImdhdGV3YXlfY29ubmVjdGlvbl9pZCI6InRyZVRwWGlRMVFWc2hhTFBhejBseFEiLCJhZG1pdHRlZF9hdCI6MTgwMDAwMDAwMCwiaXNzdWVkX2F0IjoxODAwMDAwMDAwLCJleHBpcmVzX2F0IjoxODAwMDAwMDMwLCJyZXF1ZXN0X2JpbmRpbmciOnsia2luZCI6IndlYnNvY2tldCIsIm1ldGhvZCI6IkdFVCIsInRhcmdldCI6Ii93cyJ9LCJhdXRob3JpemF0aW9uIjp7ImtpbmQiOiJwcm9qZWN0aW9uIiwicmVmZXJlbmNlIjo3fX0.7dWWbuUtwmSsjBAGc10ktLEVOlYbfxm2InflM4Mm0YfoZhp-1SXgHYI6aBQzozCl5z_aW9euV8iYm1NFOH8DAg" + }, + { + "name": "kind_mismatch_http_assertion_on_websocket", + "valid": false, + "reason": "assertion kind http, request is a websocket upgrade", + "now": 1800000000, + "method": "GET", + "target": "/ws", + "websocket": true, + "principal_id": "55555555-5555-4555-8555-555555555555", + "display_name": "", + "assertion": "eyJhbGciOiJFZERTQSIsImtpZCI6InRlc3QtMjAyNi0xMCIsInR5cCI6Im5lcnZlLWFjdG9yLWNvbnRleHQrandzIn0.eyJ2ZXJzaW9uIjoiMSIsInNpZ25lcl9pZCI6InVybjpuZXJ2ZTpnYXRld2F5OmFjdG9yOjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMToyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJhdWRpZW5jZSI6InVybjpuZXJ2ZTpydW50aW1lOjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMToyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJ0ZW5hbnRfaWQiOiIxMTExMTExMS0xMTExLTQxMTEtODExMS0xMTExMTExMTExMTEiLCJhZ2VudF9pZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiIsInByaW5jaXBhbF9pZCI6IjU1NTU1NTU1LTU1NTUtNDU1NS04NTU1LTU1NTU1NTU1NTU1NSIsImFjdG9yX2tpbmQiOiJodW1hbiIsInByb2ZpbGUiOnsiZGlzcGxheV9uYW1lIjoiVGVzdCBQZXJzb24iLCJwcmltYXJ5X2VtYWlsIjoicGVyc29uQGV4YW1wbGUuY29tIiwidmVyc2lvbiI6M30sInNvdXJjZSI6IndlYiIsImF1dGhlbnRpY2F0aW9uX21ldGhvZCI6ImdhdGV3YXlfc2Vzc2lvbiIsInJlcXVlc3RfaWQiOiJyZXEtR0VUIiwiYXNzZXJ0aW9uX2lkIjoiTkdjaXJOSkFSTTNNYzRUVjBud1lXUSIsImFkbWl0dGVkX2F0IjoxODAwMDAwMDAwLCJpc3N1ZWRfYXQiOjE4MDAwMDAwMDAsImV4cGlyZXNfYXQiOjE4MDAwMDAwMzAsInJlcXVlc3RfYmluZGluZyI6eyJraW5kIjoiaHR0cCIsIm1ldGhvZCI6IkdFVCIsInRhcmdldCI6Ii93cyJ9LCJhdXRob3JpemF0aW9uIjp7ImtpbmQiOiJwcm9qZWN0aW9uIiwicmVmZXJlbmNlIjo3fX0.oO2kyuXldPt7dG8EUULsSTW3lWRzCAsZgV3E2fTvl1PLszofiJfB-wh61jb-eEesLomkN-PkIKwACPHT4yyyBA" + } + ] +} diff --git a/tests/test_actor_context_verify.py b/tests/test_actor_context_verify.py new file mode 100644 index 000000000..b650bc8d5 --- /dev/null +++ b/tests/test_actor_context_verify.py @@ -0,0 +1,1130 @@ +"""Verification of the gateway's actor context (``auth.external``). + +With ``auth.external.gateway_jwks_file`` set, external mode accepts the +``X-Nerve-Actor-Context`` header only with the gateway's Ed25519 signature and +claims for this tenant, agent and request. The golden vectors in +``fixtures/actor_context_vectors.json`` come from the gateway's own signer at a +fixed clock, with throw-away keys: each valid vector must be accepted and each +invalid one refused, at the vector's clock. The other tests sign with a key +made here, in the same format. +""" + +from __future__ import annotations + +import base64 +import json +import logging +import time +import uuid +from pathlib import Path +from types import SimpleNamespace +from typing import Annotated + +import httpx +import pytest +import pytest_asyncio +import yaml +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, + PublicFormat, +) +from fastapi import APIRouter, Depends, FastAPI +from fastapi.testclient import TestClient + +from nerve.cli import doctor_report +from nerve.config import ( + ExternalAuthConfig, + NerveConfig, + get_config, + load_config, + set_config, + validate_config_keys, +) +from nerve.config_reload import reload_all +from nerve.db import Database +from nerve.gateway import actor_context, auth, server +from nerve.gateway.actor_context import ( + ActorContextVerifier, + AssertionRejected, + KeyFileError, + KeysUnavailable, + parse_key_set, + read_key_file, +) +from nerve.gateway.auth import ( + ACTOR_CONTEXT_HEADER, + AUTH_MODE_ENV, + AUTH_MODE_EXTERNAL, + decode_actor_context, + pin_auth_mode, + require_auth, +) +from nerve.identity import Actor +from nerve.migrate import bootstrap_identity +from tests.test_external_auth import ( + _SECRET, + _close_code, + _no_lifespan, + _open_bootstrapped_db, + _RecordingEngine, +) + +VECTORS = json.loads( + (Path(__file__).parent / "fixtures" / "actor_context_vectors.json").read_text(encoding="utf-8"), +) +TENANT, AGENT = VECTORS["tenant_id"], VECTORS["agent_id"] +VALID = [vector for vector in VECTORS["vectors"] if vector["valid"]] +INVALID = [vector for vector in VECTORS["vectors"] if not vector["valid"]] +# The vectors' IDs have no letters, so a form without hyphens is the +# non-canonical spelling of the same UUID. +OTHER_SPELLING = {TENANT: TENANT.replace("-", ""), AGENT: AGENT.replace("-", "")} +ALICE = "6f1c2a8e-3b4d-4e5f-8a9b-0c1d2e3f4a5b" +BOB = "0a9b8c7d-6e5f-4a3b-9c2d-1e0f9a8b7c6d" +KID = "test-key" +REFUSED_DETAIL = "The actor context failed verification" + + +def _names(vectors: list[dict]) -> list[str]: + return [vector["name"] for vector in vectors] + + +def _vector(name: str) -> dict: + return next(vector for vector in VECTORS["vectors"] if vector["name"] == name) + + +def _b64(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +class Clock: + def __init__(self, now: float) -> None: + self.now = now + + def __call__(self) -> float: + return self.now + + +class Signer: + """Signs actor contexts in the gateway's format, with keys made here.""" + + def __init__(self, key_file: Path) -> None: + self.key_file = key_file + self.keys = {KID: Ed25519PrivateKey.generate()} + self.publish() + + def jwk(self, kid: str) -> dict: + public = self.keys[kid].public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + return {"kty": "OKP", "crv": "Ed25519", "alg": "EdDSA", "use": "sig", "kid": kid, "x": _b64(public)} + + def publish(self, *kids: str) -> None: + document = {"keys": [self.jwk(kid) for kid in kids or self.keys]} + self.key_file.write_text(json.dumps(document), encoding="utf-8") + + def add_key(self, kid: str) -> str: + self.keys[kid] = Ed25519PrivateKey.generate() + return kid + + @staticmethod + def claims(now: int, *, method="GET", target="/api/whoami", websocket=False, **changes) -> dict: + claims = { + "version": "1", + "signer_id": f"urn:nerve:gateway:actor:{TENANT}:{AGENT}", + "audience": f"urn:nerve:runtime:{TENANT}:{AGENT}", + "tenant_id": TENANT, + "agent_id": AGENT, + "principal_id": ALICE, + "actor_kind": "human", + "profile": {"display_name": "Alice", "primary_email": None, "version": 1}, + "source": "web", + "authentication_method": "gateway_session", + "request_id": "req-1", + "assertion_id": uuid.uuid4().hex, + "admitted_at": now, + "issued_at": now, + "expires_at": now + 30, + "request_binding": { + "kind": "websocket" if websocket else "http", "method": method, "target": target, + }, + "authorization": {"kind": "projection", "reference": 1}, + } + if websocket: + claims["gateway_connection_id"] = "connection-1" + claims.update(changes) + return claims + + def sign(self, claims: dict, *, kid: str | None = KID, key=None, **header) -> str: + protected = {"alg": "EdDSA", "typ": "nerve-actor-context+jws", **header} + if kid is not None: + protected["kid"] = kid + signing_input = f"{_b64(json.dumps(protected).encode())}.{_b64(json.dumps(claims).encode())}" + signature = (key or self.keys[kid or KID]).sign(signing_input.encode()) + return f"{signing_input}.{_b64(signature)}" + + def assertion(self, now: int, **claims) -> str: + return self.sign(self.claims(now, **claims)) + + +NOW = 1_800_000_000 + + +@pytest.fixture(autouse=True) +def _no_process_verifier(monkeypatch): + """Each test starts without the process verifier, and leaves none.""" + monkeypatch.setattr(auth, "_actor_verifier", None) + + +@pytest.fixture +def signer(tmp_path) -> Signer: + return Signer(tmp_path / "actor-keys.json") + + +def verifier(key_file: Path, clock: Clock | None = None, monotonic: Clock | None = None): + return ActorContextVerifier( + key_file, TENANT, AGENT, + now=clock or Clock(NOW), monotonic=monotonic or Clock(1000.0), + ) + + +def refused(check: ActorContextVerifier, value: str, **request) -> str: + request = {"method": "GET", "target": "/api/whoami", "websocket": False, **request} + with pytest.raises(AssertionRejected) as error: + check.verify(value, **request) + return str(error.value) + + +def accepted(check: ActorContextVerifier, value: str, **request) -> None: + request = {"method": "GET", "target": "/api/whoami", "websocket": False, **request} + check.verify(value, **request) + + +@pytest.fixture +def reads(monkeypatch) -> list[Path]: + """Every read of a key file by the verifier, in order.""" + paths: list[Path] = [] + read = actor_context.read_key_file + + def counted(path: Path): + paths.append(path) + return read(path) + + monkeypatch.setattr(actor_context, "read_key_file", counted) + return paths + + +# --------------------------------------------------------------------------- # +# The gateway's own assertions # +# --------------------------------------------------------------------------- # + + +class TestGoldenVectors: + @pytest.mark.parametrize("form", ["key_set", "ring"]) + @pytest.mark.parametrize("vector", VALID, ids=_names(VALID)) + def test_a_valid_vector_is_accepted(self, tmp_path, vector, form): + key_file = tmp_path / "keys.json" + key_file.write_text(json.dumps(VECTORS[form]), encoding="utf-8") + check = verifier(key_file, Clock(vector["now"])) + + check.verify( + vector["assertion"], + method=vector["method"], target=vector["target"], websocket=vector["websocket"], + ) + assert decode_actor_context(vector["assertion"]) == ( + vector["principal_id"], vector["display_name"], + ) + + @pytest.mark.parametrize("vector", INVALID, ids=_names(INVALID)) + def test_an_invalid_vector_is_refused(self, tmp_path, vector): + key_file = tmp_path / "keys.json" + key_file.write_text(json.dumps(VECTORS["key_set"]), encoding="utf-8") + check = verifier(key_file, Clock(vector["now"])) + + with pytest.raises(AssertionRejected): + check.verify( + vector["assertion"], + method=vector["method"], target=vector["target"], websocket=vector["websocket"], + ) + + def test_other_members_of_the_document_are_ignored(self): + """The ``ring`` form holds the same keys beside other members.""" + keys = parse_key_set(json.dumps(VECTORS["ring"]).encode()) + + assert set(keys) == set(parse_key_set(json.dumps(VECTORS["key_set"]).encode())) + assert set(keys) == {"test-2026-10", "test-2026-11"} + + +# --------------------------------------------------------------------------- # +# The checks # +# --------------------------------------------------------------------------- # + + +class TestSignature: + def test_a_changed_payload_is_refused(self, signer): + value = signer.assertion(NOW) + header, _payload, signature = value.split(".") + forged = _b64(json.dumps(Signer.claims(NOW, principal_id=BOB)).encode()) + + assert "signature" in refused(verifier(signer.key_file), f"{header}.{forged}.{signature}") + + def test_a_changed_signature_is_refused(self, signer): + header, payload, signature = signer.assertion(NOW).split(".") + raw = bytearray(base64.urlsafe_b64decode(signature + "==")) + raw[0] ^= 1 + + assert "signature" in refused(verifier(signer.key_file), f"{header}.{payload}.{_b64(bytes(raw))}") + + def test_a_signature_from_another_key_with_a_known_kid_is_refused(self, signer): + value = signer.sign(Signer.claims(NOW), key=Ed25519PrivateKey.generate()) + + assert "signature" in refused(verifier(signer.key_file), value) + + @pytest.mark.parametrize(("header", "problem"), [ + ({"typ": "JWT"}, "typ"), + ({"alg": "none"}, "algorithm"), + ({"alg": "HS256"}, "algorithm"), + ({"jwk": {"kty": "OKP", "crv": "Ed25519", "x": "AAAA"}}, "key"), + ({"jku": "https://keys.example/jwks.json"}, "key"), + ({"x5c": ["MIIB"]}, "key"), + ({"x5u": "https://keys.example/cert.pem"}, "key"), + ({"crit": ["exp"]}, "critical"), + ({"kid": None}, "kid"), + ({"kid": "k" * 129}, "kid"), + ], ids=["typ", "alg_none", "alg_hmac", "jwk", "jku", "x5c", "x5u", "crit", "no_kid", "long_kid"]) + def test_a_header_that_is_not_the_gateways_is_refused(self, signer, header, problem): + kid = header.pop("kid", KID) + value = signer.sign(Signer.claims(NOW), kid=kid, key=signer.keys[KID], **header) + + assert problem in refused(verifier(signer.key_file), value) + + @pytest.mark.parametrize("value", [ + "", "abc", "a.b", "a.b.c.d", "!!.e30.x", "e30.!!.x", "W10.e30.x", "a" * 9000, + ], ids=["empty", "one_part", "two_parts", "four_parts", "header_not_base64url", + "payload_not_base64url", "header_not_object", "oversized"]) + def test_garbage_is_refused(self, signer, value): + refused(verifier(signer.key_file), value) + + +class TestClaims: + @pytest.mark.parametrize(("changes", "problem"), [ + ({"version": "2"}, "version"), + ({"version": 1}, "version"), + ({"tenant_id": str(uuid.uuid4())}, "tenant_id"), + ({"agent_id": OTHER_SPELLING[AGENT]}, "agent_id"), + ({"signer_id": f"urn:nerve:gateway:actor:{TENANT}:{uuid.uuid4()}"}, "signer_id"), + ({"audience": f"urn:nerve:runtime:{TENANT}:{uuid.uuid4()}"}, "audience"), + ({"actor_kind": "service"}, "actor_kind"), + ({"request_binding": None}, "request_binding"), + ({"expires_at": NOW + 31}, "lifetime"), + ({"expires_at": NOW}, "lifetime"), + ({"issued_at": str(NOW)}, "integer"), + ({"expires_at": float(NOW + 30)}, "integer"), + ({"issued_at": True}, "integer"), + ({"assertion_id": ""}, "assertion_id"), + ], ids=["version", "version_number", "tenant", "agent_other_spelling", "signer", "audience", + "actor_kind", "no_binding", "lifetime_31s", "lifetime_0s", "issued_at_string", + "expires_at_float", "issued_at_bool", "no_assertion_id"]) + def test_a_claim_for_another_agent_or_shape_is_refused(self, signer, changes, problem): + value = signer.sign(Signer.claims(NOW, **changes)) + + assert problem in refused(verifier(signer.key_file), value) + + @pytest.mark.parametrize(("now", "ok"), [ + (NOW - 30, True), (NOW - 31, False), (NOW + 60, True), (NOW + 61, False), + ], ids=["issued_30s_ahead", "issued_31s_ahead", "expired_30s_ago", "expired_31s_ago"]) + def test_the_clock_may_differ_by_30_seconds(self, signer, now, ok): + check = verifier(signer.key_file, Clock(now)) + value = signer.assertion(NOW) + + if ok: + accepted(check, value) + else: + refused(check, value) + + @pytest.mark.parametrize(("bound", "target", "ok"), [ + ("/api/plans", "/api/plans", True), + ("/api/plans?", "/api/plans", True), + ("/api/plans?a=1&b=2", "/api/plans?a=1&b=2", True), + ("/api/plans?", "/api/plans?a=1", False), + ("/api/plans", "/api/plans?a=1", False), + ("/api/plans?b=2&a=1", "/api/plans?a=1&b=2", False), + ("/api/plan", "/api/plans", False), + ], ids=["exact", "bare_question_mark", "query", "query_added", "query_added_to_path", + "query_reordered", "other_path"]) + def test_the_target_is_the_request_target(self, signer, bound, target, ok): + check = verifier(signer.key_file) + value = signer.assertion(NOW, target=bound) + + if ok: + accepted(check, value, target=target) + else: + assert "target" in refused(check, value, target=target) + + def test_a_websocket_assertion_needs_a_connection_id(self, signer): + check = verifier(signer.key_file) + claims = Signer.claims(NOW, target="/ws", websocket=True) + del claims["gateway_connection_id"] + + assert "gateway_connection_id" in refused( + check, signer.sign(claims), target="/ws", websocket=True, + ) + accepted(check, signer.assertion(NOW, target="/ws", websocket=True), target="/ws", websocket=True) + + def test_the_reason_names_the_kid_and_the_request_but_not_the_target(self, signer): + value = signer.assertion(NOW, target="/api/files?token=s3cr3t-1") + + reason = refused(verifier(signer.key_file), value, target="/api/files?token=s3cr3t-2") + + assert f"kid {KID!r}" in reason and "request 'req-1'" in reason + assert "s3cr3t" not in reason and "/api/files" not in reason + + +class TestReplay: + def test_an_assertion_is_accepted_once(self, signer): + check = verifier(signer.key_file) + value = signer.assertion(NOW) + + accepted(check, value) + assert "already used" in refused(check, value) + + def test_a_refused_assertion_is_not_remembered(self, signer): + check = verifier(signer.key_file) + value = signer.assertion(NOW, method="POST") + + refused(check, value, method="GET") + accepted(check, value, method="POST") + + def test_a_full_cache_forgets_expired_ids_before_the_oldest_live_one(self, signer, monkeypatch): + monkeypatch.setattr(actor_context, "MAXIMUM_REMEMBERED_ASSERTIONS", 2) + clock = Clock(NOW) + check = verifier(signer.key_file, clock) + oldest = signer.assertion(NOW) + accepted(check, oldest) + accepted(check, signer.assertion(NOW - 25)) # expires at NOW + 5 + + clock.now = NOW + 40 # the second one has expired, the oldest has not + accepted(check, signer.assertion(NOW + 40)) + assert "already used" in refused(check, oldest) + + accepted(check, signer.assertion(NOW + 40)) # full of live IDs: the oldest goes + accepted(check, oldest) + + +# --------------------------------------------------------------------------- # +# The key file # +# --------------------------------------------------------------------------- # + + +class TestKeyFile: + def test_an_unknown_kid_rereads_the_file_at_most_once_a_minute(self, signer, reads): + monotonic = Clock(1000.0) + check = verifier(signer.key_file, monotonic=monotonic) + rotated = signer.add_key("test-key-2") + value = signer.sign(Signer.claims(NOW), kid=rotated) + + refused(check, value) + monotonic.now += 30 + refused(check, value) + refused(check, signer.sign(Signer.claims(NOW), kid="no-such-key", key=signer.keys[KID])) + + assert len(reads) == 1 + signer.publish() + monotonic.now += 31 + accepted(check, value) + assert len(reads) == 2 + + def test_a_known_kid_does_not_reread_the_file(self, signer, reads): + monotonic = Clock(1000.0) + check = verifier(signer.key_file, monotonic=monotonic) + signer.key_file.write_text("{not json", encoding="utf-8") + + monotonic.now += 3600 + accepted(check, signer.assertion(NOW)) + + assert len(reads) == 1 + + def test_a_failed_reread_keeps_the_last_good_keys(self, signer, reads): + monotonic = Clock(1000.0) + check = verifier(signer.key_file, monotonic=monotonic) + signer.key_file.write_text('{"keys": []}', encoding="utf-8") + + monotonic.now += 61 + refused(check, signer.sign(Signer.claims(NOW), kid="unknown-kid", key=signer.keys[KID])) + with pytest.raises(KeyFileError, match="has no keys"): + check.reload_keys() + + assert len(reads) == 3 + accepted(check, signer.assertion(NOW)) + + def test_a_reload_removes_a_key(self, signer): + check = verifier(signer.key_file) + rotated = signer.add_key("test-key-2") + signer.publish(rotated) + + assert check.reload_keys() == 1 + assert "not in the key set" in refused(check, signer.assertion(NOW)) + accepted(check, signer.sign(Signer.claims(NOW), kid=rotated)) + + def test_without_a_usable_file_there_are_no_keys_until_a_read_succeeds(self, tmp_path, signer, caplog): + missing = tmp_path / "later.json" + monotonic = Clock(1000.0) + with caplog.at_level(logging.WARNING, logger="nerve.gateway.actor_context"): + check = verifier(missing, monotonic=monotonic) + + assert "cannot read" in caplog.text + with pytest.raises(KeysUnavailable): + accepted(check, signer.assertion(NOW)) + missing.write_text(signer.key_file.read_text(encoding="utf-8"), encoding="utf-8") + with pytest.raises(KeysUnavailable): + accepted(check, signer.assertion(NOW)) + monotonic.now += 61 + accepted(check, signer.assertion(NOW)) + + def test_a_file_with_a_private_key_is_refused(self, signer): + private = signer.keys[KID].private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption()) + document = {"keys": [{**signer.jwk(KID), "d": _b64(private)}]} + signer.key_file.write_text(json.dumps(document), encoding="utf-8") + + with pytest.raises(KeyFileError, match="private key members") as error: + read_key_file(signer.key_file) + assert _b64(private) not in str(error.value) + with pytest.raises(KeysUnavailable): + accepted(verifier(signer.key_file), signer.assertion(NOW)) + + @pytest.mark.parametrize(("change", "problem"), [ + ({"kty": "EC", "crv": "P-256"}, "not an Ed25519 key"), + ({"crv": "X25519"}, "not an Ed25519 key"), + ({"alg": "ES256"}, "not an EdDSA signing key"), + ({"alg": None}, "not an EdDSA signing key"), + ({"use": "enc"}, "not an EdDSA signing key"), + ({"use": None}, "not an EdDSA signing key"), + ({"x": "AAAA"}, "not a valid Ed25519 public key"), + ({"x": "not base64url!"}, "not a valid Ed25519 public key"), + ({"kid": ""}, "no usable kid"), + ], ids=["ec_key", "x25519", "other_alg", "no_alg", "encryption", "no_use", "short_x", "bad_x", + "no_kid"]) + def test_a_file_with_an_unusable_key_is_refused(self, signer, change, problem): + body = json.dumps({"keys": [signer.jwk(KID), {**signer.jwk(KID), "kid": "other", **change}]}) + + with pytest.raises(KeyFileError, match=problem): + parse_key_set(body.encode()) + + @pytest.mark.parametrize("body", [ + b"", b"[]", b'{"keys": []}', b'{"keys": {}}', b"\xff", b"[" * 100_000 + b"]" * 100_000, + ], ids=["empty", "array", "no_keys", "keys_object", "not_utf8", "deeply_nested"]) + def test_a_file_without_keys_is_refused(self, body): + with pytest.raises(KeyFileError): + parse_key_set(body) + + def test_a_repeated_kid_is_refused(self, signer): + body = json.dumps({"keys": [signer.jwk(KID), signer.jwk(KID)]}) + + with pytest.raises(KeyFileError, match="more than once"): + parse_key_set(body.encode()) + + +# --------------------------------------------------------------------------- # +# Requests # +# --------------------------------------------------------------------------- # + + +def _config(tmp_path: Path, **external) -> NerveConfig: + return NerveConfig.from_dict({ + "workspace": str(tmp_path / "ws"), + "codex": {"home_dir": str(tmp_path / "codex-home")}, + "auth": { + "jwt_secret": _SECRET, + "external": {"tenant_id": TENANT, "agent_id": AGENT, **external}, + }, + }) + + +def _external(monkeypatch) -> None: + monkeypatch.setenv(AUTH_MODE_ENV, AUTH_MODE_EXTERNAL) + pin_auth_mode(AUTH_MODE_EXTERNAL) + + +def _vector_key_file(tmp_path: Path) -> Path: + key_file = tmp_path / "actor-keys.json" + key_file.write_text(json.dumps(VECTORS["key_set"]), encoding="utf-8") + return key_file + + +def _app() -> FastAPI: + """Every path, behind ``require_auth`` twice: on the router and on the route. + + FastAPI runs the dependency once per request, so an accepted assertion + is not seen again as a replay. + """ + app = FastAPI() + router = APIRouter(dependencies=[Depends(require_auth)]) + + @router.api_route("/{path:path}", methods=["GET", "POST"]) + async def whoami(path: str, actor: Annotated[Actor, Depends(require_auth)]): + return {"actor_id": actor.actor_id, "display_name": actor.display_name} + + app.include_router(router) + return app + + +def _client() -> httpx.AsyncClient: + return httpx.AsyncClient(transport=httpx.ASGITransport(app=_app()), base_url="http://nerve-test") + + +async def _send(vector: dict, value: str | None = None) -> httpx.Response: + async with _client() as client: + return await client.request( + vector["method"], vector["target"], + headers={ACTOR_CONTEXT_HEADER: value or vector["assertion"]}, + ) + + +@pytest_asyncio.fixture +async def instance(tmp_path, monkeypatch, wire_identity_store): + """External mode with the vectors' key set, at a clock that a test sets.""" + _external(monkeypatch) + key_file = _vector_key_file(tmp_path) + config = _config(tmp_path, gateway_jwks_file=str(key_file)) + set_config(config) + database = Database(tmp_path / "nerve.db") + await database.connect() + try: + await bootstrap_identity(database, config) + wire_identity_store(database) + clock = Clock(VECTORS["now"]) + monkeypatch.setattr(auth, "_actor_verifier", verifier(key_file, clock)) + yield SimpleNamespace(db=database, config=config, clock=clock) + finally: + await database.close() + set_config(NerveConfig()) + + +def _own_keys(instance, tmp_path: Path, monkeypatch) -> tuple[Signer, Clock]: + """Switch the instance to keys made here, at a clock that a test sets.""" + signer, clock = Signer(tmp_path / "own-keys.json"), Clock(NOW) + instance.config.auth.external.gateway_jwks_file = signer.key_file + monkeypatch.setattr(auth, "_actor_verifier", verifier(signer.key_file, clock)) + return signer, clock + + +HTTP_VALID = [vector for vector in VALID if not vector["websocket"]] +HTTP_INVALID = [vector for vector in INVALID if not vector["websocket"]] + + +@pytest.mark.asyncio +class TestHttpRequests: + @pytest.mark.parametrize("vector", HTTP_VALID, ids=_names(HTTP_VALID)) + async def test_a_valid_vector_acts_as_its_principal(self, instance, vector): + instance.clock.now = vector["now"] + + res = await _send(vector) + + assert res.status_code == 200 + assert res.json() == {"actor_id": vector["principal_id"], "display_name": vector["display_name"]} + assert (await instance.db.get_actor_ref(vector["principal_id"]))["display_name"] == "Test Person" + + @pytest.mark.parametrize("vector", HTTP_INVALID, ids=_names(HTTP_INVALID)) + async def test_an_invalid_vector_is_refused_without_the_reason(self, instance, vector): + instance.clock.now = vector["now"] + + res = await _send(vector) + + assert res.status_code == 401 + assert res.json()["detail"] == REFUSED_DETAIL + assert await instance.db.get_actor_ref(vector["principal_id"]) is None + + async def test_a_replayed_assertion_is_refused(self, instance): + vector = _vector("api_token_source") + + assert (await _send(vector)).status_code == 200 + assert (await _send(vector)).status_code == 401 + + async def test_the_reason_goes_to_the_log_without_the_header(self, instance, caplog): + vector = _vector("expired_beyond_skew") + instance.clock.now = vector["now"] + + with caplog.at_level(logging.WARNING, logger="nerve.gateway.auth"): + assert (await _send(vector)).status_code == 401 + + assert "assertion has expired (kid 'test-2026-10', request 'req-GET')" in caplog.text + assert vector["assertion"] not in caplog.text + assert vector["assertion"].split(".")[2] not in caplog.text + + async def test_an_unsigned_header_is_refused(self, instance): + payload = _b64(json.dumps({"principal_id": ALICE}).encode()) + + res = await _send(_vector("api_token_source"), f"{_b64(b'{}')}.{payload}.x") + + assert res.status_code == 401 + + async def test_no_usable_key_file_gives_503(self, instance, tmp_path): + instance.config.auth.external.gateway_jwks_file = tmp_path / "missing.json" + + res = await _send(_vector("api_token_source")) + + assert res.status_code == 503 + assert res.json()["detail"] == auth.NO_ACTOR_KEYS_DETAIL + + @pytest.mark.parametrize(("key", "value"), [ + ("tenant_id", OTHER_SPELLING[TENANT]), + ("agent_id", ""), + ("verification", "warn"), + ("gateway_jwks_file", Path("actor-keys.json")), + ]) + async def test_invalid_settings_give_503(self, instance, key, value): + setattr(instance.config.auth.external, key, value) + + assert (await _send(_vector("api_token_source"))).status_code == 503 + + async def test_log_mode_logs_a_failed_check_and_uses_the_header(self, instance, caplog): + instance.config.auth.external.verification = "log" + vector = _vector("target_mismatch") + + with caplog.at_level(logging.WARNING, logger="nerve.gateway.auth"): + res = await _send(vector) + + assert res.status_code == 200 + assert res.json()["actor_id"] == vector["principal_id"] + assert "used anyway" in caplog.text and "another request target" in caplog.text + assert vector["assertion"] not in caplog.text + + async def test_log_mode_continues_without_a_key_set(self, instance, tmp_path): + instance.config.auth.external.verification = "log" + instance.config.auth.external.gateway_jwks_file = tmp_path / "missing.json" + vector = _vector("api_token_source") + + res = await _send(vector) + + assert res.status_code == 200 + assert res.json()["actor_id"] == vector["principal_id"] + + async def test_log_mode_still_refuses_a_header_it_cannot_read(self, instance): + instance.config.auth.external.verification = "log" + + assert (await _send(_vector("api_token_source"), "abc")).status_code == 401 + + async def test_without_the_key_file_setting_the_header_is_not_checked(self, instance): + instance.config.auth.external.gateway_jwks_file = None + vector = _vector("expired_beyond_skew") + + res = await _send(vector) + + assert res.status_code == 200 + assert res.json()["actor_id"] == vector["principal_id"] + + @pytest.mark.parametrize(("changes", "problem"), [ + ({"principal_id": "alice"}, "UUID"), + ({"profile": {"display_name": 42}}, "display name"), + ], ids=["principal_not_uuid", "display_name_not_string"]) + async def test_a_verified_header_that_cannot_be_read_is_refused( + self, instance, tmp_path, monkeypatch, changes, problem, + ): + signer, _clock = _own_keys(instance, tmp_path, monkeypatch) + + async with _client() as client: + res = await client.get( + "/api/whoami", headers={ACTOR_CONTEXT_HEADER: signer.sign(Signer.claims(NOW, **changes))}, + ) + + assert res.status_code == 401 + assert problem in res.json()["detail"] + + async def test_who_am_i_through_the_gateway_app(self, instance, tmp_path, monkeypatch): + signer, _clock = _own_keys(instance, tmp_path, monkeypatch) + value = signer.assertion(NOW, target="/api/auth/me") + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=server.create_app()), base_url="http://nerve-test", + ) as client: + res = await client.get("/api/auth/me", headers={ACTOR_CONTEXT_HEADER: value}) + again = await client.get("/api/auth/me", headers={ACTOR_CONTEXT_HEADER: value}) + + assert res.status_code == 200 + assert res.json()["actor"] == { + "id": ALICE, "kind": "human", "display_name": "Alice", "username": None, + } + assert again.status_code == 401 + + +UPLOAD = "/api/files/upload" +BOUNDARY = "nerve-test-boundary" + + +def _upload_form(session_id: str) -> tuple[bytes, bytes]: + """A multipart upload of one file, in two parts.""" + head = ( + f'--{BOUNDARY}\r\nContent-Disposition: form-data; name="session_id"\r\n\r\n{session_id}\r\n' + f'--{BOUNDARY}\r\nContent-Disposition: form-data; name="files"; filename="notes.txt"\r\n' + "Content-Type: text/plain\r\n\r\nthe first part, " + ).encode() + return head, f"the second part\r\n--{BOUNDARY}--\r\n".encode() + + +def _upload_headers(*values: str) -> httpx.Headers: + return httpx.Headers( + [(ACTOR_CONTEXT_HEADER, value) for value in values] + + [("Content-Type", f"multipart/form-data; boundary={BOUNDARY}")], + ) + + +@pytest.mark.asyncio +class TestArrival: + """The gateway app checks a request when it arrives, before it reads the body.""" + + @pytest_asyncio.fixture + async def uploads(self, instance, tmp_path, monkeypatch): + """The real upload route, with keys made here and a session to upload to.""" + signer, clock = _own_keys(instance, tmp_path, monkeypatch) + await instance.db.create_session("upload-session", source="web", actor=None) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=server.create_app()), base_url="http://nerve-test", + ) as client: + yield SimpleNamespace(client=client, signer=signer, clock=clock) + + async def test_a_slow_upload_is_checked_when_it_arrives(self, uploads, caplog): + value = uploads.signer.assertion(NOW, method="POST", target=UPLOAD) + head, tail = _upload_form("upload-session") + + async def slow_body(): + yield head + uploads.clock.now = NOW + 120 # the transfer outlasts the assertion + yield tail + + res = await uploads.client.post(UPLOAD, content=slow_body(), headers=_upload_headers(value)) + + assert res.status_code == 200 + assert [file["filename"] for file in res.json()["files"]] == ["notes.txt"] + + uploads.clock.now = NOW + 10 + with caplog.at_level(logging.WARNING, logger="nerve.gateway.auth"): + again = await uploads.client.post(UPLOAD, content=head + tail, headers=_upload_headers(value)) + assert again.status_code == 401 + assert "already used" in caplog.text + + async def test_two_headers_are_still_refused(self, uploads): + first, second = ( + uploads.signer.assertion(NOW, method="POST", target=UPLOAD) for _ in range(2) + ) + head, tail = _upload_form("upload-session") + + res = await uploads.client.post(UPLOAD, content=head + tail, headers=_upload_headers(first, second)) + + assert res.status_code == 401 + assert ACTOR_CONTEXT_HEADER in res.json()["detail"] + + +@pytest.fixture +def ws_instance(tmp_path, monkeypatch, wire_identity_store): + """The real ``/ws`` endpoint, verifying with the vectors' key set.""" + _external(monkeypatch) + key_file = _vector_key_file(tmp_path) + config = _config(tmp_path, gateway_jwks_file=str(key_file)) + set_config(config) + clock = Clock(VECTORS["now"]) + monkeypatch.setattr(auth, "_actor_verifier", verifier(key_file, clock)) + app = server.create_app() + app.router.lifespan_context = _no_lifespan + with TestClient(app) as client: + database = client.portal.call(_open_bootstrapped_db, tmp_path / "nerve.db", config) + wire_identity_store(database) + monkeypatch.setattr(server, "_engine", _RecordingEngine(), raising=False) + try: + yield SimpleNamespace(client=client, config=config, clock=clock) + finally: + client.portal.call(database.close) + set_config(NerveConfig()) + + +class TestWebSocket: + def test_the_upgrade_vector_is_admitted_once(self, ws_instance): + vector = _vector("websocket_upgrade") + ws_instance.clock.now = vector["now"] + headers = {ACTOR_CONTEXT_HEADER: vector["assertion"]} + + with ws_instance.client.websocket_connect("/ws", headers=headers) as socket: + assert socket.receive_json()["type"] == "session_switched" + (connection, _socket), = server._live_sockets.values() + assert connection.actor == Actor( + actor_id=vector["principal_id"], kind="human", account_id=None, + display_name=vector["display_name"], + ) + assert _close_code(ws_instance.client, "/ws", headers) == 4001 + + def test_an_http_assertion_does_not_open_a_socket(self, ws_instance): + vector = _vector("kind_mismatch_http_assertion_on_websocket") + ws_instance.clock.now = vector["now"] + + assert _close_code(ws_instance.client, "/ws", {ACTOR_CONTEXT_HEADER: vector["assertion"]}) == 4001 + assert server._live_sockets == {} + + def test_no_usable_key_file_refuses_the_socket(self, ws_instance, tmp_path): + ws_instance.config.auth.external.gateway_jwks_file = tmp_path / "missing.json" + vector = _vector("websocket_upgrade") + + assert _close_code(ws_instance.client, "/ws", {ACTOR_CONTEXT_HEADER: vector["assertion"]}) == 4001 + + +# --------------------------------------------------------------------------- # +# Configuration, startup, reload and doctor # +# --------------------------------------------------------------------------- # + + +class TestConfig: + def test_the_settings_are_read(self): + config = NerveConfig.from_dict({"auth": {"external": { + "gateway_jwks_file": "/run/nerve/actor-keys.json", + "tenant_id": TENANT, "agent_id": AGENT, "verification": " Log ", + }}}) + + assert config.auth.external == ExternalAuthConfig( + gateway_jwks_file=Path("/run/nerve/actor-keys.json"), + tenant_id=TENANT, agent_id=AGENT, verification="log", + ) + assert config.auth.external.problems() == [] + + @pytest.mark.parametrize("auth_section", [{}, {"external": None}, {"jwt_secret": "s"}]) + def test_without_the_section_nothing_is_checked(self, auth_section): + external = NerveConfig.from_dict({"auth": auth_section}).auth.external + + assert external == ExternalAuthConfig() + assert external.verification == "require" + assert external.problems() == [] + + @pytest.mark.parametrize(("key", "value", "problem"), [ + ("gateway_jwks_file", "actor-keys.json", "absolute path"), + ("tenant_id", OTHER_SPELLING[TENANT], "tenant_id must be a lowercase UUID"), + ("tenant_id", ALICE.upper(), "tenant_id must be a lowercase UUID"), + ("agent_id", "", "agent_id must be a lowercase UUID"), + ("agent_id", "agent-1", "agent_id must be a lowercase UUID"), + ("verification", "warn", "verification must be require or log"), + ]) + def test_unusable_settings_are_named(self, key, value, problem): + settings = { + "gateway_jwks_file": "/run/nerve/actor-keys.json", "tenant_id": TENANT, "agent_id": AGENT, + key: value, + } + problems = NerveConfig.from_dict({"auth": {"external": settings}}).auth.external.problems() + + assert len(problems) == 1 and problem in problems[0] + + def test_the_keys_are_known(self): + merged = {"auth": {"external": { + "gateway_jwks_file": "/run/nerve/actor-keys.json", + "tenant_id": TENANT, "agent_id": AGENT, "verification": "require", + }}} + + assert validate_config_keys(merged) == [] + + +class TestStartup: + def test_startup_logs_the_check_and_reads_the_key_file(self, tmp_path, monkeypatch, caplog, reads): + _external(monkeypatch) + key_file = _vector_key_file(tmp_path) + set_config(_config(tmp_path, gateway_jwks_file=str(key_file))) + try: + with caplog.at_level(logging.INFO, logger="nerve.gateway.server"): + server.create_app() + finally: + set_config(NerveConfig()) + + assert [ + record.levelname for record in caplog.records + if f"verifies the {ACTOR_CONTEXT_HEADER} header (verification=require, " + f"key file {key_file})" in record.getMessage() + ] == ["INFO"] + assert "without a signature check" not in caplog.text + assert reads == [key_file] + + def test_log_mode_is_a_warning_at_startup(self, tmp_path, monkeypatch, caplog): + _external(monkeypatch) + set_config(_config(tmp_path, gateway_jwks_file=str(_vector_key_file(tmp_path)), verification="log")) + try: + with caplog.at_level(logging.INFO, logger="nerve.gateway.server"): + server.create_app() + finally: + set_config(NerveConfig()) + + assert [ + record.levelname for record in caplog.records + if "uses it also when the check fails (verification=log" in record.getMessage() + ] == ["WARNING"] + + def test_startup_warns_when_the_key_file_cannot_be_used(self, tmp_path, monkeypatch, caplog): + _external(monkeypatch) + set_config(_config(tmp_path, gateway_jwks_file=str(tmp_path / "missing.json"))) + try: + with caplog.at_level(logging.WARNING): + server.create_app() + finally: + set_config(NerveConfig()) + + assert "actor key file cannot be used" in caplog.text + + def test_startup_warns_about_invalid_settings(self, tmp_path, monkeypatch, caplog): + _external(monkeypatch) + set_config(_config(tmp_path, gateway_jwks_file="actor-keys.json")) + try: + with caplog.at_level(logging.WARNING, logger="nerve.gateway.server"): + server.create_app() + finally: + set_config(NerveConfig()) + + assert "cannot be verified: auth.external.gateway_jwks_file must be an absolute path" in caplog.text + + def test_without_the_key_file_setting_startup_keeps_the_warning(self, tmp_path, monkeypatch, caplog): + _external(monkeypatch) + set_config(_config(tmp_path)) + try: + with caplog.at_level(logging.WARNING, logger="nerve.gateway.server"): + server.create_app() + finally: + set_config(NerveConfig()) + + assert "without a signature check" in caplog.text + assert auth._actor_verifier is None + + +def _write_config(config_dir: Path, workspace: Path, **external) -> None: + (config_dir / "config.yaml").write_text(yaml.safe_dump({ + "workspace": str(workspace), + "auth": {"external": {"tenant_id": TENANT, "agent_id": AGENT, **external}}, + }), encoding="utf-8") + + +@pytest.mark.asyncio +class TestReload: + @pytest.fixture + def config_dir(self, tmp_path): + config_dir, workspace = tmp_path / "cfg", tmp_path / "ws" + config_dir.mkdir() + (workspace / "config").mkdir(parents=True) + yield config_dir + set_config(NerveConfig()) + + async def test_a_reload_reads_the_key_file_again(self, tmp_path, monkeypatch, config_dir, signer, reads): + _external(monkeypatch) + _write_config(config_dir, tmp_path / "ws", gateway_jwks_file=str(signer.key_file)) + set_config(load_config(config_dir)) + check = auth.actor_context_verifier(get_config().auth.external) + signer.add_key("test-key-2") + signer.publish() + + summary = await reload_all(None, None, config_dir) + + assert summary["actor_context"] == "2 gateway key(s)" + assert auth._actor_verifier is check and len(reads) == 2 + + async def test_an_unusable_file_is_a_reload_error_and_keeps_the_keys( + self, tmp_path, monkeypatch, config_dir, signer, + ): + _external(monkeypatch) + _write_config(config_dir, tmp_path / "ws", gateway_jwks_file=str(signer.key_file)) + set_config(load_config(config_dir)) + check = auth.actor_context_verifier(get_config().auth.external) + signer.key_file.write_text('{"keys": []}', encoding="utf-8") + + summary = await reload_all(None, None, config_dir) + + assert summary["actor_context"].startswith("error: ") + assert auth._actor_verifier is check + check.verify( + signer.assertion(int(time.time())), method="GET", target="/api/whoami", websocket=False, + ) + + async def test_a_reload_applies_a_new_key_file(self, tmp_path, monkeypatch, config_dir, signer, reads): + _external(monkeypatch) + _write_config(config_dir, tmp_path / "ws", gateway_jwks_file=str(signer.key_file)) + set_config(load_config(config_dir)) + before = auth.actor_context_verifier(get_config().auth.external) + moved = tmp_path / "moved.json" + moved.write_text(signer.key_file.read_text(encoding="utf-8"), encoding="utf-8") + _write_config(config_dir, tmp_path / "ws", gateway_jwks_file=str(moved)) + + summary = await reload_all(None, None, config_dir) + + assert summary["actor_context"] == "1 gateway key(s)" + assert auth._actor_verifier is not before and auth._actor_verifier.key_file == moved + assert reads == [signer.key_file, moved] + + async def test_a_new_key_file_that_cannot_be_used_is_a_reload_error( + self, tmp_path, monkeypatch, config_dir, signer, + ): + _external(monkeypatch) + _write_config(config_dir, tmp_path / "ws", gateway_jwks_file=str(tmp_path / "missing.json")) + + summary = await reload_all(None, None, config_dir) + + assert summary["actor_context"].startswith("error: no usable key set") + + async def test_local_mode_and_no_key_file_report_nothing(self, tmp_path, monkeypatch, config_dir, signer): + _write_config(config_dir, tmp_path / "ws", gateway_jwks_file=str(signer.key_file)) + assert "actor_context" not in await reload_all(None, None, config_dir) + + _external(monkeypatch) + _write_config(config_dir, tmp_path / "ws") + assert "actor_context" not in await reload_all(None, None, config_dir) + + +class TestDoctor: + def test_the_key_file_is_read(self, tmp_path, monkeypatch): + monkeypatch.setenv(AUTH_MODE_ENV, AUTH_MODE_EXTERNAL) + key_file = _vector_key_file(tmp_path) + + report = doctor_report(_config(tmp_path, gateway_jwks_file=str(key_file))) + + assert ( + f"[OK] Actor context verified (verification: require): agent {AGENT}, " + f"2 gateway key(s) from {key_file}" + ) in report + + def test_log_mode_is_a_warning(self, tmp_path, monkeypatch): + monkeypatch.setenv(AUTH_MODE_ENV, AUTH_MODE_EXTERNAL) + key_file = _vector_key_file(tmp_path) + + report = doctor_report(_config(tmp_path, gateway_jwks_file=str(key_file), verification="log")) + + assert ( + "[WARN] Actor context: auth.external.verification is log, so a header that fails " + f"the check is used anyway (agent {AGENT}, 2 gateway key(s) from {key_file})" + ) in report + assert "[OK] Actor context" not in report + + def test_a_missing_key_file_is_an_error(self, tmp_path, monkeypatch): + monkeypatch.setenv(AUTH_MODE_ENV, AUTH_MODE_EXTERNAL) + + report = doctor_report(_config(tmp_path, gateway_jwks_file=str(tmp_path / "missing.json"))) + + assert "[ERR] Actor context: auth.external.gateway_jwks_file: cannot read" in report + + def test_a_key_file_with_a_private_key_is_an_error(self, tmp_path, monkeypatch, signer): + monkeypatch.setenv(AUTH_MODE_ENV, AUTH_MODE_EXTERNAL) + private = _b64(signer.keys[KID].private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())) + signer.key_file.write_text(json.dumps({"keys": [{**signer.jwk(KID), "d": private}]}), encoding="utf-8") + + report = doctor_report(_config(tmp_path, gateway_jwks_file=str(signer.key_file))) + + assert "[ERR] Actor context: auth.external.gateway_jwks_file:" in report + assert "private key members" in report + assert private not in report + + def test_unusable_settings_are_errors(self, tmp_path, monkeypatch): + monkeypatch.setenv(AUTH_MODE_ENV, AUTH_MODE_EXTERNAL) + config = _config(tmp_path, gateway_jwks_file=str(_vector_key_file(tmp_path)), agent_id="agent-1") + + report = doctor_report(config) + + assert "[ERR] Actor context: auth.external.agent_id must be a lowercase UUID" in report + + def test_without_the_key_file_setting_it_warns(self, tmp_path, monkeypatch): + monkeypatch.setenv(AUTH_MODE_ENV, AUTH_MODE_EXTERNAL) + + report = doctor_report(_config(tmp_path)) + + assert "[WARN] Actor context: the X-Nerve-Actor-Context header is not verified" in report + assert "[ERR] Actor context" not in report + + def test_local_mode_does_not_read_it(self, tmp_path): + report = doctor_report(_config(tmp_path, gateway_jwks_file=str(tmp_path / "missing.json"))) + + assert "Actor context" not in report