entries = new ArrayList<>();
+ try {
+ for (EncryptionType encType : KEYTAB_ENC_TYPES) {
+ EncryptionKey key = EncryptionHandler.string2Key(principalName, passPhrase, encType);
+ System.out.println("Adding keytab entry of type: " + encType.getName());
+ entries.add(new KeytabEntry(principal, timeStamp, key.getKvno(), key));
+ }
+ } catch (KrbException e) {
+ throw new IOException("Failed to create keytab entries", e);
}
- keytab.setEntries(entries);
- keytab.write(keytabFile);
+ keytab.addKeytabEntries(entries);
+ keytab.store(keytabFile);
}
}
diff --git a/util/embedded-ldap/src/main/java/org/keycloak/util/ldap/LDAPEmbeddedServer.java b/util/embedded-ldap/src/main/java/org/keycloak/util/ldap/LDAPEmbeddedServer.java
index 6b7999f11d8f..49bd38f291ba 100644
--- a/util/embedded-ldap/src/main/java/org/keycloak/util/ldap/LDAPEmbeddedServer.java
+++ b/util/embedded-ldap/src/main/java/org/keycloak/util/ldap/LDAPEmbeddedServer.java
@@ -214,12 +214,15 @@ public void init() throws Exception {
public void start() throws Exception {
+ long t0 = System.currentTimeMillis();
log.info("Starting LDAP server..");
ldapServer.start();
+ long elapsed = System.currentTimeMillis() - t0;
// Verify the server started properly
if (ldapServer.isStarted() && ldapServer.getDirectoryService().isStarted()) {
- log.info("LDAP server started.");
- } else if(!ldapServer.isStarted()) {
+ log.infof("LDAP server started in %d ms (port=%d, startTLS=%s, ssl=%s).",
+ elapsed, bindPort, enableStartTLS, enableSSL);
+ } else if (!ldapServer.isStarted()) {
throw new RuntimeException("Failed to start the LDAP server!");
} else if (!ldapServer.getDirectoryService().isStarted()) {
throw new RuntimeException("Failed to start the directory service for the LDAP server!");
@@ -321,13 +324,13 @@ protected LdapServer createLdapServer() {
}
if (enableStartTLS) {
try {
- ldapServer.addExtendedOperationHandler(new StartTlsHandler());
+ ldapServer.addExtendedOperationHandler(new TLS13StartTlsHandler());
} catch (Exception e) {
throw new IllegalStateException("Cannot add the StartTLS extension handler: ", e);
}
for (ExtendedOperationHandler eoh : ldapServer.getExtendedOperationHandlers()) {
if (eoh.getOid().equals(StartTlsHandler.EXTENSION_OID)) {
- log.info("Enabled StartTLS support on the LDAP server.");
+ log.info("Enabled StartTLS support on the LDAP server (using TLS13StartTlsHandler).");
break;
}
}
@@ -419,8 +422,10 @@ public void stop() throws Exception {
protected void stopLdapServer() {
+ long t0 = System.currentTimeMillis();
log.info("Stopping LDAP server.");
ldapServer.stop();
+ log.infof("LDAP server stopped in %d ms.", System.currentTimeMillis() - t0);
}
diff --git a/util/embedded-ldap/src/main/java/org/keycloak/util/ldap/TLS13StartTlsHandler.java b/util/embedded-ldap/src/main/java/org/keycloak/util/ldap/TLS13StartTlsHandler.java
new file mode 100644
index 000000000000..356defc2bfcd
--- /dev/null
+++ b/util/embedded-ldap/src/main/java/org/keycloak/util/ldap/TLS13StartTlsHandler.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright 2026 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.util.ldap;
+
+import java.security.SecureRandom;
+import java.util.List;
+import javax.net.ssl.SSLContext;
+
+import org.apache.directory.api.ldap.extras.extended.startTls.StartTlsResponse;
+import org.apache.directory.api.ldap.extras.extended.startTls.StartTlsResponseImpl;
+import org.apache.directory.api.ldap.model.message.ExtendedRequest;
+import org.apache.directory.api.ldap.model.message.ResultCodeEnum;
+import org.apache.directory.server.ldap.LdapServer;
+import org.apache.directory.server.ldap.LdapSession;
+import org.apache.directory.server.ldap.handlers.extended.StartTlsFilter;
+import org.apache.directory.server.ldap.handlers.extended.StartTlsHandler;
+import org.apache.directory.server.protocol.shared.transport.TcpTransport;
+import org.apache.directory.server.protocol.shared.transport.Transport;
+import org.apache.mina.core.filterchain.IoFilterChain;
+import org.apache.mina.filter.ssl.SslFilter;
+import org.jboss.logging.Logger;
+
+/**
+ * Replacement for the stock AM27 {@link StartTlsHandler} that updates the default
+ * enabled protocols and adds diagnostics. The filter-chain logic is identical to AM27
+ * (add {@link SslFilter} + {@link StartTlsFilter}, then write the response — the
+ * {@code StartTlsFilter} bypasses encryption for the response).
+ *
+ * Protocol defaults: the stock AM27 handler defaults to {@code TLSv1, TLSv1.1,
+ * TLSv1.2}. Modern JDKs (16+) disable TLSv1 and TLSv1.1, making TLSv1.2 the only
+ * effective protocol. This handler defaults to {@code TLSv1.2, TLSv1.3} instead,
+ * enabling TLSv1.3 whose single-flight handshake avoids timing-sensitive races observed
+ * in the multi-flight TLSv1.2 handshake under MINA's {@link SslFilter}.
+ *
+ *
This handler initializes its own {@link SSLContext} because the parent's
+ * {@code sslContext} field is private and inaccessible from a subclass.
+ */
+public class TLS13StartTlsHandler extends StartTlsHandler {
+
+ private static final Logger log = Logger.getLogger(TLS13StartTlsHandler.class);
+
+ private SSLContext sslCtx;
+ private List ciphers;
+ private List protocols;
+ private boolean needClientAuth;
+ private boolean wantClientAuth;
+
+ @Override
+ public void setLdapServer(LdapServer ldapServer) {
+ super.setLdapServer(ldapServer);
+
+ try {
+ sslCtx = SSLContext.getInstance("TLS");
+ sslCtx.init(
+ ldapServer.getKeyManagerFactory().getKeyManagers(),
+ ldapServer.getTrustManagers(),
+ new SecureRandom());
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to initialize SSLContext for StartTLS handler", e);
+ }
+
+ for (Transport transport : ldapServer.getTransports()) {
+ if (transport instanceof TcpTransport) {
+ TcpTransport tcp = (TcpTransport) transport;
+ ciphers = tcp.getCipherSuite();
+ protocols = tcp.getEnabledProtocols();
+ needClientAuth = tcp.isNeedClientAuth();
+ wantClientAuth = tcp.isWantClientAuth();
+ break;
+ }
+ }
+ }
+
+ @Override
+ public void handleExtendedOperation(LdapSession session, ExtendedRequest req) throws Exception {
+ log.debug("Handling StartTLS request.");
+
+ IoFilterChain chain = session.getIoSession().getFilterChain();
+ SslFilter sslFilter = (SslFilter) chain.get("sslFilter");
+
+ if (sslFilter == null) {
+ sslFilter = new SslFilter(sslCtx);
+
+ if (ciphers != null && !ciphers.isEmpty()) {
+ sslFilter.setEnabledCipherSuites(ciphers.toArray(new String[0]));
+ }
+ if (protocols != null && !protocols.isEmpty()) {
+ sslFilter.setEnabledProtocols(protocols.toArray(new String[0]));
+ } else {
+ sslFilter.setEnabledProtocols(new String[]{"TLSv1.2", "TLSv1.3"});
+ }
+ sslFilter.setNeedClientAuth(needClientAuth);
+ sslFilter.setWantClientAuth(wantClientAuth);
+
+ chain.addFirst("startTls", new StartTlsFilter());
+ chain.addFirst("sslFilter", sslFilter);
+ log.debug("SslFilter + StartTlsFilter added to chain.");
+ }
+
+ StartTlsResponse res = new StartTlsResponseImpl(req.getMessageId());
+ res.getLdapResult().setResultCode(ResultCodeEnum.SUCCESS);
+ res.setResponseName(EXTENSION_OID);
+
+ session.getIoSession().write(res);
+ log.debug("StartTLS response sent (via StartTlsFilter bypass).");
+ }
+}
diff --git a/util/embedded-ldap/src/main/resources/kerberos/default-users-kc2.ldif b/util/embedded-ldap/src/main/resources/kerberos/default-users-kc2.ldif
index 8758643320d3..a52543974868 100644
--- a/util/embedded-ldap/src/main/resources/kerberos/default-users-kc2.ldif
+++ b/util/embedded-ldap/src/main/resources/kerberos/default-users-kc2.ldif
@@ -9,6 +9,8 @@ objectClass: organizationalUnit
objectClass: top
ou: People
+# Kerby's LdapIdentityGetHelper requires krb5AccountDisabled, krb5AccountLockedOut,
+# krb5AccountExpirationTime, and krb5KDCFlags on each entry — NPEs without them.
dn: uid=krbtgt,ou=People,dc=kc2,dc=com
objectClass: top
objectClass: person
@@ -21,6 +23,10 @@ uid: krbtgt
userPassword: secret
krb5PrincipalName: krbtgt/KC2.COM@KC2.COM
krb5KeyVersionNumber: 0
+krb5AccountDisabled: FALSE
+krb5AccountLockedOut: FALSE
+krb5AccountExpirationTime: 99991231235959Z
+krb5KDCFlags: 126
# Cross-realm trust support! Realm KEYCLOAK.ORG will trust the realm KC2.COM
dn: uid=krbtgt2,ou=People,dc=kc2,dc=com
@@ -35,6 +41,10 @@ uid: krbtgt2
userPassword: secret
krb5PrincipalName: krbtgt/KEYCLOAK.ORG@KC2.COM
krb5KeyVersionNumber: 0
+krb5AccountDisabled: FALSE
+krb5AccountLockedOut: FALSE
+krb5AccountExpirationTime: 99991231235959Z
+krb5KDCFlags: 126
dn: uid=ldap,ou=People,dc=kc2,dc=com
objectClass: top
@@ -48,6 +58,10 @@ uid: ldap
userPassword: randall
krb5PrincipalName: ${ldapSaslPrincipal}
krb5KeyVersionNumber: 0
+krb5AccountDisabled: FALSE
+krb5AccountLockedOut: FALSE
+krb5AccountExpirationTime: 99991231235959Z
+krb5KDCFlags: 126
dn: uid=HTTP,ou=People,dc=kc2,dc=com
objectClass: top
@@ -61,6 +75,10 @@ uid: HTTP
userPassword: httppwd
krb5PrincipalName: HTTP/${hostname}@KC2.COM
krb5KeyVersionNumber: 0
+krb5AccountDisabled: FALSE
+krb5AccountLockedOut: FALSE
+krb5AccountExpirationTime: 99991231235959Z
+krb5KDCFlags: 126
dn: uid=hnelson2,ou=People,dc=kc2,dc=com
objectClass: top
@@ -75,6 +93,10 @@ uid: hnelson2
userPassword: secret
krb5PrincipalName: hnelson2@KC2.COM
krb5KeyVersionNumber: 0
+krb5AccountDisabled: FALSE
+krb5AccountLockedOut: FALSE
+krb5AccountExpirationTime: 99991231235959Z
+krb5KDCFlags: 126
dn: uid=jduke2,ou=People,dc=kc2,dc=com
objectClass: top
@@ -89,6 +111,10 @@ uid: jduke2
userPassword: theduke
krb5PrincipalName: jduke2@KC2.COM
krb5KeyVersionNumber: 0
+krb5AccountDisabled: FALSE
+krb5AccountLockedOut: FALSE
+krb5AccountExpirationTime: 99991231235959Z
+krb5KDCFlags: 126
dn: uid=gsstestserver,ou=People,dc=kc2,dc=com
objectClass: top
@@ -101,4 +127,8 @@ sn: Service
uid: gsstestserver
userPassword: gsstestpwd
krb5PrincipalName: gsstestserver/xxx@KC2.COM
-krb5KeyVersionNumber: 0
\ No newline at end of file
+krb5KeyVersionNumber: 0
+krb5AccountDisabled: FALSE
+krb5AccountLockedOut: FALSE
+krb5AccountExpirationTime: 99991231235959Z
+krb5KDCFlags: 126
diff --git a/util/embedded-ldap/src/main/resources/kerberos/default-users.ldif b/util/embedded-ldap/src/main/resources/kerberos/default-users.ldif
index 414119341bac..0cf8610f1572 100644
--- a/util/embedded-ldap/src/main/resources/kerberos/default-users.ldif
+++ b/util/embedded-ldap/src/main/resources/kerberos/default-users.ldif
@@ -9,6 +9,8 @@ objectClass: organizationalUnit
objectClass: top
ou: People
+# Kerby's LdapIdentityGetHelper requires krb5AccountDisabled, krb5AccountLockedOut,
+# krb5AccountExpirationTime, and krb5KDCFlags on each entry — NPEs without them.
dn: uid=krbtgt,ou=People,dc=keycloak,dc=org
objectClass: top
objectClass: person
@@ -21,6 +23,10 @@ uid: krbtgt
userPassword: secret
krb5PrincipalName: krbtgt/KEYCLOAK.ORG@KEYCLOAK.ORG
krb5KeyVersionNumber: 0
+krb5AccountDisabled: FALSE
+krb5AccountLockedOut: FALSE
+krb5AccountExpirationTime: 99991231235959Z
+krb5KDCFlags: 126
# Cross-realm trust support! Realm KEYCLOAK.ORG will trust the realm KC2.COM
dn: uid=krbtgt2,ou=People,dc=keycloak,dc=org
@@ -35,6 +41,10 @@ uid: krbtgt2
userPassword: secret
krb5PrincipalName: krbtgt/KEYCLOAK.ORG@KC2.COM
krb5KeyVersionNumber: 0
+krb5AccountDisabled: FALSE
+krb5AccountLockedOut: FALSE
+krb5AccountExpirationTime: 99991231235959Z
+krb5KDCFlags: 126
dn: uid=ldap,ou=People,dc=keycloak,dc=org
objectClass: top
@@ -48,6 +58,10 @@ uid: ldap
userPassword: randall
krb5PrincipalName: ${ldapSaslPrincipal}
krb5KeyVersionNumber: 0
+krb5AccountDisabled: FALSE
+krb5AccountLockedOut: FALSE
+krb5AccountExpirationTime: 99991231235959Z
+krb5KDCFlags: 126
dn: uid=HTTP,ou=People,dc=keycloak,dc=org
objectClass: top
@@ -61,6 +75,10 @@ uid: HTTP
userPassword: httppwd
krb5PrincipalName: HTTP/${hostname}@KEYCLOAK.ORG
krb5KeyVersionNumber: 0
+krb5AccountDisabled: FALSE
+krb5AccountLockedOut: FALSE
+krb5AccountExpirationTime: 99991231235959Z
+krb5KDCFlags: 126
dn: uid=hnelson,ou=People,dc=keycloak,dc=org
objectClass: top
@@ -75,6 +93,10 @@ uid: hnelson
userPassword: secret
krb5PrincipalName: hnelson@KEYCLOAK.ORG
krb5KeyVersionNumber: 0
+krb5AccountDisabled: FALSE
+krb5AccountLockedOut: FALSE
+krb5AccountExpirationTime: 99991231235959Z
+krb5KDCFlags: 126
dn: uid=jduke,ou=People,dc=keycloak,dc=org
objectClass: top
@@ -89,6 +111,10 @@ uid: jduke
userPassword: theduke
krb5PrincipalName: jduke@KEYCLOAK.ORG
krb5KeyVersionNumber: 0
+krb5AccountDisabled: FALSE
+krb5AccountLockedOut: FALSE
+krb5AccountExpirationTime: 99991231235959Z
+krb5KDCFlags: 126
dn: uid=gsstestserver,ou=People,dc=keycloak,dc=org
objectClass: top
@@ -102,3 +128,7 @@ uid: gsstestserver
userPassword: gsstestpwd
krb5PrincipalName: gsstestserver/xxx@KEYCLOAK.ORG
krb5KeyVersionNumber: 0
+krb5AccountDisabled: FALSE
+krb5AccountLockedOut: FALSE
+krb5AccountExpirationTime: 99991231235959Z
+krb5KDCFlags: 126