commit cc9b0a37f26fc30a88dcb72f1a541df4c98f9ac2 Author: scott Date: Wed Jul 1 13:59:12 2026 -0700 first commit diff --git a/keycloak-hybrid-x509-spi/Dockerfile.example b/keycloak-hybrid-x509-spi/Dockerfile.example new file mode 100644 index 0000000..67659c3 --- /dev/null +++ b/keycloak-hybrid-x509-spi/Dockerfile.example @@ -0,0 +1,26 @@ +# Example: adding the hybrid x509 SPI to a custom Keycloak image. +# Merge these lines into your EXISTING Dockerfile — keep your current base tag, +# build options (KC_DB, KC_FEATURES, ...), and entrypoint. + +FROM quay.io/keycloak/keycloak:26.0 AS builder + +COPY target/keycloak-hybrid-x509-spi-1.0.0.jar /opt/keycloak/providers/ + +# Build-time option: must be set before `kc.sh build` (runtime-only won't work +# for images started with --optimized). `request` = client cert OPTIONAL. +ENV KC_HTTPS_CLIENT_AUTH=request + +RUN /opt/keycloak/bin/kc.sh build + +FROM quay.io/keycloak/keycloak:26.0 +COPY --from=builder /opt/keycloak/ /opt/keycloak/ + +# Runtime config (usually set via Helm/manifest env instead of baked in): +# KC_SPI_X509CERT_LOOKUP_PROVIDER=hybrid +# KC_SPI_X509CERT_LOOKUP_HYBRID_SSL_CLIENT_CERT= +# KC_SPI_X509CERT_LOOKUP_HYBRID_TRUST_MODE=log # then 'enforce' after cycle 3 +# KC_TRUSTSTORE_PATHS=/opt/keycloak/conf/truststores # CA bundle (KC 25+) +# KC_LOG_LEVEL=INFO,com.example.keycloak.x509:debug # during rollout + +ENTRYPOINT ["/opt/keycloak/bin/kc.sh"] +CMD ["start", "--optimized"] diff --git a/keycloak-hybrid-x509-spi/README.md b/keycloak-hybrid-x509-spi/README.md new file mode 100644 index 0000000..17a5b2e --- /dev/null +++ b/keycloak-hybrid-x509-spi/README.md @@ -0,0 +1,198 @@ +# 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 `` in `pom.xml` to match +your deployed version (any 24.x–26.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=[,] +#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 24–26 (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 `-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=` — 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//account -H "ssl-client-cert: "` — +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=`. +> 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. diff --git a/keycloak-hybrid-x509-spi/pom.xml b/keycloak-hybrid-x509-spi/pom.xml new file mode 100644 index 0000000..97e105a --- /dev/null +++ b/keycloak-hybrid-x509-spi/pom.xml @@ -0,0 +1,84 @@ + + + 4.0.0 + + com.example.keycloak + keycloak-hybrid-x509-spi + 1.0.0 + jar + + Keycloak Hybrid X509 Certificate Lookup SPI + + X509ClientCertificateLookup provider that accepts the client certificate either from a + reverse-proxy header (F5 BigIP break-and-inspect path) when the request comes from a + trusted proxy, or directly from the TLS handshake (direct/TGW path). + + + + + 26.0.8 + 17 + UTF-8 + + + + + + org.keycloak + keycloak-core + ${keycloak.version} + provided + + + org.keycloak + keycloak-server-spi + ${keycloak.version} + provided + + + org.keycloak + keycloak-server-spi-private + ${keycloak.version} + provided + + + org.keycloak + keycloak-services + ${keycloak.version} + provided + + + jakarta.ws.rs + jakarta.ws.rs-api + 3.1.0 + provided + + + org.jboss.logging + jboss-logging + 3.5.3.Final + provided + + + + org.junit.jupiter + junit-jupiter + 5.10.2 + test + + + + + ${project.artifactId}-${project.version} + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + + diff --git a/keycloak-hybrid-x509-spi/src/main/java/com/example/keycloak/x509/CertDecoding.java b/keycloak-hybrid-x509-spi/src/main/java/com/example/keycloak/x509/CertDecoding.java new file mode 100644 index 0000000..f1a8513 --- /dev/null +++ b/keycloak-hybrid-x509-spi/src/main/java/com/example/keycloak/x509/CertDecoding.java @@ -0,0 +1,143 @@ +package com.example.keycloak.x509; + +import org.jboss.logging.Logger; + +import java.io.ByteArrayInputStream; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Decodes a client certificate forwarded in an HTTP header, tolerating the encoding + * variants seen across proxies (nginx, F5 iRules, HAProxy): + * + * - plain PEM (possibly with newlines folded to spaces or tabs by header handling) + * - URL-encoded PEM (nginx $ssl_client_escaped_cert style) + * - percent-encoded PEM where '+' is a literal base64 char (some F5 iRules only + * escape newlines, so a full URL-decode would corrupt '+' into a space) + * - bare base64-encoded DER (no PEM markers) + * + * Strategies are tried in order; the first one that yields a certificate wins, and the + * winning strategy is logged at DEBUG so one test run reveals the F5's actual format. + */ +final class CertDecoding { + + private static final Logger logger = Logger.getLogger(CertDecoding.class); + + private static final Pattern PEM_BLOCK = Pattern.compile( + "-----BEGIN [A-Z0-9 ]*CERTIFICATE-----(.*?)-----END [A-Z0-9 ]*CERTIFICATE-----", + Pattern.DOTALL); + + private CertDecoding() { + } + + static List decodeToCertificates(String rawHeaderValue) throws Exception { + String raw = stripQuotes(rawHeaderValue.trim()); + Exception lastFailure = null; + + String[] strategies = {"as-is", "url-decoded", "percent-only-decoded", "base64-der"}; + for (String strategy : strategies) { + try { + List certs = switch (strategy) { + case "as-is" -> parsePem(raw); + case "url-decoded" -> parsePem(URLDecoder.decode(raw, StandardCharsets.UTF_8)); + case "percent-only-decoded" -> parsePem(decodePercentSequencesOnly(raw)); + case "base64-der" -> parseBareBase64Der(raw); + default -> null; + }; + if (certs != null && !certs.isEmpty()) { + logger.debugf("x509-hybrid: header cert decoded using strategy '%s' (%d cert(s))", + strategy, certs.size()); + return certs; + } + } catch (Exception e) { + lastFailure = e; + } + } + throw lastFailure != null ? lastFailure + : new IllegalArgumentException("header value did not contain a decodable certificate"); + } + + /** Extracts every PEM certificate block; returns null if no PEM markers are present. */ + private static List parsePem(String text) throws Exception { + if (!text.contains("-----BEGIN")) { + return null; + } + List certs = new ArrayList<>(); + Matcher m = PEM_BLOCK.matcher(text); + while (m.find()) { + // header transports fold newlines into spaces/tabs; strip all whitespace from the body + byte[] der = Base64.getMimeDecoder().decode(m.group(1).replaceAll("\\s+", "")); + certs.add(toCertificate(der)); + } + return certs.isEmpty() ? null : certs; + } + + private static List parseBareBase64Der(String text) throws Exception { + byte[] der = Base64.getMimeDecoder().decode(text.replaceAll("\\s+", "")); + List certs = new ArrayList<>(); + certs.add(toCertificate(der)); + return certs; + } + + private static X509Certificate toCertificate(byte[] der) throws Exception { + return (X509Certificate) CertificateFactory.getInstance("X.509") + .generateCertificate(new ByteArrayInputStream(der)); + } + + /** Decodes %XX sequences but leaves '+' alone (unlike URLDecoder, which turns it into a space). */ + static String decodePercentSequencesOnly(String s) { + StringBuilder out = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '%' && i + 2 < s.length()) { + try { + out.append((char) Integer.parseInt(s.substring(i + 1, i + 3), 16)); + i += 2; + continue; + } catch (NumberFormatException ignored) { + // not a valid escape; fall through and keep the literal '%' + } + } + out.append(c); + } + return out.toString(); + } + + private static String stripQuotes(String s) { + if (s.length() >= 2 && s.charAt(0) == '"' && s.charAt(s.length() - 1) == '"') { + return s.substring(1, s.length() - 1); + } + return s; + } + + /** Lowercase hex SHA-256 fingerprint without separators, e.g. "9f86d08188..." */ + static String sha256Fingerprint(X509Certificate cert) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(cert.getEncoded()); + StringBuilder sb = new StringBuilder(digest.length * 2); + for (byte b : digest) { + sb.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16)); + } + return sb.toString(); + } catch (Exception e) { + return null; + } + } + + /** Normalizes a user-supplied fingerprint: strips colons/spaces, lowercases. */ + static String normalizeFingerprint(String fp) { + return fp.replaceAll("[:\\s]", "").toLowerCase(); + } + + static String safePrefix(String s, int len) { + return s.length() <= len ? s : s.substring(0, len); + } +} diff --git a/keycloak-hybrid-x509-spi/src/main/java/com/example/keycloak/x509/CidrMatcher.java b/keycloak-hybrid-x509-spi/src/main/java/com/example/keycloak/x509/CidrMatcher.java new file mode 100644 index 0000000..eb61441 --- /dev/null +++ b/keycloak-hybrid-x509-spi/src/main/java/com/example/keycloak/x509/CidrMatcher.java @@ -0,0 +1,88 @@ +package com.example.keycloak.x509; + +import java.net.InetAddress; +import java.net.UnknownHostException; + +/** + * Matches an IP address against a CIDR block ("10.20.0.0/24", "fd00::/8") or a single + * address ("10.20.0.5", treated as /32 or /128). IPv4 and IPv6 supported. + */ +final class CidrMatcher { + + private final String original; + private final byte[] network; + private final int prefixBits; + + private CidrMatcher(String original, byte[] network, int prefixBits) { + this.original = original; + this.network = network; + this.prefixBits = prefixBits; + } + + static CidrMatcher parse(String cidr) { + String spec = cidr.trim(); + String addrPart = spec; + int prefix = -1; + int slash = spec.indexOf('/'); + if (slash >= 0) { + addrPart = spec.substring(0, slash); + prefix = Integer.parseInt(spec.substring(slash + 1)); + } + byte[] addr = resolveLiteral(addrPart); + int maxBits = addr.length * 8; + if (prefix < 0) { + prefix = maxBits; + } + if (prefix > maxBits) { + throw new IllegalArgumentException("prefix /" + prefix + " too long for " + addrPart); + } + return new CidrMatcher(spec, addr, prefix); + } + + boolean matches(String ip) { + byte[] candidate; + try { + candidate = resolveLiteral(stripScopeAndPort(ip)); + } catch (Exception e) { + return false; + } + if (candidate.length != network.length) { + return false; + } + int fullBytes = prefixBits / 8; + for (int i = 0; i < fullBytes; i++) { + if (candidate[i] != network[i]) { + return false; + } + } + int remainderBits = prefixBits % 8; + if (remainderBits == 0) { + return true; + } + int mask = 0xFF << (8 - remainderBits); + return (candidate[fullBytes] & mask) == (network[fullBytes] & mask); + } + + private static byte[] resolveLiteral(String addr) { + try { + // InetAddress.getByName on a literal does not hit DNS + return InetAddress.getByName(addr).getAddress(); + } catch (UnknownHostException e) { + throw new IllegalArgumentException("not an IP literal: " + addr, e); + } + } + + private static String stripScopeAndPort(String ip) { + String s = ip.trim(); + int scope = s.indexOf('%'); + if (scope >= 0) { + s = s.substring(0, scope); + } + return s; + } + + @Override + public String toString() { + return original; + } +} diff --git a/keycloak-hybrid-x509-spi/src/main/java/com/example/keycloak/x509/HybridX509ClientCertificateLookup.java b/keycloak-hybrid-x509-spi/src/main/java/com/example/keycloak/x509/HybridX509ClientCertificateLookup.java new file mode 100644 index 0000000..86d76fb --- /dev/null +++ b/keycloak-hybrid-x509-spi/src/main/java/com/example/keycloak/x509/HybridX509ClientCertificateLookup.java @@ -0,0 +1,302 @@ +package com.example.keycloak.x509; + +import jakarta.ws.rs.core.HttpHeaders; +import org.jboss.logging.Logger; +import org.keycloak.common.ClientConnection; +import org.keycloak.http.HttpRequest; +import org.keycloak.models.KeycloakSession; +import org.keycloak.services.x509.X509ClientCertificateLookup; +import org.keycloak.truststore.TruststoreProvider; + +import javax.security.auth.x500.X500Principal; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; + +/** + * Resolves the end-user X509 certificate from one of two sources: + * + * 1. HEADER PATH - the F5 BigIP terminates the user's mTLS, re-encrypts to Keycloak, + * and forwards the user's certificate in an HTTP header. + * 2. DIRECT PATH - the client (TGW / brokerage traffic that bypasses the F5) performs + * the TLS handshake directly with Keycloak, which runs with + * https-client-auth=request behind an L4-passthrough NLB. + * + * The header is only trustworthy when the request demonstrably came from the F5. + * Otherwise any client with network reach could forge the header and impersonate a user. + * Trust is established by (in order): the proxy's own TLS client certificate fingerprint, + * its certificate subject DN, or its source IP (see LookupConfig). + * + * Decision table (per request): + * + * header present + peer trusted -> use header cert + * header present + peer untrusted, LOG -> use header cert, WARN loudly + * header present + peer untrusted, ENFORCE -> ignore header, fall through to direct path + * no header + peer trusted -> null (the proxy's own cert is NOT a user) + * no header + peer untrusted -> use TLS peer cert if present, else null + */ +public class HybridX509ClientCertificateLookup implements X509ClientCertificateLookup { + + private static final Logger logger = Logger.getLogger(HybridX509ClientCertificateLookup.class); + + private final KeycloakSession session; + private final LookupConfig cfg; + + public HybridX509ClientCertificateLookup(KeycloakSession session, LookupConfig cfg) { + this.session = session; + this.cfg = cfg; + } + + @Override + public X509Certificate[] getCertificateChain(HttpRequest httpRequest) throws GeneralSecurityException { + X509Certificate[] peerChain = peerChain(httpRequest); + String remoteAddr = remoteAddr(); + TrustResult trust = evaluateProxyTrust(peerChain, remoteAddr); + + if (cfg.verbose) { + logger.infof("x509-hybrid: remoteAddr=%s peer=%s trust=%s", + remoteAddr, describePeer(peerChain), trust); + } + + List headerChain = cfg.headerCertEnabled + ? extractFromHeaders(httpRequest.getHttpHeaders()) + : null; + + if (headerChain != null && !headerChain.isEmpty()) { + if (trust.trusted()) { + if (cfg.verbose) { + logger.infof("x509-hybrid: using HEADER cert from trusted proxy (%s): subject=%s", + trust.reason(), headerChain.get(0).getSubjectX500Principal()); + } + return toChainArray(maybeRebuildChain(headerChain)); + } + if (cfg.trustMode == LookupConfig.TrustMode.LOG) { + logger.warnf("x509-hybrid: honoring cert header from UNTRUSTED peer remoteAddr=%s peer=%s " + + "(trust-mode=log). This request would be REJECTED in enforce mode. " + + "If this peer is your F5, add its cert fingerprint or source CIDR to the trusted-proxy config.", + remoteAddr, describePeer(peerChain)); + return toChainArray(maybeRebuildChain(headerChain)); + } + logger.warnf("x509-hybrid: IGNORED cert header from untrusted peer remoteAddr=%s peer=%s (trust-mode=enforce). " + + "Possible header forgery, or the F5 trust config is missing/stale.", + remoteAddr, describePeer(peerChain)); + // fall through: if this peer presented its own valid TLS cert, it may authenticate as itself + } + + if (trust.trusted()) { + // Connection is from the proxy itself; its client cert identifies the proxy, never a user. + if (cfg.verbose) { + logger.infof("x509-hybrid: trusted proxy connection without usable cert header -> no user certificate"); + } + return null; + } + + if (cfg.directCertEnabled && peerChain != null && peerChain.length > 0) { + if (cfg.verbose) { + logger.infof("x509-hybrid: using DIRECT TLS peer cert: remoteAddr=%s subject=%s", + remoteAddr, peerChain[0].getSubjectX500Principal()); + } + return peerChain; + } + + if (cfg.verbose) { + logger.infof("x509-hybrid: no certificate found (no header, no TLS peer cert)"); + } + return null; + } + + // ------------------------------------------------------------------ trust + + record TrustResult(boolean trusted, String reason) { + @Override + public String toString() { + return trusted ? "TRUSTED(" + reason + ")" : "untrusted"; + } + } + + private TrustResult evaluateProxyTrust(X509Certificate[] peerChain, String remoteAddr) { + if (peerChain != null && peerChain.length > 0) { + X509Certificate peer = peerChain[0]; + if (!cfg.trustedProxyCertSha256.isEmpty()) { + String fp = CertDecoding.sha256Fingerprint(peer); + if (fp != null && cfg.trustedProxyCertSha256.contains(fp)) { + return new TrustResult(true, "cert-fingerprint"); + } + } + if (!cfg.trustedProxySubjectDns.isEmpty()) { + String dn = peer.getSubjectX500Principal().getName(X500Principal.RFC2253); + for (String allowed : cfg.trustedProxySubjectDns) { + if (dn.equalsIgnoreCase(allowed)) { + // NOTE: safe only because Keycloak's TLS layer (https-client-auth=request) + // already validated this cert against the server truststore. + return new TrustResult(true, "cert-subject-dn"); + } + } + } + } + if (remoteAddr != null && !cfg.trustedProxyCidrs.isEmpty()) { + for (CidrMatcher cidr : cfg.trustedProxyCidrs) { + if (cidr.matches(remoteAddr)) { + return new TrustResult(true, "source-ip " + cidr); + } + } + } + return new TrustResult(false, null); + } + + // ----------------------------------------------------------------- header + + private List extractFromHeaders(HttpHeaders headers) { + String raw = firstHeader(headers, cfg.sslClientCertHeader); + if (raw == null || raw.isBlank() || "(null)".equals(raw.trim())) { + return null; + } + List chain; + try { + chain = CertDecoding.decodeToCertificates(raw); + } catch (Exception e) { + logger.warnf("x509-hybrid: failed to parse cert header '%s' (length=%d, starts with '%s...'): %s", + cfg.sslClientCertHeader, raw.length(), CertDecoding.safePrefix(raw, 30), e.getMessage()); + return null; + } + // Optional extra chain headers (prefix-0, prefix-1, ...), nginx-style. The F5 usually + // sends only the leaf; missing chain headers are normal and the chain is rebuilt from + // the truststore instead. + for (int i = 0; i < cfg.certificateChainLength; i++) { + String chainRaw = firstHeader(headers, cfg.sslCertChainPrefix + "-" + i); + if (chainRaw == null || chainRaw.isBlank() || "(null)".equals(chainRaw.trim())) { + break; + } + try { + chain.addAll(CertDecoding.decodeToCertificates(chainRaw)); + } catch (Exception e) { + logger.warnf("x509-hybrid: failed to parse chain header '%s-%d': %s", + cfg.sslCertChainPrefix, i, e.getMessage()); + break; + } + } + return chain; + } + + private static String firstHeader(HttpHeaders headers, String name) { + return headers == null ? null : headers.getRequestHeaders().getFirst(name); + } + + /** + * The X509 authenticator validates the full chain it is given. Proxies typically forward + * only the leaf certificate, so (like Keycloak's built-in nginx provider) we rebuild the + * issuer chain from the server truststore. + */ + private List maybeRebuildChain(List chain) { + if (!cfg.rebuildChainFromTruststore || chain.size() != 1) { + return chain; + } + try { + TruststoreProvider truststoreProvider = session.getProvider(TruststoreProvider.class); + KeyStore truststore = truststoreProvider == null ? null : truststoreProvider.getTruststore(); + if (truststore == null) { + logger.debug("x509-hybrid: no truststore available; passing leaf cert only"); + return chain; + } + List anchors = truststoreCertificates(truststore); + List rebuilt = new ArrayList<>(chain); + X509Certificate current = chain.get(0); + for (int depth = 0; depth < cfg.truststoreChainDepth; depth++) { + if (isSelfSigned(current)) { + break; + } + X509Certificate issuer = findIssuer(current, anchors); + if (issuer == null) { + break; + } + rebuilt.add(issuer); + current = issuer; + } + if (cfg.verbose && rebuilt.size() > 1) { + logger.infof("x509-hybrid: rebuilt chain from truststore, length=%d", rebuilt.size()); + } + return rebuilt; + } catch (Exception e) { + logger.debugf("x509-hybrid: chain rebuild failed, passing leaf only: %s", e.getMessage()); + return chain; + } + } + + private static List truststoreCertificates(KeyStore truststore) throws Exception { + List certs = new ArrayList<>(); + Enumeration aliases = truststore.aliases(); + while (aliases.hasMoreElements()) { + Certificate c = truststore.getCertificate(aliases.nextElement()); + if (c instanceof X509Certificate x509) { + certs.add(x509); + } + } + return certs; + } + + private static X509Certificate findIssuer(X509Certificate cert, List candidates) { + for (X509Certificate candidate : candidates) { + if (cert.getIssuerX500Principal().equals(candidate.getSubjectX500Principal())) { + try { + cert.verify(candidate.getPublicKey()); + return candidate; + } catch (Exception ignored) { + // same DN, wrong key (e.g. rotated CA) - keep looking + } + } + } + return null; + } + + private static boolean isSelfSigned(X509Certificate cert) { + if (!cert.getSubjectX500Principal().equals(cert.getIssuerX500Principal())) { + return false; + } + try { + cert.verify(cert.getPublicKey()); + return true; + } catch (Exception e) { + return false; + } + } + + // ------------------------------------------------------------------ misc + + private X509Certificate[] peerChain(HttpRequest httpRequest) { + try { + return httpRequest.getClientCertificateChain(); + } catch (Exception e) { + logger.debugf("x509-hybrid: could not read TLS peer chain: %s", e.getMessage()); + return null; + } + } + + private String remoteAddr() { + try { + ClientConnection connection = session.getContext().getConnection(); + return connection == null ? null : connection.getRemoteAddr(); + } catch (Exception e) { + return null; + } + } + + private static String describePeer(X509Certificate[] peerChain) { + if (peerChain == null || peerChain.length == 0) { + return "no-tls-client-cert"; + } + X509Certificate peer = peerChain[0]; + return "subject=[" + peer.getSubjectX500Principal() + "] sha256=" + CertDecoding.sha256Fingerprint(peer); + } + + private static X509Certificate[] toChainArray(List chain) { + return chain.toArray(new X509Certificate[0]); + } + + @Override + public void close() { + } +} diff --git a/keycloak-hybrid-x509-spi/src/main/java/com/example/keycloak/x509/HybridX509ClientCertificateLookupFactory.java b/keycloak-hybrid-x509-spi/src/main/java/com/example/keycloak/x509/HybridX509ClientCertificateLookupFactory.java new file mode 100644 index 0000000..8ef1abc --- /dev/null +++ b/keycloak-hybrid-x509-spi/src/main/java/com/example/keycloak/x509/HybridX509ClientCertificateLookupFactory.java @@ -0,0 +1,41 @@ +package com.example.keycloak.x509; + +import org.keycloak.Config; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.services.x509.X509ClientCertificateLookup; +import org.keycloak.services.x509.X509ClientCertificateLookupFactory; + +/** + * Factory for the "hybrid" X509 client certificate lookup provider. + * Activate with: --spi-x509cert-lookup-provider=hybrid + */ +public class HybridX509ClientCertificateLookupFactory implements X509ClientCertificateLookupFactory { + + public static final String PROVIDER_ID = "hybrid"; + + private volatile LookupConfig config; + + @Override + public X509ClientCertificateLookup create(KeycloakSession session) { + return new HybridX509ClientCertificateLookup(session, config); + } + + @Override + public void init(Config.Scope scope) { + this.config = LookupConfig.from(scope); + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return PROVIDER_ID; + } +} diff --git a/keycloak-hybrid-x509-spi/src/main/java/com/example/keycloak/x509/LookupConfig.java b/keycloak-hybrid-x509-spi/src/main/java/com/example/keycloak/x509/LookupConfig.java new file mode 100644 index 0000000..21080b0 --- /dev/null +++ b/keycloak-hybrid-x509-spi/src/main/java/com/example/keycloak/x509/LookupConfig.java @@ -0,0 +1,114 @@ +package com.example.keycloak.x509; + +import org.jboss.logging.Logger; +import org.keycloak.Config; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Configuration for the hybrid provider, read from Keycloak SPI options: + * + * --spi-x509cert-lookup-hybrid-