Files
kc/keycloak-hybrid-x509-spi/README.md
2026-07-01 13:59:12 -07:00

199 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Keycloak Hybrid X509 Certificate Lookup SPI
Custom `x509cert-lookup` provider that lets a single Keycloak instance authenticate
x509/CAC users arriving via **two paths at once**:
1. **F5 path (header):** F5 BigIP does L7 break-and-inspect, validates the user's cert,
re-encrypts to Keycloak (through the L4-passthrough NLB), and forwards the user cert
in an HTTP header — like the built-in `nginx` provider handles today.
2. **Direct path (TLS):** TGW/brokerage traffic that bypasses the F5 performs mTLS
directly with Keycloak (which terminates TLS itself behind the passthrough NLB with
`https-client-auth=request`), and the cert is read straight off the handshake.
The critical addition over stock Keycloak: **the header is only honored when the request
provably came from the F5** (by the F5's own TLS client cert fingerprint/DN, or source IP).
Without this, any client on the TGW could forge the header and impersonate any user.
Per-request decision logic:
| Cert header | Peer is trusted proxy | `trust-mode` | Result |
|---|---|---|---|
| present | yes | any | header cert used |
| present | no | `log` | header cert used + **loud warning** |
| present | no | `enforce` | header **ignored**; TLS peer cert used if present |
| absent | yes | any | no user cert (the F5's own cert is never a user) |
| absent | no | any | TLS peer cert used if present |
No changes are needed to your realm's X509 browser authentication flow — the
authenticator receives the cert the same way regardless of source.
---
## 1. Build
```bash
mvn package # or: podman run --rm -v $PWD:/src -w /src maven:3.9-eclipse-temurin-17 mvn package
```
Produces `target/keycloak-hybrid-x509-spi-1.0.0.jar` (a few KB — contains only these
classes; all Keycloak APIs are `provided`). Set `<keycloak.version>` in `pom.xml` to match
your deployed version (any 24.x26.x works; the APIs used are stable across that range).
## 2. Deploy (custom image)
Add to your existing Keycloak Dockerfile:
```dockerfile
FROM quay.io/keycloak/keycloak:26.0 AS builder # match your current base/tag
COPY keycloak-hybrid-x509-spi-1.0.0.jar /opt/keycloak/providers/
# https-client-auth is a BUILD-time option in Quarkus Keycloak — it must be present
# when kc.sh build runs, not just at start.
ENV KC_HTTPS_CLIENT_AUTH=request
# ...plus whatever build options your image already sets (KC_DB, KC_FEATURES, ...)
RUN /opt/keycloak/bin/kc.sh build
FROM quay.io/keycloak/keycloak:26.0
COPY --from=builder /opt/keycloak/ /opt/keycloak/
ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]
CMD ["start", "--optimized"]
```
If your image doesn't use `--optimized`, dropping the jar into `/opt/keycloak/providers/`
and setting `KC_HTTPS_CLIENT_AUTH=request` at runtime is enough — Keycloak re-augments on
start. **`request` means the client cert is OPTIONAL**: connections without one (OIDC app
backchannels, admin console, the F5 before it's given a cert) proceed exactly as before.
Do NOT use `required`.
## 3. Runtime configuration (env vars on the Keycloak pod)
```bash
# Switch the lookup provider from nginx to hybrid
KC_SPI_X509CERT_LOOKUP_PROVIDER=hybrid
# Header carrying the user cert — COPY THE VALUE from your existing
# KC_SPI_X509CERT_LOOKUP_NGINX_SSL_CLIENT_CERT (default "ssl-client-cert")
KC_SPI_X509CERT_LOOKUP_HYBRID_SSL_CLIENT_CERT=ssl-client-cert
# Day-one: permissive mode (headers accepted from anyone, like today, but everything logged)
KC_SPI_X509CERT_LOOKUP_HYBRID_TRUST_MODE=log
# After harvesting values from the logs (see rollout plan), add trust rules:
#KC_SPI_X509CERT_LOOKUP_HYBRID_TRUSTED_PROXY_CERT_SHA256=<sha256hex>[,<sha256hex-next-rotation>]
#KC_SPI_X509CERT_LOOKUP_HYBRID_TRUSTED_PROXY_CIDRS=10.x.y.0/28
#KC_SPI_X509CERT_LOOKUP_HYBRID_TRUSTED_PROXY_SUBJECT_DN=CN=f5.internal.example,OU=Infra,O=YourOrg
# ...then flip:
#KC_SPI_X509CERT_LOOKUP_HYBRID_TRUST_MODE=enforce
```
> Keycloak 26 prefers a double-dash format (`KC_SPI_X509CERT_LOOKUP__HYBRID__TRUST_MODE`);
> the single-dash form above works on 2426 (26 logs a deprecation warning). Use one style
> consistently.
All options (prefix `spi-x509cert-lookup-hybrid-`):
| Option | Default | Meaning |
|---|---|---|
| `ssl-client-cert` | `ssl-client-cert` | Header with the user cert (PEM, URL-encoded PEM, or base64 DER — auto-detected) |
| `ssl-cert-chain-prefix` | `ssl-cert-chain` | Optional chain headers `<prefix>-0..n` |
| `certificate-chain-length` | `1` | Max chain headers to read |
| `trust-mode` | `log` | `log` = honor all headers, warn on untrusted; `enforce` = ignore headers from untrusted peers |
| `trusted-proxy-cert-sha256` | — | Comma-separated SHA-256 fingerprints of the F5's client cert(s). **Strongest check.** List old+new during rotations. |
| `trusted-proxy-subject-dn` | — | `\|`-separated exact subject DNs (RFC2253). Safe because TLS already validated the chain. Survives rotation. |
| `trusted-proxy-cidrs` | — | Comma-separated CIDRs/IPs for the F5's egress addresses. Weakest; requires NLB client-IP preservation. |
| `header-cert-enabled` | `true` | Master switch for the header path |
| `direct-cert-enabled` | `true` | Master switch for the direct-TLS path |
| `rebuild-chain-from-truststore` | `true` | Rebuild issuer chain for header certs from the Keycloak truststore (matches nginx-provider behavior) |
| `truststore-chain-depth` | `4` | Max issuers appended during rebuild |
| `verbose` | `true` | Per-request INFO logging of the decision (peer fingerprint, source IP, path taken). Set `false` after rollout. |
**Truststore:** your existing truststore (already working for header-cert validation) must
also be reachable by the TLS layer so Keycloak can request/validate browser certs on the
direct path. On KC 25+ point `KC_TRUSTSTORE_PATHS` at your CA bundle; on KC 24 use the
`spi-truststore-file-*` options you likely already have. The CAs listed there are also what
browsers use to filter the cert-picker dialog.
## 4. Rollout / test plan (one 30-min cycle each, in order)
**Cycle 0 — no deploy, info gathering.** From the current deployment grab:
`KC_SPI_X509CERT_LOOKUP_NGINX_SSL_CLIENT_CERT` (header name), truststore config, base image
tag, and whether `start --optimized` is used. Ask the F5 team to start on the one-pager
(section 6) in parallel.
**Cycle 1 — deploy in log mode (zero behavior change expected).**
Jar + `KC_HTTPS_CLIENT_AUTH=request` + provider=hybrid + `trust-mode=log`.
Verify: existing F5 CAC login still works. Then grep logs for `x509-hybrid`:
- startup line shows the parsed config;
- each F5 login logs `remoteAddr=` (→ your CIDR value) and `peer=` (→ `no-tls-client-cert`
until the F5 presents one);
- `header cert decoded using strategy '...'` (DEBUG) confirms the F5's encoding.
If a TGW-side test client exists already, hit Keycloak directly: expect a browser cert
prompt and `using DIRECT TLS peer cert` in the logs.
**Cycle 2 — F5 presents its client cert.** After the F5 change, each F5 login logs
`peer=subject=[...] sha256=<fingerprint>` — that fingerprint is your
`trusted-proxy-cert-sha256` value. Confirm logins still work (`request` mode tolerates the
new cert automatically, provided the F5 cert's CA is in the truststore).
**Cycle 3 — enforce.** Set the trust rules + `trust-mode=enforce`. Verify: F5 login works,
direct TGW login works, and the negative test — from a TGW-side box,
`curl -k https://keycloak.../realms/<realm>/account -H "ssl-client-cert: <any user PEM>"`
produces `IGNORED cert header from untrusted peer` in the logs and no authentication.
**Cycle 4 — quiet down.** `verbose=false`, optionally keep DEBUG off. Done.
Rollback at any cycle: set `KC_SPI_X509CERT_LOOKUP_PROVIDER=nginx` and restart — the
built-in provider and all its config are untouched by this deployment.
## 5. Security notes
- **Never** leave `trust-mode=log` long-term once TGW routes are open: it preserves the
legacy trust-any-header behavior. The provider logs a warning at startup to this effect.
- Fingerprint pinning breaks when the F5 rotates its client cert — list the next cert's
fingerprint alongside the current one before rotation, or rely on `subject-dn` (which
survives rotation and is chain-validated by the TLS layer in `request` mode).
- Source-IP trust requires the NLB target group to have **client IP preservation** enabled
(targets registered by instance ID have it on by default; by IP it's configurable). The
`remoteAddr=` log line from Cycle 1 tells you definitively what Keycloak sees.
- The F5 pre-screens users against an allowlist and (presumably) checks revocation; the
direct path has no such screen. Enable **OCSP/CRL revocation checking** in the realm's
X509 authenticator config (Authentication → your browser x509 flow → config) if it isn't
already on, and confirm the cert-to-user mapping attribute is strict enough that only
provisioned brokerage users resolve.
- `https-client-auth=request` sends a TLS CertificateRequest on every connection. Browsers
without a matching cert and server-to-server OIDC clients simply continue certless; users
hitting Keycloak directly get the platform cert-picker (expected CAC behavior).
## 6. One-pager for the F5 team
> Keycloak's listener will request (not require) a TLS client certificate. We need the
> BigIP virtual server that fronts Keycloak to authenticate itself on its **server-side
> (re-encrypt) SSL profile**:
> 1. Issue a client certificate for the BigIP from an internal CA (or reuse an existing
> device cert). Any subject is fine, e.g. `CN=bigip-keycloak-proxy,OU=Infra,O=<org>`.
> 2. On the **Server SSL profile** used for the Keycloak pool: set this certificate/key so
> the BigIP presents it during the TLS handshake with the backend.
> 3. Send us: the certificate's SHA-256 fingerprint
> (`openssl x509 -in cert.pem -noout -fingerprint -sha256`), its exact subject DN, the
> issuing CA chain (PEM), and the self-IP/SNAT addresses the BigIP uses toward the
> Keycloak NLB.
> 4. No iRule/header changes needed — keep injecting the client cert header exactly as today.
> Timing note: this can be deployed before or after our Keycloak change; the Keycloak side
> is backward compatible either way (`https-client-auth=request` is optional-cert).
## 7. Troubleshooting via logs (category `com.example.keycloak.x509`)
| Log line | Meaning |
|---|---|
| `initialized. trust-mode=...` | Config as parsed at startup — check this first |
| `using HEADER cert from trusted proxy` | F5 path, healthy (enforced) |
| `honoring cert header from UNTRUSTED peer` | Log-mode: would fail in enforce — fix trust rules before flipping |
| `IGNORED cert header from untrusted peer` | Enforce-mode rejection: forgery attempt, or your F5 trust rule is wrong/stale |
| `using DIRECT TLS peer cert` | TGW path, healthy |
| `trusted proxy connection without usable cert header` | F5 request with no/blank header (e.g. health checks) — normal |
| `failed to parse cert header` | Unexpected header encoding — the logged prefix shows what arrived |
| `no certificate found` | Certless request (normal for non-CAC flows) |
Set `KC_LOG_LEVEL=INFO,com.example.keycloak.x509:debug` during rollout for
decode-strategy and chain-rebuild details.