first commit
This commit is contained in:
26
keycloak-hybrid-x509-spi/Dockerfile.example
Normal file
26
keycloak-hybrid-x509-spi/Dockerfile.example
Normal file
@@ -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=<your existing header name>
|
||||||
|
# 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"]
|
||||||
198
keycloak-hybrid-x509-spi/README.md
Normal file
198
keycloak-hybrid-x509-spi/README.md
Normal file
@@ -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 `<keycloak.version>` 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=<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 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 `<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.
|
||||||
84
keycloak-hybrid-x509-spi/pom.xml
Normal file
84
keycloak-hybrid-x509-spi/pom.xml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>com.example.keycloak</groupId>
|
||||||
|
<artifactId>keycloak-hybrid-x509-spi</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<name>Keycloak Hybrid X509 Certificate Lookup SPI</name>
|
||||||
|
<description>
|
||||||
|
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).
|
||||||
|
</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<!-- Set this to match your deployed Keycloak version exactly (24.x - 26.x supported). -->
|
||||||
|
<keycloak.version>26.0.8</keycloak.version>
|
||||||
|
<maven.compiler.release>17</maven.compiler.release>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- All Keycloak deps are 'provided': the server supplies them at runtime.
|
||||||
|
The final jar must contain ONLY our classes. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.keycloak</groupId>
|
||||||
|
<artifactId>keycloak-core</artifactId>
|
||||||
|
<version>${keycloak.version}</version>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.keycloak</groupId>
|
||||||
|
<artifactId>keycloak-server-spi</artifactId>
|
||||||
|
<version>${keycloak.version}</version>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.keycloak</groupId>
|
||||||
|
<artifactId>keycloak-server-spi-private</artifactId>
|
||||||
|
<version>${keycloak.version}</version>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.keycloak</groupId>
|
||||||
|
<artifactId>keycloak-services</artifactId>
|
||||||
|
<version>${keycloak.version}</version>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>jakarta.ws.rs</groupId>
|
||||||
|
<artifactId>jakarta.ws.rs-api</artifactId>
|
||||||
|
<version>3.1.0</version>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.jboss.logging</groupId>
|
||||||
|
<artifactId>jboss-logging</artifactId>
|
||||||
|
<version>3.5.3.Final</version>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<version>5.10.2</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<finalName>${project.artifactId}-${project.version}</finalName>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<version>3.2.5</version>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -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<X509Certificate> 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<X509Certificate> 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<X509Certificate> parsePem(String text) throws Exception {
|
||||||
|
if (!text.contains("-----BEGIN")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<X509Certificate> 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<X509Certificate> parseBareBase64Der(String text) throws Exception {
|
||||||
|
byte[] der = Base64.getMimeDecoder().decode(text.replaceAll("\\s+", ""));
|
||||||
|
List<X509Certificate> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<X509Certificate> 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<X509Certificate> extractFromHeaders(HttpHeaders headers) {
|
||||||
|
String raw = firstHeader(headers, cfg.sslClientCertHeader);
|
||||||
|
if (raw == null || raw.isBlank() || "(null)".equals(raw.trim())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<X509Certificate> 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<X509Certificate> maybeRebuildChain(List<X509Certificate> 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<X509Certificate> anchors = truststoreCertificates(truststore);
|
||||||
|
List<X509Certificate> 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<X509Certificate> truststoreCertificates(KeyStore truststore) throws Exception {
|
||||||
|
List<X509Certificate> certs = new ArrayList<>();
|
||||||
|
Enumeration<String> 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<X509Certificate> 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<X509Certificate> chain) {
|
||||||
|
return chain.toArray(new X509Certificate[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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-<option>=<value>
|
||||||
|
* (env: KC_SPI_X509CERT_LOOKUP_HYBRID_<OPTION_WITH_UNDERSCORES>)
|
||||||
|
*
|
||||||
|
* Keycloak 26+ also accepts the double-dash form --spi-x509cert-lookup--hybrid--<option>.
|
||||||
|
*/
|
||||||
|
final class LookupConfig {
|
||||||
|
|
||||||
|
private static final Logger logger = Logger.getLogger(LookupConfig.class);
|
||||||
|
|
||||||
|
enum TrustMode { LOG, ENFORCE }
|
||||||
|
|
||||||
|
/** Header carrying the end-user certificate. Copy the value from your existing
|
||||||
|
* spi-x509cert-lookup-nginx-ssl-client-cert setting. */
|
||||||
|
final String sslClientCertHeader;
|
||||||
|
/** Optional chain header prefix; headers are read as "<prefix>-0", "<prefix>-1", ... */
|
||||||
|
final String sslCertChainPrefix;
|
||||||
|
final int certificateChainLength;
|
||||||
|
|
||||||
|
final TrustMode trustMode;
|
||||||
|
final Set<String> trustedProxyCertSha256;
|
||||||
|
final Set<String> trustedProxySubjectDns;
|
||||||
|
final List<CidrMatcher> trustedProxyCidrs;
|
||||||
|
|
||||||
|
final boolean headerCertEnabled;
|
||||||
|
final boolean directCertEnabled;
|
||||||
|
final boolean rebuildChainFromTruststore;
|
||||||
|
final int truststoreChainDepth;
|
||||||
|
final boolean verbose;
|
||||||
|
|
||||||
|
private LookupConfig(Config.Scope scope) {
|
||||||
|
this.sslClientCertHeader = scope.get("ssl-client-cert", "ssl-client-cert");
|
||||||
|
this.sslCertChainPrefix = scope.get("ssl-cert-chain-prefix", "ssl-cert-chain");
|
||||||
|
this.certificateChainLength = scope.getInt("certificate-chain-length", 1);
|
||||||
|
|
||||||
|
String mode = scope.get("trust-mode", "log");
|
||||||
|
this.trustMode = "enforce".equalsIgnoreCase(mode) ? TrustMode.ENFORCE : TrustMode.LOG;
|
||||||
|
|
||||||
|
this.trustedProxyCertSha256 = splitList(scope.get("trusted-proxy-cert-sha256"), ",").stream()
|
||||||
|
.map(CertDecoding::normalizeFingerprint)
|
||||||
|
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||||
|
// DNs contain commas, so this list is '|'-separated
|
||||||
|
this.trustedProxySubjectDns = new LinkedHashSet<>(splitList(scope.get("trusted-proxy-subject-dn"), "\\|"));
|
||||||
|
|
||||||
|
List<CidrMatcher> cidrs = new ArrayList<>();
|
||||||
|
for (String cidr : splitList(scope.get("trusted-proxy-cidrs"), ",")) {
|
||||||
|
try {
|
||||||
|
cidrs.add(CidrMatcher.parse(cidr));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.errorf("x509-hybrid: invalid trusted-proxy-cidrs entry '%s' ignored: %s", cidr, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.trustedProxyCidrs = cidrs;
|
||||||
|
|
||||||
|
this.headerCertEnabled = scope.getBoolean("header-cert-enabled", true);
|
||||||
|
this.directCertEnabled = scope.getBoolean("direct-cert-enabled", true);
|
||||||
|
this.rebuildChainFromTruststore = scope.getBoolean("rebuild-chain-from-truststore", true);
|
||||||
|
this.truststoreChainDepth = scope.getInt("truststore-chain-depth", 4);
|
||||||
|
this.verbose = scope.getBoolean("verbose", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
static LookupConfig from(Config.Scope scope) {
|
||||||
|
LookupConfig cfg = new LookupConfig(scope);
|
||||||
|
|
||||||
|
logger.infof("x509-hybrid: initialized. trust-mode=%s header=%s chain-prefix=%s chain-length=%d "
|
||||||
|
+ "trusted-fingerprints=%d trusted-dns=%d trusted-cidrs=%s "
|
||||||
|
+ "header-cert-enabled=%s direct-cert-enabled=%s rebuild-chain=%s verbose=%s",
|
||||||
|
cfg.trustMode, cfg.sslClientCertHeader, cfg.sslCertChainPrefix, cfg.certificateChainLength,
|
||||||
|
cfg.trustedProxyCertSha256.size(), cfg.trustedProxySubjectDns.size(), cfg.trustedProxyCidrs,
|
||||||
|
cfg.headerCertEnabled, cfg.directCertEnabled, cfg.rebuildChainFromTruststore, cfg.verbose);
|
||||||
|
|
||||||
|
boolean noTrustRules = cfg.trustedProxyCertSha256.isEmpty()
|
||||||
|
&& cfg.trustedProxySubjectDns.isEmpty()
|
||||||
|
&& cfg.trustedProxyCidrs.isEmpty();
|
||||||
|
if (noTrustRules && cfg.trustMode == TrustMode.ENFORCE) {
|
||||||
|
logger.error("x509-hybrid: trust-mode=enforce with NO trusted proxy rules configured - "
|
||||||
|
+ "ALL cert headers will be ignored and header-based (F5) logins WILL FAIL. "
|
||||||
|
+ "Configure trusted-proxy-cert-sha256 / trusted-proxy-subject-dn / trusted-proxy-cidrs.");
|
||||||
|
} else if (noTrustRules) {
|
||||||
|
logger.warn("x509-hybrid: running in trust-mode=log with no trusted proxy rules: cert headers are "
|
||||||
|
+ "accepted from ANY peer (matches legacy nginx-provider behavior, vulnerable to header "
|
||||||
|
+ "forgery once non-F5 traffic can reach this listener). Watch the logs for peer "
|
||||||
|
+ "fingerprints/IPs, configure trust rules, then switch to trust-mode=enforce.");
|
||||||
|
} else if (cfg.trustMode == TrustMode.LOG) {
|
||||||
|
logger.warn("x509-hybrid: trust rules configured but trust-mode=log - forged headers are still "
|
||||||
|
+ "honored (with warnings). Switch to trust-mode=enforce once the warnings are clean.");
|
||||||
|
}
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> splitList(String value, String separatorRegex) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return Arrays.stream(value.split(separatorRegex))
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(s -> !s.isEmpty())
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
com.example.keycloak.x509.HybridX509ClientCertificateLookupFactory
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package com.example.keycloak.x509;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.cert.X509Certificate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class CertDecodingTest {
|
||||||
|
|
||||||
|
// Self-signed test certificate, CN=test.user.1234567890
|
||||||
|
private static final String TEST_PEM = """
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDhzCCAm+gAwIBAgIUBaOeyED21UOh/33woCQe9GRwy9cwDQYJKoZIhvcNAQEL
|
||||||
|
BQAwUzELMAkGA1UEBhMCVVMxETAPBgNVBAoMCFRlc3QgT3JnMRIwEAYDVQQLDAlU
|
||||||
|
ZXN0VXNlcnMxHTAbBgNVBAMMFHRlc3QudXNlci4xMjM0NTY3ODkwMB4XDTI2MDcw
|
||||||
|
MTIwNTEzMloXDTM2MDYyODIwNTEzMlowUzELMAkGA1UEBhMCVVMxETAPBgNVBAoM
|
||||||
|
CFRlc3QgT3JnMRIwEAYDVQQLDAlUZXN0VXNlcnMxHTAbBgNVBAMMFHRlc3QudXNl
|
||||||
|
ci4xMjM0NTY3ODkwMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtbhO
|
||||||
|
nZyqiatx3Zf3SWFbAvGsCf3QAKII6rkhsE68qm5Ai4qgsd5wAW6BOOYP7JC/b/b1
|
||||||
|
z4I5PnXINMD8w4cae/KcXQH/+wyqG0pp1cGwH6Id2nsruaknHJx4cUT+Kp8Eo9ZV
|
||||||
|
n32pM0S2q1cl+UwzQI7yhTN1dHzx912ijhkmfD3xw9OArTjQKyx35RoxGhy9Svhp
|
||||||
|
GJrovGe2/BQ0QGIcRJe0Y/YPycbhEA8RpzQ+JDKBYc0Kqri6We1HP58NPJazsZtT
|
||||||
|
wl7PsRETWy9tCPitI8oVwyYuQn84aBIoASd3/USKiMGnl5V1pTLZBX4K+Qmg6wO3
|
||||||
|
/f4M3A93LjebSxhOBwIDAQABo1MwUTAdBgNVHQ4EFgQUsfzo2qXmnf53eIVCFtta
|
||||||
|
rci/yTIwHwYDVR0jBBgwFoAUsfzo2qXmnf53eIVCFttarci/yTIwDwYDVR0TAQH/
|
||||||
|
BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEARGNHhtQNvhlx+7tIDo3IyAQ6uP0N
|
||||||
|
esCg8r2a/P6OzhOQGwztzgSRzFlG/cLDshJsaXHgltKDVF1sQqpF/90IFV0vAmLT
|
||||||
|
Gsy+UTsDPfk5KCM5AxsNAXm7UC3aJ3LivCDw7nF+UMWutwSCXI1LUdaTBZtNOKBL
|
||||||
|
FyiRXD6ueSOQ4pyuUyDJLDnNhNdBIzqTzhSA0d2zbBiZPgDk7NEsNncKroaFopLd
|
||||||
|
XoGIMK3kA9MoiQilVByYLNp0kKPjD59VHdPy3ci7TpH59hyzVjGROESm+UddH1dS
|
||||||
|
kDMUxZ2XCvivjLeP2YNqDCFk4O/ceQCv7Kbtjve/BHUudtu8DDuo3CDTcg==
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String EXPECTED_CN = "CN=test.user.1234567890";
|
||||||
|
|
||||||
|
private static void assertIsTestCert(List<X509Certificate> certs) {
|
||||||
|
assertNotNull(certs);
|
||||||
|
assertEquals(1, certs.size());
|
||||||
|
assertTrue(certs.get(0).getSubjectX500Principal().getName().contains(EXPECTED_CN));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void plainPem() throws Exception {
|
||||||
|
assertIsTestCert(CertDecoding.decodeToCertificates(TEST_PEM));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pemWithNewlinesFoldedToSpaces() throws Exception {
|
||||||
|
// header transports commonly fold newlines into spaces
|
||||||
|
String folded = TEST_PEM.strip().replace("\n", " ");
|
||||||
|
assertIsTestCert(CertDecoding.decodeToCertificates(folded));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pemWithNewlinesFoldedToTabs() throws Exception {
|
||||||
|
String folded = TEST_PEM.strip().replace("\n", "\t");
|
||||||
|
assertIsTestCert(CertDecoding.decodeToCertificates(folded));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void urlEncodedPem() throws Exception {
|
||||||
|
// nginx $ssl_client_escaped_cert style: full URI-component encoding
|
||||||
|
String encoded = URLEncoder.encode(TEST_PEM.strip(), StandardCharsets.UTF_8);
|
||||||
|
assertIsTestCert(CertDecoding.decodeToCertificates(encoded));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void percentEncodedNewlinesOnlyWithLiteralPlusChars() throws Exception {
|
||||||
|
// F5 iRule style that escapes only newlines: '+' stays literal. A naive
|
||||||
|
// URLDecoder pass would corrupt '+' to ' '; the percent-only strategy must win.
|
||||||
|
String encoded = TEST_PEM.strip().replace("\n", "%0A");
|
||||||
|
assertTrue(encoded.contains("+"), "test cert body should exercise literal '+' chars");
|
||||||
|
assertIsTestCert(CertDecoding.decodeToCertificates(encoded));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bareBase64Der() throws Exception {
|
||||||
|
String base64Body = TEST_PEM
|
||||||
|
.replace("-----BEGIN CERTIFICATE-----", "")
|
||||||
|
.replace("-----END CERTIFICATE-----", "")
|
||||||
|
.replaceAll("\\s+", "");
|
||||||
|
assertIsTestCert(CertDecoding.decodeToCertificates(base64Body));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void quotedHeaderValue() throws Exception {
|
||||||
|
assertIsTestCert(CertDecoding.decodeToCertificates("\"" + TEST_PEM.strip() + "\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void garbageThrows() {
|
||||||
|
assertThrows(Exception.class, () -> CertDecoding.decodeToCertificates("not-a-certificate!!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fingerprintIsStableAndNormalized() throws Exception {
|
||||||
|
List<X509Certificate> certs = CertDecoding.decodeToCertificates(TEST_PEM);
|
||||||
|
String fp = CertDecoding.sha256Fingerprint(certs.get(0));
|
||||||
|
assertNotNull(fp);
|
||||||
|
assertEquals(64, fp.length());
|
||||||
|
// colon-separated uppercase (openssl -fingerprint style) normalizes to the same value
|
||||||
|
String colonized = fp.toUpperCase().replaceAll("(..)(?!$)", "$1:");
|
||||||
|
assertEquals(fp, CertDecoding.normalizeFingerprint(colonized));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void percentOnlyDecoderLeavesPlusAlone() {
|
||||||
|
assertEquals("a+b c", CertDecoding.decodePercentSequencesOnly("a+b%20c"));
|
||||||
|
assertEquals("100% legit", CertDecoding.decodePercentSequencesOnly("100% legit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cidrMatching() {
|
||||||
|
CidrMatcher block = CidrMatcher.parse("10.20.30.0/24");
|
||||||
|
assertTrue(block.matches("10.20.30.1"));
|
||||||
|
assertTrue(block.matches("10.20.30.254"));
|
||||||
|
assertFalse(block.matches("10.20.31.1"));
|
||||||
|
assertFalse(block.matches("not-an-ip"));
|
||||||
|
|
||||||
|
CidrMatcher single = CidrMatcher.parse("192.168.1.5");
|
||||||
|
assertTrue(single.matches("192.168.1.5"));
|
||||||
|
assertFalse(single.matches("192.168.1.6"));
|
||||||
|
|
||||||
|
CidrMatcher v6 = CidrMatcher.parse("fd00::/8");
|
||||||
|
assertTrue(v6.matches("fd00::1"));
|
||||||
|
assertTrue(v6.matches("fdab:cdef::9"));
|
||||||
|
assertFalse(v6.matches("fe80::1"));
|
||||||
|
assertFalse(v6.matches("10.0.0.1")); // family mismatch
|
||||||
|
|
||||||
|
CidrMatcher oddPrefix = CidrMatcher.parse("10.0.0.0/10");
|
||||||
|
assertTrue(oddPrefix.matches("10.63.255.255"));
|
||||||
|
assertFalse(oddPrefix.matches("10.64.0.0"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
com.example.keycloak.x509.HybridX509ClientCertificateLookupFactory
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
|
|||||||
|
artifactId=keycloak-hybrid-x509-spi
|
||||||
|
groupId=com.example.keycloak
|
||||||
|
version=1.0.0
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
com/example/keycloak/x509/HybridX509ClientCertificateLookupFactory.class
|
||||||
|
com/example/keycloak/x509/LookupConfig.class
|
||||||
|
com/example/keycloak/x509/CidrMatcher.class
|
||||||
|
com/example/keycloak/x509/LookupConfig$TrustMode.class
|
||||||
|
com/example/keycloak/x509/HybridX509ClientCertificateLookup.class
|
||||||
|
com/example/keycloak/x509/HybridX509ClientCertificateLookup$TrustResult.class
|
||||||
|
com/example/keycloak/x509/CertDecoding.class
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/src/src/main/java/com/example/keycloak/x509/CertDecoding.java
|
||||||
|
/src/src/main/java/com/example/keycloak/x509/CidrMatcher.java
|
||||||
|
/src/src/main/java/com/example/keycloak/x509/HybridX509ClientCertificateLookup.java
|
||||||
|
/src/src/main/java/com/example/keycloak/x509/HybridX509ClientCertificateLookupFactory.java
|
||||||
|
/src/src/main/java/com/example/keycloak/x509/LookupConfig.java
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
com/example/keycloak/x509/CertDecodingTest.class
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/src/src/test/java/com/example/keycloak/x509/CertDecodingTest.java
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,4 @@
|
|||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Test set: com.example.keycloak.x509.CertDecodingTest
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Tests run: 11, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.601 s -- in com.example.keycloak.x509.CertDecodingTest
|
||||||
Binary file not shown.
Reference in New Issue
Block a user