+ * This class wraps the UnboundID LDAP SDK and provides a simplified API for LDAP operations + * with proper lifecycle management and connection pooling. + *
+ * Usage: + *
{@code + * LdapClient client = new LdapClient(connectionProperties); + * client.start(); + * try { + * boolean authenticated = client.bindUser(userDn, password); + * // ... use client + * } finally { + * client.stop(); + * } + * }+ */ +public class LdapClient { + + private static final @NotNull Logger log = LoggerFactory.getLogger(LdapClient.class); + + private final @NotNull LdapConnectionProperties connectionProperties; + private final @NotNull UserDnResolver userDnResolver; + private volatile LDAPConnectionPool connectionPool; + private volatile boolean started = false; + + /** + * Creates a new LDAP client with the specified connection properties. + * + * @param connectionProperties The connection configuration + */ + public LdapClient(final @NotNull LdapConnectionProperties connectionProperties) { + this.connectionProperties = connectionProperties; + this.userDnResolver = connectionProperties.createUserDnResolver(); + } + + /** + * Starts the LDAP client and initializes the connection pool. + * + * @throws LDAPException if the connection pool cannot be created + * @throws GeneralSecurityException if there's an SSL/TLS configuration issue + * @throws IllegalStateException if the client is already started + */ + public synchronized void start() throws LDAPException, GeneralSecurityException { + if (started) { + throw new IllegalStateException("LDAP client is already started"); + } + + log.debug("Starting LDAP client, connecting to {}:{}", + connectionProperties.servers().hosts()[0], connectionProperties.servers().ports()[0]); + + final var connectionOptions = connectionProperties.createConnectionOptions(); + + SocketFactory socketFactory = null; + StartTLSPostConnectProcessor startTlsProcessor = null; + switch (connectionProperties.tlsMode()) { + case NONE -> {} //NOOP + case LDAPS -> { + final SSLContext sslContext = connectionProperties.createSSLContext(); + socketFactory = sslContext.getSocketFactory(); + } + case START_TLS -> { + final SSLContext sslContext = connectionProperties.createSSLContext(); + startTlsProcessor = new StartTLSPostConnectProcessor(sslContext); + } + }; + + final var simpleBindEntity = connectionProperties.ldapSimpleBind(); + final var baseDn = new DN(connectionProperties.baseDn()); + + + final var bindDn = new DN(ImmutableList.
+ * This is the recommended method for authentication. The username will be resolved to a full DN + * using the configured DN template (e.g., "uid={username},ou=people,dc=example,dc=com"). + * + * @param username The username to authenticate (e.g., "jdoe") + * @param password The user's password + * @return {@code true} if authentication was successful, {@code false} otherwise + * @throws LDAPException if there's an LDAP protocol error (not authentication failure) + * @throws IllegalStateException if the client is not started + */ + public boolean authenticateUser(final @NotNull String username, final byte @NotNull [] password) + throws LDAPException { + return bindUser(userDnResolver.resolveDn(username), password); + } + + /** + * Checks if the LDAP client is currently started. + * + * @return {@code true} if the client is started, {@code false} otherwise + */ + public boolean isStarted() { + return started; + } + + /** + * Ensures that the client is started, throwing an exception if not. + * + * @throws IllegalStateException if the client is not started + */ + private void ensureStarted() { + if (!started) { + throw new IllegalStateException("LDAP client is not started. Call start() first."); + } + } +} diff --git a/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/LdapConnectionProperties.java b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/LdapConnectionProperties.java new file mode 100644 index 0000000000..bf804fc46e --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/LdapConnectionProperties.java @@ -0,0 +1,291 @@ +/* + * Copyright 2019-present HiveMQ GmbH + * + * 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 com.hivemq.api.auth.provider.impl.ldap; + +import com.hivemq.configuration.entity.api.ldap.LdapAuthenticationEntity; +import com.hivemq.configuration.entity.api.ldap.LdapServerEntity; +import com.hivemq.configuration.entity.api.ldap.LdapSimpleBindEntity; +import com.hivemq.configuration.entity.api.ldap.TrustStoreEntity; +import com.unboundid.ldap.sdk.LDAPConnectionOptions; +import com.unboundid.util.ssl.SSLUtil; +import com.unboundid.util.ssl.TrustAllTrustManager; +import com.unboundid.util.ssl.TrustStoreTrustManager; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import javax.net.ssl.SSLContext; +import java.security.GeneralSecurityException; +import java.util.List; + +import static java.util.Arrays.stream; + +/** + * Record representing LDAP connection properties. + *
+ * Encapsulates all the connection details needed to connect to an LDAP server,
+ * including TLS configuration, timeouts, truststore information, and DN resolution.
+ *
+ * @param servers List of LDapServers to connect to
+ * @param tlsMode The TLS/SSL mode to use
+ * @param trustStore An optional truststore for connecting to an LDAP server
+ * @param connectTimeoutMillis Connection timeout in milliseconds (0 = use default)
+ * @param responseTimeoutMillis Response timeout in milliseconds (0 = use default)
+ * @param maxConnections Maximum number of connections in the connection pool
+ * @param userDnTemplate The DN template for resolving usernames to DNs (e.g., "uid={username},ou=people,{baseDn}")
+ * @param baseDn The base DN of the LDAP directory (e.g., "dc=example,dc=com")
+ * @param acceptAnyCertificateForTesting ⚠️ TEST ONLY - When true, disables all certificate validation
+ * and accepts any certificate including self-signed and expired certificates.
+ * NEVER use in production! Only for integration tests with
+ * testcontainers. Default: false
+ */
+public record LdapConnectionProperties(
+ @NotNull LdapServers servers,
+ @NotNull TlsMode tlsMode,
+ @Nullable TrustStore trustStore,
+ int connectTimeoutMillis,
+ int responseTimeoutMillis,
+ int maxConnections,
+ @NotNull String userDnTemplate,
+ @NotNull String baseDn,
+ @NotNull String assignedRole,
+ boolean acceptAnyCertificateForTesting,
+ @NotNull LdapSimpleBind ldapSimpleBind) {
+
+ /**
+ * This class represents the simple bind credentials for an LDAP connection.
+ */
+ public record LdapSimpleBind (@NotNull String rdns, @NotNull String userPassword){
+ public static LdapSimpleBind fromEntity(final @NotNull LdapSimpleBindEntity ldapSimpleBindEntity) {
+ return new LdapSimpleBind(
+ ldapSimpleBindEntity.getRdns(),
+ ldapSimpleBindEntity.getUserPassword());
+ }
+ }
+
+ /**
+ * This class represents the simple bind credentials for an LDAP connection.
+ */
+ public record LdapServers (@NotNull String[] hosts, int @NotNull [] ports){
+ public static LdapServers fromEntity(final @NotNull List
+ * This factory method converts an {@link LdapAuthenticationEntity}
+ * (loaded from config.xml) into runtime connection properties.
+ *
+ * @param entity The LDAP authentication configuration from XML
+ * @return Configured LdapConnectionProperties for runtime use
+ * @throws IllegalArgumentException if the entity configuration is invalid
+ */
+ public static @NotNull LdapConnectionProperties fromEntity(
+ final @NotNull LdapAuthenticationEntity entity) {
+ // Parse TLS mode from string
+ final TlsMode tlsMode;
+ try {
+ tlsMode = TlsMode.valueOf(entity.getTlsMode().toUpperCase());
+ } catch (final IllegalArgumentException e) {
+ throw new IllegalArgumentException(
+ "Invalid TLS mode: " + entity.getTlsMode() + ". Must be one of: NONE, LDAPS, START_TLS", e);
+ }
+
+ return new LdapConnectionProperties(
+ LdapServers.fromEntity(entity.getServers()),
+ tlsMode,
+ entity.getTrustStore() != null ? TrustStore.fromEntity(entity.getTrustStore()) : null,
+ entity.getConnectTimeoutMillis(),
+ entity.getResponseTimeoutMillis(), entity.getMaxConnections(),
+ entity.getUserDnTemplate(),
+ entity.getBaseDn(),
+ entity.getAssignedRole(),
+ false, // Never allow test-only certificate acceptance from XML config
+ LdapSimpleBind.fromEntity(entity.getSimpleBindEntity())
+ );
+ }
+
+ /**
+ * Validates the connection properties.
+ *
+ * @throws IllegalArgumentException if the configuration is invalid
+ */
+ public LdapConnectionProperties {
+ stream(servers.ports()).forEach(port -> {
+ if (port < 1 || port > 65535) {
+ throw new IllegalArgumentException("Port must be between 1 and 65535, got: " + port);
+ }
+ });
+ if (connectTimeoutMillis < 0) {
+ throw new IllegalArgumentException("Connect timeout cannot be negative: " + connectTimeoutMillis);
+ }
+ if (responseTimeoutMillis < 0) {
+ throw new IllegalArgumentException("Response timeout cannot be negative: " + responseTimeoutMillis);
+ }
+ if (userDnTemplate.isBlank()) {
+ throw new IllegalArgumentException("User DN template cannot be empty");
+ }
+ if (!userDnTemplate.contains("{username}")) {
+ throw new IllegalArgumentException("User DN template must contain {username} placeholder");
+ }
+ if (baseDn.isBlank()) {
+ throw new IllegalArgumentException("Base DN cannot be empty");
+ }
+ if (assignedRole.isBlank()) {
+ throw new IllegalArgumentException("Assigned Role cannot be empty");
+ }
+ }
+
+ /**
+ * Creates a DN resolver using the configured template and base DN.
+ *
+ * @return A UserDnResolver configured with the template from this properties object
+ */
+ public @NotNull UserDnResolver createUserDnResolver() {
+ return new TemplateDnResolver(userDnTemplate, baseDn);
+ }
+
+ /**
+ * Creates an SSLContext from the truststore configuration.
+ *
+ * Certificate validation behavior depends on configuration:
+ *
+ * Unlike {@link TemplateDnResolver} which constructs the DN using string templates, this resolver
+ * queries the LDAP directory to find the user's actual DN. This is useful when:
+ *
+ * Example search filters:
+ *
+ * Performance Note: This resolver performs an LDAP search for each DN resolution,
+ * which adds latency compared to template-based resolution. Consider using {@link TemplateDnResolver}
+ * if your LDAP structure is simple and predictable.
+ */
+public class SearchFilterDnResolver implements UserDnResolver {
+
+ private static final @NotNull Logger log = LoggerFactory.getLogger(SearchFilterDnResolver.class);
+ private static final @NotNull String USERNAME_PLACEHOLDER = "{username}";
+
+ private final @NotNull LDAPConnectionPool connectionPool;
+ private final @NotNull String searchBase;
+ private final @NotNull String searchFilterTemplate;
+ private final @NotNull SearchScope searchScope;
+ private final int timeoutSeconds;
+
+ /**
+ * Creates a new search filter-based DN resolver.
+ *
+ * @param connectionPool The LDAP connection pool to use for searches
+ * @param searchBase The base DN where the search should start (e.g., "ou=people,dc=example,dc=com")
+ * @param searchFilterTemplate The LDAP search filter with {username} placeholder (e.g., "(uid={username})")
+ * @param searchScope The search scope (ONE_LEVEL, SUBTREE, or BASE)
+ * @param timeoutSeconds Search timeout in seconds (0 = no timeout)
+ * @throws IllegalArgumentException if any parameter is invalid
+ */
+ public SearchFilterDnResolver(
+ final @NotNull LDAPConnectionPool connectionPool,
+ final @NotNull String searchBase,
+ final @NotNull String searchFilterTemplate,
+ final @NotNull SearchScope searchScope,
+ final int timeoutSeconds) {
+ if (searchBase.isBlank()) {
+ throw new IllegalArgumentException("Search base cannot be empty");
+ }
+ if (searchFilterTemplate.isBlank()) {
+ throw new IllegalArgumentException("Search filter template cannot be empty");
+ }
+ if (!searchFilterTemplate.contains(USERNAME_PLACEHOLDER)) {
+ throw new IllegalArgumentException("Search filter template must contain {username} placeholder");
+ }
+ if (timeoutSeconds < 0) {
+ throw new IllegalArgumentException("Timeout cannot be negative: " + timeoutSeconds);
+ }
+
+ this.connectionPool = connectionPool;
+ this.searchBase = searchBase;
+ this.searchFilterTemplate = searchFilterTemplate;
+ this.searchScope = searchScope;
+ this.timeoutSeconds = timeoutSeconds;
+ }
+
+ /**
+ * Creates a resolver with SUBTREE scope and 5 second timeout (common defaults).
+ *
+ * @param connectionPool The LDAP connection pool
+ * @param searchBase The base DN for searching
+ * @param searchFilterTemplate The search filter with {username} placeholder
+ */
+ public SearchFilterDnResolver(
+ final @NotNull LDAPConnectionPool connectionPool,
+ final @NotNull String searchBase,
+ final @NotNull String searchFilterTemplate) {
+ this(connectionPool, searchBase, searchFilterTemplate, SearchScope.SUB, 5);
+ }
+
+ @Override
+ public @NotNull String resolveDn(final @NotNull String username) {
+ if (username.isBlank()) {
+ throw new IllegalArgumentException("Username cannot be empty");
+ }
+
+ // Replace {username} placeholder with actual username and create LDAP filter
+ final var filterString = searchFilterTemplate.replace(USERNAME_PLACEHOLDER, escapeFilterValue(username));
+
+ try {
+ final var filter = Filter.create(filterString);
+
+ final var searchRequest = new SearchRequest(
+ searchBase,
+ searchScope,
+ DereferencePolicy.NEVER,
+ 1, // Size limit - we only need one result
+ timeoutSeconds,
+ false, // Types only = false (we want the full entry)
+ filter,
+ "1.1" // Return no attributes, we only need the DN
+ );
+
+ log.debug("Searching for user DN with filter: {} in base: {}", filterString, searchBase);
+
+ final var searchResult = connectionPool.search(searchRequest);
+
+ if (searchResult.getResultCode() != ResultCode.SUCCESS) {
+ throw new DnResolutionException(
+ "LDAP search failed with result code: " + searchResult.getResultCode() +
+ ", diagnostic message: " + searchResult.getDiagnosticMessage(),
+ username);
+ }
+
+ final var entryCount = searchResult.getEntryCount();
+ if (entryCount == 0) {
+ log.debug("No LDAP entry found for username: {} with filter: {}", username, filterString);
+ throw new DnResolutionException(
+ "No LDAP entry found for username: " + username,
+ username);
+ }
+
+ if (entryCount > 1) {
+ log.warn("Multiple LDAP entries ({}) found for username: {} with filter: {}. Using first result.",
+ entryCount, username, filterString);
+ }
+
+ final var entry = searchResult.getSearchEntries().getFirst();
+ final var dn = entry.getDN();
+
+ log.debug("Resolved username '{}' to DN: {}", username, dn);
+ return dn;
+
+ } catch (final LDAPSearchException e) {
+ if (e.getResultCode() == ResultCode.TIME_LIMIT_EXCEEDED) {
+ log.error("LDAP search timed out after {} seconds for username: {}", timeoutSeconds, username);
+ throw new DnResolutionException(
+ "LDAP search timed out after " + timeoutSeconds + " seconds",
+ username,
+ e);
+ }
+ log.error("LDAP search failed for username: {}, error: {}", username, e.getMessage());
+ throw new DnResolutionException(
+ "LDAP search failed: " + e.getMessage(),
+ username,
+ e);
+ } catch (final LDAPException e) {
+ log.error("Invalid LDAP filter: {}", filterString, e);
+ throw new DnResolutionException(
+ "Invalid LDAP filter: " + filterString,
+ username,
+ e);
+ }
+ }
+
+ /**
+ * Escapes special characters in LDAP filter values according to RFC 4515.
+ *
+ * Escapes: * ( ) \ NUL
+ *
+ * This prevents LDAP injection attacks when the username contains special characters.
+ *
+ * @param value The value to escape
+ * @return The escaped value safe for use in LDAP filters
+ */
+ private @NotNull String escapeFilterValue(final @NotNull String value) {
+ // UnboundID SDK provides built-in escaping
+ return Filter.encodeValue(value);
+ }
+
+ /**
+ * Returns the search base used by this resolver.
+ *
+ * @return The search base DN
+ */
+ public @NotNull String getSearchBase() {
+ return searchBase;
+ }
+
+ /**
+ * Returns the search filter template used by this resolver.
+ *
+ * @return The search filter template with {username} placeholder
+ */
+ public @NotNull String getSearchFilterTemplate() {
+ return searchFilterTemplate;
+ }
+
+ /**
+ * Returns the search scope used by this resolver.
+ *
+ * @return The search scope
+ */
+ public @NotNull SearchScope getSearchScope() {
+ return searchScope;
+ }
+
+ /**
+ * Returns the timeout in seconds used by this resolver.
+ *
+ * @return The timeout in seconds
+ */
+ public int getTimeoutSeconds() {
+ return timeoutSeconds;
+ }
+
+ /**
+ * Exception thrown when a user's DN cannot be resolved through LDAP search.
+ */
+ public static class DnResolutionException extends RuntimeException {
+ private final @NotNull String username;
+
+ public DnResolutionException(final @NotNull String message, final @NotNull String username) {
+ super(message);
+ this.username = username;
+ }
+
+ public DnResolutionException(
+ final @NotNull String message,
+ final @NotNull String username,
+ final @NotNull Throwable cause) {
+ super(message, cause);
+ this.username = username;
+ }
+
+ public @NotNull String getUsername() {
+ return username;
+ }
+ }
+}
diff --git a/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/TemplateDnResolver.java b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/TemplateDnResolver.java
new file mode 100644
index 0000000000..997ca77ea8
--- /dev/null
+++ b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/TemplateDnResolver.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2019-present HiveMQ GmbH
+ *
+ * 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 com.hivemq.api.auth.provider.impl.ldap;
+
+import com.unboundid.ldap.sdk.DN;
+import com.unboundid.util.ByteStringBuffer;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Template-based DN resolver that uses string substitution to construct Distinguished Names.
+ *
+ * This resolver replaces placeholders in a template string with actual values:
+ *
+ * Example templates:
+ *
+ * Uses port 389 by default. Not recommended for production use as credentials
+ * and data are transmitted in clear text.
+ */
+ NONE(389),
+
+ /**
+ * LDAPS - LDAP over TLS/SSL.
+ *
+ * Establishes TLS connection from the start. Uses port 636 by default.
+ * Most secure option as the entire connection is encrypted.
+ */
+ LDAPS(636),
+
+ /**
+ * StartTLS - Upgrade plain connection to TLS.
+ *
+ * Starts as plain LDAP on port 389, then upgrades to TLS using the StartTLS
+ * extended operation. Common in production environments.
+ */
+ START_TLS(389);
+
+ public final int defaultPort;
+
+ TlsMode(final int defaultPort) {
+ this.defaultPort = defaultPort;
+ }
+}
diff --git a/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/UserDnResolver.java b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/UserDnResolver.java
new file mode 100644
index 0000000000..4bdff237f3
--- /dev/null
+++ b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/UserDnResolver.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2019-present HiveMQ GmbH
+ *
+ * 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 com.hivemq.api.auth.provider.impl.ldap;
+
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Interface for resolving usernames to Distinguished Names (DNs) in LDAP.
+ *
+ * Different LDAP servers use different DN structures and attribute names.
+ * This interface provides a strategy for converting a username to a full DN
+ * that can be used for LDAP bind operations.
+ *
+ * Example DN formats:
+ *
+ * This package provides a comprehensive LDAP client implementation with support for:
+ *
+ * Best for: Simple, predictable LDAP structures
+ *
+ * Constructs DNs using string templates with placeholders:
+ *
+ * Best for: Complex LDAP structures, scattered users
+ *
+ * Searches the LDAP directory to find the user's DN using filters:
+ *
+ * Pass {@code null} for truststore path to use system's default CA certificates:
+ *
+ * All classes in this package are thread-safe once initialized. The {@link LdapClient} uses
+ * connection pooling internally to handle concurrent authentication requests efficiently.
+ *
+ * @see LdapClient Main client for LDAP authentication
+ * @see LdapConnectionProperties Connection configuration
+ * @see TemplateDnResolver Fast, template-based DN resolution
+ * @see SearchFilterDnResolver Flexible, search-based DN resolution
+ * @see TlsMode TLS/SSL encryption modes
+ */
+package com.hivemq.api.auth.provider.impl.ldap;
diff --git a/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/simple/SimpleUsernameRolesProviderImpl.java b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/simple/SimpleUsernameRolesProviderImpl.java
new file mode 100644
index 0000000000..9cc16363a0
--- /dev/null
+++ b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/simple/SimpleUsernameRolesProviderImpl.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2019-present HiveMQ GmbH
+ *
+ * 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 com.hivemq.api.auth.provider.impl.simple;
+
+import com.hivemq.api.auth.provider.IUsernameRolesProvider;
+import com.hivemq.http.core.UsernamePasswordRoles;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+/**
+ * @author Simon L Johnson
+ */
+public class SimpleUsernameRolesProviderImpl implements IUsernameRolesProvider {
+
+ private final @NotNull Map
+ * Configures connection to an LDAP server for user authentication in the Admin API.
+ * Supports plain LDAP, LDAPS (LDAP over TLS), and START_TLS modes.
+ *
+ * Example configuration:
+ *
+ * Configures truststore settings for secure LDAP connections (LDAPS or START_TLS).
+ * If no truststore is configured, the system's default CA certificates will be used.
+ */
+@XmlRootElement(name = "tls")
+@XmlAccessorType(XmlAccessType.NONE)
+@SuppressWarnings({"FieldMayBeFinal", "FieldCanBeLocal"})
+public class TrustStoreEntity {
+
+ @XmlElement(name = "truststore-path", required = true, defaultValue = "")
+ private @NotNull String trustStorePath = "";
+
+ @XmlElement(name = "truststore-password")
+ private @Nullable String trustStorePassword = null;
+
+ @XmlElement(name = "truststore-type")
+ private @Nullable String trustStoreType = null;
+
+ public @NotNull String getTrustStorePath() {
+ return trustStorePath;
+ }
+
+ public @Nullable String getTrustStorePassword() {
+ return trustStorePassword;
+ }
+
+ public @Nullable String getTrustStoreType() {
+ return trustStoreType;
+ }
+
+ @Override
+ public boolean equals(final @Nullable Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ final TrustStoreEntity that = (TrustStoreEntity) o;
+ return Objects.equals(trustStorePath, that.trustStorePath) &&
+ Objects.equals(trustStorePassword, that.trustStorePassword) &&
+ Objects.equals(trustStoreType, that.trustStoreType);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(trustStorePath, trustStorePassword, trustStoreType);
+ }
+}
diff --git a/hivemq-edge/src/main/java/com/hivemq/configuration/reader/ApiConfigurator.java b/hivemq-edge/src/main/java/com/hivemq/configuration/reader/ApiConfigurator.java
index 3ebad68de5..c4dea53209 100644
--- a/hivemq-edge/src/main/java/com/hivemq/configuration/reader/ApiConfigurator.java
+++ b/hivemq-edge/src/main/java/com/hivemq/configuration/reader/ApiConfigurator.java
@@ -16,6 +16,7 @@
package com.hivemq.configuration.reader;
import com.google.common.collect.ImmutableList;
+import com.hivemq.api.auth.provider.impl.ldap.LdapConnectionProperties;
import com.hivemq.api.config.ApiJwtConfiguration;
import com.hivemq.api.config.ApiListener;
import com.hivemq.api.config.HttpListener;
@@ -40,6 +41,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Set;
@@ -51,7 +53,7 @@ public class ApiConfigurator implements Configurator
+ * Tests the high-level API for LDAP authentication that returns usernames and roles.
+ * Uses plain LDAP (no TLS) for simplicity in testing.
+ */
+@Testcontainers
+class LdapUsernameRolesProviderIntegrationTest {
+
+ private static final String LDAP_DN_TEMPLATE = "uid={username},ou=people,{baseDn}";
+
+ @Container
+ private static final LldapContainer LLDAP_CONTAINER = new LldapContainer();
+
+ private static LdapUsernameRolesProvider provider;
+
+ @BeforeAll
+ static void setUp() throws Exception {
+ // Get the dynamically mapped port from the container
+ final var host = LLDAP_CONTAINER.getHost();
+ final var port = LLDAP_CONTAINER.getLdapPort();
+
+ // Create LdapSimpleBind for LLDAP admin authentication
+ // LLDAP admin DN: uid=admin,ou=people,{baseDn}
+ final var ldapSimpleBind =
+ new LdapConnectionProperties.LdapSimpleBind(
+ "uid=" + LLDAP_CONTAINER.getAdminUsername() + ",ou=people",
+ LLDAP_CONTAINER.getAdminPassword());
+
+ // Create connection properties for plain LDAP (no TLS for simplicity)
+ // 5 second connect timeout
+ // 10 second response timeout
+ final var ldapConnectionProperties =
+ new LdapConnectionProperties(new LdapConnectionProperties.LdapServers(new String[]{host},
+ new int[]{port}), TlsMode.NONE, null, 5000, // 5 second connect timeout
+ 10000, // 10 second response timeout
+ 1, LDAP_DN_TEMPLATE, LLDAP_CONTAINER.getBaseDn(), "ADMIN", ldapSimpleBind);
+
+ // Create test user in LLDAP
+ new LdapTestConnection(ldapConnectionProperties).createTestUser(
+ LLDAP_CONTAINER.getAdminDn(),
+ LLDAP_CONTAINER.getAdminPassword(),
+ LLDAP_CONTAINER.getBaseDn());
+
+ // Create the LdapUsernameRolesProvider
+ provider = new LdapUsernameRolesProvider(ldapConnectionProperties);
+ }
+
+ @AfterAll
+ static void tearDown() {
+ LLDAP_CONTAINER.stop();
+ }
+
+ /**
+ * Tests successful authentication with correct credentials.
+ *
+ * Verifies that:
+ * - findByUsernameAndPassword returns an Optional with UsernameRoles
+ * - The username matches the authenticated user
+ * - Roles are assigned (currently hardcoded to "ADMIN")
+ */
+ @Test
+ void testSuccessfulAuthentication() {
+ // Act
+ final Optional
+ * Verifies that:
+ * - findByUsernameAndPassword returns an empty Optional
+ * - No exception is thrown (errors are logged internally)
+ */
+ @Test
+ void testFailedAuthenticationWithWrongPassword() {
+ // Arrange
+ final String wrongPassword = "wrongpassword";
+
+ // Act
+ final Optional
+ * Verifies that:
+ * - findByUsernameAndPassword returns an empty Optional
+ * - No exception is thrown (errors are logged internally)
+ */
+ @Test
+ void testFailedAuthenticationWithNonExistentUser() {
+ // Arrange
+ final String nonExistentUser = "nonexistent";
+ final String somePassword = "somepassword";
+
+ // Act
+ final Optional
+ * Verifies the toPrincipal() method works correctly.
+ */
+ @Test
+ void testUsernameRolesToPrincipalConversion() {
+ // Arrange & Act
+ final Optional
+ * Verifies that empty passwords are rejected.
+ */
+ @Test
+ void testFailedAuthenticationWithEmptyPassword() {
+ // Arrange
+ final String emptyPassword = "";
+
+ // Act
+ final Optional
+ * Verifies that empty usernames are rejected.
+ */
+ @Test
+ void testFailedAuthenticationWithEmptyUsername() {
+ // Arrange
+ final String emptyUsername = "";
+
+ // Act
+ final Optional
+ * This test demonstrates:
+ * - Setting up an LLDAP server in a Docker container with TLS/SSL enabled
+ * - Generating a self-signed certificate for secure connections
+ * - Configuring LDAPS (LDAP over TLS) for encrypted communication
+ * - Creating a test user programmatically
+ * - Performing successful authentication (bind) with correct credentials over secure connection
+ * - Verifying authentication failure with incorrect credentials over secure connection
+ *
+ * LLDAP (Light LDAP) is a lightweight LDAP server implementation perfect for testing.
+ * The test uses UnboundID LDAP SDK for all LDAP operations and demonstrates proper
+ * TLS/SSL certificate handling in a test environment.
+ */
+@Testcontainers
+class LdapIntegrationTest {
+
+ private static final String LDAP_DN_TEMPLATE = "uid={username},ou=people,{baseDn}";
+
+ @Container
+ private static final LldapContainer LLDAP_CONTAINER = LldapContainer.builder()
+ .withLdaps()
+ .build();
+
+ private static LdapClient ldapClient;
+ private static LdapConnectionProperties ldapConnectionProperties;
+
+ @BeforeAll
+ static void setUp() throws Exception {
+ // Get the dynamically mapped LDAPS port and host from the container
+ final String host = LLDAP_CONTAINER.getHost();
+ final int port = LLDAP_CONTAINER.getLdapsPort();
+
+ // Create LdapSimpleBind for LLDAP admin authentication
+ // LLDAP admin DN: uid=admin,ou=people,{baseDn}
+ final LdapConnectionProperties.LdapSimpleBind ldapSimpleBind =
+ new LdapConnectionProperties.LdapSimpleBind(
+ "uid=" + LLDAP_CONTAINER.getAdminUsername() + ",ou=people",
+ LLDAP_CONTAINER.getAdminPassword());
+
+ // Create connection properties for LDAPS
+ ldapConnectionProperties = new LdapConnectionProperties(
+ new LdapConnectionProperties.LdapServers(new String[]{host}, new int[]{port}),
+ TlsMode.LDAPS,
+ new LdapConnectionProperties.TrustStore(LLDAP_CONTAINER.getTrustStoreFile().getAbsolutePath(), LldapContainer.KEYSTORE_PASSWORD, KeyStore.getDefaultType()),
+ 10000, // 10 second connect timeout
+ 30000, // 30 second response timeout
+ 1,
+ LDAP_DN_TEMPLATE,
+ LLDAP_CONTAINER.getBaseDn(),
+ "ADMIN",
+ ldapSimpleBind);
+
+ // Create and start LDAP client
+ ldapClient = new LdapClient(ldapConnectionProperties);
+ ldapClient.start();
+
+ // Create test user in LLDAP (using direct connection for admin operations)
+ new LdapTestConnection(ldapConnectionProperties).createTestUser(
+ LLDAP_CONTAINER.getAdminDn(),
+ LLDAP_CONTAINER.getAdminPassword(),
+ LLDAP_CONTAINER.getBaseDn());
+ }
+
+ @AfterAll
+ static void tearDown() {
+ // Stop LDAP client
+ if (ldapClient != null && ldapClient.isStarted()) {
+ ldapClient.stop();
+ }
+
+ LLDAP_CONTAINER.stop();
+ }
+
+
+ /**
+ * Tests successful LDAP bind with correct credentials over secure TLS connection.
+ *
+ * This demonstrates the typical authentication flow with TLS using the LdapClient:
+ * 1. Client uses connection pool to get a connection
+ * 2. Attempt to bind with user DN and password
+ * 3. Verify the bind was successful
+ */
+ @Test
+ void testSuccessfulBind() throws LDAPException {
+ // Act
+ final boolean authenticated = ldapClient.authenticateUser(TEST_USERNAME, LdapTestConnection.TEST_PASSWORD.getBytes(StandardCharsets.UTF_8));
+
+ // Assert
+ assertThat(authenticated)
+ .as("Bind should succeed with correct credentials over secure TLS connection")
+ .isTrue();
+ }
+
+ /**
+ * Tests failed LDAP bind with incorrect password over secure TLS connection.
+ *
+ * This demonstrates authentication failure handling with TLS using the LdapClient:
+ * 1. Client uses connection pool to get a connection
+ * 2. Attempt to bind with user DN and WRONG password
+ * 3. Verify the bind failed (returns false)
+ */
+ @Test
+ void testFailedBindWithWrongPassword() throws LDAPException {
+ // Arrange
+ final String wrongPassword = "wrong_password";
+
+ // Act
+ final boolean authenticated = ldapClient.authenticateUser(TEST_USERNAME, wrongPassword.getBytes(StandardCharsets.UTF_8));
+
+ // Assert
+ assertThat(authenticated)
+ .as("Bind should fail with wrong password even over secure TLS connection")
+ .isFalse();
+ }
+
+ /**
+ * Tests SearchFilterDnResolver with an authenticated connection pool.
+ *
+ * Note: SearchFilterDnResolver requires an authenticated connection pool to perform searches.
+ * Most LDAP servers (including LLDAP) require authentication for search operations.
+ *
+ * This test demonstrates:
+ * - Creating an authenticated connection pool with a service account
+ * - Using SearchFilterDnResolver to find user DNs
+ * - Authenticating with the resolved DN
+ */
+ @Test
+ void testSearchFilterDnResolver_withAuthenticatedPool() throws Exception {
+ // Create an authenticated connection pool using the admin account
+ // In production, you would use a dedicated service account with read-only permissions
+ final var testconnection = new LdapTestConnection(ldapConnectionProperties);
+ final var ldapClient = new LdapClient(ldapConnectionProperties);
+ ldapClient.start();
+ try (final LDAPConnection adminConnection = testconnection.createConnection()) {
+ // Bind as admin to create authenticated pool using LldapContainer's convenience methods
+ final BindRequest bindRequest = new SimpleBindRequest(
+ LLDAP_CONTAINER.getAdminDn(),
+ LLDAP_CONTAINER.getAdminPassword());
+ final BindResult bindResult = adminConnection.bind(bindRequest);
+ assertThat(bindResult.getResultCode()).isEqualTo(ResultCode.SUCCESS);
+
+ // Create an authenticated connection pool
+
+ try (final var authenticatedPool = new LDAPConnectionPool(adminConnection, 1, 5)) {
+ // Test 1: Simple UID search
+ final SearchFilterDnResolver uidResolver = new SearchFilterDnResolver(authenticatedPool,
+ "ou=people," + LLDAP_CONTAINER.getBaseDn(),
+ "(uid={username})",
+ SearchScope.ONE,
+ 5);
+
+ String resolvedDn = uidResolver.resolveDn(TEST_USERNAME);
+ assertThat(resolvedDn).as("Should resolve DN by UID")
+ .isEqualTo("uid=" + TEST_USERNAME + ",ou=people," + LLDAP_CONTAINER.getBaseDn());
+
+ // Verify we can authenticate with the resolved DN
+ final boolean authenticated =
+ ldapClient.bindUser(resolvedDn, TEST_PASSWORD.getBytes(StandardCharsets.UTF_8));
+ assertThat(authenticated).as("Should authenticate with resolved DN").isTrue();
+
+ // Test 2: Email search
+ final SearchFilterDnResolver emailResolver = new SearchFilterDnResolver(authenticatedPool,
+ LLDAP_CONTAINER.getBaseDn(),
+ "(mail={username})",
+ SearchScope.SUB,
+ 5);
+
+ resolvedDn = emailResolver.resolveDn(TEST_USERNAME + "@example.com");
+ assertThat(resolvedDn).as("Should resolve DN by email")
+ .isEqualTo("uid=" + TEST_USERNAME + ",ou=people," + LLDAP_CONTAINER.getBaseDn());
+
+ // Test 3: Complex filter
+ final SearchFilterDnResolver complexResolver = new SearchFilterDnResolver(authenticatedPool,
+ LLDAP_CONTAINER.getBaseDn(),
+ "(&(objectClass=inetOrgPerson)(uid={username}))",
+ SearchScope.SUB,
+ 5);
+
+ resolvedDn = complexResolver.resolveDn(TEST_USERNAME);
+ assertThat(resolvedDn).as("Should resolve DN with complex filter")
+ .isEqualTo("uid=" + TEST_USERNAME + ",ou=people," + LLDAP_CONTAINER.getBaseDn());
+
+ // Test 4: OR filter
+ final SearchFilterDnResolver orResolver = new SearchFilterDnResolver(authenticatedPool,
+ LLDAP_CONTAINER.getBaseDn(),
+ "(|(uid={username})(mail={username}))",
+ SearchScope.SUB,
+ 5);
+
+ resolvedDn = orResolver.resolveDn(TEST_USERNAME);
+ assertThat(resolvedDn).as("Should resolve DN with OR filter")
+ .isEqualTo("uid=" + TEST_USERNAME + ",ou=people," + LLDAP_CONTAINER.getBaseDn());
+
+ // Test 5: User not found
+ assertThatThrownBy(() -> uidResolver.resolveDn("nonexistent")).isInstanceOf(SearchFilterDnResolver.DnResolutionException.class)
+ .hasMessageContaining("No LDAP entry found for username: nonexistent");
+
+ }
+ }
+ }
+}
diff --git a/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/LdapTlsModesIntegrationTest.java b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/LdapTlsModesIntegrationTest.java
new file mode 100644
index 0000000000..5b506bc93a
--- /dev/null
+++ b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/LdapTlsModesIntegrationTest.java
@@ -0,0 +1,208 @@
+/*
+ * Copyright 2019-present HiveMQ GmbH
+ *
+ * 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 com.hivemq.api.auth.provider.impl.ldap;
+
+import com.hivemq.api.auth.provider.impl.ldap.testcontainer.LdapTestConnection;
+import com.hivemq.api.auth.provider.impl.ldap.testcontainer.LldapContainer;
+import com.unboundid.ldap.sdk.LDAPException;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import java.nio.charset.StandardCharsets;
+
+import static com.hivemq.api.auth.provider.impl.ldap.testcontainer.LdapTestConnection.TEST_PASSWORD;
+import static com.hivemq.api.auth.provider.impl.ldap.testcontainer.LdapTestConnection.TEST_USERNAME;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Integration tests for different TLS modes and timeouts.
+ *
+ * Tests three TLS modes:
+ *
+ * This test demonstrates:
+ *
+ * OpenLDAP container configuration:
+ *
+ * Note on START_TLS Tests: START_TLS tests use {@code acceptAnyCertificateForTesting}
+ * to trust the self-signed certificates generated by the OpenLDAP container. This is safe for integration
+ * tests but should never be used in production.
+ */
+@Testcontainers
+class OpenLdapTest {
+
+ private static final String LDAP_DN_TEMPLATE = "uid={username},ou=people,{baseDn}";
+
+ /**
+ * OpenLDAP container with test data seeded from LDIF file and TLS enabled.
+ *
+ * The container is configured with:
+ * - Domain: example.org
+ * - Admin password: admin
+ * - TLS enabled for START_TLS testing
+ * - Custom LDIF file loaded from classpath: ldap/test-data.ldif
+ *
+ * OpenLDAP will automatically load all LDIF files from the bootstrap directory on startup.
+ * The osixia/openldap image generates self-signed certificates automatically when TLS is enabled.
+ * START_TLS tests use acceptAnyCertificateForTesting to trust these self-signed certificates.
+ */
+ @Container
+ private static final OpenLdapContainer OPENLDAP_CONTAINER = OpenLdapContainer.builder()
+ .withTls(true) // Enable TLS for START_TLS testing
+ .withLdifFile("ldap/test-data.ldif")
+ .build();
+
+ private static LdapClient ldapClient;
+ private static LdapConnectionProperties connectionProperties;
+
+ @BeforeAll
+ static void setUp() throws Exception {
+ // Get dynamically mapped port
+ final String host = OPENLDAP_CONTAINER.getHost();
+ final int port = OPENLDAP_CONTAINER.getLdapPort();
+
+ // Create LdapSimpleBind for OpenLDAP admin authentication
+ // OpenLDAP admin DN: cn=admin,{baseDn}
+ final LdapConnectionProperties.LdapSimpleBind ldapSimpleBind =
+ new LdapConnectionProperties.LdapSimpleBind(
+ "cn=admin",
+ OPENLDAP_CONTAINER.getAdminPassword());
+
+ // Create connection properties
+ connectionProperties = new LdapConnectionProperties(
+ new LdapConnectionProperties.LdapServers(new String[]{host}, new int[]{port}),
+ TlsMode.NONE, // Plain LDAP for this test
+ null,
+ 5000,
+ 10000,
+ 1,
+ LDAP_DN_TEMPLATE,
+ OPENLDAP_CONTAINER.getBaseDn(),
+ "ADMIN",
+ ldapSimpleBind);
+
+ // Create and start LDAP client
+ ldapClient = new LdapClient(connectionProperties);
+ ldapClient.start();
+
+ // Wait for OpenLDAP to finish loading LDIF files and TLS configuration
+ // When TLS is enabled, the container needs time to generate certificates
+ Thread.sleep(8000);
+ }
+
+ @AfterAll
+ static void tearDown() {
+ if (ldapClient != null && ldapClient.isStarted()) {
+ ldapClient.stop();
+ }
+ OPENLDAP_CONTAINER.stop();
+ }
+
+ /**
+ * Tests basic connectivity to OpenLDAP server.
+ *
+ * Verifies that:
+ * - Container is running
+ * - LDAP port is accessible
+ * - Admin bind succeeds
+ */
+ @Test
+ void testBasicConnectivity() throws Exception {
+ // Act - Connect and bind as admin
+ final var testconnection = new LdapTestConnection(connectionProperties);
+ try (final LDAPConnection connection = testconnection.createConnection()) {
+ final BindRequest bindRequest = new SimpleBindRequest(OPENLDAP_CONTAINER.getAdminDn(), OPENLDAP_CONTAINER.getAdminPassword());
+ final BindResult bindResult = connection.bind(bindRequest);
+
+ // Assert
+ assertThat(bindResult.getResultCode())
+ .as("Admin bind should succeed")
+ .isEqualTo(ResultCode.SUCCESS);
+
+ assertThat(connection.isConnected())
+ .as("Connection should be active")
+ .isTrue();
+ }
+ }
+
+ /**
+ * Tests that LDIF file data was successfully loaded into the directory.
+ *
+ * This test verifies:
+ * - Organizational units exist (ou=people, ou=groups)
+ * - Users were created from LDIF (alice, bob, charlie)
+ * - Groups were created from LDIF (developers, administrators)
+ * - User attributes are correct
+ */
+ @Test
+ void testLdifDataLoaded() throws Exception {
+ final var testconnection = new LdapTestConnection(connectionProperties);
+ try (final LDAPConnection connection = testconnection.createConnection()) {
+ // Bind as admin
+ connection.bind(OPENLDAP_CONTAINER.getAdminDn(), OPENLDAP_CONTAINER.getAdminPassword());
+
+ // Search for all users in ou=people
+ final SearchRequest searchRequest = new SearchRequest(
+ "ou=people," + OPENLDAP_CONTAINER.getBaseDn(),
+ SearchScope.ONE,
+ "(objectClass=inetOrgPerson)");
+
+ final SearchResult searchResult = connection.search(searchRequest);
+
+ // Assert - Should find 3 users (alice, bob, charlie)
+ assertThat(searchResult.getResultCode()).isEqualTo(ResultCode.SUCCESS);
+ assertThat(searchResult.getEntryCount())
+ .as("Should have loaded 3 users from LDIF file")
+ .isEqualTo(3);
+
+ // Verify alice exists with correct attributes
+ final SearchResultEntry alice = searchResult.getSearchEntries().stream()
+ .filter(entry -> entry.getAttributeValue("uid").equals("alice"))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("Alice not found"));
+
+ assertThat(alice.getAttributeValue("cn")).isEqualTo("Alice Anderson");
+ assertThat(alice.getAttributeValue("sn")).isEqualTo("Anderson");
+ assertThat(alice.getAttributeValue("mail")).isEqualTo("alice@example.org");
+ assertThat(alice.getAttributeValue("description")).isEqualTo("Software Engineer");
+ }
+ }
+
+ /**
+ * Tests authentication with users loaded from LDIF file.
+ *
+ * Demonstrates that users from the LDIF file can authenticate successfully.
+ */
+ @Test
+ void testAuthenticationWithLdifUsers() throws Exception {
+ // Test alice
+ final boolean aliceAuth = ldapClient.authenticateUser("alice", "alice123".getBytes(StandardCharsets.UTF_8));
+ assertThat(aliceAuth)
+ .as("Alice should authenticate with correct password")
+ .isTrue();
+
+ // Test bob
+ final boolean bobAuth = ldapClient.authenticateUser("bob", "bob456".getBytes(StandardCharsets.UTF_8));
+ assertThat(bobAuth)
+ .as("Bob should authenticate with correct password")
+ .isTrue();
+
+ // Test charlie
+ final boolean charlieAuth = ldapClient.authenticateUser("charlie", "charlie789".getBytes(StandardCharsets.UTF_8));
+ assertThat(charlieAuth)
+ .as("Charlie should authenticate with correct password")
+ .isTrue();
+
+ // Test wrong password
+ final boolean wrongAuth = ldapClient.authenticateUser("alice", "wrongpassword".getBytes(StandardCharsets.UTF_8));
+ assertThat(wrongAuth)
+ .as("Authentication should fail with wrong password")
+ .isFalse();
+ }
+
+ /**
+ * Tests searching for groups loaded from LDIF file.
+ *
+ * Verifies that groups and their members are correctly loaded.
+ */
+ @Test
+ void testGroupsFromLdif() throws Exception {
+ final var testconnection = new LdapTestConnection(connectionProperties);
+ try (final LDAPConnection connection = testconnection.createConnection()) {
+ // Bind as admin
+ connection.bind(OPENLDAP_CONTAINER.getAdminDn(), OPENLDAP_CONTAINER.getAdminPassword());
+
+ // Search for the developers group
+ final SearchRequest searchRequest = new SearchRequest(
+ "cn=developers,ou=groups," + OPENLDAP_CONTAINER.getBaseDn(),
+ SearchScope.BASE,
+ "(objectClass=groupOfNames)");
+
+ final SearchResult searchResult = connection.search(searchRequest);
+
+ // Assert
+ assertThat(searchResult.getResultCode()).isEqualTo(ResultCode.SUCCESS);
+ assertThat(searchResult.getEntryCount()).isEqualTo(1);
+
+ final SearchResultEntry developersGroup = searchResult.getSearchEntries().getFirst();
+
+ // Verify group members
+ final String[] members = developersGroup.getAttributeValues("member");
+ assertThat(members)
+ .as("Developers group should have 2 members")
+ .hasSize(2)
+ .contains(
+ "uid=alice,ou=people," + OPENLDAP_CONTAINER.getBaseDn(),
+ "uid=bob,ou=people," + OPENLDAP_CONTAINER.getBaseDn()
+ );
+
+ assertThat(developersGroup.getAttributeValue("description"))
+ .isEqualTo("Software Development Team");
+ }
+ }
+
+ /**
+ * Tests SearchFilterDnResolver with OpenLDAP and LDIF data.
+ *
+ * Demonstrates using search filters to find users by different attributes.
+ */
+ @Test
+ void testSearchFilterDnResolver() throws Exception {
+ final var testconnection = new LdapTestConnection(connectionProperties);
+ try (final LDAPConnection connection = testconnection.createConnection()) {
+ // Bind as admin to create authenticated pool
+ connection.bind(OPENLDAP_CONTAINER.getAdminDn(), OPENLDAP_CONTAINER.getAdminPassword());
+
+ try (final LDAPConnectionPool pool = new LDAPConnectionPool(connection, 1, 5)) {
+ // Test 1: Search by UID
+ final SearchFilterDnResolver uidResolver = new SearchFilterDnResolver(pool,
+ OPENLDAP_CONTAINER.getBaseDn(),
+ "(uid={username})",
+ SearchScope.SUB,
+ 5);
+
+ String dn = uidResolver.resolveDn("alice");
+ assertThat(dn).as("Should resolve alice's DN by UID")
+ .isEqualTo("uid=alice,ou=people," + OPENLDAP_CONTAINER.getBaseDn());
+
+ // Test 2: Search by email
+ final SearchFilterDnResolver emailResolver = new SearchFilterDnResolver(pool,
+ OPENLDAP_CONTAINER.getBaseDn(),
+ "(mail={username})",
+ SearchScope.SUB,
+ 5);
+
+ dn = emailResolver.resolveDn("bob@example.org");
+ assertThat(dn).as("Should resolve bob's DN by email")
+ .isEqualTo("uid=bob,ou=people," + OPENLDAP_CONTAINER.getBaseDn());
+
+ // Test 3: Search by common name
+ final SearchFilterDnResolver cnResolver = new SearchFilterDnResolver(pool,
+ OPENLDAP_CONTAINER.getBaseDn(),
+ "(cn={username})",
+ SearchScope.SUB,
+ 5);
+
+ dn = cnResolver.resolveDn("Charlie Chen");
+ assertThat(dn).as("Should resolve charlie's DN by common name")
+ .isEqualTo("uid=charlie,ou=people," + OPENLDAP_CONTAINER.getBaseDn());
+
+ }
+ }
+ }
+
+ /**
+ * Tests searching for users by attributes from LDIF data.
+ *
+ * Demonstrates various LDAP search queries on the loaded test data.
+ */
+ @Test
+ void testComplexSearchQueries() throws Exception {
+ final var testconnection = new LdapTestConnection(connectionProperties);
+ try (final LDAPConnection connection = testconnection.createConnection()) {
+ // Bind as admin
+ connection.bind(OPENLDAP_CONTAINER.getAdminDn(), OPENLDAP_CONTAINER.getAdminPassword());
+
+ // Search 1: Find all users with email ending in @example.org
+ final SearchRequest search1 = new SearchRequest(
+ OPENLDAP_CONTAINER.getBaseDn(),
+ SearchScope.SUB,
+ "(mail=*@example.org)");
+
+ final SearchResult result1 = connection.search(search1);
+ assertThat(result1.getEntryCount())
+ .as("All users should have @example.org email")
+ .isEqualTo(3);
+
+ // Search 2: Find users with uidNumber >= 10002
+ final SearchRequest search2 = new SearchRequest(
+ OPENLDAP_CONTAINER.getBaseDn(),
+ SearchScope.SUB,
+ "(uidNumber>=10002)");
+
+ final SearchResult result2 = connection.search(search2);
+ assertThat(result2.getEntryCount())
+ .as("Bob and Charlie have uidNumber >= 10002")
+ .isEqualTo(2);
+
+ // Search 3: Find all groups
+ final SearchRequest search3 = new SearchRequest(
+ "ou=groups," + OPENLDAP_CONTAINER.getBaseDn(),
+ SearchScope.ONE,
+ "(objectClass=groupOfNames)");
+
+ final SearchResult result3 = connection.search(search3);
+ assertThat(result3.getEntryCount())
+ .as("Should have 2 groups from LDIF")
+ .isEqualTo(2);
+
+ // Verify group names
+ final List
+ * Tests that the organizational units (ou=people, ou=groups) exist.
+ */
+ @Test
+ void testOrganizationalStructure() throws Exception {
+ final var testconnection = new LdapTestConnection(connectionProperties);
+ try (final LDAPConnection connection = testconnection.createConnection()) {
+ // Bind as admin
+ connection.bind(OPENLDAP_CONTAINER.getAdminDn(), OPENLDAP_CONTAINER.getAdminPassword());
+
+ // Verify ou=people exists
+ final SearchRequest peopleSearch = new SearchRequest(
+ "ou=people," + OPENLDAP_CONTAINER.getBaseDn(),
+ SearchScope.BASE,
+ "(objectClass=organizationalUnit)");
+
+ final SearchResult peopleResult = connection.search(peopleSearch);
+ assertThat(peopleResult.getEntryCount())
+ .as("ou=people should exist")
+ .isEqualTo(1);
+
+ assertThat(peopleResult.getSearchEntries().getFirst().getAttributeValue("description"))
+ .isEqualTo("Container for user accounts");
+
+ // Verify ou=groups exists
+ final SearchRequest groupsSearch = new SearchRequest(
+ "ou=groups," + OPENLDAP_CONTAINER.getBaseDn(),
+ SearchScope.BASE,
+ "(objectClass=organizationalUnit)");
+
+ final SearchResult groupsResult = connection.search(groupsSearch);
+ assertThat(groupsResult.getEntryCount())
+ .as("ou=groups should exist")
+ .isEqualTo(1);
+
+ assertThat(groupsResult.getSearchEntries().getFirst().getAttributeValue("description"))
+ .isEqualTo("Container for group definitions");
+ }
+ }
+
+ /**
+ * Tests authentication over START_TLS (upgrade from plain to encrypted).
+ *
+ * START_TLS is a common approach where:
+ *
+ * This is different from LDAPS which uses TLS from the start on port 636.
+ *
+ * Note: This test uses acceptAnyCertificateForTesting to trust
+ * the self-signed certificate generated by the OpenLDAP container. This is safe
+ * for integration tests but should never be used in production.
+ */
+ @Test
+ void testStartTlsAuthentication() throws Exception {
+ // Arrange
+ final String host = OPENLDAP_CONTAINER.getHost();
+ final int port = OPENLDAP_CONTAINER.getLdapPort();
+
+ // Note: Using acceptAnyCertificateForTesting to trust self-signed cert from OpenLDAP container
+ // In production, use proper CA-signed certificates and validate them
+ final LdapConnectionProperties.LdapSimpleBind ldapSimpleBind =
+ new LdapConnectionProperties.LdapSimpleBind(
+ "cn=admin",
+ OPENLDAP_CONTAINER.getAdminPassword());
+
+ final LdapConnectionProperties startTlsProps = new LdapConnectionProperties(
+ new LdapConnectionProperties.LdapServers(new String[]{host}, new int[]{port}),
+ TlsMode.START_TLS,
+ null, // No truststore needed
+ 5000,
+ 10000,
+ 1,
+ LDAP_DN_TEMPLATE,
+ OPENLDAP_CONTAINER.getBaseDn(),
+ "ADMIN",
+ true, // TEST ONLY: Accept any certificate
+ ldapSimpleBind);
+
+ final LdapClient startTlsClient = new LdapClient(startTlsProps);
+
+ // Act
+ startTlsClient.start();
+
+ try {
+ // Authenticate alice over START_TLS
+ final boolean aliceAuth = startTlsClient.authenticateUser("alice", "alice123".getBytes(StandardCharsets.UTF_8));
+ assertThat(aliceAuth)
+ .as("Alice should authenticate with correct password over START_TLS")
+ .isTrue();
+
+ // Authenticate bob over START_TLS
+ final boolean bobAuth = startTlsClient.authenticateUser("bob", "bob456".getBytes(StandardCharsets.UTF_8));
+ assertThat(bobAuth)
+ .as("Bob should authenticate with correct password over START_TLS")
+ .isTrue();
+
+ // Verify wrong password fails even over START_TLS
+ final boolean wrongPasswordAuth = startTlsClient.authenticateUser("alice", "wrongpassword".getBytes(StandardCharsets.UTF_8));
+ assertThat(wrongPasswordAuth)
+ .as("Authentication should fail with wrong password even over START_TLS")
+ .isFalse();
+
+ } finally {
+ if (startTlsClient.isStarted()) {
+ startTlsClient.stop();
+ }
+ }
+ }
+
+ /**
+ * Tests START_TLS connection upgrade is working correctly.
+ *
+ * This test verifies that:
+ *
+ * This test shows that:
+ *
+ * The key difference is that START_TLS encrypts all traffic after the upgrade,
+ * while plain LDAP sends credentials and data in cleartext.
+ */
+ @Test
+ void testComparisonPlainVsStartTls() throws Exception {
+ // Arrange
+ final String host = OPENLDAP_CONTAINER.getHost();
+ final int port = OPENLDAP_CONTAINER.getLdapPort();
+
+ final LdapConnectionProperties.LdapSimpleBind ldapSimpleBind =
+ new LdapConnectionProperties.LdapSimpleBind(
+ "cn=admin",
+ OPENLDAP_CONTAINER.getAdminPassword());
+
+ // Plain LDAP client
+ final LdapConnectionProperties plainProps = new LdapConnectionProperties(
+ new LdapConnectionProperties.LdapServers(new String[]{host}, new int[]{port}),
+ TlsMode.NONE,
+ null,
+ 5000,
+ 10000,
+ 1,
+ LDAP_DN_TEMPLATE,
+ OPENLDAP_CONTAINER.getBaseDn(),
+ "ADMIN",
+ ldapSimpleBind);
+ final LdapClient plainClient = new LdapClient(plainProps);
+
+ // START_TLS client
+ final LdapConnectionProperties startTlsProps = new LdapConnectionProperties(
+ new LdapConnectionProperties.LdapServers(new String[]{host}, new int[]{port}),
+ TlsMode.START_TLS,
+ null,
+ 5000,
+ 10000,
+ 1,
+ LDAP_DN_TEMPLATE,
+ OPENLDAP_CONTAINER.getBaseDn(),
+ "ADMIN",
+ true, // TEST ONLY: Accept any certificate
+ ldapSimpleBind);
+ final LdapClient startTlsClient = new LdapClient(startTlsProps);
+
+ // Act & Assert
+ try {
+ // Both should start successfully (same port!)
+ plainClient.start();
+ startTlsClient.start();
+
+ // Both should authenticate successfully
+ final boolean plainAuth = plainClient.authenticateUser("alice", "alice123".getBytes(StandardCharsets.UTF_8));
+ final boolean startTlsAuth = startTlsClient.authenticateUser("alice", "alice123".getBytes(StandardCharsets.UTF_8));
+
+ assertThat(plainAuth)
+ .as("Plain LDAP authentication should succeed")
+ .isTrue();
+
+ assertThat(startTlsAuth)
+ .as("START_TLS authentication should succeed")
+ .isTrue();
+
+ // Key difference: START_TLS encrypts credentials and data,
+ // while plain LDAP sends everything in cleartext.
+ // We can't easily verify encryption in the test, but the fact that
+ // START_TLS works proves the upgrade mechanism is functioning.
+
+ } finally {
+ if (plainClient.isStarted()) {
+ plainClient.stop();
+ }
+ if (startTlsClient.isStarted()) {
+ startTlsClient.stop();
+ }
+ }
+ }
+
+ /**
+ * Tests START_TLS with SearchFilterDnResolver to ensure encrypted search operations work.
+ *
+ * This demonstrates that complex LDAP operations (not just authentication)
+ * work correctly over START_TLS encrypted connections.
+ *
+ * Note: This test creates a manually authenticated connection pool because
+ * SearchFilterDnResolver requires admin privileges to search the directory.
+ */
+ @Test
+ void testStartTlsWithSearchFilterDnResolver() throws Exception {
+ // Arrange
+ final String host = OPENLDAP_CONTAINER.getHost();
+ final int port = OPENLDAP_CONTAINER.getLdapPort();
+
+ final LdapConnectionProperties.LdapSimpleBind ldapSimpleBind =
+ new LdapConnectionProperties.LdapSimpleBind(
+ "cn=admin",
+ OPENLDAP_CONTAINER.getAdminPassword());
+
+ final LdapConnectionProperties startTlsProps = new LdapConnectionProperties(
+ new LdapConnectionProperties.LdapServers(new String[]{host}, new int[]{port}),
+ TlsMode.START_TLS,
+ null,
+ 5000,
+ 10000,
+ 1,
+ LDAP_DN_TEMPLATE,
+ OPENLDAP_CONTAINER.getBaseDn(),
+ "ADMIN",
+ true, // TEST ONLY: Accept any certificate
+ ldapSimpleBind);
+
+ // Create a START_TLS connection and bind as admin to enable searches
+ final var testconnection = new LdapTestConnection(connectionProperties);
+ try (final LDAPConnection connection = testconnection.createConnection()) {
+ // Bind as admin to create authenticated pool
+ connection.bind(OPENLDAP_CONTAINER.getAdminDn(), OPENLDAP_CONTAINER.getAdminPassword());
+
+ try (final LDAPConnectionPool pool = new LDAPConnectionPool(connection, 1, 5)) {
+ // Test search operations over encrypted connection
+ final SearchFilterDnResolver resolver = new SearchFilterDnResolver(pool,
+ OPENLDAP_CONTAINER.getBaseDn(),
+ "(uid={username})",
+ SearchScope.SUB,
+ 5);
+
+ // Act - Perform searches over START_TLS
+ final String aliceDn = resolver.resolveDn("alice");
+ final String bobDn = resolver.resolveDn("bob");
+ final String charlieDn = resolver.resolveDn("charlie");
+
+ // Assert - All searches should work over encrypted connection
+ assertThat(aliceDn).as("Should resolve alice's DN over START_TLS")
+ .isEqualTo("uid=alice,ou=people," + OPENLDAP_CONTAINER.getBaseDn());
+
+ assertThat(bobDn).as("Should resolve bob's DN over START_TLS")
+ .isEqualTo("uid=bob,ou=people," + OPENLDAP_CONTAINER.getBaseDn());
+
+ assertThat(charlieDn).as("Should resolve charlie's DN over START_TLS")
+ .isEqualTo("uid=charlie,ou=people," + OPENLDAP_CONTAINER.getBaseDn());
+
+ }
+ }
+ }
+}
diff --git a/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/SearchFilterDnResolverTest.java b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/SearchFilterDnResolverTest.java
new file mode 100644
index 0000000000..c13d4579f9
--- /dev/null
+++ b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/SearchFilterDnResolverTest.java
@@ -0,0 +1,448 @@
+/*
+ * Copyright 2019-present HiveMQ GmbH
+ *
+ * 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 com.hivemq.api.auth.provider.impl.ldap;
+
+import com.unboundid.ldap.sdk.LDAPConnectionPool;
+import com.unboundid.ldap.sdk.LDAPSearchException;
+import com.unboundid.ldap.sdk.ResultCode;
+import com.unboundid.ldap.sdk.SearchRequest;
+import com.unboundid.ldap.sdk.SearchResult;
+import com.unboundid.ldap.sdk.SearchResultEntry;
+import com.unboundid.ldap.sdk.SearchScope;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+import java.util.Arrays;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link SearchFilterDnResolver}.
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class SearchFilterDnResolverTest {
+
+ @Mock
+ private LDAPConnectionPool connectionPool;
+
+ @Test
+ void testConstructorValidation_emptySearchBase() {
+ assertThatThrownBy(() -> new SearchFilterDnResolver(
+ connectionPool,
+ "",
+ "(uid={username})",
+ SearchScope.SUB,
+ 5))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Search base cannot be empty");
+ }
+
+ @Test
+ void testConstructorValidation_blankSearchBase() {
+ assertThatThrownBy(() -> new SearchFilterDnResolver(
+ connectionPool,
+ " ",
+ "(uid={username})",
+ SearchScope.SUB,
+ 5))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Search base cannot be empty");
+ }
+
+ @Test
+ void testConstructorValidation_emptySearchFilter() {
+ assertThatThrownBy(() -> new SearchFilterDnResolver(
+ connectionPool,
+ "ou=people,dc=example,dc=com",
+ "",
+ SearchScope.SUB,
+ 5))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Search filter template cannot be empty");
+ }
+
+ @Test
+ void testConstructorValidation_searchFilterWithoutPlaceholder() {
+ assertThatThrownBy(() -> new SearchFilterDnResolver(
+ connectionPool,
+ "ou=people,dc=example,dc=com",
+ "(uid=hardcoded)",
+ SearchScope.SUB,
+ 5))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Search filter template must contain {username} placeholder");
+ }
+
+ @Test
+ void testConstructorValidation_negativeTimeout() {
+ assertThatThrownBy(() -> new SearchFilterDnResolver(
+ connectionPool,
+ "ou=people,dc=example,dc=com",
+ "(uid={username})",
+ SearchScope.SUB,
+ -1))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Timeout cannot be negative");
+ }
+
+ @Test
+ void testSimpleConstructor_usesDefaults() {
+ final SearchFilterDnResolver resolver = new SearchFilterDnResolver(
+ connectionPool,
+ "ou=people,dc=example,dc=com",
+ "(uid={username})"
+ );
+
+ assertThat(resolver.getSearchBase()).isEqualTo("ou=people,dc=example,dc=com");
+ assertThat(resolver.getSearchFilterTemplate()).isEqualTo("(uid={username})");
+ assertThat(resolver.getSearchScope()).isEqualTo(SearchScope.SUB);
+ assertThat(resolver.getTimeoutSeconds()).isEqualTo(5);
+ }
+
+ @Test
+ void testResolveDn_emptyUsername() {
+ final SearchFilterDnResolver resolver = new SearchFilterDnResolver(
+ connectionPool,
+ "ou=people,dc=example,dc=com",
+ "(uid={username})"
+ );
+
+ assertThatThrownBy(() -> resolver.resolveDn(""))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Username cannot be empty");
+ }
+
+ @Test
+ void testResolveDn_blankUsername() {
+ final SearchFilterDnResolver resolver = new SearchFilterDnResolver(
+ connectionPool,
+ "ou=people,dc=example,dc=com",
+ "(uid={username})"
+ );
+
+ assertThatThrownBy(() -> resolver.resolveDn(" "))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Username cannot be empty");
+ }
+
+ @Test
+ void testResolveDn_successfulSearch() throws Exception {
+ // Arrange
+ final SearchFilterDnResolver resolver = new SearchFilterDnResolver(
+ connectionPool,
+ "ou=people,dc=example,dc=com",
+ "(uid={username})",
+ SearchScope.SUB,
+ 10
+ );
+
+ final SearchResultEntry mockEntry = mock(SearchResultEntry.class);
+ when(mockEntry.getDN()).thenReturn("uid=jdoe,ou=people,dc=example,dc=com");
+
+ final SearchResult mockResult = mock(SearchResult.class);
+ when(mockResult.getResultCode()).thenReturn(ResultCode.SUCCESS);
+ when(mockResult.getEntryCount()).thenReturn(1);
+ when(mockResult.getSearchEntries()).thenReturn(Arrays.asList(mockEntry));
+
+ when(connectionPool.search(any(SearchRequest.class))).thenReturn(mockResult);
+
+ // Act
+ final String dn = resolver.resolveDn("jdoe");
+
+ // Assert
+ assertThat(dn).isEqualTo("uid=jdoe,ou=people,dc=example,dc=com");
+
+ // Verify search request parameters
+ final ArgumentCaptor
+ * Certificate validation behavior depends on configuration:
+ *
+ * The connection type depends on the configured TLS mode:
+ *
+ * LLDAP provides a simplified user management approach. We need to:
+ * 1. Connect as admin
+ * 2. Add the test user to the people organizational unit
+ * 3. Set the user's password
+ *
+ * Note: This method uses the low-level connection API directly since it performs
+ * administrative operations (adding users) that are not part of the normal client API.
+ */
+ public void createTestUser(@NotNull final String adminDn, @NotNull final String adminPassword, @NotNull final String baseDn) throws LDAPException, GeneralSecurityException {
+ final var testconnection = new LdapTestConnection(ldapConnectionProperties);
+ try (final var adminConnection = testconnection.createConnection()) {
+ // Bind as admin using LldapContainer's convenience methods
+ final BindRequest bindRequest = new SimpleBindRequest(
+ adminDn,
+ adminPassword);
+ final BindResult bindResult = adminConnection.bind(bindRequest);
+
+ assertThat(bindResult.getResultCode()).isEqualTo(ResultCode.SUCCESS);
+
+ // Add test user using proper Attribute objects
+ final String testUserDnString = "uid=" + TEST_USERNAME + ",ou=people," + baseDn;
+
+ final AddRequest addRequest = new AddRequest(testUserDnString,
+ new Attribute("objectClass", "inetOrgPerson", "posixAccount"),
+ new Attribute("uid", TEST_USERNAME),
+ new Attribute("cn", TEST_USERNAME),
+ new Attribute("sn", "User"),
+ new Attribute("mail", TEST_USERNAME + "@example.com"), // Required by LLDAP
+ new Attribute("uidNumber", "1000"),
+ new Attribute("gidNumber", "1000"),
+ new Attribute("homeDirectory", "/home/" + TEST_USERNAME)
+ );
+
+ adminConnection.add(addRequest);
+
+ // Set the password using a ModifyRequest (this ensures proper password hashing)
+ final ModifyRequest modifyRequest = new ModifyRequest(testUserDnString,
+ new Modification(ModificationType.REPLACE,
+ "userPassword",
+ TEST_PASSWORD));
+
+ adminConnection.modify(modifyRequest);
+ }
+ }
+
+}
+
diff --git a/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/testcontainer/LldapContainer.java b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/testcontainer/LldapContainer.java
new file mode 100644
index 0000000000..819901207e
--- /dev/null
+++ b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/testcontainer/LldapContainer.java
@@ -0,0 +1,520 @@
+/*
+ * Copyright 2019-present HiveMQ GmbH
+ *
+ * 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 com.hivemq.api.auth.provider.impl.ldap.testcontainer;
+
+import com.hivemq.api.auth.provider.impl.ldap.LdapConnectionProperties;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
+import org.bouncycastle.cert.X509v3CertificateBuilder;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+import org.bouncycastle.util.io.pem.PemObject;
+import org.bouncycastle.util.io.pem.PemWriter;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.utility.DockerImageName;
+import org.testcontainers.utility.MountableFile;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.math.BigInteger;
+import java.nio.file.Files;
+import java.security.KeyPairGenerator;
+import java.security.KeyStore;
+import java.security.SecureRandom;
+import java.security.Security;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+
+/**
+ * Testcontainer for LLDAP (Light LDAP) server.
+ *
+ * LLDAP is a lightweight LDAP server designed for authentication and user management.
+ * It supports:
+ *
+ * Limitations:
+ *
+ * Example usage with defaults:
+ *
+ * Example usage with builder:
+ *
+ * Default configuration:
+ *
+ * Can only be called after the container has started.
+ *
+ * @return the mapped LDAP port
+ */
+ public int getLdapPort() {
+ return getMappedPort(ldapPort);
+ }
+
+ /**
+ * Gets the dynamically mapped LDAPS port.
+ *
+ * Can only be called after the container has started and if LDAPS is enabled.
+ *
+ * @return the mapped LDAPS port
+ * @throws IllegalStateException if LDAPS is not enabled
+ */
+ public int getLdapsPort() {
+ if (!ldapsEnabled) {
+ throw new IllegalStateException("LDAPS is not enabled. Use withLdaps() builder method to enable LDAPS.");
+ }
+ return getMappedPort(ldapsPort);
+ }
+
+ /**
+ * Returns whether LDAPS is enabled for this container.
+ *
+ * @return true if LDAPS is enabled, false otherwise
+ */
+ public boolean isLdapsEnabled() {
+ return ldapsEnabled;
+ }
+
+ /**
+ * Gets the dynamically mapped HTTP port.
+ *
+ * Can only be called after the container has started.
+ *
+ * @return the mapped HTTP port
+ */
+ public int getHttpPort() {
+ return getMappedPort(httpPort);
+ }
+
+ /**
+ * Gets the base DN configured for this container.
+ *
+ * @return the base DN
+ */
+ public @NotNull String getBaseDn() {
+ return baseDn;
+ }
+
+ /**
+ * Gets the admin username configured for this container.
+ *
+ * @return the admin username
+ */
+ public @NotNull String getAdminUsername() {
+ return adminUsername;
+ }
+
+ /**
+ * Gets the admin password configured for this container.
+ *
+ * @return the admin password
+ */
+ public @NotNull String getAdminPassword() {
+ return adminPassword;
+ }
+
+ /**
+ * Gets the admin DN (Distinguished Name) for binding as admin.
+ *
+ * Format: uid={adminUsername},ou=people,{baseDn}
+ *
+ * @return the admin DN
+ */
+ public @NotNull String getAdminDn() {
+ return "uid=" + adminUsername + ",ou=people," + baseDn;
+ }
+
+ /**
+ * Builder for LldapContainer.
+ *
+ * Provides a fluent API for configuring the container with custom settings.
+ * All settings are optional and have reasonable defaults.
+ */
+ public static class Builder {
+ private String dockerImageName = DEFAULT_IMAGE_NAME;
+ private int ldapPort = DEFAULT_LDAP_PORT;
+ private int ldapsPort = DEFAULT_LDAPS_PORT;
+ private int httpPort = DEFAULT_HTTP_PORT;
+ private String baseDn = DEFAULT_BASE_DN;
+ private String adminUsername = DEFAULT_ADMIN_USERNAME;
+ private String adminPassword = DEFAULT_ADMIN_PASSWORD;
+ private File certFile;
+ private File keyFile;
+ private File trustStoreFile;
+
+ private Builder() {
+ }
+
+ /**
+ * Sets the Docker image name.
+ *
+ * Default: lldap/lldap:v0.5.0
+ *
+ * @param dockerImageName the Docker image name
+ * @return this builder
+ */
+ public @NotNull Builder dockerImageName(final @NotNull String dockerImageName) {
+ this.dockerImageName = dockerImageName;
+ return this;
+ }
+
+ /**
+ * Sets the LDAP port inside the container.
+ *
+ * Default: 3890
+ *
+ * Note: This is the internal port. Use {@link LldapContainer#getLdapPort()} to get the mapped external port.
+ *
+ * @param ldapPort the LDAP port
+ * @return this builder
+ */
+ public @NotNull Builder ldapPort(final int ldapPort) {
+ this.ldapPort = ldapPort;
+ return this;
+ }
+
+ /**
+ * Sets the LDAPS port inside the container.
+ *
+ * Default: 6360
+ *
+ * Note: This is the internal port. Use {@link LldapContainer#getLdapsPort()} to get the mapped external port.
+ * This port is only exposed if LDAPS is enabled via {@link #withLdaps()}.
+ *
+ * @param ldapsPort the LDAPS port
+ * @return this builder
+ */
+ public @NotNull Builder ldapsPort(final int ldapsPort) {
+ this.ldapsPort = ldapsPort;
+ return this;
+ }
+
+ /**
+ * Sets the HTTP port inside the container.
+ *
+ * Default: 17170
+ *
+ * Note: This is the internal port. Use {@link LldapContainer#getHttpPort()} to get the mapped external port.
+ *
+ * @param httpPort the HTTP port
+ * @return this builder
+ */
+ public @NotNull Builder httpPort(final int httpPort) {
+ this.httpPort = httpPort;
+ return this;
+ }
+
+ /**
+ * Sets the base DN for the LDAP directory.
+ *
+ * Default: dc=example,dc=com
+ *
+ * @param baseDn the base DN (e.g., "dc=example,dc=com")
+ * @return this builder
+ */
+ public @NotNull Builder baseDn(final @NotNull String baseDn) {
+ this.baseDn = baseDn;
+ return this;
+ }
+
+ /**
+ * Sets the admin username.
+ *
+ * Default: admin
+ *
+ * @param adminUsername the admin username
+ * @return this builder
+ */
+ public @NotNull Builder adminUsername(final @NotNull String adminUsername) {
+ this.adminUsername = adminUsername;
+ return this;
+ }
+
+ /**
+ * Sets the admin password.
+ *
+ * Default: admin_password
+ *
+ * @param adminPassword the admin password
+ * @return this builder
+ */
+ public @NotNull Builder adminPassword(final @NotNull String adminPassword) {
+ this.adminPassword = adminPassword;
+ return this;
+ }
+
+ /**
+ * Enables LDAPS (TLS from connection start) with custom certificates.
+ *
+ * When enabled, LLDAP will accept TLS connections using an internally generated certificate and key.
+ *
+ * @return this builder
+ */
+ public @NotNull Builder withLdaps() {
+ try {
+ final var certFiles = generateSelfSignedCertificate();
+ certFile = certFiles.certFile();
+ keyFile = certFiles.keyFile();
+ trustStoreFile = certFiles.trustStoreFile();
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ return this;
+ }
+
+ /**
+ * Builds the LldapContainer with the configured settings.
+ *
+ * @return a new LldapContainer instance
+ */
+ public @NotNull LldapContainer build() {
+ return new LldapContainer(dockerImageName, ldapPort, ldapsPort, httpPort, baseDn,
+ adminUsername, adminPassword, certFile, keyFile, trustStoreFile);
+ }
+ }
+
+
+
+ public record CertFiles(@com.hivemq.extension.sdk.api.annotations.NotNull File certFile, @com.hivemq.extension.sdk.api.annotations.NotNull File keyFile, @com.hivemq.extension.sdk.api.annotations.NotNull File trustStoreFile) {};
+ /**
+ * Generates a self-signed certificate for TLS testing using BouncyCastle.
+ *
+ * This creates:
+ * - A private key
+ * - A self-signed X.509 certificate
+ * - PEM-formatted files for LLDAP server configuration
+ * - A TrustStore file containing the certificate for client-side certificate validation
+ *
+ * The SSLContext will be created dynamically from the truststore by {@link LdapConnectionProperties}.
+ */
+ public static CertFiles generateSelfSignedCertificate() throws Exception {
+ Security.addProvider(new BouncyCastleProvider());
+
+ // Generate RSA key pair
+ final var keyPairGenerator = KeyPairGenerator.getInstance("RSA");
+ keyPairGenerator.initialize(2048, new SecureRandom());
+ final var keyPair = keyPairGenerator.generateKeyPair();
+
+ // Build X.509 certificate using BouncyCastle
+ final var now = Instant.now();
+ final var notBefore = Date.from(now);
+ final var notAfter = Date.from(now.plus(365, ChronoUnit.DAYS));
+
+ final var subject = new X500Name("CN=localhost");
+ final var serial = BigInteger.valueOf(System.currentTimeMillis());
+ final var publicKeyInfo = SubjectPublicKeyInfo.getInstance(keyPair.getPublic().getEncoded());
+
+ final var certBuilder = new X509v3CertificateBuilder(
+ subject, // issuer
+ serial,
+ notBefore,
+ notAfter,
+ subject, // subject (same as issuer for self-signed)
+ publicKeyInfo
+ );
+
+ // Sign the certificate
+ final var signer = new JcaContentSignerBuilder("SHA256WithRSAEncryption")
+ .setProvider("BC")
+ .build(keyPair.getPrivate());
+
+ final var certHolder = certBuilder.build(signer);
+ final var cert = new JcaX509CertificateConverter()
+ .setProvider("BC")
+ .getCertificate(certHolder);
+
+ // Write certificate to PEM file
+ final var certFile = Files.createTempFile("ldap-cert", ".pem").toFile();
+ try (final var pemWriter = new PemWriter(new FileWriter(certFile))) {
+ pemWriter.writeObject(new PemObject("CERTIFICATE", cert.getEncoded()));
+ }
+
+ // Write private key to PEM file
+ final var keyFile = Files.createTempFile("ldap-key", ".pem").toFile();
+ try (final var pemWriter = new PemWriter(new FileWriter(keyFile))) {
+ pemWriter.writeObject(new PemObject("PRIVATE KEY", keyPair.getPrivate().getEncoded()));
+ }
+
+ // Create a TrustStore and add our self-signed certificate to it
+ final var trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
+ trustStore.load(null, null); // Initialize empty truststore
+ trustStore.setCertificateEntry("ldap-server", cert);
+
+ // Save the TrustStore to a file
+ // The SSLContext will be created dynamically from this truststore by LdapConnectionProperties
+ final var trustStoreFile = Files.createTempFile("ldap-truststore", ".jks").toFile();
+ try (final var outputStream = new java.io.FileOutputStream(trustStoreFile)) {
+ trustStore.store(outputStream, KEYSTORE_PASSWORD.toCharArray());
+ }
+ return new CertFiles(certFile, keyFile, trustStoreFile);
+ }
+}
diff --git a/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/testcontainer/OpenLdapContainer.java b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/testcontainer/OpenLdapContainer.java
new file mode 100644
index 0000000000..73b59c41d4
--- /dev/null
+++ b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/testcontainer/OpenLdapContainer.java
@@ -0,0 +1,522 @@
+/*
+ * Copyright 2019-present HiveMQ GmbH
+ *
+ * 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 com.hivemq.api.auth.provider.impl.ldap.testcontainer;
+
+import org.jetbrains.annotations.NotNull;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.utility.DockerImageName;
+import org.testcontainers.utility.MountableFile;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Testcontainer for OpenLDAP server using osixia/openldap Docker image.
+ *
+ * OpenLDAP is a full-featured LDAP server implementation that supports:
+ *
+ * Example usage with defaults:
+ *
+ * Example usage with builder and LDIF seeding:
+ *
+ * Example with custom configuration:
+ *
+ * Default configuration:
+ *
+ * Example: "example.org" -> "dc=example,dc=org"
+ *
+ * @param domain the domain name
+ * @return the base DN
+ */
+ private static @NotNull String domainToBaseDn(final @NotNull String domain) {
+ final String[] parts = domain.split("\\.");
+ final StringBuilder baseDn = new StringBuilder();
+ for (int i = 0; i < parts.length; i++) {
+ if (i > 0) {
+ baseDn.append(",");
+ }
+ baseDn.append("dc=").append(parts[i]);
+ }
+ return baseDn.toString();
+ }
+
+ /**
+ * Creates a new builder for OpenLdapContainer.
+ *
+ * @return a new builder instance
+ */
+ public static @NotNull Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Gets the dynamically mapped LDAP port.
+ *
+ * Can only be called after the container has started.
+ *
+ * @return the mapped LDAP port
+ */
+ public int getLdapPort() {
+ return getMappedPort(ldapPort);
+ }
+
+ /**
+ * Gets the domain configured for this container.
+ *
+ * @return the domain (e.g., "example.org")
+ */
+ public @NotNull String getDomain() {
+ return domain;
+ }
+
+ /**
+ * Gets the base DN derived from the configured domain.
+ *
+ * The base DN is automatically derived from the domain.
+ * Example: "example.org" -> "dc=example,dc=org"
+ *
+ * @return the base DN
+ */
+ public @NotNull String getBaseDn() {
+ return baseDn;
+ }
+
+ /**
+ * Gets the admin password configured for this container.
+ *
+ * @return the admin password
+ */
+ public @NotNull String getAdminPassword() {
+ return adminPassword;
+ }
+
+ /**
+ * Gets the admin DN (Distinguished Name) for binding as admin.
+ *
+ * Format: cn=admin,{baseDn}
+ *
+ * @return the admin DN
+ */
+ public @NotNull String getAdminDn() {
+ return "cn=admin," + baseDn;
+ }
+
+ /**
+ * Builder for OpenLdapContainer.
+ *
+ * Provides a fluent API for configuring the container with custom settings.
+ * All settings are optional and have reasonable defaults.
+ */
+ public static class Builder {
+ private String dockerImageName = DEFAULT_IMAGE_NAME;
+ private int ldapPort = DEFAULT_LDAP_PORT;
+ private String domain = DEFAULT_DOMAIN;
+ private String organisation = DEFAULT_ORGANISATION;
+ private String adminPassword = DEFAULT_ADMIN_PASSWORD;
+ private String configPassword = DEFAULT_CONFIG_PASSWORD;
+ private boolean readonlyUser = false;
+ private String backend = DEFAULT_BACKEND;
+ private boolean rfc2307bisSchema = false;
+ private boolean tls = false;
+ private boolean replication = false;
+ private boolean removeConfigAfterSetup = true;
+ private String sslHelperPrefix = DEFAULT_SSL_HELPER_PREFIX;
+ private final List
+ * Default: osixia/openldap:1.5.0
+ *
+ * @param dockerImageName the Docker image name
+ * @return this builder
+ */
+ public @NotNull Builder dockerImageName(final @NotNull String dockerImageName) {
+ this.dockerImageName = dockerImageName;
+ return this;
+ }
+
+ /**
+ * Sets the LDAP port inside the container.
+ *
+ * Default: 389
+ *
+ * Note: This is the internal port. Use {@link OpenLdapContainer#getLdapPort()} to get the mapped external port.
+ *
+ * @param ldapPort the LDAP port
+ * @return this builder
+ */
+ public @NotNull Builder ldapPort(final int ldapPort) {
+ this.ldapPort = ldapPort;
+ return this;
+ }
+
+ /**
+ * Sets the domain for the LDAP directory.
+ *
+ * The base DN is automatically derived from this domain.
+ * Example: "example.org" -> base DN will be "dc=example,dc=org"
+ *
+ * Default: example.org
+ *
+ * @param domain the domain (e.g., "example.org")
+ * @return this builder
+ */
+ public @NotNull Builder domain(final @NotNull String domain) {
+ this.domain = domain;
+ return this;
+ }
+
+ /**
+ * Sets the organisation name.
+ *
+ * This is used for the organisation attribute in the directory.
+ *
+ * Default: Example Inc
+ *
+ * @param organisation the organisation name
+ * @return this builder
+ */
+ public @NotNull Builder organisation(final @NotNull String organisation) {
+ this.organisation = organisation;
+ return this;
+ }
+
+ /**
+ * Sets the admin password.
+ *
+ * Default: admin
+ *
+ * @param adminPassword the admin password
+ * @return this builder
+ */
+ public @NotNull Builder adminPassword(final @NotNull String adminPassword) {
+ this.adminPassword = adminPassword;
+ return this;
+ }
+
+ /**
+ * Sets the config password.
+ *
+ * This is the password for the config admin user (cn=admin,cn=config).
+ *
+ * Default: config
+ *
+ * @param configPassword the config password
+ * @return this builder
+ */
+ public @NotNull Builder configPassword(final @NotNull String configPassword) {
+ this.configPassword = configPassword;
+ return this;
+ }
+
+ /**
+ * Enables or disables the readonly user.
+ *
+ * When enabled, creates a readonly user account in the directory.
+ *
+ * Default: false
+ *
+ * @param readonlyUser true to enable readonly user, false otherwise
+ * @return this builder
+ */
+ public @NotNull Builder withReadonlyUser(final boolean readonlyUser) {
+ this.readonlyUser = readonlyUser;
+ return this;
+ }
+
+ /**
+ * Sets the database backend.
+ *
+ * Common values: "mdb" (default, recommended), "hdb" (deprecated), "bdb" (deprecated)
+ *
+ * Default: mdb
+ *
+ * @param backend the backend type
+ * @return this builder
+ */
+ public @NotNull Builder withBackend(final @NotNull String backend) {
+ this.backend = backend;
+ return this;
+ }
+
+ /**
+ * Enables or disables RFC 2307bis schema.
+ *
+ * RFC 2307bis extends the standard POSIX schema with group membership.
+ *
+ * Default: false
+ *
+ * @param rfc2307bisSchema true to enable RFC 2307bis schema, false otherwise
+ * @return this builder
+ */
+ public @NotNull Builder withRfc2307bisSchema(final boolean rfc2307bisSchema) {
+ this.rfc2307bisSchema = rfc2307bisSchema;
+ return this;
+ }
+
+ /**
+ * Enables or disables TLS.
+ *
+ * When enabled, the server will support LDAPS and START_TLS.
+ *
+ * Default: false
+ *
+ * @param tls true to enable TLS, false otherwise
+ * @return this builder
+ */
+ public @NotNull Builder withTls(final boolean tls) {
+ this.tls = tls;
+ return this;
+ }
+
+ /**
+ * Enables or disables replication.
+ *
+ * Default: false
+ *
+ * @param replication true to enable replication, false otherwise
+ * @return this builder
+ */
+ public @NotNull Builder withReplication(final boolean replication) {
+ this.replication = replication;
+ return this;
+ }
+
+ /**
+ * Enables or disables removal of config after setup.
+ *
+ * Default: true
+ *
+ * @param removeConfigAfterSetup true to remove config after setup, false otherwise
+ * @return this builder
+ */
+ public @NotNull Builder withRemoveConfigAfterSetup(final boolean removeConfigAfterSetup) {
+ this.removeConfigAfterSetup = removeConfigAfterSetup;
+ return this;
+ }
+
+ /**
+ * Sets the SSL helper prefix.
+ *
+ * Default: ldap
+ *
+ * @param sslHelperPrefix the SSL helper prefix
+ * @return this builder
+ */
+ public @NotNull Builder withSslHelperPrefix(final @NotNull String sslHelperPrefix) {
+ this.sslHelperPrefix = sslHelperPrefix;
+ return this;
+ }
+
+ /**
+ * Adds an LDIF file to be loaded on container startup.
+ *
+ * The LDIF file must be available as a classpath resource.
+ * Multiple LDIF files can be added and will be loaded in the order they are added.
+ *
+ * Example:
+ *
+ * This package provides ready-to-use Testcontainers wrappers for different LDAP server implementations,
+ * making it easy to write integration tests against real LDAP servers running in Docker containers.
+ *
+ *
+ *
+ *
+ * @return A configured SSLContext
+ * @throws GeneralSecurityException if there's an issue creating the SSLContext
+ * @throws IllegalStateException if called when TLS mode is NONE
+ */
+ public @NotNull SSLContext createSSLContext() throws GeneralSecurityException {
+ if (tlsMode.equals(TlsMode.NONE)) {
+ throw new IllegalStateException("SSLContext is not needed for TLS mode: " + tlsMode);
+ }
+
+ // TEST ONLY: Accept any certificate without validation
+ if (acceptAnyCertificateForTesting) {
+ // WARNING: This disables certificate validation - only for testing!
+ // Configure SSLUtil to accept any certificate and hostname
+ final SSLUtil sslUtil = new SSLUtil(new TrustAllTrustManager());
+ // Set default SSLContext protocol to work with OpenLDAP container
+ return sslUtil.createSSLContext("TLS");
+ }
+
+ if (trustStore() == null || trustStore().trustStorePath().isBlank()) {
+ // Use system default CA certificates (Java's default truststore)
+ final SSLUtil sslUtil = new SSLUtil();
+ return sslUtil.createSSLContext();
+ }
+
+ // Use custom truststore for self-signed certificates or internal CAs
+ final SSLUtil sslUtil = new SSLUtil(new TrustStoreTrustManager(
+ trustStore().trustStorePath(),
+ trustStore().trustStorePassword() != null ? trustStore().trustStorePassword().toCharArray() : null,
+ trustStore().trustStoreType(),
+ true));
+ return sslUtil.createSSLContext();
+ }
+
+ /**
+ * Creates connection options with configured timeouts.
+ *
+ * @return Configured LDAPConnectionOptions
+ */
+ public @NotNull LDAPConnectionOptions createConnectionOptions() {
+ final LDAPConnectionOptions options = new LDAPConnectionOptions();
+
+ if (connectTimeoutMillis() > 0) {
+ options.setConnectTimeoutMillis(connectTimeoutMillis());
+ }
+
+ if (connectTimeoutMillis() > 0) {
+ options.setResponseTimeoutMillis(connectTimeoutMillis());
+ }
+
+ if (responseTimeoutMillis() > 0) {
+ options.setResponseTimeoutMillis(responseTimeoutMillis());
+ }
+
+ return options;
+ }
+}
diff --git a/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/LdapUsernameRolesProvider.java b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/LdapUsernameRolesProvider.java
new file mode 100644
index 0000000000..3bb9f2c205
--- /dev/null
+++ b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/LdapUsernameRolesProvider.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2019-present HiveMQ GmbH
+ *
+ * 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 com.hivemq.api.auth.provider.impl.ldap;
+
+import com.hivemq.api.auth.provider.IUsernameRolesProvider;
+import com.unboundid.ldap.sdk.LDAPException;
+import org.jetbrains.annotations.NotNull;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.security.GeneralSecurityException;
+import java.util.Optional;
+import java.util.Set;
+
+public class LdapUsernameRolesProvider implements IUsernameRolesProvider {
+
+ private static final @NotNull Logger log = LoggerFactory.getLogger(LdapUsernameRolesProvider.class);
+
+ private final @NotNull LdapClient ldapClient;
+ private final @NotNull Set
+ *
+ *
+ * Simple UID search: "(uid={username})"
+ * Email search: "(mail={username})"
+ * Active Directory: "(sAMAccountName={username})"
+ * Multiple attributes: "(|(uid={username})(mail={username}))"
+ * Complex filter: "(&(objectClass=inetOrgPerson)(uid={username}))"
+ *
+ *
+ *
+ *
+ * OpenLDAP: "uid={username},ou=people,{baseDn}"
+ * Active Directory: "cn={username},cn=Users,{baseDn}"
+ * Email-based: "mail={username},ou=staff,{baseDn}"
+ * Custom attribute: "employeeNumber={username},ou=employees,{baseDn}"
+ * Multiple OUs: "uid={username},ou=engineering,ou=staff,{baseDn}"
+ *
+ */
+public class TemplateDnResolver implements UserDnResolver {
+
+ private static final @NotNull String USERNAME_PLACEHOLDER = "{username}";
+ private static final @NotNull String BASE_DN_PLACEHOLDER = "{baseDn}";
+
+ private final @NotNull String template;
+ private final @NotNull String baseDn;
+
+ /**
+ * Creates a new template-based DN resolver.
+ *
+ * @param template The DN template with placeholders (e.g., "uid={username},ou=people,{baseDn}")
+ * @param baseDn The base DN of the LDAP directory (e.g., "dc=example,dc=com")
+ * @throws IllegalArgumentException if template or baseDn is null or empty
+ */
+ public TemplateDnResolver(final @NotNull String template, final @NotNull String baseDn) {
+ if (template.isBlank()) {
+ throw new IllegalArgumentException("DN template cannot be empty");
+ }
+ if (baseDn.isBlank()) {
+ throw new IllegalArgumentException("Base DN cannot be empty");
+ }
+ if (!template.contains(USERNAME_PLACEHOLDER)) {
+ throw new IllegalArgumentException("DN template must contain {username} placeholder");
+ }
+
+ this.template = template;
+ this.baseDn = baseDn;
+ }
+
+ @Override
+ public @NotNull String resolveDn(final @NotNull String username) {
+ if (username.isBlank()) {
+ throw new IllegalArgumentException("Username cannot be empty");
+ }
+ final var escaped = new ByteStringBuffer();
+ DN.getDNEscapingStrategy().escape(username, escaped);
+ // Replace placeholders in template
+ return template
+ .replace(USERNAME_PLACEHOLDER, escaped.toString())
+ .replace(BASE_DN_PLACEHOLDER, baseDn);
+ }
+
+ /**
+ * Returns the template used by this resolver.
+ *
+ * @return The DN template string
+ */
+ public @NotNull String getTemplate() {
+ return template;
+ }
+
+ /**
+ * Returns the base DN used by this resolver.
+ *
+ * @return The base DN string
+ */
+ public @NotNull String getBaseDn() {
+ return baseDn;
+ }
+}
diff --git a/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/TlsMode.java b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/TlsMode.java
new file mode 100644
index 0000000000..512bd4498f
--- /dev/null
+++ b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/TlsMode.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2019-present HiveMQ GmbH
+ *
+ * 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 com.hivemq.api.auth.provider.impl.ldap;
+
+/**
+ * TLS/SSL modes for LDAP connections.
+ */
+public enum TlsMode {
+ /**
+ * No encryption - plain LDAP connection.
+ *
+ *
+ */
+@FunctionalInterface
+public interface UserDnResolver {
+
+ /**
+ * Resolves a username to a Distinguished Name.
+ *
+ * @param username The username to resolve (e.g., "jdoe", "john.doe@company.com")
+ * @return The full Distinguished Name (e.g., "uid=jdoe,ou=people,dc=example,dc=com")
+ */
+ @NotNull String resolveDn(final @NotNull String username);
+}
diff --git a/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/package-info.java b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/package-info.java
new file mode 100644
index 0000000000..a0b0ce4605
--- /dev/null
+++ b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/package-info.java
@@ -0,0 +1,184 @@
+/*
+ * Copyright 2019-present HiveMQ GmbH
+ *
+ * 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.
+ */
+
+/**
+ * LDAP client library for HiveMQ Edge authentication.
+ *
+ *
+ *
+ * Quick Start
+ *
+ * Basic Usage with Template DN Resolution
+ * {@code
+ * // 1. Configure connection properties
+ * LdapConnectionProperties props = new LdapConnectionProperties(
+ * "ldap.example.com", // LDAP server host
+ * 636, // Port (636 for LDAPS)
+ * TlsMode.LDAPS, // TLS mode
+ * "/path/to/truststore.jks", // Truststore (or null for system CAs)
+ * "changeit".toCharArray(), // Truststore password
+ * "JKS", // Truststore type
+ * 10000, // Connect timeout (ms)
+ * 30000, // Response timeout (ms)
+ * "uid={username},ou=people,{baseDn}", // DN template
+ * "dc=example,dc=com" // Base DN
+ * );
+ *
+ * // 2. Create and start LDAP client
+ * LdapClient client = new LdapClient(props);
+ * client.start();
+ *
+ * try {
+ * // 3. Authenticate a user
+ * boolean authenticated = client.authenticateUser("jdoe", "password123");
+ * if (authenticated) {
+ * System.out.println("Authentication successful!");
+ * }
+ * } finally {
+ * // 4. Always stop the client when done
+ * client.stop();
+ * }
+ * }
+ *
+ * Advanced Usage with Search Filter DN Resolution
+ * {@code
+ * // Configure and start client (same as above)
+ * LdapClient client = new LdapClient(props);
+ * client.start();
+ *
+ * try {
+ * // Create a search-based DN resolver
+ * SearchFilterDnResolver resolver = new SearchFilterDnResolver(
+ * client.getConnectionPool(),
+ * "dc=example,dc=com", // Search base
+ * "(|(uid={username})(mail={username}))", // Search filter (uid OR mail)
+ * SearchScope.SUB, // Search entire subtree
+ * 5 // Timeout (seconds)
+ * );
+ *
+ * // Resolve the DN and authenticate
+ * String userDn = resolver.resolveDn("jdoe@example.com");
+ * boolean authenticated = client.bindUser(userDn, "password123");
+ *
+ * } catch (SearchFilterDnResolver.DnResolutionException e) {
+ * System.err.println("User not found: " + e.getUsername());
+ * } finally {
+ * client.stop();
+ * }
+ * }
+ *
+ * DN Resolution Strategies
+ *
+ * Template-Based Resolution ({@link TemplateDnResolver})
+ *
Performance: Fast (no LDAP query)
+ *
Flexibility: Limited
+ *
+ *
+ *
+ * Search Filter-Based Resolution ({@link SearchFilterDnResolver})
+ *
Performance: Slower (requires LDAP query)
+ *
Flexibility: High
+ *
+ *
+ *
+ * TLS Configuration
+ *
+ * LDAPS (Recommended)
+ * {@code
+ * TlsMode.LDAPS // TLS from connection start, usually port 636
+ * }
+ *
+ * StartTLS
+ * {@code
+ * TlsMode.START_TLS // Upgrade plain connection to TLS, usually port 389
+ * }
+ *
+ * Plain LDAP (Not Recommended for Production)
+ * {@code
+ * TlsMode.NONE // No encryption, credentials sent in clear text
+ * }
+ *
+ * System CA Certificates
+ * {@code
+ * new LdapConnectionProperties(
+ * "ldap.example.com",
+ * 636,
+ * TlsMode.LDAPS,
+ * null, // Use system CAs (Let's Encrypt, DigiCert, etc.)
+ * null,
+ * null,
+ * "uid={username},ou=people,{baseDn}",
+ * "dc=example,dc=com"
+ * );
+ * }
+ *
+ * Error Handling
+ *
+ * {@code
+ * try {
+ * client.start();
+ * boolean authenticated = client.authenticateUser("user", "pass");
+ *
+ * } catch (LDAPException e) {
+ * // LDAP protocol errors (connection issues, invalid credentials, etc.)
+ * if (e.getResultCode() == ResultCode.INVALID_CREDENTIALS) {
+ * // Wrong password
+ * } else if (e.getResultCode() == ResultCode.CONNECT_ERROR) {
+ * // Cannot connect to LDAP server
+ * }
+ * } catch (GeneralSecurityException e) {
+ * // SSL/TLS errors (certificate issues, etc.)
+ * } catch (SearchFilterDnResolver.DnResolutionException e) {
+ * // User DN could not be resolved (user not found, timeout, etc.)
+ * String username = e.getUsername();
+ * }
+ * }
+ *
+ * Thread Safety
+ * {@code
+ *
+ */
+@XmlRootElement(name = "ldap")
+@XmlAccessorType(XmlAccessType.NONE)
+@SuppressWarnings({"FieldMayBeFinal", "FieldCanBeLocal"})
+public class LdapAuthenticationEntity {
+
+ @XmlElementWrapper(name = "servers", required = true)
+ @XmlElement(name = "ldap-server")
+ private @NotNull List
+ *
+ */
+@Testcontainers
+class LdapTlsModesIntegrationTest {
+
+ private static final String LDAP_DN_TEMPLATE = "uid={username},ou=people,{baseDn}";
+
+ @Container
+ private static final LldapContainer LLDAP_CONTAINER = LldapContainer.builder()
+ .withLdaps()
+ .build();
+
+ private static LdapClient ldapClient;
+
+ @BeforeAll
+ static void setUp() throws Exception {
+ // Get the dynamically mapped port from the container
+ final var host = LLDAP_CONTAINER.getHost();
+ final var port = LLDAP_CONTAINER.getLdapPort();
+
+ // Create LdapSimpleBind for LLDAP admin authentication
+ // LLDAP admin DN: uid=admin,ou=people,{baseDn}
+ final var ldapSimpleBind =
+ new LdapConnectionProperties.LdapSimpleBind(
+ "uid=" + LLDAP_CONTAINER.getAdminUsername() + ",ou=people",
+ LLDAP_CONTAINER.getAdminPassword());
+
+ // Create connection properties for plain LDAP (no TLS)
+ // 5 second connect timeout
+ // 10 second response timeout
+ final var ldapConnectionProperties =
+ new LdapConnectionProperties(new LdapConnectionProperties.LdapServers(new String[]{host},
+ new int[]{port}), TlsMode.NONE, null, 5000, // 5 second connect timeout
+ 10000, // 10 second response timeout
+ 1, LDAP_DN_TEMPLATE, LLDAP_CONTAINER.getBaseDn(), "ADMIN", ldapSimpleBind);
+
+ // Create and start LDAP client
+ ldapClient = new LdapClient(ldapConnectionProperties);
+ ldapClient.start();
+
+ // Create test user
+ new LdapTestConnection(ldapConnectionProperties).createTestUser(
+ LLDAP_CONTAINER.getAdminDn(),
+ LLDAP_CONTAINER.getAdminPassword(),
+ LLDAP_CONTAINER.getBaseDn());
+ }
+
+ @AfterAll
+ static void tearDown() {
+ if (ldapClient != null && ldapClient.isStarted()) {
+ ldapClient.stop();
+ }
+ LLDAP_CONTAINER.stop();
+ }
+
+ /**
+ * Tests authentication over plain LDAP (no encryption).
+ */
+ @Test
+ void testPlainLdapAuthentication() throws LDAPException {
+ // Act
+ final boolean authenticated = ldapClient.authenticateUser(TEST_USERNAME, LdapTestConnection.TEST_PASSWORD.getBytes(StandardCharsets.UTF_8));
+
+ // Assert
+ assertThat(authenticated)
+ .as("Authentication should succeed over plain LDAP")
+ .isTrue();
+ }
+
+ /**
+ * Tests that authentication fails with wrong password over plain LDAP.
+ */
+ @Test
+ void testPlainLdapAuthenticationFailsWithWrongPassword() throws LDAPException {
+ // Act
+ final boolean authenticated = ldapClient.authenticateUser(TEST_USERNAME, "wrongpassword".getBytes(StandardCharsets.UTF_8));
+
+ // Assert
+ assertThat(authenticated)
+ .as("Authentication should fail with wrong password")
+ .isFalse();
+ }
+
+ /**
+ * Tests connection timeout by trying to connect to a non-responsive host.
+ */
+ @Test
+ void testConnectionTimeout() throws Exception{
+ // Arrange - use a non-routable IP that will timeout
+ final LdapConnectionProperties.LdapSimpleBind ldapSimpleBind =
+ new LdapConnectionProperties.LdapSimpleBind(
+ "uid=" + LLDAP_CONTAINER.getAdminUsername() + ",ou=people",
+ LLDAP_CONTAINER.getAdminPassword());
+
+ final LdapConnectionProperties timeoutProps = new LdapConnectionProperties(
+ new LdapConnectionProperties.LdapServers(new String[]{"10.255.255.1"}, new int[]{389}), // Non-routable IP
+ TlsMode.NONE,
+ null,
+ 1000, // 1 second timeout - should fail quickly
+ 5000,
+ 1,
+ LDAP_DN_TEMPLATE,
+ LLDAP_CONTAINER.getBaseDn(),
+ "ADMIN",
+ ldapSimpleBind);
+
+ final LdapClient timeoutClient = new LdapClient(timeoutProps);
+
+
+ timeoutClient.start();
+
+ final long startTime = System.currentTimeMillis();
+ assertThatThrownBy(() -> timeoutClient.authenticateUser("test", "test".getBytes(StandardCharsets.UTF_8)))
+ .isInstanceOf(LDAPException.class);
+ final long duration = System.currentTimeMillis() - startTime;
+
+ // Should timeout within reasonable time (less than 5 seconds)
+ // allowing some margin for processing
+ assertThat(duration)
+ .as("Connection should timeout quickly")
+ .isLessThan(5000);
+ }
+
+ /**
+ * Tests that default timeouts are used when set to 0.
+ */
+ @Test
+ void testDefaultTimeouts() throws Exception {
+ // Arrange
+ final String host = LLDAP_CONTAINER.getHost();
+ final int port = LLDAP_CONTAINER.getLdapPort();
+
+ final LdapConnectionProperties.LdapSimpleBind ldapSimpleBind =
+ new LdapConnectionProperties.LdapSimpleBind(
+ "uid=" + LLDAP_CONTAINER.getAdminUsername() + ",ou=people",
+ LLDAP_CONTAINER.getAdminPassword());
+
+ final LdapConnectionProperties defaultTimeoutProps = new LdapConnectionProperties(
+ new LdapConnectionProperties.LdapServers(new String[]{host}, new int[]{port}),
+ TlsMode.NONE,
+ null,
+ 0, // Use default timeout
+ 0, // Use default timeout
+ 1,
+ LDAP_DN_TEMPLATE,
+ LLDAP_CONTAINER.getBaseDn(),
+ "ADMIN",
+ ldapSimpleBind);
+
+ final LdapClient defaultTimeoutClient = new LdapClient(defaultTimeoutProps);
+
+ // Act
+ defaultTimeoutClient.start();
+
+ try {
+ final boolean authenticated = defaultTimeoutClient.authenticateUser(TEST_USERNAME, TEST_PASSWORD.getBytes(StandardCharsets.UTF_8));
+
+ // Assert
+ assertThat(authenticated)
+ .as("Authentication should work with default timeouts")
+ .isTrue();
+ } finally {
+ defaultTimeoutClient.stop();
+ }
+ }
+
+}
diff --git a/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/OpenLdapTest.java b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/OpenLdapTest.java
new file mode 100644
index 0000000000..92c63f5449
--- /dev/null
+++ b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/OpenLdapTest.java
@@ -0,0 +1,718 @@
+/*
+ * Copyright 2019-present HiveMQ GmbH
+ *
+ * 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 com.hivemq.api.auth.provider.impl.ldap;
+
+import com.hivemq.api.auth.provider.impl.ldap.testcontainer.LdapTestConnection;
+import com.hivemq.api.auth.provider.impl.ldap.testcontainer.OpenLdapContainer;
+import com.unboundid.ldap.sdk.BindRequest;
+import com.unboundid.ldap.sdk.BindResult;
+import com.unboundid.ldap.sdk.LDAPConnection;
+import com.unboundid.ldap.sdk.LDAPConnectionPool;
+import com.unboundid.ldap.sdk.ResultCode;
+import com.unboundid.ldap.sdk.SearchRequest;
+import com.unboundid.ldap.sdk.SearchResult;
+import com.unboundid.ldap.sdk.SearchResultEntry;
+import com.unboundid.ldap.sdk.SearchScope;
+import com.unboundid.ldap.sdk.SimpleBindRequest;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Integration tests using OpenLDAP testcontainer.
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ */
+ @Test
+ void testStartTlsEncryptionIsActive() throws Exception {
+ // Arrange
+ final String host = OPENLDAP_CONTAINER.getHost();
+ final int port = OPENLDAP_CONTAINER.getLdapPort();
+
+ final LdapConnectionProperties.LdapSimpleBind ldapSimpleBind =
+ new LdapConnectionProperties.LdapSimpleBind(
+ "cn=admin",
+ OPENLDAP_CONTAINER.getAdminPassword());
+
+ final LdapConnectionProperties startTlsProps = new LdapConnectionProperties(
+ new LdapConnectionProperties.LdapServers(new String[]{host}, new int[]{port}),
+ TlsMode.START_TLS,
+ null,
+ 5000,
+ 10000,
+ 1,
+ LDAP_DN_TEMPLATE,
+ OPENLDAP_CONTAINER.getBaseDn(),
+ "ADMIN",
+ true, // TEST ONLY: Accept any certificate
+ ldapSimpleBind);
+
+ final LdapClient startTlsClient = new LdapClient(startTlsProps);
+
+ // Act
+ startTlsClient.start();
+
+ try {
+ // Multiple authentication attempts to verify encryption stays active
+ for (int i = 0; i < 3; i++) {
+ final boolean authenticated = startTlsClient.authenticateUser("alice", "alice123".getBytes(StandardCharsets.UTF_8));
+ assertThat(authenticated)
+ .as("Authentication attempt %d should succeed over encrypted connection", i + 1)
+ .isTrue();
+ }
+
+ // Also test with different users
+ assertThat(startTlsClient.authenticateUser("bob", "bob456".getBytes(StandardCharsets.UTF_8)))
+ .as("Bob should authenticate over encrypted connection")
+ .isTrue();
+
+ assertThat(startTlsClient.authenticateUser("charlie", "charlie789".getBytes(StandardCharsets.UTF_8)))
+ .as("Charlie should authenticate over encrypted connection")
+ .isTrue();
+
+ } finally {
+ if (startTlsClient.isStarted()) {
+ startTlsClient.stop();
+ }
+ }
+ }
+
+ /**
+ * Compares plain LDAP vs START_TLS to demonstrate the difference.
+ *
+ *
+ *
+ *
+ *
+ * @return A configured SSLContext
+ * @throws GeneralSecurityException if there's an issue creating the SSLContext
+ * @throws IllegalStateException if called when TLS mode is NONE
+ */
+ public @NotNull SSLContext createSSLContext() throws GeneralSecurityException {
+ if (ldapConnectionProperties.tlsMode().equals(TlsMode.NONE)) {
+ throw new IllegalStateException("SSLContext is not needed for TLS mode: " + ldapConnectionProperties.tlsMode());
+ }
+
+ // TEST ONLY: Accept any certificate without validation
+ if (ldapConnectionProperties.acceptAnyCertificateForTesting()) {
+ // WARNING: This disables certificate validation - only for testing!
+ // Configure SSLUtil to accept any certificate and hostname
+ final SSLUtil sslUtil = new SSLUtil(new TrustAllTrustManager());
+ // Set default SSLContext protocol to work with OpenLDAP container
+ return sslUtil.createSSLContext("TLS");
+ }
+
+ if (ldapConnectionProperties.trustStore() == null) {
+ // Use system default CA certificates (Java's default truststore)
+ final SSLUtil sslUtil = new SSLUtil();
+ return sslUtil.createSSLContext();
+ }
+
+ // Use custom truststore for self-signed certificates or internal CAs
+ final SSLUtil sslUtil = new SSLUtil(new TrustStoreTrustManager(
+ ldapConnectionProperties.trustStore().trustStorePath(),
+ ldapConnectionProperties.trustStore().trustStorePassword() != null ? ldapConnectionProperties.trustStore().trustStorePassword().toCharArray() : null,
+ ldapConnectionProperties.trustStore().trustStoreType(),
+ true));
+ return sslUtil.createSSLContext();
+ }
+
+ /**
+ * Creates connection options with configured timeouts.
+ *
+ * @return Configured LDAPConnectionOptions
+ */
+ private @NotNull LDAPConnectionOptions createConnectionOptions() {
+ final LDAPConnectionOptions options = new LDAPConnectionOptions();
+
+ if (ldapConnectionProperties.connectTimeoutMillis() > 0) {
+ options.setConnectTimeoutMillis(ldapConnectionProperties.connectTimeoutMillis());
+ }
+
+ if (ldapConnectionProperties.responseTimeoutMillis() > 0) {
+ options.setResponseTimeoutMillis(ldapConnectionProperties.responseTimeoutMillis());
+ }
+
+ return options;
+ }
+
+ /**
+ * Creates a new LDAP connection using the configured properties.
+ *
+ *
+ *
+ * @return A new LDAPConnection instance
+ * @throws LDAPException if the connection fails
+ * @throws GeneralSecurityException if there's an SSL/TLS issue
+ */
+ public @NotNull LDAPConnection createConnection() throws LDAPException, GeneralSecurityException {
+ final LDAPConnectionOptions connectionOptions = createConnectionOptions();
+
+ return switch (ldapConnectionProperties.tlsMode()) {
+ case NONE -> createPlainConnection(connectionOptions);
+ case LDAPS -> createLdapsConnection(connectionOptions);
+ case START_TLS -> createStartTlsConnection(connectionOptions);
+ };
+ }
+
+ /**
+ * Creates a plain LDAP connection without encryption.
+ */
+ private @NotNull LDAPConnection createPlainConnection(final @NotNull LDAPConnectionOptions options)
+ throws LDAPException {
+ return new LDAPConnection(options, ldapConnectionProperties.servers().hosts()[0], ldapConnectionProperties.servers().ports()[0]);
+ }
+
+ /**
+ * Creates an LDAPS connection (TLS from start).
+ */
+ private @NotNull LDAPConnection createLdapsConnection(final @NotNull LDAPConnectionOptions options)
+ throws LDAPException, GeneralSecurityException {
+ final SSLContext sslContext = createSSLContext();
+ return new LDAPConnection(sslContext.getSocketFactory(), options, ldapConnectionProperties.servers().hosts()[0], ldapConnectionProperties.servers().ports()[0]);
+ }
+
+ /**
+ * Creates a connection and upgrades it to TLS using StartTLS.
+ */
+ private @NotNull LDAPConnection createStartTlsConnection(final @NotNull LDAPConnectionOptions options)
+ throws LDAPException, GeneralSecurityException {
+ // First create plain connection
+ final LDAPConnection connection = new LDAPConnection(options, ldapConnectionProperties.servers().hosts()[0], ldapConnectionProperties.servers().ports()[0]);
+
+ try {
+ // Upgrade to TLS using StartTLS extended operation
+ final SSLContext sslContext = createSSLContext();
+ final StartTLSExtendedRequest startTLSRequest = new StartTLSExtendedRequest(sslContext);
+ final ExtendedResult startTLSResult = connection.processExtendedOperation(startTLSRequest);
+
+ if (startTLSResult.getResultCode() != ResultCode.SUCCESS) {
+ throw new LDAPException(startTLSResult.getResultCode(),
+ "StartTLS failed: " + startTLSResult.getDiagnosticMessage());
+ }
+
+ return connection;
+ } catch (final Exception e) {
+ // Close the connection if StartTLS fails
+ connection.close();
+ throw e;
+ }
+ }
+
+
+ /**
+ * Creates a test user in LLDAP using the admin account.
+ *
+ *
+ *
+ *
+ * {@code
+ * @Container
+ * static LldapContainer ldap = new LldapContainer();
+ *
+ * @Test
+ * void testLdapConnection() {
+ * String host = ldap.getHost();
+ * int port = ldap.getLdapPort();
+ * String baseDn = ldap.getBaseDn(); // dc=example,dc=com
+ * String adminDn = ldap.getAdminDn(); // uid=admin,dc=example,dc=com
+ * String password = ldap.getAdminPassword(); // admin_password
+ * }
+ * }
+ * {@code
+ * @Container
+ * static LldapContainer ldap = LldapContainer.builder()
+ * .baseDn("dc=mycompany,dc=org")
+ * .adminUsername("admin")
+ * .adminPassword("secret123")
+ * .ldapPort(3890)
+ * .withLdaps(certFile, keyFile)
+ * .build();
+ * }
+ *
+ * @see OpenLdapContainer for a full-featured alternative that supports START_TLS
+ */
+public class LldapContainer extends GenericContainer
+ *
+ */
+ public LldapContainer() {
+ this(DEFAULT_IMAGE_NAME, DEFAULT_LDAP_PORT, DEFAULT_LDAPS_PORT, DEFAULT_HTTP_PORT, DEFAULT_BASE_DN,
+ DEFAULT_ADMIN_USERNAME, DEFAULT_ADMIN_PASSWORD, null, null, null);
+ }
+
+ private LldapContainer(
+ final @NotNull String dockerImageName,
+ final int ldapPort,
+ final int ldapsPort,
+ final int httpPort,
+ final @NotNull String baseDn,
+ final @NotNull String adminUsername,
+ final @NotNull String adminPassword,
+ final @Nullable File certFile,
+ final @Nullable File keyFile,
+ final @Nullable File trustStoreFile) {
+ super(DockerImageName.parse(dockerImageName));
+ this.ldapPort = ldapPort;
+ this.ldapsPort = ldapsPort;
+ this.httpPort = httpPort;
+ this.baseDn = baseDn;
+ this.adminUsername = adminUsername;
+ this.adminPassword = adminPassword;
+ this.ldapsEnabled = (certFile != null && keyFile != null);
+ this.trustStoreFile = trustStoreFile;
+ configure(certFile, keyFile);
+ }
+
+ private void configure(final @Nullable File certFile, final @Nullable File keyFile) {
+ if (ldapsEnabled) {
+ withExposedPorts(ldapPort, ldapsPort, httpPort);
+ } else {
+ withExposedPorts(ldapPort, httpPort);
+ }
+
+ withEnv("LLDAP_LDAP_PORT", String.valueOf(ldapPort));
+ withEnv("LLDAP_LDAP_BASE_DN", baseDn);
+ withEnv("LLDAP_LDAP_USER_DN", adminUsername);
+ withEnv("LLDAP_LDAP_USER_PASS", adminPassword);
+
+ // Configure LDAPS if certificates are provided
+ if (ldapsEnabled) {
+ withEnv("LLDAP_LDAPS_PORT", String.valueOf(ldapsPort));
+ withEnv("LLDAP_LDAPS_OPTIONS__ENABLED", "true");
+ withEnv("LLDAP_LDAPS_OPTIONS__CERT_FILE", "/data/cert.pem");
+ withEnv("LLDAP_LDAPS_OPTIONS__KEY_FILE", "/data/key.pem");
+ withCopyFileToContainer(MountableFile.forHostPath(certFile.toPath()), "/data/cert.pem");
+ withCopyFileToContainer(MountableFile.forHostPath(keyFile.toPath()), "/data/key.pem");
+ }
+
+ waitingFor(Wait.forLogMessage(".*Starting LLDAP.*", 1)
+ .withStartupTimeout(Duration.ofSeconds(60)));
+ }
+
+ @Override
+ public void stop() {
+ super.stop();
+ // Clean up certificate and truststore files
+ if (certFile != null && certFile.exists()) {
+ certFile.delete();
+ }
+ if (keyFile != null && keyFile.exists()) {
+ keyFile.delete();
+ }
+ if (trustStoreFile != null && trustStoreFile.exists()) {
+ trustStoreFile.delete();
+ }
+ }
+
+ /**
+ * Creates a new builder for LldapContainer.
+ *
+ * @return a new builder instance
+ */
+ public static @NotNull Builder builder() {
+ return new Builder();
+ }
+
+ public @Nullable File getTrustStoreFile() {
+ return trustStoreFile;
+ }
+
+ /**
+ * Gets the dynamically mapped LDAP port.
+ *
+ *
+ * {@code
+ * @Container
+ * static OpenLdapContainer ldap = new OpenLdapContainer();
+ *
+ * @Test
+ * void testLdapConnection() {
+ * String host = ldap.getHost();
+ * int port = ldap.getLdapPort();
+ * String baseDn = ldap.getBaseDn(); // dc=example,dc=org
+ * String adminDn = ldap.getAdminDn(); // cn=admin,dc=example,dc=org
+ * String password = ldap.getAdminPassword(); // admin
+ * }
+ * }
+ * {@code
+ * @Container
+ * static OpenLdapContainer ldap = OpenLdapContainer.builder()
+ * .domain("mycompany.org")
+ * .organisation("My Company Inc")
+ * .adminPassword("secret123")
+ * .withLdifFile("test-data.ldif")
+ * .build();
+ * }
+ * {@code
+ * @Container
+ * static OpenLdapContainer ldap = OpenLdapContainer.builder()
+ * .domain("example.com")
+ * .adminPassword("admin")
+ * .withTls(true)
+ * .withBackend("mdb")
+ * .withRfc2307bisSchema(false)
+ * .withReadonlyUser(false)
+ * .withReplication(false)
+ * .build();
+ * }
+ *
+ * @see LldapContainer for a lightweight alternative (does not support START_TLS)
+ */
+public class OpenLdapContainer extends GenericContainer
+ *
+ */
+ public OpenLdapContainer() {
+ this(DEFAULT_IMAGE_NAME, DEFAULT_LDAP_PORT, DEFAULT_DOMAIN, DEFAULT_ORGANISATION,
+ DEFAULT_ADMIN_PASSWORD, DEFAULT_CONFIG_PASSWORD, false, DEFAULT_BACKEND, false,
+ false, false, true, DEFAULT_SSL_HELPER_PREFIX, new ArrayList<>());
+ }
+
+ private OpenLdapContainer(
+ final @NotNull String dockerImageName,
+ final int ldapPort,
+ final @NotNull String domain,
+ final @NotNull String organisation,
+ final @NotNull String adminPassword,
+ final @NotNull String configPassword,
+ final boolean readonlyUser,
+ final @NotNull String backend,
+ final boolean rfc2307bisSchema,
+ final boolean tls,
+ final boolean replication,
+ final boolean removeConfigAfterSetup,
+ final @NotNull String sslHelperPrefix,
+ final @NotNull List{@code
+ * OpenLdapContainer ldap = OpenLdapContainer.builder()
+ * .withLdifFile("ldap/users.ldif")
+ * .withLdifFile("ldap/groups.ldif")
+ * .build();
+ * }
+ *
+ * @param classpathResourcePath the classpath path to the LDIF file (e.g., "ldap/test-data.ldif")
+ * @return this builder
+ */
+ public @NotNull Builder withLdifFile(final @NotNull String classpathResourcePath) {
+ this.ldifFiles.add(classpathResourcePath);
+ return this;
+ }
+
+ /**
+ * Builds the OpenLdapContainer with the configured settings.
+ *
+ * @return a new OpenLdapContainer instance
+ */
+ public @NotNull OpenLdapContainer build() {
+ return new OpenLdapContainer(dockerImageName, ldapPort, domain, organisation,
+ adminPassword, configPassword, readonlyUser, backend,
+ rfc2307bisSchema, tls, replication, removeConfigAfterSetup,
+ sslHelperPrefix, ldifFiles);
+ }
+ }
+}
diff --git a/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/testcontainer/package-info.java b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/testcontainer/package-info.java
new file mode 100644
index 0000000000..4303d625a1
--- /dev/null
+++ b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/testcontainer/package-info.java
@@ -0,0 +1,283 @@
+/*
+ * Copyright 2019-present HiveMQ GmbH
+ *
+ * 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.
+ */
+
+/**
+ * Reusable testcontainer implementations for LDAP servers.
+ * Available Containers
+ *
+ *
+ *
+ * Quick Start
+ *
+ * LLDAP Container (Lightweight, Simple Setup)
+ * {@code
+ * @Testcontainers
+ * class MyLdapTest {
+ * @Container
+ * static LldapContainer ldap = new LldapContainer(); // Uses sensible defaults
+ *
+ * @Test
+ * void testAuthentication() {
+ * String host = ldap.getHost();
+ * int port = ldap.getLdapPort();
+ * String baseDn = ldap.getBaseDn(); // dc=example,dc=com
+ * String adminDn = ldap.getAdminDn(); // uid=admin,dc=example,dc=com
+ * String adminPw = ldap.getAdminPassword(); // admin_password
+ * // ... connect and test
+ * }
+ * }
+ * }
+ *
+ * OpenLDAP Container (Full-Featured, LDIF Seeding)
+ * {@code
+ * @Testcontainers
+ * class MyOpenLdapTest {
+ * @Container
+ * static OpenLdapContainer ldap = OpenLdapContainer.builder()
+ * .withLdifFile("ldap/users.ldif") // Load test data from classpath
+ * .withLdifFile("ldap/groups.ldif") // Multiple files supported
+ * .build();
+ *
+ * @Test
+ * void testWithPreloadedData() {
+ * String host = ldap.getHost();
+ * int port = ldap.getLdapPort();
+ * String baseDn = ldap.getBaseDn(); // dc=example,dc=org (from domain)
+ * String adminDn = ldap.getAdminDn(); // cn=admin,dc=example,dc=org
+ * String adminPw = ldap.getAdminPassword(); // admin
+ * // ... test with preloaded users and groups
+ * }
+ * }
+ * }
+ *
+ * Builder Pattern
+ *
+ * Both containers support fluent builder API for custom configuration:
+ *
+ * LLDAP Builder Example
+ * {@code
+ * LldapContainer ldap = LldapContainer.builder()
+ * .baseDn("dc=mycompany,dc=org")
+ * .adminUsername("admin")
+ * .adminPassword("secret123")
+ * .ldapPort(3890)
+ * .withLdaps(certFile, keyFile) // Enable LDAPS
+ * .build();
+ * }
+ *
+ * OpenLDAP Builder Example
+ * {@code
+ * OpenLdapContainer ldap = OpenLdapContainer.builder()
+ * .domain("mycompany.org") // Base DN derived automatically
+ * .organisation("My Company Inc")
+ * .adminPassword("secret123")
+ * .withTls(true) // Enable TLS/START_TLS
+ * .withBackend("mdb")
+ * .withRfc2307bisSchema(false)
+ * .withLdifFile("ldap/test-data.ldif")
+ * .build();
+ * }
+ *
+ * Comparison: LLDAP vs OpenLDAP
+ *
+ *
+ *
+ *
+ *
+ *
+ * Feature
+ * LLDAP
+ * OpenLDAP
+ *
+ *
+ * Container Size
+ * Small (~50MB)
+ * Larger (~200MB)
+ *
+ *
+ * Startup Time
+ * Fast (2-5s)
+ * Slower (5-10s)
+ *
+ *
+ * Plain LDAP
+ * ✓ Yes
+ * ✓ Yes
+ *
+ *
+ * LDAPS
+ * ✓ Yes
+ * ✓ Yes
+ *
+ *
+ * START_TLS
+ * ✗ No
+ * ✓ Yes
+ *
+ *
+ * LDIF Seeding
+ * Manual (via code)
+ * ✓ Automatic
+ *
+ *
+ * Schema Support
+ * Basic
+ * Full
+ *
+ *
+ * Best For
+ * Simple auth tests
+ * Complex LDAP scenarios
+ * When to Use Which
+ *
+ * Use LLDAP ({@link com.hivemq.api.auth.provider.impl.ldap.testcontainer.LldapContainer}) when:
+ *
+ *
+ *
+ * Use OpenLDAP ({@link com.hivemq.api.auth.provider.impl.ldap.testcontainer.OpenLdapContainer}) when:
+ *
+ *
+ *
+ * Common Patterns
+ *
+ * Pattern 1: Default Configuration Test
+ * {@code
+ * @Testcontainers
+ * class QuickTest {
+ * @Container
+ * static LldapContainer ldap = new LldapContainer();
+ *
+ * @Test
+ * void testConnection() {
+ * // All defaults work out of the box
+ * LdapConnectionProperties props = new LdapConnectionProperties(
+ * ldap.getHost(),
+ * ldap.getLdapPort(),
+ * TlsMode.NONE,
+ * null, null, null,
+ * 5000, 10000,
+ * "uid={username},ou=people,{baseDn}",
+ * ldap.getBaseDn()
+ * );
+ * LdapClient client = new LdapClient(props);
+ * client.start();
+ * // ... test
+ * }
+ * }
+ * }
+ *
+ * Pattern 2: LDIF Seeding with OpenLDAP
+ * {@code
+ * @Testcontainers
+ * class DataTest {
+ * @Container
+ * static OpenLdapContainer ldap = OpenLdapContainer.builder()
+ * .withLdifFile("ldap/users.ldif")
+ * .withLdifFile("ldap/groups.ldif")
+ * .build();
+ *
+ * @Test
+ * void testWithPreloadedUsers() {
+ * // Users and groups from LDIF files are already loaded
+ * try (LDAPConnection conn = createConnection()) {
+ * conn.bind(ldap.getAdminDn(), ldap.getAdminPassword());
+ * SearchResult result = conn.search("ou=people," + ldap.getBaseDn(), ...);
+ * // ... assertions
+ * }
+ * }
+ * }
+ * }
+ *
+ * Pattern 3: Custom Domain and Credentials
+ * {@code
+ * @Testcontainers
+ * class CustomConfigTest {
+ * @Container
+ * static OpenLdapContainer ldap = OpenLdapContainer.builder()
+ * .domain("mycompany.internal") // Base DN: dc=mycompany,dc=internal
+ * .organisation("MyCompany")
+ * .adminPassword("MySecretPassword")
+ * .build();
+ *
+ * @Test
+ * void testCustomConfig() {
+ * assertEquals("dc=mycompany,dc=internal", ldap.getBaseDn());
+ * assertEquals("cn=admin,dc=mycompany,dc=internal", ldap.getAdminDn());
+ * assertEquals("MySecretPassword", ldap.getAdminPassword());
+ * }
+ * }
+ * }
+ *
+ * Pattern 4: TLS/LDAPS Testing
+ * {@code
+ * @Testcontainers
+ * class TlsTest {
+ * static File certFile;
+ * static File keyFile;
+ *
+ * static {
+ * // Generate self-signed certificate
+ * // ... (see LdapTlsModesIntegrationTest for example)
+ * }
+ *
+ * @Container
+ * static LldapContainer ldap = LldapContainer.builder()
+ * .withLdaps(certFile, keyFile)
+ * .build();
+ *
+ * @Test
+ * void testLdaps() {
+ * // Connect with LDAPS
+ * LdapConnectionProperties props = new LdapConnectionProperties(
+ * ldap.getHost(),
+ * ldap.getLdapPort(),
+ * TlsMode.LDAPS,
+ * trustStorePath,
+ * trustStorePassword,
+ * KeyStore.getDefaultType(),
+ * 5000, 10000,
+ * dnTemplate,
+ * ldap.getBaseDn()
+ * );
+ * // ... test encrypted connection
+ * }
+ * }
+ * }
+ *
+ * @see com.hivemq.api.auth.provider.impl.ldap.testcontainer.LldapContainer
+ * @see com.hivemq.api.auth.provider.impl.ldap.testcontainer.OpenLdapContainer
+ * @see com.hivemq.api.auth.provider.impl.ldap.OpenLdapTest
+ * @see com.hivemq.api.auth.provider.impl.ldap.LdapTlsModesIntegrationTest
+ */
+package com.hivemq.api.auth.provider.impl.ldap.testcontainer;
diff --git a/hivemq-edge/src/test/java/com/hivemq/configuration/writer/ConfigFileWriterTest.java b/hivemq-edge/src/test/java/com/hivemq/configuration/writer/ConfigFileWriterTest.java
index bf1e059364..a4e036f86a 100644
--- a/hivemq-edge/src/test/java/com/hivemq/configuration/writer/ConfigFileWriterTest.java
+++ b/hivemq-edge/src/test/java/com/hivemq/configuration/writer/ConfigFileWriterTest.java
@@ -53,6 +53,8 @@ public void rewriteUnchangedConfigurationYieldsSameXML() throws IOException, SAX
configFileReader.writeConfigToXML(new ConfigurationFile(tempCopyFile).file().get(), false, false);
final String copiedFileContent = FileUtils.readFileToString(tempCopyFile, UTF_8);
+
+
final Diff diff = XMLUnit.compareXML(originalXml, copiedFileContent);
if (!diff.identical()) {
System.err.println("xml diff found " + diff);
diff --git a/hivemq-edge/src/test/resources/ldap/test-data.ldif b/hivemq-edge/src/test/resources/ldap/test-data.ldif
new file mode 100644
index 0000000000..b85767d096
--- /dev/null
+++ b/hivemq-edge/src/test/resources/ldap/test-data.ldif
@@ -0,0 +1,95 @@
+# Test LDIF file for OpenLDAP container seeding
+# This file demonstrates how to populate an LDAP directory with test data
+#
+# Structure:
+# dc=example,dc=org
+# ├── ou=people (organizational unit for users)
+# │ ├── uid=alice (user)
+# │ ├── uid=bob (user)
+# │ └── uid=charlie (user)
+# └── ou=groups (organizational unit for groups)
+# ├── cn=developers (group)
+# └── cn=administrators (group)
+
+# Create organizational unit for people
+dn: ou=people,dc=example,dc=org
+objectClass: organizationalUnit
+objectClass: top
+ou: people
+description: Container for user accounts
+
+# Create organizational unit for groups
+dn: ou=groups,dc=example,dc=org
+objectClass: organizationalUnit
+objectClass: top
+ou: groups
+description: Container for group definitions
+
+# User: Alice (Software Engineer)
+dn: uid=alice,ou=people,dc=example,dc=org
+objectClass: inetOrgPerson
+objectClass: posixAccount
+objectClass: top
+uid: alice
+cn: Alice Anderson
+sn: Anderson
+givenName: Alice
+mail: alice@example.org
+userPassword: alice123
+uidNumber: 10001
+gidNumber: 10001
+homeDirectory: /home/alice
+loginShell: /bin/bash
+description: Software Engineer
+
+# User: Bob (DevOps Engineer)
+dn: uid=bob,ou=people,dc=example,dc=org
+objectClass: inetOrgPerson
+objectClass: posixAccount
+objectClass: top
+uid: bob
+cn: Bob Builder
+sn: Builder
+givenName: Bob
+mail: bob@example.org
+userPassword: bob456
+uidNumber: 10002
+gidNumber: 10002
+homeDirectory: /home/bob
+loginShell: /bin/bash
+description: DevOps Engineer
+
+# User: Charlie (System Administrator)
+dn: uid=charlie,ou=people,dc=example,dc=org
+objectClass: inetOrgPerson
+objectClass: posixAccount
+objectClass: top
+uid: charlie
+cn: Charlie Chen
+sn: Chen
+givenName: Charlie
+mail: charlie@example.org
+userPassword: charlie789
+uidNumber: 10003
+gidNumber: 10003
+homeDirectory: /home/charlie
+loginShell: /bin/bash
+description: System Administrator
+
+# Group: Developers
+dn: cn=developers,ou=groups,dc=example,dc=org
+objectClass: groupOfNames
+objectClass: top
+cn: developers
+description: Software Development Team
+member: uid=alice,ou=people,dc=example,dc=org
+member: uid=bob,ou=people,dc=example,dc=org
+
+# Group: Administrators
+dn: cn=administrators,ou=groups,dc=example,dc=org
+objectClass: groupOfNames
+objectClass: top
+cn: administrators
+description: System Administrators
+member: uid=charlie,ou=people,dc=example,dc=org
+member: uid=bob,ou=people,dc=example,dc=org