Skip to content

Commit 5c267cb

Browse files
rustyconoverclaude
andcommitted
Add mutual TLS (mTLS) authentication and bump version to 0.1.18
Add mtls_authenticate, mtls_authenticate_fingerprint, mtls_authenticate_subject for PEM-in-header client certs (nginx, AWS ALB, Cloudflare) and mtls_authenticate_xfcc for Envoy XFCC headers. Includes documentation, 38 tests, and cryptography optional dependency. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent dda0d1f commit 5c267cb

9 files changed

Lines changed: 1341 additions & 7 deletions

File tree

docs/api/auth.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,13 @@ write the callback yourself:
5151
| [`bearer_authenticate`](oauth.md#bearer_authenticate) | Opaque tokens / API keys with custom validation | None |
5252
| [`bearer_authenticate_static`](oauth.md#bearer_authenticate_static) | Fixed set of pre-shared tokens | None |
5353
| [`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 |
54+
| [`mtls_authenticate`](mtls.md#mtls_authenticate) | Client certificate with custom validation | `vgi-rpc[mtls]` |
55+
| [`mtls_authenticate_fingerprint`](mtls.md#mtls_authenticate_fingerprint) | Certificate fingerprint lookup | `vgi-rpc[mtls]` |
56+
| [`mtls_authenticate_subject`](mtls.md#mtls_authenticate_subject) | Certificate Subject CN extraction | `vgi-rpc[mtls]` |
57+
| [`mtls_authenticate_xfcc`](mtls.md#mtls_authenticate_xfcc) | Envoy XFCC header parsing | None |
58+
| [`chain_authenticate`](oauth.md#chain_authenticate) | Compose multiple authenticators (e.g. JWT + mTLS + API key) | None |
5559

56-
See [OAuth Discovery](oauth.md) for details and examples.
60+
See [OAuth Discovery](oauth.md) for bearer/JWT details and [Mutual TLS](mtls.md) for mTLS details.
5761

5862
Over pipe/subprocess transport, `ctx.auth` is always `AuthContext.anonymous()`.
5963

docs/api/mtls.md

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
# Mutual TLS (mTLS)
2+
3+
Client certificate authentication for vgi-rpc HTTP services behind TLS-terminating proxies.
4+
5+
## Quick Overview
6+
7+
vgi-rpc delegates TLS termination to reverse proxies and load balancers.
8+
For mTLS, the proxy verifies client certificates and forwards certificate
9+
information as HTTP headers. vgi-rpc provides authenticate callback factories
10+
that extract identity from these headers and return `AuthContext`.
11+
12+
Two header conventions are supported:
13+
14+
| Convention | Proxies | Header | Extra deps |
15+
|---|---|---|---|
16+
| **PEM-in-header** | nginx, AWS ALB, Cloudflare | `X-SSL-Client-Cert` (configurable) | `vgi-rpc[mtls]` |
17+
| **XFCC** | Envoy | `x-forwarded-client-cert` | None |
18+
19+
!!! danger "Header Spoofing Warning"
20+
21+
The reverse proxy **MUST** strip client-supplied `X-SSL-Client-Cert` /
22+
`x-forwarded-client-cert` headers before forwarding. Failure to do so
23+
allows clients to forge certificate identity. These factories trust the
24+
header unconditionally — certificate chain validation is the proxy's
25+
responsibility.
26+
27+
### Basic setup (PEM-in-header)
28+
29+
```python
30+
from vgi_rpc import AuthContext, RpcServer
31+
from vgi_rpc.http import mtls_authenticate_subject, make_wsgi_app
32+
33+
auth = mtls_authenticate_subject(
34+
allowed_subjects=frozenset({"my-service", "other-service"}),
35+
)
36+
37+
server = RpcServer(MyService, MyServiceImpl())
38+
app = make_wsgi_app(server, authenticate=auth)
39+
```
40+
41+
### Basic setup (XFCC / Envoy)
42+
43+
```python
44+
from vgi_rpc import RpcServer
45+
from vgi_rpc.http import mtls_authenticate_xfcc, make_wsgi_app
46+
47+
auth = mtls_authenticate_xfcc()
48+
49+
server = RpcServer(MyService, MyServiceImpl())
50+
app = make_wsgi_app(server, authenticate=auth)
51+
```
52+
53+
## PEM-in-Header Factories
54+
55+
These factories parse URL-encoded PEM certificates from proxy headers.
56+
Require `pip install vgi-rpc[mtls]` (cryptography).
57+
58+
### mtls_authenticate()
59+
60+
Generic factory with full control over certificate validation.
61+
Mirrors the `bearer_authenticate` pattern.
62+
63+
```python
64+
from cryptography import x509
65+
from vgi_rpc import AuthContext
66+
from vgi_rpc.http import mtls_authenticate, make_wsgi_app
67+
68+
def validate(cert: x509.Certificate) -> AuthContext:
69+
cn_attrs = cert.subject.get_attributes_for_oid(
70+
x509.oid.NameOID.COMMON_NAME
71+
)
72+
cn = str(cn_attrs[0].value) if cn_attrs else ""
73+
if cn not in ALLOWED_SERVICES:
74+
raise ValueError(f"Unknown client: {cn}")
75+
return AuthContext(
76+
domain="mtls",
77+
authenticated=True,
78+
principal=cn,
79+
claims={"serial": format(cert.serial_number, "x")},
80+
)
81+
82+
auth = mtls_authenticate(validate=validate)
83+
```
84+
85+
| Parameter | Type | Default | Description |
86+
|---|---|---|---|
87+
| `validate` | `Callable[[x509.Certificate], AuthContext]` | required | Receives parsed cert, returns `AuthContext` or raises `ValueError` |
88+
| `header` | `str` | `"X-SSL-Client-Cert"` | Header containing URL-encoded PEM |
89+
| `check_expiry` | `bool` | `False` | Verify `not_valid_before` / `not_valid_after` |
90+
91+
**Common header names by proxy:**
92+
93+
| Proxy | Header |
94+
|---|---|
95+
| nginx | `X-SSL-Client-Cert` (default) |
96+
| AWS ALB | `X-Amzn-Mtls-Clientcert` |
97+
| Cloudflare | `X-SSL-Client-Cert` |
98+
99+
### mtls_authenticate_fingerprint()
100+
101+
Convenience factory that looks up certificates by fingerprint.
102+
Mirrors `bearer_authenticate_static`.
103+
104+
```python
105+
from vgi_rpc import AuthContext
106+
from vgi_rpc.http import mtls_authenticate_fingerprint, make_wsgi_app
107+
108+
# Fingerprints: lowercase hex, no colons
109+
# Get with: openssl x509 -fingerprint -sha256 -noout -in cert.pem | tr -d ':'
110+
fingerprints = {
111+
"a1b2c3d4e5f6...": AuthContext(
112+
domain="mtls", authenticated=True, principal="service-a",
113+
),
114+
"f6e5d4c3b2a1...": AuthContext(
115+
domain="mtls", authenticated=True, principal="service-b",
116+
claims={"role": "admin"},
117+
),
118+
}
119+
120+
auth = mtls_authenticate_fingerprint(fingerprints=fingerprints)
121+
```
122+
123+
| Parameter | Type | Default | Description |
124+
|---|---|---|---|
125+
| `fingerprints` | `Mapping[str, AuthContext]` | required | Lowercase hex fingerprint → `AuthContext` |
126+
| `header` | `str` | `"X-SSL-Client-Cert"` | Header containing URL-encoded PEM |
127+
| `algorithm` | `str` | `"sha256"` | Hash algorithm (`sha256`, `sha1`, `sha384`, `sha512`) |
128+
| `domain` | `str` | `"mtls"` | Domain for `AuthContext` |
129+
| `check_expiry` | `bool` | `False` | Verify certificate validity period |
130+
131+
!!! note "Fingerprint format"
132+
133+
Fingerprints must be **lowercase hex without colons**. To get the SHA-256
134+
fingerprint from a PEM file:
135+
136+
```bash
137+
openssl x509 -fingerprint -sha256 -noout -in cert.pem \
138+
| sed 's/.*=//; s/://g' | tr '[:upper:]' '[:lower:]'
139+
```
140+
141+
### mtls_authenticate_subject()
142+
143+
Convenience factory that extracts the Subject Common Name as the principal
144+
and populates claims with certificate metadata.
145+
146+
```python
147+
from vgi_rpc.http import mtls_authenticate_subject, make_wsgi_app
148+
149+
# Accept only specific client CNs
150+
auth = mtls_authenticate_subject(
151+
allowed_subjects=frozenset({"frontend", "batch-worker", "admin-cli"}),
152+
check_expiry=True,
153+
)
154+
155+
# Or accept any valid certificate (deliberate security decision)
156+
auth_any = mtls_authenticate_subject()
157+
```
158+
159+
| Parameter | Type | Default | Description |
160+
|---|---|---|---|
161+
| `header` | `str` | `"X-SSL-Client-Cert"` | Header containing URL-encoded PEM |
162+
| `domain` | `str` | `"mtls"` | Domain for `AuthContext` |
163+
| `allowed_subjects` | `frozenset[str] \| None` | `None` | Restrict to these CNs; `None` = accept any |
164+
| `check_expiry` | `bool` | `False` | Verify certificate validity period |
165+
166+
The returned `AuthContext.claims` contains:
167+
168+
| Claim | Description |
169+
|---|---|
170+
| `subject_dn` | Full RFC 4514 Distinguished Name |
171+
| `serial` | Certificate serial number (hex) |
172+
| `not_valid_after` | Expiry timestamp (ISO 8601) |
173+
174+
## XFCC (Envoy)
175+
176+
The `x-forwarded-client-cert` (XFCC) header is Envoy's standard for
177+
forwarding client certificate information. No `cryptography` dependency
178+
is needed — these factories parse the structured text header directly.
179+
180+
### XfccElement
181+
182+
Frozen dataclass representing a single element from the XFCC header.
183+
184+
| Field | Type | Description |
185+
|---|---|---|
186+
| `hash` | `str \| None` | Certificate hash |
187+
| `cert` | `str \| None` | URL-decoded PEM certificate (if present) |
188+
| `subject` | `str \| None` | Certificate subject DN |
189+
| `uri` | `str \| None` | SAN URI (e.g. SPIFFE ID) |
190+
| `dns` | `tuple[str, ...]` | SAN DNS names |
191+
| `by` | `str \| None` | Server certificate identity |
192+
193+
### mtls_authenticate_xfcc()
194+
195+
Factory that parses the `x-forwarded-client-cert` header and extracts
196+
client identity.
197+
198+
```python
199+
from vgi_rpc.http import mtls_authenticate_xfcc, make_wsgi_app
200+
201+
# Default: extract CN from Subject field
202+
auth = mtls_authenticate_xfcc()
203+
204+
# Custom validation (e.g. SPIFFE ID)
205+
from vgi_rpc import AuthContext
206+
from vgi_rpc.http._mtls import XfccElement
207+
208+
def validate_spiffe(elem: XfccElement) -> AuthContext:
209+
if not elem.uri or not elem.uri.startswith("spiffe://"):
210+
raise ValueError("Missing SPIFFE ID")
211+
return AuthContext(
212+
domain="spiffe",
213+
authenticated=True,
214+
principal=elem.uri,
215+
claims={"hash": elem.hash} if elem.hash else {},
216+
)
217+
218+
auth = mtls_authenticate_xfcc(validate=validate_spiffe)
219+
```
220+
221+
| Parameter | Type | Default | Description |
222+
|---|---|---|---|
223+
| `validate` | `Callable[[XfccElement], AuthContext] \| None` | `None` | Custom validation; `None` uses Subject CN |
224+
| `domain` | `str` | `"mtls"` | Domain for `AuthContext` (when using default extraction) |
225+
| `select_element` | `"first" \| "last"` | `"first"` | Which element in multi-proxy chains |
226+
227+
**Element selection in multi-proxy chains:**
228+
229+
When requests traverse multiple Envoy proxies, the XFCC header contains
230+
one element per hop. `select_element` controls which is used:
231+
232+
- `"first"` (default) — original client certificate
233+
- `"last"` — nearest proxy's certificate
234+
235+
## Combining with Other Authenticators
236+
237+
Use `chain_authenticate` to accept mTLS **or** bearer tokens:
238+
239+
```python
240+
from vgi_rpc.http import (
241+
bearer_authenticate_static,
242+
chain_authenticate,
243+
mtls_authenticate_subject,
244+
make_wsgi_app,
245+
)
246+
from vgi_rpc import AuthContext, RpcServer
247+
248+
# mTLS for service-to-service calls
249+
mtls_auth = mtls_authenticate_subject(
250+
allowed_subjects=frozenset({"backend-svc"}),
251+
)
252+
253+
# API keys for human/CI access
254+
api_key_auth = bearer_authenticate_static(tokens={
255+
"sk-ci-bot": AuthContext(
256+
domain="apikey", authenticated=True, principal="ci-bot",
257+
),
258+
})
259+
260+
# Try mTLS first, fall back to API key
261+
auth = chain_authenticate(mtls_auth, api_key_auth)
262+
263+
server = RpcServer(MyService, MyServiceImpl())
264+
app = make_wsgi_app(server, authenticate=auth)
265+
```
266+
267+
The chain tries each authenticator in order. `ValueError` (missing or
268+
invalid credentials) falls through to the next; other exceptions propagate
269+
immediately. See [chain_authenticate](oauth.md#chain_authenticate) for details.
270+
271+
## Proxy Configuration
272+
273+
### nginx
274+
275+
```nginx
276+
server {
277+
listen 443 ssl;
278+
ssl_client_certificate /etc/nginx/ca.pem;
279+
ssl_verify_client on;
280+
281+
location / {
282+
# CRITICAL: strip any client-supplied header first
283+
proxy_set_header X-SSL-Client-Cert $ssl_client_escaped_cert;
284+
proxy_pass http://upstream;
285+
}
286+
}
287+
```
288+
289+
### AWS ALB
290+
291+
AWS ALB automatically populates `X-Amzn-Mtls-Clientcert` when mTLS
292+
is enabled. Use `header="X-Amzn-Mtls-Clientcert"` in the factory:
293+
294+
```python
295+
auth = mtls_authenticate_subject(header="X-Amzn-Mtls-Clientcert")
296+
```
297+
298+
### Envoy
299+
300+
```yaml
301+
http_filters:
302+
- name: envoy.filters.http.set_metadata
303+
# Envoy automatically sets x-forwarded-client-cert
304+
# Use SANITIZE or SANITIZE_SET to strip client-supplied headers
305+
forward_client_cert_details: SANITIZE_SET
306+
set_current_client_cert_details:
307+
subject: true
308+
uri: true
309+
dns: true
310+
cert: true
311+
```
312+
313+
Use `mtls_authenticate_xfcc()` (no header configuration needed).
314+
315+
## Installation
316+
317+
```bash
318+
# XFCC support (no extra deps beyond [http])
319+
pip install vgi-rpc[http]
320+
321+
# PEM-in-header support (adds cryptography)
322+
pip install vgi-rpc[http,mtls]
323+
```

docs/index.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ hide:
1515

1616
Transport-agnostic RPC framework built on [Apache Arrow](https://arrow.apache.org/) IPC serialization.
1717

18-
<p class="built-by"><a href="https://vgi-rpc.query.farm">Website</a> · Built by <a href="https://query.farm">🚜 Query.Farm</a></p>
18+
<p class="built-by">Built by <a href="https://query.farm">🚜 Query.Farm</a></p>
1919

2020
</div>
2121

@@ -28,7 +28,8 @@ Define RPC interfaces as Python [`Protocol`](https://docs.python.org/3/library/t
2828
- **Two method types** — unary and [streaming](api/streaming.md) (producer and exchange patterns)
2929
- **Transport-agnostic** — in-process pipes, subprocess, Unix domain sockets, shared memory, or [HTTP](api/http.md) — see [Transports](api/transports.md)
3030
- **Automatic schema inference** — Python type annotations map to [Arrow types](api/serialization.md#type-mappings)
31-
- **Pluggable authentication**[`AuthContext`](api/auth.md) + middleware for HTTP auth (JWT, API key, etc.)
31+
- **Pluggable authentication**[`AuthContext`](api/auth.md) + middleware for HTTP auth (JWT, API key, [mTLS](api/mtls.md), etc.)
32+
- **Mutual TLS**[client certificate authentication](api/mtls.md) via proxy headers (PEM-in-header, Envoy XFCC) with fingerprint, subject, and custom validation
3233
- **OAuth discovery**[RFC 9728](api/oauth.md) protected resource metadata + JWT authentication via Authlib
3334
- **Runtime introspection** — opt-in [`__describe__`](api/introspection.md) RPC method for dynamic service discovery
3435
- **CLI tool**[`vgi-rpc describe` and `vgi-rpc call`](api/cli.md) for ad-hoc service interaction
@@ -102,6 +103,7 @@ pip install vgi-rpc[cli] # CLI tool (typer + httpx)
102103
pip install vgi-rpc[external] # External storage fetch (aiohttp + zstandard)
103104
pip install vgi-rpc[otel] # OpenTelemetry instrumentation
104105
pip install vgi-rpc[oauth] # JWT authentication (Authlib)
106+
pip install vgi-rpc[mtls] # mTLS client certificate auth (cryptography)
105107
```
106108

107109
Requires Python 3.13+.

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ nav:
119119
- Streaming: api/streaming.md
120120
- Auth & Context: api/auth.md
121121
- OAuth Discovery: api/oauth.md
122+
- Mutual TLS: api/mtls.md
122123
- Transports: api/transports.md
123124
- HTTP: api/http.md
124125
- Serialization: api/serialization.md

0 commit comments

Comments
 (0)