Skip to content

Commit 06320cc

Browse files
rustyconoverclaude
andcommitted
feat(client): oauth=/oauth_refresh_token= constructor kwargs
Client's own docstring said it plainly: "Static token support only -- no JWT / OAuth flows." Wires the new vgi_rpc.http.VgiOAuthAuth (companion vgi-rpc-python change) into Client's HTTP transport: - oauth: bool -- obtain and refresh bearer tokens automatically via OAuth (device-code flow) whenever the worker answers 401 with an RFC 9728 challenge. - oauth_refresh_token: pre-seeded refresh token, skips interactive login on the first request. Implies oauth=True. - oauth_flow / oauth_timeout_seconds / oauth_prompt: forwarded to VgiOAuthAuth verbatim. - Client.oauth_identity(): parsed id_token claims once logged in, or None. Validation mirrors the DuckDB VGI extension's ATTACH-option checks: oauth/oauth_refresh_token is mutually exclusive with both bearer_token (two credential sources) and httpx_client (the escape hatch is verbatim-or-nothing -- build a custom VgiOAuthAuth and pass it via httpx_client=httpx2.Client(auth=...) instead). stop() closes the VgiOAuthAuth's own internal flow client alongside the shared httpx2.Client. Live-verified against a real OAuth-gated worker (https://vgi-volcanos.fly.dev, Google device-code auth): discovery, challenge parsing, and the device-code request all completed correctly, reaching the real "Visit: https://www.google.com/device / Enter code: ..." prompt before the (intentionally short) test timeout -- the only remaining step is a human completing the browser login, which an automated test can't do. Depends on an unreleased vgi-rpc-python (VgiOAuthAuth) -- tested locally against an editable checkout; oauth_identity()/oauth= will raise AttributeError against the currently-pinned vgi-rpc==0.43.2 until a new vgi-rpc-python release ships and the floor here is bumped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNfjyfi9Lfkvrba9PNiNgP
1 parent 455aedd commit 06320cc

2 files changed

Lines changed: 316 additions & 11 deletions

File tree

‎tests/test_client_oauth.py‎

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
# Copyright 2025, 2026 Query Farm LLC - https://query.farm
2+
3+
"""Tests for vgi.client.Client's OAuth wiring (oauth=/oauth_refresh_token=/oauth_flow=).
4+
5+
The device-code/PKCE flow logic itself is tested exhaustively in
6+
vgi-rpc-python's tests/test_oauth_client.py (mock IdP, no network). These
7+
tests cover the integration seam: does `Client` build a `VgiOAuthAuth` with
8+
the right configuration, does construction-time validation reject bad
9+
combinations, and does a full round trip actually work when driven through
10+
`Client`'s real request path (via the `httpx_client=` escape hatch, wired to
11+
a mock IdP the same way vgi-rpc-python's own tests are).
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import httpx2
17+
import pytest
18+
from vgi_rpc.http import VgiOAuthAuth
19+
20+
from vgi.client.client import Client
21+
22+
RESOURCE_METADATA_URL = "https://api.example.com/.well-known/oauth-protected-resource"
23+
DEVICE_AUTH_URL = "https://auth.example.com/device/code"
24+
TOKEN_URL = "https://auth.example.com/token"
25+
26+
27+
class _MockIdp:
28+
"""Minimal mock IdP + protected resource for one full device-code round trip."""
29+
30+
def __init__(self) -> None:
31+
self.device_auth_calls = 0
32+
self.granted_token = "mock-access-token"
33+
34+
def handler(self, request: httpx2.Request) -> httpx2.Response:
35+
"""Route a request to the matching mock endpoint by exact URL."""
36+
url = str(request.url)
37+
if url == RESOURCE_METADATA_URL:
38+
return httpx2.Response(
39+
200,
40+
json={
41+
"resource": "https://api.example.com",
42+
"authorization_servers": ["https://auth.example.com"],
43+
"device_code_client_id": "device-client",
44+
"device_code_client_secret": "device-secret",
45+
},
46+
)
47+
if url == "https://auth.example.com/.well-known/openid-configuration":
48+
return httpx2.Response(
49+
200,
50+
json={
51+
"token_endpoint": TOKEN_URL,
52+
"device_authorization_endpoint": DEVICE_AUTH_URL,
53+
"grant_types_supported": ["urn:ietf:params:oauth:grant-type:device_code"],
54+
},
55+
)
56+
if url == DEVICE_AUTH_URL:
57+
self.device_auth_calls += 1
58+
return httpx2.Response(
59+
200,
60+
json={
61+
"device_code": "devcode-123",
62+
"user_code": "ABCD-EFGH",
63+
"verification_uri": "https://auth.example.com/activate",
64+
"expires_in": 600,
65+
"interval": 0,
66+
},
67+
)
68+
if url == TOKEN_URL:
69+
return httpx2.Response(200, json={"access_token": self.granted_token, "token_type": "Bearer"})
70+
if url.endswith("/vgi/catalogs"):
71+
token = request.headers.get("authorization", "")
72+
if token:
73+
return httpx2.Response(200, json={"authorization": token})
74+
return httpx2.Response(
75+
401,
76+
headers={"www-authenticate": f'Bearer resource_metadata="{RESOURCE_METADATA_URL}"'},
77+
)
78+
return httpx2.Response(404, json={"error": "not_found", "url": url}) # pragma: no cover
79+
80+
81+
class TestConstructorValidation:
82+
"""Tests for the mutually-exclusive oauth/bearer_token/httpx_client combinations."""
83+
84+
def test_oauth_and_bearer_token_raises(self) -> None:
85+
"""Passing both oauth=True and bearer_token= raises ValueError."""
86+
with pytest.raises(ValueError, match="mutually exclusive"):
87+
Client(transport="http", base_url="https://example.com", oauth=True, bearer_token="tok")
88+
89+
def test_oauth_refresh_token_and_bearer_token_raises(self) -> None:
90+
"""oauth_refresh_token= (which implies oauth=True) also conflicts with bearer_token=."""
91+
with pytest.raises(ValueError, match="mutually exclusive"):
92+
Client(
93+
transport="http",
94+
base_url="https://example.com",
95+
oauth_refresh_token="seed",
96+
bearer_token="tok",
97+
)
98+
99+
def test_oauth_and_httpx_client_raises(self) -> None:
100+
"""Passing both oauth=True and httpx_client= raises ValueError."""
101+
with pytest.raises(ValueError, match="httpx_client"):
102+
Client(
103+
transport="http",
104+
base_url="https://example.com",
105+
oauth=True,
106+
httpx_client=httpx2.Client(),
107+
)
108+
109+
def test_invalid_oauth_flow_raises(self) -> None:
110+
"""An unrecognized oauth_flow value raises ValueError, not a silent no-op."""
111+
with pytest.raises(ValueError, match="oauth_flow"):
112+
Client(transport="http", base_url="https://example.com", oauth=True, oauth_flow="carrier-pigeon") # type: ignore[arg-type]
113+
114+
def test_invalid_oauth_prompt_raises(self) -> None:
115+
"""An unrecognized oauth_prompt value raises ValueError, not a silent no-op."""
116+
with pytest.raises(ValueError, match="oauth_prompt"):
117+
Client(transport="http", base_url="https://example.com", oauth=True, oauth_prompt="shout") # type: ignore[arg-type]
118+
119+
def test_bearer_token_alone_still_works(self) -> None:
120+
"""The existing bearer_token= path is unaffected by the new oauth params."""
121+
client = Client(transport="http", base_url="https://example.com", bearer_token="tok")
122+
assert client._bearer_token == "tok"
123+
assert not client._use_oauth
124+
125+
126+
class TestOAuthWiring:
127+
"""Tests that Client(oauth=...) builds a correctly-configured VgiOAuthAuth."""
128+
129+
def test_oauth_true_builds_vgi_oauth_auth(self) -> None:
130+
"""oauth=True lazily builds a VgiOAuthAuth with the client's own oauth_* settings."""
131+
client = Client(
132+
transport="http",
133+
base_url="https://example.com",
134+
oauth=True,
135+
oauth_flow="device_code",
136+
oauth_timeout_seconds=42.0,
137+
oauth_prompt="consent",
138+
)
139+
assert client.oauth_identity() is None # nothing has authenticated yet
140+
client._get_or_create_httpx_client()
141+
assert isinstance(client._oauth_auth, VgiOAuthAuth)
142+
assert client._oauth_auth._flow == "device_code" # noqa: SLF001 -- white-box wiring check
143+
assert client._oauth_auth._timeout_seconds == 42.0 # noqa: SLF001
144+
assert client._oauth_auth._prompt == "consent" # noqa: SLF001
145+
client._oauth_auth.close()
146+
147+
def test_oauth_refresh_token_seeds_the_auth_object(self) -> None:
148+
"""oauth_refresh_token= implies oauth=True and seeds the refresh token."""
149+
client = Client(
150+
transport="http",
151+
base_url="https://example.com",
152+
oauth_refresh_token="pre-seeded-token",
153+
)
154+
assert client._use_oauth
155+
client._get_or_create_httpx_client()
156+
assert client._oauth_auth is not None
157+
assert client._oauth_auth._token.refresh_token == "pre-seeded-token" # noqa: SLF001
158+
client._oauth_auth.close()
159+
160+
161+
class TestOAuthEndToEnd:
162+
"""A full device-code login round trip through the exact client Client would use.
163+
164+
Uses the httpx_client= escape hatch to wire a VgiOAuthAuth against a mock
165+
IdP (httpx2.MockTransport) -- the same technique vgi-rpc-python's own
166+
tests use. The device-code/PKCE flow mechanics are covered exhaustively
167+
there (mock IdP, no network); this test's job is narrower: prove
168+
``Client`` actually issues its RPCs through the httpx2.Client it was
169+
handed (so a real attach would carry the OAuth-obtained token), and that
170+
a 401-triggered login on that client completes end to end.
171+
"""
172+
173+
def test_client_issues_requests_through_the_oauth_authed_httpx_client(self) -> None:
174+
"""A 401 on the client Client._get_or_create_httpx_client() returns triggers a real login."""
175+
idp = _MockIdp()
176+
auth = VgiOAuthAuth(
177+
base_url="https://example.com",
178+
flow="device_code",
179+
timeout_seconds=5.0,
180+
transport=httpx2.MockTransport(idp.handler),
181+
)
182+
httpx_client = httpx2.Client(
183+
transport=httpx2.MockTransport(idp.handler), auth=auth, base_url="https://example.com"
184+
)
185+
# Not started (no worker spawned) -- this test only exercises the HTTP/OAuth
186+
# plumbing, not a full RPC call, so client.stop() (which requires start())
187+
# doesn't apply; the httpx_client/auth are closed directly instead.
188+
client = Client(transport="http", base_url="https://example.com", httpx_client=httpx_client)
189+
try:
190+
# This is the exact client object Client's own RPC machinery sends
191+
# every request through -- not a parallel one built just for the test.
192+
assert client._get_or_create_httpx_client() is httpx_client
193+
194+
resp = httpx_client.get("/vgi/catalogs")
195+
assert resp.status_code == 200
196+
assert resp.json()["authorization"] == f"Bearer {idp.granted_token}"
197+
assert idp.device_auth_calls == 1
198+
199+
# A second request reuses the cached token -- no second login.
200+
httpx_client.get("/vgi/catalogs")
201+
assert idp.device_auth_calls == 1
202+
finally:
203+
httpx_client.close()
204+
auth.close()

0 commit comments

Comments
 (0)