diff --git a/docs/accounts.md b/docs/accounts.md index 9ee7e3f6..a1f7c7b6 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 d39b98ad..5a06f241 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 698163f0..10c8f9df 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 26155965..88e5ded4 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 da2e5ac1..ce50c138 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 c799711d..d1962de5 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 00000000..6562eacd --- /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 2759454b..7781f7ed 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 fd5907f2..e5b47355 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 00000000..85a18350 --- /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 00000000..b650bc8d --- /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