Skip to content

Commit e0099ff

Browse files
rustyconoverclaude
andcommitted
feat(auth): accept sealed grants and resolve_token bearers over HTTP
issue_grant minted a credential meant to be presented later as an ordinary bearer, but no VGI worker accepted it, and a worker's resolve_token was never consulted for authentication. vgi-rpc 0.49.0 adds both (WIRE_PROTOCOL.md §16). This wires them into vgi-python's HTTP surface. - Grant keys come from VGI_RPC_GRANT_KEYS (plus _AUDIENCE and _MAX_TTL_SECONDS) or vgi-serve --grant-key (repeatable, republished to the environment for granian children), via vgi.rpc_server.resolve_grant_keys. A malformed key stops startup. With keys, the HTTP worker hosts Identity even without hooks and mints sealed vgig1. grants unless the worker overrides mint_grant. - make_wsgi_app composes the chain: the deployment's authenticator, then grants, then resolve_token. A bad vgig1. token is a 401 that never reaches the resolver. - RpcServer now defaults to reading the grant env on every transport. build_rpc_server passes grant_keys explicitly (None off HTTP), so grants stay HTTP-only like the rest of Identity. - vgi-rpc refuses to OR identity bearers beside an authenticator that depends on proxy-injected evidence, which would have made a VGI_PROXY_PROOF_MODE worker with grant keys refuse to start. create_app now puts the bearers inside VGI's own gate: require_all(gate, inner + grants + resolve_token). - The --identity fixture authenticator passes a bare Authorization header through, mirroring the reference. - Startup allowlist rules are unchanged. resolve_token still requires VGI_INTROSPECT_PRINCIPALS; keys alone need none. tests/test_grant_auth.py has 32 tests: an end-to-end issue_grant with a fresh auth_time, then a vgi.v2 call with Bearer <grant> that is authenticated as the owner; resolve_token acceptance and refusal; a check that the resolver never sees a bad grant; and the shared grant_token_vectors.json (mint, accept, reject) through create_app, read as UTF-8. Mutation-checked: dropping the gate composition, the create_app keys and the HTTP-only gating each turns tests red. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 68cd5e7 commit e0099ff

7 files changed

Lines changed: 489 additions & 7 deletions

File tree

‎CLAUDE.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@ vgi-client --input data.parquet --function sum_all_columns --worker vgi-fixture-
297297
| `VGI_OAUTH_DEVICE_CODE_CLIENT_ID` | Client ID for device-code flow (optional, URL-safe chars only) |
298298
| `VGI_OAUTH_DEVICE_CODE_CLIENT_SECRET` | Client secret for device-code flow (optional, URL-safe chars only) |
299299
| `VGI_OAUTH_USE_ID_TOKEN` | When `1`/`true`/`yes`, clients use OIDC `id_token` as Bearer instead of `access_token` |
300+
| `VGI_RPC_GRANT_KEYS` | Comma-separated base64 32-byte sealed-grant keys (first mints, all verify); enables grants as HTTP bearers. Also `VGI_RPC_GRANT_AUDIENCE`, `VGI_RPC_GRANT_MAX_TTL_SECONDS` |
300301
| `VGI_INTROSPECT_PRINCIPALS` | Comma-separated principals permitted to call `vgi_rpc.Identity.v1`'s `introspect_token`. Required — no permissive default — whenever the worker implements `resolve_token()` (see below) |
301302
| `VGI_WORKER_ACCESS_LOG_SAMPLE` | Fraction of *successful* calls to keep in the access log, `0.0`–`1.0`. Errors are always kept; the decision is per call, so every record of one stream shares a fate |
302303
| `VGI_WORKER_ACCESS_LOG_ASYNC` | When `1`/`true`/`yes`, emit access-log records from a listener thread. Bounded queue; full means drop, and a crash loses whatever is queued |
@@ -484,6 +485,24 @@ sidecar outage raised as one is read as "not my credential, try the next" and
484485
ends up a 401 from the end of the chain, restarting every session in the fleet
485486
over a thirty-second blip.
486487

488+
### Grants and `resolve_token` as HTTP Bearers
489+
490+
With `VGI_RPC_GRANT_KEYS` (or `vgi-serve --grant-key`) set, the HTTP worker
491+
hosts Identity even without hooks. It mints sealed `vgig1.` grants through
492+
`issue_grant`, unless the worker overrides `mint_grant`, and accepts them back
493+
as `Authorization: Bearer` credentials, authenticated as the grant's owner. A
494+
worker that overrides `resolve_token` also has it consulted for bearers. The
495+
order is the deployment's authenticator, then grants, then `resolve_token`. A
496+
bad `vgig1.` token is a 401 and never reaches `resolve_token`. vgi-rpc composes
497+
this in `make_wsgi_app`. vgi-python resolves the keys in
498+
`vgi.rpc_server.resolve_grant_keys` and passes them explicitly to `RpcServer`
499+
(`None` off HTTP, never vgi-rpc's `"env"` default), so grants stay HTTP-only. It
500+
also puts the bearers *inside* VGI's proxy-proof gate
501+
(`serve._compose_identity_bearer`), because vgi-rpc refuses to OR them beside
502+
one. Allowlist rules are unchanged. See `docs/authentication.md` and
503+
`tests/test_grant_auth.py`, which also runs the shared
504+
`grant_token_vectors.json` through `create_app`.
505+
487506
### Hosting Additional Protocols (`hosted_protocols`)
488507

489508
Every transport (stdio, unix/named pipe, TCP, Iroh, HTTP) builds its `RpcServer`

‎docs/authentication.md‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,48 @@ When both `VGI_BEARER_TOKENS` and `VGI_JWT_ISSUER` are set, they are
3939
chained — JWT validation is attempted first, falling back to bearer token
4040
lookup.
4141

42+
## Grants and resolved tokens as bearer credentials
43+
44+
`vgi_rpc.Identity.v1`'s `issue_grant` mints a standing credential that
45+
unattended automation later presents as an ordinary bearer. Over HTTP the
46+
worker accepts two kinds of identity credential after the deployment's own
47+
authenticator:
48+
49+
1. **Sealed grants** (opt-in). Set `VGI_RPC_GRANT_KEYS` (or
50+
`vgi-serve --http --grant-key KEY`, repeatable). `issue_grant` then mints a
51+
`vgig1.` grant sealed with the first key, unless the worker overrides
52+
`mint_grant`. Every key verifies, so to rotate you add the new key first and
53+
drop the old one once its grants have expired. A request carrying
54+
`Authorization: Bearer vgig1.…` is authenticated as the grant's owner, with
55+
`domain="grant"` and claims `grant_id`, `scopes` and `purpose`. The claims
56+
carry no `auth_time`, so a grant cannot mint another grant.
57+
2. **`resolve_token`**. A worker that overrides
58+
[`Worker.resolve_token`][vgi.worker.Worker.resolve_token] has it consulted
59+
for any other bearer, with `domain="token"`. `None` means the credential is
60+
unknown, which is a 401 unless something else accepts it.
61+
`AuthUnavailableError` is a 503 with the hook's `Retry-After`.
62+
63+
The order is the deployment's authenticator (JWT, static bearer), then sealed
64+
grants, then `resolve_token`. A `vgig1.` token that does not verify is a 401
65+
that stops the chain: it never reaches `resolve_token`. A JWS-shaped token or
66+
one over 4096 bytes never reaches the hook either. Your own `authenticate`
67+
callback must raise `ValueError` for a credential it does not recognise, so the
68+
chain moves on. One that answers anonymous for every request ends the chain
69+
before grants are checked. With no authenticator configured, a request with no
70+
`Authorization` header stays anonymous as before.
71+
72+
| Variable | Description |
73+
|----------|-------------|
74+
| `VGI_RPC_GRANT_KEYS` | Comma-separated standard-base64 keys, 32 bytes each. The first mints and all verify. Unset means grants are off. A malformed key stops startup |
75+
| `VGI_RPC_GRANT_AUDIENCE` | Bound into every grant, so two deployments that share a key still reject each other's grants |
76+
| `VGI_RPC_GRANT_MAX_TTL_SECONDS` | Ceiling on a grant's lifetime (default 7 days) |
77+
78+
The startup rules are unchanged. Overriding `resolve_token` still requires
79+
`VGI_INTROSPECT_PRINCIPALS`. Grant keys alone need no allowlist, because
80+
minting answers only for the caller. Grants and `resolve_token` bearers are
81+
HTTP-only, like the rest of Identity. When `VGI_PROXY_PROOF_MODE` is on, both
82+
are accepted only *inside* the proxy-proof gate, never as an alternative to it.
83+
4284
## Programmatic API
4385

4486
```python test="skip"

‎tests/test_grant_auth.py‎

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
# Copyright 2025, 2026 Query Farm LLC - https://query.farm
2+
3+
"""Identity credentials accepted as HTTP bearers: sealed grants and ``resolve_token``.
4+
5+
vgi-rpc's ``Identity.v1`` mints grants "presented later by unattended
6+
automation as an ordinary bearer". These tests close that loop through
7+
vgi-python's own HTTP surface (``create_app``), not vgi-rpc in isolation:
8+
9+
- with ``VGI_RPC_GRANT_KEYS`` set, ``issue_grant`` mints a sealed grant and a
10+
``vgi.v2`` call presenting it is authenticated as the grant's owner;
11+
- a worker that overrides ``resolve_token`` has it consulted for bearers;
12+
- the shared vectors (``vgi_rpc/conformance/grant_token_vectors.json``) are
13+
accepted and rejected through the full chain;
14+
- VGI's proxy-proof gate still guards every request when grants are on.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import base64
20+
import json
21+
import time
22+
import types
23+
from collections.abc import Iterator
24+
from importlib.resources import files
25+
from typing import Any
26+
27+
import pyarrow as pa
28+
import pytest
29+
from vgi_rpc.grants import GRANT_KEYS_ENV, GrantKeys, mint_grant_token
30+
from vgi_rpc.http import http_connect
31+
from vgi_rpc.http._testing import _SyncTestClient
32+
from vgi_rpc.rpc import AuthContext
33+
from vgi_rpc.rpc._token_identity import Identity
34+
35+
from tests.test_serve import _PeerAuthWorker
36+
from vgi.arguments import Arguments
37+
from vgi.auth import TokenIdentity
38+
from vgi.client import Client, ClientError
39+
from vgi.rpc_server import build_rpc_server, resolve_grant_keys
40+
from vgi.serve import create_app
41+
42+
_KEY_B64 = base64.b64encode(bytes(range(32))).decode()
43+
_OTHER_KEY_B64 = base64.b64encode(bytes(range(32, 64))).decode()
44+
_SIGNING_KEY = b"grant-auth-test-signing-key-0001"
45+
_IDENTITY = "vgi_rpc.Identity.v1"
46+
_GRANT_ENV = (GRANT_KEYS_ENV, "VGI_RPC_GRANT_AUDIENCE", "VGI_RPC_GRANT_MAX_TTL_SECONDS")
47+
48+
_VECTORS = json.loads(files("vgi_rpc.conformance").joinpath("grant_token_vectors.json").read_text(encoding="utf-8"))
49+
50+
51+
@pytest.fixture(autouse=True)
52+
def _clean_env(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
53+
"""No ambient grant, auth or allowlist configuration leaks into a test."""
54+
for key in (*_GRANT_ENV, "VGI_INTROSPECT_PRINCIPALS", "VGI_BEARER_TOKENS", "VGI_JWT_ISSUER"):
55+
monkeypatch.delenv(key, raising=False)
56+
for key in ("VGI_PROXY_PROOF_MODE", "VGI_PROXY_PROOF_ORIGIN_ID", "VGI_PROXY_PROOF_SECRETS"):
57+
monkeypatch.delenv(key, raising=False)
58+
yield
59+
60+
61+
def _header_authenticate(req: Any) -> AuthContext:
62+
"""Deployment authenticator: ``X-Test-Principal`` with a fresh ``auth_time``.
63+
64+
Raises ``ValueError`` for a bare bearer, as the chain contract requires, so
65+
grants and ``resolve_token`` get to answer it.
66+
"""
67+
principal = req.get_header("X-Test-Principal")
68+
if principal:
69+
return AuthContext(domain="test", authenticated=True, principal=principal, claims={"auth_time": time.time()})
70+
if req.get_header("Authorization"):
71+
raise ValueError("not a test-principal request")
72+
return AuthContext.anonymous()
73+
74+
75+
def _whoami(app: Any, *, bearer: str) -> str:
76+
"""Call ``peer_auth_echo`` (a vgi.v2 scalar function) and return its principal."""
77+
client = _SyncTestClient(app, default_headers={"Authorization": f"Bearer {bearer}"})
78+
with Client.from_http("http://vgi.test", httpx_client=client) as vgi:
79+
out = list(
80+
vgi.scalar_function(
81+
function_name="peer_auth_echo",
82+
schema_path=["main"],
83+
arguments=Arguments(positional=(pa.scalar("value"),)),
84+
input=iter((pa.record_batch({"value": pa.array([1], type=pa.int64())}),)),
85+
)
86+
)
87+
return str(out[0].column("result")[0].as_py())
88+
89+
90+
def _app(worker: type = _PeerAuthWorker, **kwargs: Any) -> Any:
91+
return create_app(worker, describe=False, signing_key=_SIGNING_KEY, **kwargs)
92+
93+
94+
class TestGrantKeyConfiguration:
95+
"""Opt-in, from the environment or ``--grant-key``; malformed keys stop startup."""
96+
97+
def test_no_key_means_grants_off(self) -> None:
98+
"""No key means grants off."""
99+
assert resolve_grant_keys() is None
100+
assert _IDENTITY not in build_rpc_server(_PeerAuthWorker(quiet=True), transport="http").bindings
101+
102+
def test_env_keys_parse(self, monkeypatch: pytest.MonkeyPatch) -> None:
103+
"""Env keys parse."""
104+
monkeypatch.setenv(GRANT_KEYS_ENV, f"{_KEY_B64},{_OTHER_KEY_B64}")
105+
monkeypatch.setenv("VGI_RPC_GRANT_AUDIENCE", "aud")
106+
keys = resolve_grant_keys()
107+
assert keys is not None
108+
assert keys.keys == (bytes(range(32)), bytes(range(32, 64)))
109+
assert keys.audience == "aud"
110+
111+
def test_explicit_keys_win_over_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
112+
"""Explicit keys win over env."""
113+
monkeypatch.setenv(GRANT_KEYS_ENV, _OTHER_KEY_B64)
114+
keys = resolve_grant_keys([_KEY_B64])
115+
assert keys is not None
116+
assert keys.keys == (bytes(range(32)),)
117+
118+
@pytest.mark.parametrize("bad", ["not-base64!", base64.b64encode(b"short").decode()])
119+
def test_malformed_key_refuses_to_start(self, monkeypatch: pytest.MonkeyPatch, bad: str) -> None:
120+
"""Malformed key refuses to start."""
121+
monkeypatch.setenv(GRANT_KEYS_ENV, bad)
122+
with pytest.raises(SystemExit):
123+
_app()
124+
125+
def test_keys_alone_host_issue_grant_without_an_allowlist(self, monkeypatch: pytest.MonkeyPatch) -> None:
126+
"""Minting is not an oracle, so the introspection allowlist rule does not apply."""
127+
monkeypatch.setenv(GRANT_KEYS_ENV, _KEY_B64)
128+
server = build_rpc_server(_PeerAuthWorker(quiet=True), transport="http", grant_keys=resolve_grant_keys())
129+
assert server.bindings[_IDENTITY].methods.keys() == {"issue_grant"}
130+
131+
def test_keys_are_http_only(self, monkeypatch: pytest.MonkeyPatch) -> None:
132+
"""Keys are http only."""
133+
monkeypatch.setenv(GRANT_KEYS_ENV, _KEY_B64)
134+
server = build_rpc_server(_PeerAuthWorker(quiet=True), transport="unix", grant_keys=resolve_grant_keys())
135+
assert _IDENTITY not in server.bindings
136+
137+
138+
class TestSealedGrantEndToEnd:
139+
"""issue_grant with fresh auth, then a vgi.v2 call with ``Bearer <grant>``, as the owner."""
140+
141+
def test_minted_grant_authenticates_as_its_owner(self, monkeypatch: pytest.MonkeyPatch) -> None:
142+
"""Minted grant authenticates as its owner."""
143+
monkeypatch.setenv(GRANT_KEYS_ENV, _KEY_B64)
144+
app = _app(authenticate=_header_authenticate)
145+
146+
minter = _SyncTestClient(app, default_headers={"X-Test-Principal": "alice@example.com"})
147+
with http_connect(Identity, client=minter) as identity: # type: ignore[type-abstract]
148+
grant = identity.issue_grant(purpose="nightly-report", scopes=["read"], ttl_seconds=600)
149+
assert grant.token.startswith("vgig1.")
150+
151+
assert _whoami(app, bearer=grant.token) == "alice@example.com"
152+
153+
def test_tampered_grant_is_refused(self, monkeypatch: pytest.MonkeyPatch) -> None:
154+
"""Tampered grant is refused."""
155+
monkeypatch.setenv(GRANT_KEYS_ENV, _KEY_B64)
156+
keys = GrantKeys.parse([_KEY_B64])
157+
token, _ = mint_grant_token(keys, principal="alice", scopes=[], purpose="p", ttl_seconds=60)
158+
tampered = token[:-2] + ("A" if token[-2] != "A" else "B") + token[-1]
159+
with pytest.raises(ClientError):
160+
_whoami(_app(authenticate=_header_authenticate), bearer=tampered)
161+
162+
def test_unknown_bearer_without_grants_configured_stays_unaccepted(self) -> None:
163+
"""No keys, no hook: nothing changes -- the deployment authenticator decides."""
164+
app = _app(authenticate=_header_authenticate)
165+
with pytest.raises(ClientError):
166+
_whoami(app, bearer="vgig1.AAAA")
167+
168+
169+
class _ResolvingWorker(_PeerAuthWorker):
170+
"""Resolves one opaque credential; counts calls so routing is observable."""
171+
172+
calls: list[str] = []
173+
174+
@classmethod
175+
def resolve_token(cls, token: str) -> TokenIdentity | None:
176+
"""Resolve token."""
177+
cls.calls.append(token)
178+
return TokenIdentity(principal="svc-reporting") if token == "opaque-api-key" else None
179+
180+
181+
class TestResolveTokenBearer:
182+
"""A worker's resolve_token is consulted for bearers it did not mint."""
183+
184+
@pytest.fixture
185+
def app(self, monkeypatch: pytest.MonkeyPatch) -> Any:
186+
"""App."""
187+
monkeypatch.setenv("VGI_INTROSPECT_PRINCIPALS", "proxy-a")
188+
monkeypatch.setenv(GRANT_KEYS_ENV, _KEY_B64)
189+
_ResolvingWorker.calls = []
190+
return _app(_ResolvingWorker, authenticate=_header_authenticate)
191+
192+
def test_resolved_bearer_authenticates(self, app: Any) -> None:
193+
"""Resolved bearer authenticates."""
194+
assert _whoami(app, bearer="opaque-api-key") == "svc-reporting"
195+
196+
def test_unresolved_bearer_is_refused(self, app: Any) -> None:
197+
"""Unresolved bearer is refused."""
198+
with pytest.raises(ClientError):
199+
_whoami(app, bearer="unknown-key")
200+
201+
def test_a_bad_grant_never_reaches_the_resolver(self, app: Any) -> None:
202+
"""A bad grant never reaches the resolver."""
203+
with pytest.raises(ClientError):
204+
_whoami(app, bearer="vgig1.AAAA")
205+
assert "vgig1.AAAA" not in _ResolvingWorker.calls
206+
207+
def test_allowlist_rule_unchanged(self, monkeypatch: pytest.MonkeyPatch) -> None:
208+
"""Allowlist rule unchanged."""
209+
monkeypatch.delenv("VGI_INTROSPECT_PRINCIPALS", raising=False)
210+
with pytest.raises(SystemExit):
211+
_app(_ResolvingWorker)
212+
213+
214+
def _vector_env(monkeypatch: pytest.MonkeyPatch, verify_keys: list[str], audience: str, max_ttl: int) -> None:
215+
monkeypatch.setenv(GRANT_KEYS_ENV, ",".join(verify_keys))
216+
monkeypatch.setenv("VGI_RPC_GRANT_AUDIENCE", audience)
217+
monkeypatch.setenv("VGI_RPC_GRANT_MAX_TTL_SECONDS", str(max_ttl))
218+
219+
220+
def _at(monkeypatch: pytest.MonkeyPatch, now: float) -> None:
221+
"""Pin the grant verifier's clock without touching the process clock."""
222+
import vgi_rpc.grants
223+
224+
monkeypatch.setattr(vgi_rpc.grants, "time", types.SimpleNamespace(time=lambda: now))
225+
226+
227+
class TestSharedVectors:
228+
"""The shared vectors, accepted and rejected through create_app's whole chain."""
229+
230+
@pytest.mark.parametrize("case", _VECTORS["mint"], ids=lambda c: c["name"])
231+
def test_minted_vector_authenticates(self, monkeypatch: pytest.MonkeyPatch, case: dict[str, Any]) -> None:
232+
"""Minted vector authenticates."""
233+
verify = [case["minting_key_b64"], *_VECTORS["defaults"]["verify_keys_b64"]]
234+
_vector_env(monkeypatch, list(dict.fromkeys(verify)), case["audience"], case["max_ttl_seconds"])
235+
_at(monkeypatch, case["now"] + 1)
236+
assert _whoami(_app(), bearer=case["token"]) == case["claims"]["principal"]
237+
238+
@pytest.mark.parametrize("case", _VECTORS["accept"], ids=lambda c: c["name"])
239+
def test_accept_case(self, monkeypatch: pytest.MonkeyPatch, case: dict[str, Any]) -> None:
240+
"""Accept case."""
241+
d = _VECTORS["defaults"]
242+
_vector_env(
243+
monkeypatch,
244+
case.get("verify_keys_b64", d["verify_keys_b64"]),
245+
case.get("audience", d["audience"]),
246+
case.get("max_ttl_seconds", d["max_ttl_seconds"]),
247+
)
248+
_at(monkeypatch, case.get("now", d["now"]))
249+
assert _whoami(_app(), bearer=case["token"]) != "anonymous"
250+
251+
@pytest.mark.parametrize("case", _VECTORS["reject"], ids=lambda c: c["name"])
252+
def test_reject_case(self, monkeypatch: pytest.MonkeyPatch, case: dict[str, Any]) -> None:
253+
"""Reject case."""
254+
d = _VECTORS["defaults"]
255+
_vector_env(
256+
monkeypatch,
257+
case.get("verify_keys_b64", d["verify_keys_b64"]),
258+
case.get("audience", d["audience"]),
259+
case.get("max_ttl_seconds", d["max_ttl_seconds"]),
260+
)
261+
_at(monkeypatch, case.get("now", d["now"]))
262+
with pytest.raises(ClientError):
263+
_whoami(_app(), bearer=case["token"])
264+
265+
266+
class TestProxyProofGateStillGuards:
267+
"""Grants go inside VGI's proxy-proof gate, never beside it."""
268+
269+
@pytest.fixture
270+
def gated(self, monkeypatch: pytest.MonkeyPatch) -> None:
271+
"""Gated."""
272+
monkeypatch.setenv("VGI_PROXY_PROOF_MODE", "require")
273+
monkeypatch.setenv("VGI_PROXY_PROOF_ORIGIN_ID", "worker-a")
274+
monkeypatch.setenv("VGI_PROXY_PROOF_SECRETS", "k:" + "11" * 32)
275+
monkeypatch.setenv(GRANT_KEYS_ENV, _KEY_B64)
276+
277+
def test_gated_worker_with_grants_starts(self, gated: None) -> None:
278+
"""vgi-rpc refuses to OR grants beside a proxy gate; create_app composes inside it."""
279+
from vgi.serve import _resolve_authenticate
280+
281+
_app(authenticate=_resolve_authenticate())
282+
283+
def test_valid_grant_without_proof_is_refused(self, gated: None) -> None:
284+
"""Valid grant without proof is refused."""
285+
from vgi.serve import _resolve_authenticate
286+
287+
keys = GrantKeys.parse([_KEY_B64])
288+
token, _ = mint_grant_token(keys, principal="alice", scopes=[], purpose="p", ttl_seconds=60)
289+
with pytest.raises(ClientError):
290+
_whoami(_app(authenticate=_resolve_authenticate()), bearer=token)

‎vgi/_test_fixtures/http_server.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ def _optional_bearer(req: Any) -> AuthContext:
372372
from vgi._test_fixtures.catalog_contents import CONTENTS_WORKERS
373373
from vgi._test_fixtures.narrow_bind.worker import NarrowBindWorker
374374
from vgi._test_fixtures.twin_catalogs import TwinAWorker, TwinBWorker
375-
from vgi.rpc_server import build_rpc_server
375+
from vgi.rpc_server import build_rpc_server, resolve_grant_keys
376376

377377
worker_classes: list[type] = [
378378
ExampleWorker,
@@ -408,6 +408,7 @@ def _optional_bearer(req: Any) -> AuthContext:
408408
describe=describe,
409409
external_location=external_location,
410410
introspect_principals=introspect_principals,
411+
grant_keys=resolve_grant_keys(),
411412
)
412413
wsgi_app = make_wsgi_app(
413414
server,

0 commit comments

Comments
 (0)