first commit

This commit is contained in:
2026-07-01 13:59:12 -07:00
commit cc9b0a37f2
27 changed files with 1235 additions and 0 deletions

View 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"]

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

View 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>

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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() {
}
}

View File

@@ -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;
}
}

View File

@@ -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());
}
}

View File

@@ -0,0 +1 @@
com.example.keycloak.x509.HybridX509ClientCertificateLookupFactory

View File

@@ -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"));
}
}

View File

@@ -0,0 +1 @@
com.example.keycloak.x509.HybridX509ClientCertificateLookupFactory

View File

@@ -0,0 +1,3 @@
artifactId=keycloak-hybrid-x509-spi
groupId=com.example.keycloak
version=1.0.0

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
com/example/keycloak/x509/CertDecodingTest.class

View File

@@ -0,0 +1 @@
/src/src/test/java/com/example/keycloak/x509/CertDecodingTest.java

File diff suppressed because one or more lines are too long

View File

@@ -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