Skip to content

Commit c8462e6

Browse files
rustyconoverclaude
andcommitted
Bump version to 0.1.16 and add RFC 9728 OAuth discovery with JWT authentication
Add server-side OAuth Protected Resource Metadata (RFC 9728), client-side discovery via http_oauth_metadata() and 401-based flow, and a jwt_authenticate() factory using Authlib for turnkey JWKS-backed JWT validation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent af2a6ed commit c8462e6

File tree

16 files changed

+1351
-9
lines changed

16 files changed

+1351
-9
lines changed

docs/api/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Everything else is optional and can be added incrementally.
2121
| [Core RPC](core.md) | `RpcServer`, `RpcConnection`, errors, `serve_pipe`, `connect` | Yes |
2222
| [Streaming](streaming.md) | `Stream`, `StreamState`, `ProducerState`, `ExchangeState` | If using streams |
2323
| [Auth & Context](auth.md) | `AuthContext`, `CallContext`, `ClientLog` | If using auth or logging |
24+
| [OAuth Discovery](oauth.md) | `OAuthResourceMetadata`, `jwt_authenticate`, `http_oauth_metadata` | `pip install vgi-rpc[http,oauth]` |
2425
| [Transports](transports.md) | `PipeTransport`, `SubprocessTransport`, `ShmPipeTransport` | Built-in |
2526
| [Serialization](serialization.md) | `ArrowSerializableDataclass`, `ArrowType`, `IpcValidation` | If using custom dataclasses |
2627
| [HTTP](http.md) | `make_wsgi_app`, `http_connect`, `make_sync_client` | `pip install vgi-rpc[http]` |

docs/api/oauth.md

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
# OAuth Discovery
2+
3+
RFC 9728 Protected Resource Metadata and JWT authentication for vgi-rpc HTTP services.
4+
5+
## Quick Overview
6+
7+
vgi-rpc HTTP servers can advertise their OAuth configuration so clients
8+
discover auth requirements automatically — no out-of-band configuration needed.
9+
10+
### Server setup
11+
12+
```python
13+
from vgi_rpc.http import OAuthResourceMetadata, jwt_authenticate, make_wsgi_app
14+
from vgi_rpc import RpcServer
15+
16+
metadata = OAuthResourceMetadata(
17+
resource="https://api.example.com/vgi",
18+
authorization_servers=("https://auth.example.com",),
19+
scopes_supported=("read", "write"),
20+
)
21+
22+
auth = jwt_authenticate(
23+
issuer="https://auth.example.com",
24+
audience="https://api.example.com/vgi",
25+
)
26+
27+
server = RpcServer(MyService, MyServiceImpl())
28+
app = make_wsgi_app(
29+
server,
30+
authenticate=auth,
31+
oauth_resource_metadata=metadata,
32+
)
33+
```
34+
35+
### Client discovery
36+
37+
```python
38+
from vgi_rpc.http import http_oauth_metadata, http_connect
39+
40+
meta = http_oauth_metadata("https://api.example.com")
41+
print(meta.authorization_servers) # ("https://auth.example.com",)
42+
43+
with http_connect(MyService, "https://api.example.com") as svc:
44+
result = svc.protected_method()
45+
```
46+
47+
## How It Works
48+
49+
1. Server serves `/.well-known/oauth-protected-resource` (RFC 9728)
50+
2. 401 responses include `WWW-Authenticate: Bearer resource_metadata="..."`
51+
3. Client fetches metadata to discover authorization server(s)
52+
4. Client authenticates with the AS and sends Bearer token
53+
54+
### Discovery from a 401
55+
56+
If a client doesn't know the server's auth requirements upfront, it can
57+
discover them from a 401 response:
58+
59+
```python
60+
from vgi_rpc.http import parse_resource_metadata_url, fetch_oauth_metadata
61+
62+
# 1. Make a request that returns 401
63+
resp = client.post("/vgi/my_method", ...)
64+
65+
# 2. Parse the metadata URL from WWW-Authenticate header
66+
www_auth = resp.headers["www-authenticate"]
67+
metadata_url = parse_resource_metadata_url(www_auth)
68+
# "https://api.example.com/.well-known/oauth-protected-resource/vgi"
69+
70+
# 3. Fetch the metadata
71+
meta = fetch_oauth_metadata(metadata_url)
72+
print(meta.authorization_servers) # use these to authenticate
73+
```
74+
75+
## OAuthResourceMetadata
76+
77+
Frozen dataclass configuring the server's RFC 9728 metadata document.
78+
Pass to `make_wsgi_app(oauth_resource_metadata=...)` to enable OAuth discovery.
79+
80+
| Field | Type | Required | Description |
81+
|---|---|---|---|
82+
| `resource` | `str` | Yes | Canonical URL of the protected resource |
83+
| `authorization_servers` | `tuple[str, ...]` | Yes | Authorization server issuer URLs (must be non-empty) |
84+
| `scopes_supported` | `tuple[str, ...]` | No | OAuth scopes the resource understands |
85+
| `bearer_methods_supported` | `tuple[str, ...]` | No | Token delivery methods (default `("header",)`) |
86+
| `resource_signing_alg_values_supported` | `tuple[str, ...]` | No | JWS algorithms for signed responses |
87+
| `resource_name` | `str \| None` | No | Human-readable name |
88+
| `resource_documentation` | `str \| None` | No | URL to developer docs |
89+
| `resource_policy_uri` | `str \| None` | No | URL to privacy policy |
90+
| `resource_tos_uri` | `str \| None` | No | URL to terms of service |
91+
92+
Raises `ValueError` if `resource` is empty or `authorization_servers` is empty.
93+
94+
## jwt_authenticate()
95+
96+
Factory that creates a JWT-validating `authenticate` callback using Authlib.
97+
98+
```python
99+
from vgi_rpc.http import jwt_authenticate
100+
101+
auth = jwt_authenticate(
102+
issuer="https://auth.example.com",
103+
audience="https://api.example.com/vgi",
104+
jwks_uri="https://auth.example.com/.well-known/jwks.json", # optional
105+
principal_claim="sub", # default
106+
domain="jwt", # default
107+
)
108+
```
109+
110+
| Parameter | Type | Default | Description |
111+
|---|---|---|---|
112+
| `issuer` | `str` | required | Expected `iss` claim |
113+
| `audience` | `str` | required | Expected `aud` claim |
114+
| `jwks_uri` | `str \| None` | `None` | JWKS URL (discovered from OIDC if `None`) |
115+
| `claims_options` | `Mapping \| None` | `None` | Additional Authlib claim options |
116+
| `principal_claim` | `str` | `"sub"` | JWT claim for `AuthContext.principal` |
117+
| `domain` | `str` | `"jwt"` | Domain for `AuthContext` |
118+
119+
**JWKS caching**: Keys are fetched lazily on first request and cached in-process.
120+
On unknown `kid` (decode error), keys are automatically refreshed once. Other
121+
validation failures (expired token, wrong issuer/audience) fail immediately
122+
without a network round-trip.
123+
124+
Requires `pip install vgi-rpc[oauth]` — raises a clear `ImportError` with
125+
install instructions if Authlib is not available.
126+
127+
## http_oauth_metadata()
128+
129+
Client-side function to discover a server's OAuth configuration.
130+
131+
```python
132+
from vgi_rpc.http import http_oauth_metadata
133+
134+
meta = http_oauth_metadata("https://api.example.com")
135+
if meta is not None:
136+
print(meta.authorization_servers)
137+
```
138+
139+
Returns `OAuthResourceMetadataResponse` or `None` (if server returns 404).
140+
141+
**Note:** The `prefix` parameter (default `"/vgi"`) must match the server's
142+
`make_wsgi_app(prefix=...)`. A mismatch results in a 404 (`None` return).
143+
144+
## fetch_oauth_metadata()
145+
146+
Fetch metadata from an explicit URL (typically from a 401 `WWW-Authenticate` header).
147+
148+
```python
149+
from vgi_rpc.http import fetch_oauth_metadata
150+
151+
meta = fetch_oauth_metadata("https://api.example.com/.well-known/oauth-protected-resource/vgi")
152+
```
153+
154+
## parse_resource_metadata_url()
155+
156+
Extract the `resource_metadata` URL from a `WWW-Authenticate` header.
157+
158+
```python
159+
from vgi_rpc.http import parse_resource_metadata_url
160+
161+
url = parse_resource_metadata_url('Bearer resource_metadata="https://..."')
162+
# "https://..."
163+
```
164+
165+
Returns `None` if the header doesn't contain `resource_metadata`.
166+
167+
## OAuthResourceMetadataResponse
168+
169+
Frozen dataclass returned by `http_oauth_metadata()` and `fetch_oauth_metadata()`.
170+
Same fields as `OAuthResourceMetadata` (the server-side config class).
171+
172+
## Standards Compliance
173+
174+
- [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728) — OAuth 2.0 Protected Resource Metadata
175+
- [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414) — OAuth 2.0 Authorization Server Metadata
176+
- [RFC 6750](https://www.rfc-editor.org/rfc/rfc6750) — Bearer Token Usage
177+
- Compatible with MCP's OAuth implementation
178+
179+
## Installation
180+
181+
```bash
182+
# OAuth discovery (no extra deps beyond [http])
183+
pip install vgi-rpc[http]
184+
185+
# JWT authentication (adds Authlib)
186+
pip install vgi-rpc[http,oauth]
187+
```

docs/examples.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,16 @@ HTTP authentication with Bearer tokens. Shows `authenticate` callback, `AuthCont
9696
--8<-- "examples/auth.py"
9797
```
9898

99+
### OAuth Discovery
100+
101+
OAuth 2.0 Protected Resource Metadata (RFC 9728) with JWT authentication.
102+
Shows `OAuthResourceMetadata`, `jwt_authenticate()`, and client-side
103+
discovery via `http_oauth_metadata()`.
104+
105+
```python
106+
--8<-- "examples/oauth_discovery.py"
107+
```
108+
99109
### Introspection
100110

101111
Runtime service discovery with `enable_describe=True`. Shows `introspect()` for pipe transport and `http_introspect()` for HTTP.

examples/oauth_discovery.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""OAuth Discovery with JWT authentication.
2+
3+
Demonstrates:
4+
1. Configuring OAuthResourceMetadata (RFC 9728)
5+
2. Using jwt_authenticate() for JWT token validation
6+
3. Client-side OAuth metadata discovery via http_oauth_metadata()
7+
4. An RPC service that returns the authenticated user's identity
8+
9+
Requires ``pip install vgi-rpc[http,oauth]``
10+
11+
Run::
12+
13+
python examples/oauth_discovery.py
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import time
19+
from typing import Protocol
20+
21+
import falcon
22+
from authlib.jose import JsonWebKey, jwt
23+
24+
from vgi_rpc import AuthContext, CallContext, RpcServer
25+
from vgi_rpc.http import OAuthResourceMetadata, http_connect, http_oauth_metadata, jwt_authenticate, make_sync_client
26+
27+
# ---------------------------------------------------------------------------
28+
# 1. Define the service Protocol
29+
# ---------------------------------------------------------------------------
30+
31+
32+
class IdentityService(Protocol):
33+
"""A service that returns the caller's identity."""
34+
35+
def whoami(self) -> str:
36+
"""Return the caller's identity."""
37+
...
38+
39+
def my_claims(self) -> str:
40+
"""Return the caller's JWT claims."""
41+
...
42+
43+
44+
# ---------------------------------------------------------------------------
45+
# 2. Implement with CallContext
46+
# ---------------------------------------------------------------------------
47+
48+
49+
class IdentityServiceImpl:
50+
"""Concrete implementation of IdentityService."""
51+
52+
def whoami(self, ctx: CallContext) -> str:
53+
"""Return the caller's identity."""
54+
ctx.auth.require_authenticated()
55+
return f"You are {ctx.auth.principal} (domain={ctx.auth.domain})"
56+
57+
def my_claims(self, ctx: CallContext) -> str:
58+
"""Return the caller's JWT claims."""
59+
ctx.auth.require_authenticated()
60+
claims = dict(ctx.auth.claims)
61+
return f"Claims for {ctx.auth.principal}: {claims}"
62+
63+
64+
# ---------------------------------------------------------------------------
65+
# 3. Configure OAuth metadata + JWT auth
66+
# ---------------------------------------------------------------------------
67+
68+
69+
def main() -> None:
70+
"""Run the OAuth discovery example end-to-end."""
71+
# Generate a test RSA key pair
72+
key = JsonWebKey.generate_key("RSA", 2048, is_private=True)
73+
key_dict = key.as_dict(is_private=True)
74+
key_dict["kid"] = "test-key-1"
75+
jwk_public = JsonWebKey.import_key(key_dict).as_dict()
76+
77+
# Create a test JWT
78+
now = int(time.time())
79+
header = {"alg": "RS256", "kid": "test-key-1"}
80+
payload = {
81+
"iss": "https://auth.example.com",
82+
"aud": "https://api.example.com/vgi",
83+
"sub": "alice",
84+
"iat": now,
85+
"exp": now + 3600,
86+
"name": "Alice Example",
87+
"scope": "read write",
88+
}
89+
token_bytes: bytes = jwt.encode(header, payload, key_dict)
90+
token = token_bytes.decode()
91+
92+
# Build a JWKS-based authenticate callback.
93+
# In production, jwt_authenticate() fetches keys from a real JWKS endpoint.
94+
# For this example, we create a local callback that uses the generated key.
95+
def _local_authenticate(req: falcon.Request) -> AuthContext:
96+
"""Validate JWT using local key (simulates jwt_authenticate behaviour)."""
97+
auth_header = req.get_header("Authorization") or ""
98+
if not auth_header.startswith("Bearer "):
99+
raise ValueError("Missing or invalid Authorization header")
100+
raw_token = auth_header[7:]
101+
claims = jwt.decode(
102+
raw_token,
103+
jwk_public,
104+
claims_options={
105+
"iss": {"essential": True, "value": "https://auth.example.com"},
106+
"aud": {"essential": True, "value": "https://api.example.com/vgi"},
107+
},
108+
)
109+
claims.validate()
110+
return AuthContext(
111+
domain="jwt",
112+
authenticated=True,
113+
principal=str(claims.get("sub", "")),
114+
claims=dict(claims),
115+
)
116+
117+
metadata = OAuthResourceMetadata(
118+
resource="https://api.example.com/vgi",
119+
authorization_servers=("https://auth.example.com",),
120+
scopes_supported=("read", "write"),
121+
resource_name="Example Identity Service",
122+
)
123+
124+
server = RpcServer(IdentityService, IdentityServiceImpl())
125+
126+
client = make_sync_client(
127+
server,
128+
signing_key=b"example-oauth-key",
129+
authenticate=_local_authenticate,
130+
default_headers={"Authorization": f"Bearer {token}"},
131+
oauth_resource_metadata=metadata,
132+
)
133+
134+
# --- 4. Client discovers metadata, then calls service ---
135+
meta = http_oauth_metadata(client=client)
136+
assert meta is not None
137+
print(f"authorization_servers: {meta.authorization_servers}")
138+
print(f"scopes_supported: {meta.scopes_supported}")
139+
print(f"resource_name: {meta.resource_name}")
140+
141+
with http_connect(IdentityService, client=client) as svc:
142+
print(svc.whoami())
143+
print(svc.my_claims())
144+
145+
# Verify jwt_authenticate is importable (requires authlib)
146+
_auth_fn = jwt_authenticate(
147+
issuer="https://auth.example.com",
148+
audience="https://api.example.com/vgi",
149+
jwks_uri="https://auth.example.com/.well-known/jwks.json",
150+
)
151+
print(f"jwt_authenticate factory created: {_auth_fn is not None}")
152+
153+
154+
if __name__ == "__main__":
155+
main()

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ nav:
118118
- Core RPC: api/core.md
119119
- Streaming: api/streaming.md
120120
- Auth & Context: api/auth.md
121+
- OAuth Discovery: api/oauth.md
121122
- Transports: api/transports.md
122123
- HTTP: api/http.md
123124
- Serialization: api/serialization.md

0 commit comments

Comments
 (0)