ADR-039: Rust Gateway mTLS + Certificate-Bound Token Validation
Metadataβ
| Field | Value |
|---|---|
| Status | β Accepted |
| Date | 2026-02-09 |
| Decision Makers | Platform Team, Security Team |
| Linear | CAB-864 |
Related Decisionsβ
- ADR-027: X509 Header-Based Authentication β F5βKeycloak header contract
- ADR-028: RFC 8705 Certificate Binding Validation β fingerprint normalization, timing-safe comparison (MCP Gateway, Python)
- ADR-029: mTLS Certificate Lifecycle Management β provisioning, rotation, grace period
- ADR-024: Gateway Unified Modes
- CAB-1121: Consumer OAuth2 Integration (Phase 2, completed)
Contextβ
ADR-027/028/029 established STOA's mTLS architecture: F5 terminates TLS and forwards X-SSL-* headers, Keycloak validates certificates via its x509cert-lookup SPI, and the MCP Gateway (Python) verifies RFC 8705 cnf.x5t#S256 binding using fingerprint_utils.py.
The stoa-gateway (Rust/axum) has zero mTLS support today. It must implement the same certificate-bound token validation as the MCP Gateway, but in Rust β with different patterns due to the language and framework.
Current Rust Auth Architectureβ
stoa-gateway/src/auth/
βββ mod.rs β exports
βββ claims.rs β Claims struct (sub, exp, tenant, roles, scopes)
β StoaRole enum: CpiAdmin, TenantAdmin, DevOps, Viewer
β NO cnf field, NO certificate metadata
βββ middleware.rs β combined_auth_middleware: JWT β API Key fallback
β AuthenticatedUser, AuthUser/OptionalAuthUser extractors
βββ jwt.rs β JwtValidator: RS256 via JWKS (moka cache 5min)
βββ api_key.rs β ApiKeyValidator: moka cache β CP API /keys/validate
βββ oidc.rs β OIDC discovery + JWKS caching
βββ rbac.rs β RbacEnforcer, Action enum, tenant isolation
Grep for mtls|x509|certificate|X-SSL|X-Client-Cert|cnf in auth/ = zero matches.
Why a Separate ADRβ
ADR-028 covers RFC 8705 binding validation for the MCP Gateway (Python):
- Uses
secrets.compare_digest()for timing-safe comparison - Uses
fingerprint_utils.pyfor format normalization - UAC-driven configuration via
security.authentication.mtls.cert_binding.*
The Rust Gateway requires different implementation decisions:
- Different crypto primitives (no
secrets.compare_digest, usesubtle::ConstantTimeEq) - Different configuration system (Figment + STOA_ env vars, not UAC)
- Different middleware model (axum layers + extractors, not FastAPI dependencies)
- Must integrate with the existing
Claimsstruct andcombined_auth_middleware()
Decisionβ
1. New Module: auth/mtls.rsβ
A dedicated module for mTLS header extraction and certificate-token binding verification.
Structs:
CertificateInfo
βββ fingerprint: String β SHA-256 hex from X-SSL-Client-Fingerprint
βββ fingerprint_b64url: String β computed: hex β bytes β base64url
βββ subject_dn: String β from X-SSL-Client-S-DN
βββ issuer_dn: String β from X-SSL-Client-I-DN
βββ serial: String β from X-SSL-Client-Serial
βββ not_before: Option<DateTime> β from X-SSL-Client-NotBefore
βββ not_after: Option<DateTime> β from X-SSL-Client-NotAfter
βββ verify_status: String β from X-SSL-Client-Verify
MtlsConfig
βββ enabled: bool β default: false (backward compatible)
βββ require_binding: bool β default: true (reject tokens without cnf when cert present)
βββ header_verify: String β default: X-SSL-Client-Verify
βββ header_fingerprint: String β default: X-SSL-Client-Fingerprint
βββ header_subject_dn: String β default: X-SSL-Client-S-DN
βββ header_issuer_dn: String β default: X-SSL-Client-I-DN
βββ header_serial: String β default: X-SSL-Client-Serial
βββ header_cert: String β default: X-SSL-Client-Cert
βββ allowed_issuers: Vec<String> β default: empty (accept all)
βββ tenant_from_dn: bool β default: true
Functions:
| Function | Input | Output | Purpose |
|---|---|---|---|
extract_certificate_from_headers | &HeaderMap, &MtlsConfig | Result<Option<CertificateInfo>> | Parse X-SSL-* headers into CertificateInfo |
verify_certificate_binding | &CertificateInfo, &CnfClaim | Result<()> | Compare cert thumbprint with JWT cnf.x5t#S256 |
hex_to_base64url | &str | Result<String> | Convert hex fingerprint to base64url for comparison |
normalize_fingerprint | &str | String | Strip colons, lowercase (consistent with ADR-028 algorithm) |
2. Extend Claims Structβ
Add cnf field to Claims in auth/claims.rs:
Claims (extended)
βββ ... (existing fields: sub, exp, iat, iss, aud, tenant, etc.)
βββ cnf: Option<CnfClaim> [NEW]
CnfClaim
βββ x5t_s256: Option<String> β maps to JSON key "x5t#S256"
(serde rename: #[serde(rename = "x5t#S256")])
This is a non-breaking change: cnf is Option, existing tokens without it deserialize identically.
3. Middleware Pipeline Integrationβ
Insert mTLS processing into the existing combined_auth_middleware() in two stages:
Key design choice: mTLS extraction happens before JWT validation (to fail fast on invalid certificates), binding verification happens after (needs both cert and JWT claims).
4. Timing-Safe Comparisonβ
Use subtle::ConstantTimeEq (from the subtle crate) for thumbprint comparison, matching the security guarantees of ADR-028's secrets.compare_digest().
cert_fingerprint_hex (normalized) β bytes
cnf_x5t_s256 (base64url decoded) β bytes
bytes.ct_eq(&other_bytes) β timing-safe comparison
Not == or PartialEq β those may short-circuit on first differing byte.
5. Fingerprint Normalization (Consistent with ADR-028)β
Reimplement the same normalization algorithm from fingerprint_utils.py in Rust:
Input format detection:
contains ':' β hex_colons β strip colons β lowercase
matches [a-fA-F0-9]+ β hex β lowercase
otherwise β base64url β decode β hex lowercase
All comparisons done on hex lowercase (consistent with ADR-028).
6. Configuration (Figment)β
New fields in config.rs under the existing Figment configuration system:
| Env Variable | Type | Default | Description |
|---|---|---|---|
STOA_MTLS_ENABLED | bool | false | Master switch |
STOA_MTLS_REQUIRE_BINDING | bool | true | Reject tokens without cnf when cert present |
STOA_MTLS_HEADER_VERIFY | String | X-SSL-Client-Verify | Verify status header name |
STOA_MTLS_HEADER_FINGERPRINT | String | X-SSL-Client-Fingerprint | Fingerprint header name |
STOA_MTLS_HEADER_SUBJECT_DN | String | X-SSL-Client-S-DN | Subject DN header name |
STOA_MTLS_HEADER_ISSUER_DN | String | X-SSL-Client-I-DN | Issuer DN header name |
STOA_MTLS_HEADER_SERIAL | String | X-SSL-Client-Serial | Serial number header name |
STOA_MTLS_HEADER_CERT | String | X-SSL-Client-Cert | PEM cert header name |
STOA_MTLS_ALLOWED_ISSUERS | String (comma-separated) | empty | Allowed issuer DNs |
STOA_MTLS_TENANT_FROM_DN | bool | true | Extract tenant from Subject DN OU |
Header names are configurable to support different TLS terminators (F5, nginx, Envoy, HAProxy) per ADR-028's philosophy.
7. New Axum Extractor: CertInfoβ
CertInfo(pub Option<CertificateInfo>)
Handlers that need certificate metadata use this extractor. Returns None when mTLS is disabled or no certificate was presented (does not fail β optional by design).
8. Error Responsesβ
| Condition | HTTP | Code | Body |
|---|---|---|---|
X-SSL-Client-Verify missing, mtls_enabled=true | 401 | MTLS_CERT_REQUIRED | client certificate required |
X-SSL-Client-Verify != SUCCESS | 403 | MTLS_CERT_INVALID | client certificate validation failed |
JWT missing cnf, cert present, require_binding=true | 403 | MTLS_BINDING_REQUIRED | certificate-bound token required |
| Fingerprint mismatch | 403 | MTLS_BINDING_MISMATCH | certificate binding mismatch |
| Certificate expired (NotAfter in past) | 403 | MTLS_CERT_EXPIRED | client certificate expired |
| Issuer not in allowed list | 403 | MTLS_ISSUER_DENIED | certificate issuer not allowed |
Error format follows existing Gateway JSON pattern: {"error": "...", "detail": "..."}.
9. Bulk Onboarding Endpoint (Phase 3)β
POST /api/v1/admin/consumers/bulk on the Control Plane API (Python):
- Input: CSV (multipart/form-data), max 100 rows
- Columns:
external_id, display_name, tenant_id, certificate_pem - Per row (atomic): validate cert β compute x5t_s256 β create Keycloak client + protocol mapper β store consumer
- Response:
{ total, success, failed, results: [{ row, status, consumer_id?, client_id?, error? }] } - Rows that fail do not block other rows
- Requires
cpi-adminortenant-adminrole
This endpoint reuses the existing consumer creation flow from CAB-1121 Phase 2, adding certificate processing and batch orchestration.
Consequencesβ
Positiveβ
- Parity with MCP Gateway: same RFC 8705 binding validation, same normalization algorithm (ADR-028), implemented in Rust
- Zero overhead when disabled:
mtls_enabled=falseskips all header parsing and binding checks - Backward compatible:
cnf: Option<CnfClaim>on Claims does not break existing JWT deserialization - Timing-safe:
subtle::ConstantTimeEqprevents side-channel attacks on fingerprint comparison - Vendor-flexible: configurable header names (ADR-028 principle carried forward)
- Bulk onboarding: enables provisioning 100 mTLS consumers in a single API call
Negativeβ
- Duplicated normalization logic: Rust re-implements
fingerprint_utils.py(different runtimes, cannot share code). Must stay in sync manually. - Two middleware stages: mTLS extraction (pre-JWT) and binding verification (post-JWT) adds complexity to the middleware pipeline
subtlecrate dependency: adds a new dependency for timing-safe comparison (small, well-audited crate)
Risksβ
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Normalization divergence between Python and Rust | Medium | High | Shared test vectors; CI test that verifies both produce identical output for reference inputs |
serde(rename = "x5t#S256") fails on # in field name | Low | High | Integration test with real Keycloak-issued token containing cnf claim |
| Header spoofing from inside cluster | Low | High | K8s NetworkPolicy restricting X-SSL-* header sources (ADR-027) |
| Performance regression with mTLS enabled | Low | Low | Benchmark: header parsing + SHA-256 comparison < 5us per request |
Implementation Planβ
Phase 2: Gateway mTLS Module (CAB-864 P2)β
| Step | Files | Description |
|---|---|---|
| 1 | auth/mtls.rs | MtlsConfig, CertificateInfo, CnfClaim, header extraction, binding verification, hex_to_base64url |
| 2 | auth/claims.rs | Add cnf: Option<CnfClaim> to Claims struct |
| 3 | auth/middleware.rs | Insert mTLS extraction (pre-JWT) and binding verification (post-JWT) into pipeline |
| 4 | auth/mod.rs | Export mtls module, CertInfo extractor |
| 5 | config.rs | Add MtlsConfig section with Figment env var mapping |
| 6 | Cargo.toml | Add subtle and base64 crate dependencies |
| 7 | auth/mtls.rs (tests) | Unit tests: header parsing, fingerprint normalization, binding match/mismatch, timing-safe comparison |
| 8 | auth/middleware.rs (tests) | Integration tests: full pipeline with mTLS headers + JWT + cnf claim; backward compat (disabled) |
Phase 3: Bulk Onboarding (CAB-864 P3)β
| Step | Files (control-plane-api) | Description |
|---|---|---|
| 1 | routers/consumers.py | POST /v1/admin/consumers/bulk endpoint |
| 2 | services/consumer_service.py | Batch processing logic with per-row atomicity |
| 3 | services/keycloak_service.py | Protocol mapper auto-configuration for cnf claim |
| 4 | tests/test_consumers_bulk.py | Unit + integration tests for bulk endpoint |
Referencesβ
- RFC 8705 β OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens
- subtle crate β Constant-time operations for Rust
- ADR-027: X509 Header-Based Authentication
- ADR-028: RFC 8705 Certificate Binding Validation
- ADR-029: mTLS Certificate Lifecycle Management
control-plane-api/src/services/fingerprint_utils.pyβ Python normalization reference