Skip to content

Commit dda0d1f

Browse files
rustyconoverclaude
andcommitted
Add bearer token authentication and chain authenticate combinator
Add bearer_authenticate (custom validation callback), bearer_authenticate_static (static token map), and chain_authenticate (compose multiple authenticators) to vgi_rpc.http. Includes full test coverage and documentation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4382e5d commit dda0d1f

6 files changed

Lines changed: 535 additions & 1 deletion

File tree

docs/api/auth.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,18 @@ server = RpcServer(MyService, MyServiceImpl())
4343
app = make_wsgi_app(server, authenticate=authenticate)
4444
```
4545

46+
vgi-rpc ships several built-in authenticate factories so you don't have to
47+
write the callback yourself:
48+
49+
| Factory | Use case | Extra deps |
50+
|---|---|---|
51+
| [`bearer_authenticate`](oauth.md#bearer_authenticate) | Opaque tokens / API keys with custom validation | None |
52+
| [`bearer_authenticate_static`](oauth.md#bearer_authenticate_static) | Fixed set of pre-shared tokens | None |
53+
| [`jwt_authenticate`](oauth.md#jwt_authenticate) | JWT validation against a JWKS endpoint | `vgi-rpc[oauth]` |
54+
| [`chain_authenticate`](oauth.md#chain_authenticate) | Compose multiple authenticators (e.g. JWT + API key) | None |
55+
56+
See [OAuth Discovery](oauth.md) for details and examples.
57+
4658
Over pipe/subprocess transport, `ctx.auth` is always `AuthContext.anonymous()`.
4759

4860
### Transport metadata

docs/api/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +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]` |
24+
| [OAuth Discovery](oauth.md) | `bearer_authenticate`, `chain_authenticate`, `jwt_authenticate`, `http_oauth_metadata` | Bearer/chain: `[http]`; JWT: `[http,oauth]` |
2525
| [Transports](transports.md) | `PipeTransport`, `SubprocessTransport`, `ShmPipeTransport` | Built-in |
2626
| [Serialization](serialization.md) | `ArrowSerializableDataclass`, `ArrowType`, `IpcValidation` | If using custom dataclasses |
2727
| [HTTP](http.md) | `make_wsgi_app`, `http_connect`, `make_sync_client` | `pip install vgi-rpc[http]` |

docs/api/oauth.md

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,113 @@ Pass to `make_wsgi_app(oauth_resource_metadata=...)` to enable OAuth discovery.
9191

9292
Raises `ValueError` if `resource` is empty or `authorization_servers` is empty.
9393

94+
## Bearer Token Authentication
95+
96+
For API keys, opaque tokens, or any non-JWT bearer token, use `bearer_authenticate`.
97+
No extra dependencies beyond `vgi-rpc[http]`.
98+
99+
### bearer_authenticate()
100+
101+
Factory that creates a bearer-token `authenticate` callback with a custom `validate` function.
102+
Supports any validation logic: database lookups, introspection endpoints, expiry checks, etc.
103+
104+
```python
105+
from vgi_rpc.http import bearer_authenticate, make_wsgi_app
106+
from vgi_rpc import AuthContext, RpcServer
107+
108+
def validate(token: str) -> AuthContext:
109+
# Look up token in database, call an introspection endpoint, etc.
110+
user = db.get_user_by_api_key(token)
111+
if user is None:
112+
raise ValueError("Invalid API key")
113+
return AuthContext(
114+
domain="apikey",
115+
authenticated=True,
116+
principal=user.name,
117+
claims={"role": user.role},
118+
)
119+
120+
auth = bearer_authenticate(validate=validate)
121+
122+
server = RpcServer(MyService, MyServiceImpl())
123+
app = make_wsgi_app(server, authenticate=auth)
124+
```
125+
126+
| Parameter | Type | Description |
127+
|---|---|---|
128+
| `validate` | `Callable[[str], AuthContext]` | Receives the raw token, returns `AuthContext` on success, raises `ValueError` on failure |
129+
130+
### bearer_authenticate_static()
131+
132+
Convenience wrapper for a fixed set of known tokens. Useful for development,
133+
testing, or services with a small number of pre-shared API keys.
134+
135+
```python
136+
from vgi_rpc.http import bearer_authenticate_static, make_wsgi_app
137+
from vgi_rpc import AuthContext, RpcServer
138+
139+
tokens = {
140+
"key-abc123": AuthContext(domain="apikey", authenticated=True, principal="alice"),
141+
"key-def456": AuthContext(domain="apikey", authenticated=True, principal="bob",
142+
claims={"role": "admin"}),
143+
}
144+
145+
auth = bearer_authenticate_static(tokens=tokens)
146+
147+
server = RpcServer(MyService, MyServiceImpl())
148+
app = make_wsgi_app(server, authenticate=auth)
149+
```
150+
151+
| Parameter | Type | Description |
152+
|---|---|---|
153+
| `tokens` | `Mapping[str, AuthContext]` | Maps bearer token strings to pre-built `AuthContext` values |
154+
155+
## chain_authenticate()
156+
157+
Compose multiple `authenticate` callbacks into a single callback.
158+
Authenticators are tried in order — `ValueError` (bad credentials) falls through
159+
to the next; `PermissionError` or other exceptions propagate immediately.
160+
161+
This lets you accept **both** JWT and API key tokens on the same server:
162+
163+
```python
164+
from vgi_rpc.http import (
165+
bearer_authenticate_static,
166+
chain_authenticate,
167+
jwt_authenticate,
168+
make_wsgi_app,
169+
)
170+
from vgi_rpc import AuthContext, RpcServer
171+
172+
# Accept JWTs from your identity provider
173+
jwt_auth = jwt_authenticate(
174+
issuer="https://auth.example.com",
175+
audience="https://api.example.com/vgi",
176+
)
177+
178+
# Also accept static API keys
179+
api_key_auth = bearer_authenticate_static(tokens={
180+
"sk-service-account": AuthContext(
181+
domain="apikey", authenticated=True, principal="ci-bot",
182+
),
183+
})
184+
185+
# Try JWT first, fall back to API key lookup
186+
auth = chain_authenticate(jwt_auth, api_key_auth)
187+
188+
server = RpcServer(MyService, MyServiceImpl())
189+
app = make_wsgi_app(server, authenticate=auth)
190+
```
191+
192+
| Behaviour | Exception | Result |
193+
|---|---|---|
194+
| Credentials accepted | *(none)* | Returns `AuthContext`, stops chain |
195+
| Bad / missing credentials | `ValueError` | Tries next authenticator |
196+
| Authenticated but forbidden | `PermissionError` | Propagates immediately (401) |
197+
| Bug in authenticator | Any other exception | Propagates immediately (500) |
198+
199+
Raises `ValueError` at construction time if called with no authenticators.
200+
94201
## jwt_authenticate()
95202

96203
Factory that creates a JWT-validating `authenticate` callback using Authlib.

0 commit comments

Comments
 (0)