Lightweight Docker service that acts as a bridge between a TLS client-certificate protected endpoint and the UDS authentication system.
When a user accesses this service with a client certificate, the certificate data is encrypted and forwarded to the UDS server for authentication.
Browser ──TLS + client cert──▶ nginx (:443) ──proxy_pass──▶ aiohttp (:8080)
│ │
│ encrypts cert
│ returns auto-submit form
▼ │
X-Client-Cert header POST to UDS callback ▼
UDS auth_callback
- UDS generates a signed link pointing to this service. The path contains a
JSON payload
{"url": "...", "ticket": "..."}signed with HMAC-SHA256, whereticketis a unique, single-use identifier that prevents replay attacks. - The browser performs a TLS handshake with its client certificate.
- Nginx extracts the certificate and forwards it via headers to the Python app.
- The app verifies the HMAC signature, encrypts the certificate together with the ticket (AES-256-CBC + HMAC-SHA256 Encrypt-then-MAC), and returns an auto-submitting HTML form that POSTs the encrypted payload to UDS.
- UDS decrypts the payload, verifies the signature, checks the ticket has not been used before (anti-replay), and authenticates the user.
The only required configuration is a shared HMAC key, stored in config.yaml:
hmac_key: "your-64-char-hex-string"Generate one with:
docker run --rm \
-v $(pwd)/config:/app/config \
--entrypoint /usr/local/bin/uv \
client-cert-auth run python scripts/generate_config.py| Path | Description |
|---|---|
GET /cert_auth/<signed_url> |
Receives the client certificate, encrypts it, returns an auto-submitting form that POSTs to the UDS callback URL embedded in the path. |
| Other paths | Empty 200 response (no information disclosed). |
The <signed_url> component encodes a JSON document with the target URL and a
single-use ticket, signed with HMAC-SHA256:
payload = {"url": "https://uds.example.com/.../callback", "ticket": "uuid..."}
data_b64 = base64url(json(payload))
signed_url = data_b64 . "." . hmac_hex(shared_key, data_b64)
The ticket field is a unique identifier (e.g. a UUID) generated by UDS for
each authentication request. It is included in the encrypted payload so that
UDS can verify it hasn't been used before, providing replay attack protection.
The auto-submitting form POSTs a single field:
| Field | Content |
|---|---|
payload |
AES-256-CBC encrypted + HMAC-SHA256 authenticated JSON (Encrypt-then-MAC), base64-encoded. Contains: cert, host, remote_ip, forwarded_for, ticket. |
The encrypted payload decodes to:
{
"cert": "<client certificate PEM or \"EMPTY\">",
"host": "client-cert.example.com",
"remote_ip": "192.168.1.100",
"forwarded_for": "10.0.0.1, 172.16.0.2",
"ticket": "<single-use ticket from signed URL>"
}Example of the HTML form returned by the service:
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Client Certificate Authentication</title></head>
<body>
<p>Redirecting...</p>
<form method="POST" action="https://uds.example.com/uds/page/auth/callback/client_cert/">
<input type="hidden" name="payload" value="AaDB3kVfn38...">
</form>
<script>document.getElementById('f').submit();</script>
</body>
</html>./build.shdocker run -d --name client-cert-auth -p 443:443 \
--log-opt max-size=10m --log-opt max-file=3 \
-v $(pwd)/config/config.yaml:/app/config/config.yaml:ro \
-v $(pwd)/certs:/etc/certs:ro \
client-cert-authAll of these are auto-generated on startup if not provided:
| Mount | Purpose | Auto-generated? |
|---|---|---|
/etc/certs/server.pem |
Server TLS certificate | Yes (self-signed) |
/etc/certs/key.pem |
Server private key | Yes |
/etc/certs/dhparam.pem |
DH parameters | Yes (2048-bit) |
/etc/nginx/snippets/ssl-params.conf |
Custom SSL configuration | Falls back to built-in default |
| Variable | Default | Description |
|---|---|---|
CLIENT_CERT_AUTH_LISTEN_HOST |
127.0.0.1 |
Internal listen address for the Python app |
CLIENT_CERT_AUTH_LISTEN_PORT |
8080 |
Internal listen port |
CLIENT_CERT_AUTH_CONFIG |
config/config.yaml |
Path to configuration file |
Run the test suite with:
uv run pytest tests/ -vThis project uses pyright for static type checking:
uv run pyright| Module | What is tested |
|---|---|
crypto_utils |
AES-256-CBC encrypt/decrypt roundtrip, HMAC integrity verification, tamper detection (IV, ciphertext, MAC), wrong-key rejection, long payloads, deterministic IV |
handler |
Signed URL encoding/decoding roundtrip, HMAC verification on path params, tamper detection, auto-submit form generation, payload content verification, EMPTY sentinel, catch-all handler |
config |
YAML loading, env var overrides, missing file, missing key, frozen dataclass |
docker logs -f client-cert-authBSD 3-Clause License. See LICENSE.