From 81bc6cd81b347f178a1a2b85e33b7101d1241c41 Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Mon, 05 Oct 2026 12:38:31 +0000
Subject: [PATCH] [#1155] Make the rest2ldap bind templates and HTTP Basic credentials work as documented (#1165)

---
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/SimpleBindStrategy.java                |   14 +
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapHttpApplication.java                |    9 
 opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/CredentialExtractorsTest.java          |   47 +++
 opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicJsonConfigurationTestCase.java          |  135 ++++++++++++
 opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/DnTemplateTest.java                          |    7 
 opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-rest2ldap.adoc                               |   17 +
 opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/HttpBasicAuthenticationFilterTest.java |   21 +
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplate.java                   |   16 +
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/CredentialExtractors.java              |   22 +
 opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/SaslPlainStrategyTest.java             |  108 +++++++++
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/Utils.java                             |   30 ++
 opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/SimpleBindStrategyTest.java            |  124 +++++++++++
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/DnTemplate.java                              |   26 +
 opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplateTest.java               |   23 ++
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/AuthenticationStrategies.java          |    6 
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/HttpBasicAuthenticationFilter.java     |    5 
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/SaslPlainStrategy.java                 |   28 +-
 17 files changed, 596 insertions(+), 42 deletions(-)

diff --git a/opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-rest2ldap.adoc b/opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-rest2ldap.adoc
index 2adc96b..d56c4ce 100644
--- a/opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-rest2ldap.adoc
+++ b/opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-rest2ldap.adoc
@@ -12,7 +12,7 @@
   information: "Portions copyright [year] [name of copyright owner]".
  
   Copyright 2017 ForgeRock AS.
-  Portions Copyright 2024-2025 3A Systems LLC.
+  Portions Copyright 2024-2026 3A Systems LLC.
 ////
 
 :figure-caption!:
@@ -415,6 +415,10 @@
 
 +
 For example, if the user name is also the UID of the LDAP entry, use `uid=\{username\},ou=People,dc=example,dc=com`.
+The user name is then escaped as an attribute value.
+
++
+A template which is just `\{username\}` takes the user name as the bind DN.
 
 +
 Default: `\{username\}`
@@ -445,6 +449,11 @@
 
 +
 If the user name is the LDAP bind DN, use `dn:\{username\}`.
+After `dn:`, the template is a bind DN template like `bindDnTemplate` of the `simple` bind,
+for example `dn:uid=\{username\},ou=People,dc=example,dc=com`.
+
++
+Default: `u:\{username\}`
 
 ========
 
@@ -579,6 +588,8 @@
 
 +
 This template must start with `u:` or `dn:`.
+After `dn:`, a field value is escaped as an attribute value, unless the rest of the template is just that one field:
+then the value is taken as the DN.
 
 +
 For example, if token resolution returns a JSON document where the value of the `uid` field is the UID of the user entry in the directory, you might use `u:\{uid\}` or `dn:\{uid\},ou=People,dc=example,dc=com`.
@@ -629,6 +640,8 @@
 
 +
 This template must start with `u:` or `dn:`.
+After `dn:`, a field value is escaped as an attribute value, unless the rest of the template is just that one field:
+then the value is taken as the DN.
 
 +
 For example, if token resolution returns a JSON document where the value of the `username` field is the UID of the user entry in the directory, you might use `u:\{username\}` or `dn:\{username\},ou=People,dc=example,dc=com`.
@@ -666,6 +679,8 @@
 
 +
 This template must start with `u:` or `dn:`.
+After `dn:`, a field value is escaped as an attribute value, unless the rest of the template is just that one field:
+then the value is taken as the DN.
 
 +
 In OpenAM CTS, the user name field is an array. For example, if the user name is the UID of the user entry, the use `u:{userName/0}` or `dn:{userName/0},ou=People,dc=example,dc=com`.
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/DnTemplate.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/DnTemplate.java
index 4433bda..d6d5688 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/DnTemplate.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/DnTemplate.java
@@ -84,10 +84,10 @@
         if (template.equals("..")) {
             trimmedTemplate = "";
             relativeOffset = 1;
-        } else if (template.endsWith(",..")) {
+        } else if (endsWithParentRdn(template)) {
             relativeOffset = 0;
             for (trimmedTemplate = template;
-                 trimmedTemplate.endsWith(",..");
+                 endsWithParentRdn(trimmedTemplate);
                  trimmedTemplate = trimmedTemplate.substring(0, trimmedTemplate.length() - 3)) {
                 relativeOffset++;
             }
@@ -99,17 +99,33 @@
             relativeOffset = -1;
         }
 
+        // Replace the variables with %s, and escape any '%' around them, which String.format() would read as a
+        // format specifier.
         final List<String> templateVariables = new ArrayList<>();
         final Matcher matcher = TEMPLATE_VARIABLE_RE.matcher(trimmedTemplate);
-        final StringBuffer buffer = new StringBuffer(trimmedTemplate.length());
+        final StringBuilder buffer = new StringBuilder(trimmedTemplate.length());
+        int fixedPartStart = 0;
         while (matcher.find()) {
-            matcher.appendReplacement(buffer, "%s");
+            buffer.append(trimmedTemplate.substring(fixedPartStart, matcher.start()).replace("%", "%%")).append("%s");
             templateVariables.add(matcher.group(1));
+            fixedPartStart = matcher.end();
         }
-        matcher.appendTail(buffer);
+        buffer.append(trimmedTemplate.substring(fixedPartStart).replace("%", "%%"));
         return new DnTemplate(trimmedTemplate, buffer.toString(), templateVariables, relativeOffset);
     }
 
+    /** Returns whether the template ends with a ".." RDN, that is ",.." whose comma is not escaped. */
+    private static boolean endsWithParentRdn(final String template) {
+        if (!template.endsWith(",..")) {
+            return false;
+        }
+        int backslashes = 0;
+        for (int i = template.length() - 4; i >= 0 && template.charAt(i) == '\\'; i--) {
+            backslashes++;
+        }
+        return backslashes % 2 == 0;
+    }
+
     private DnTemplate(String template, String formatString, List<String> variables, int relativeOffset) {
         this.template = template;
         this.formatString = formatString;
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapHttpApplication.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapHttpApplication.java
index 1cacf95..684eb07 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapHttpApplication.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapHttpApplication.java
@@ -447,7 +447,7 @@
         return connectionFactories.get(name);
     }
 
-    private ConditionalFilter buildBasicFilter(final JsonValue config) {
+    ConditionalFilter buildBasicFilter(final JsonValue config) {
         final String bind = config.get("bind").required().asString();
         final BindStrategy strategy = BindStrategy.valueOf(bind.toUpperCase().replace('-', '_'));
         return newBasicAuthenticationFilter(buildBindStrategy(strategy, config.get(bind).required()),
@@ -494,14 +494,14 @@
     private AuthenticationStrategy buildSimpleBindStrategy(final JsonValue config) {
         return newSimpleBindStrategy(getConnectionFactory(config.get("ldapConnectionFactory")
                                                                 .defaultTo(DEFAULT_BIND_FACTORY).asString()),
-                                     parseUserNameTemplate(config.get("bindDnTemplate").defaultTo("%s")),
+                                     parseUserNameTemplate(config.get("bindDnTemplate").defaultTo("{username}")),
                                      schema);
     }
 
     private AuthenticationStrategy buildSaslBindStrategy(JsonValue config) {
         return newSaslPlainStrategy(
                 getConnectionFactory(config.get("ldapConnectionFactory").defaultTo(DEFAULT_BIND_FACTORY).asString()),
-                schema, parseUserNameTemplate(config.get(AUTHZID_TEMPLATE).defaultTo("u:%s")));
+                schema, parseUserNameTemplate(config.get(AUTHZID_TEMPLATE).defaultTo("u:{username}")));
     }
 
     private AuthenticationStrategy buildSearchThenBindStrategy(JsonValue config) {
@@ -516,6 +516,7 @@
     }
 
     private String parseUserNameTemplate(final JsonValue template) {
-        return template.asString().replace("{username}", "%s");
+        // The strategies format the template with String.format(): keep any other '%' literal.
+        return template.asString().replace("%", "%%").replace("{username}", "%s");
     }
 }
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/AuthenticationStrategies.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/AuthenticationStrategies.java
index 35b40e6..5b0655e 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/AuthenticationStrategies.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/AuthenticationStrategies.java
@@ -38,7 +38,8 @@
      *            {@link ConnectionFactory} to the LDAP server used to perform the bind operation.
      * @param bindDNTemplate
      *            Tempalte of the DN to use for the bind operation. The first %s will be replaced by the provided
-     *            authentication-id (i.e: uid=%s,dc=example,dc=com)
+     *            authentication-id (i.e: uid=%s,dc=example,dc=com). A template which is just %s takes the
+     *            authentication-id as the bind DN.
      * @param schema
      *            {@link Schema} used to validate the DN format.*
      * @return a new simple bind {@link AuthenticationStrategy}
@@ -85,7 +86,8 @@
      *            {@link ConnectionFactory} to the LDAP server to authenticate with.
      * @param authcIdTemplate
      *            Authentication identity template containing a single %s which will be replaced by the authenticating
-     *            user's name. (i.e: (u:%s)
+     *            user's name. (i.e: (u:%s). After a "dn:" prefix the template is a bind DN template: just %s takes the
+     *            user name as the DN, otherwise the user name is escaped as an attribute value.
      * @param schema
      *            Schema used to perform DN validation.
      * @return a new SASL plain bind {@link AuthenticationStrategy}
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplate.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplate.java
index 096ad78..dc5b653 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplate.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplate.java
@@ -12,6 +12,7 @@
  * information: "Portions copyright [year] [name of copyright owner]".
  *
  * Copyright 2013-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
  */
 package org.forgerock.opendj.rest2ldap.authz;
 
@@ -48,6 +49,10 @@
         public String formatAsAuthzId(final AuthzIdTemplate t, final Object[] templateVariables) {
             // We're not interested in matching and place-holder attribute types can be tolerated,
             // so we can just use the core schema.
+            // A template which is just one placeholder takes the principal as the DN, rather than as one RDN value.
+            if ("%s".equals(t.formatString)) {
+                return DN.valueOf(String.valueOf(templateVariables[0]), Schema.getCoreSchema()).toString();
+            }
             return DN.format(t.formatString, Schema.getCoreSchema(), templateVariables).toString();
         }
     };
@@ -126,14 +131,17 @@
     }
 
     private String formatTemplate(final String template) {
-        // Parse the template keys and replace them with %s for formatting.
+        // Parse the template keys and replace them with %s for formatting. Escape any '%' around them, which
+        // String.format() would read as a format specifier.
         final Matcher matcher = TEMPLATE_KEY_RE.matcher(template);
-        final StringBuffer buffer = new StringBuffer(template.length());
+        final StringBuilder buffer = new StringBuilder(template.length());
+        int fixedPartStart = 0;
         while (matcher.find()) {
-            matcher.appendReplacement(buffer, "%s");
+            buffer.append(template.substring(fixedPartStart, matcher.start()).replace("%", "%%")).append("%s");
             keys.add(matcher.group(1));
+            fixedPartStart = matcher.end();
         }
-        matcher.appendTail(buffer);
+        buffer.append(template.substring(fixedPartStart).replace("%", "%%"));
         return type.removeTemplateKey(buffer.toString());
     }
 
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/CredentialExtractors.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/CredentialExtractors.java
index 50d5e9a..e576673 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/CredentialExtractors.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/CredentialExtractors.java
@@ -12,6 +12,7 @@
  * information: "Portions copyright [year] [name of copyright owner]".
  *
  * Copyright 2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
  */
 package org.forgerock.opendj.rest2ldap.authz;
 
@@ -98,6 +99,9 @@
         /** Reference to the HttpBasicExtractor Singleton. */
         public static final HttpBasicExtractor INSTANCE = new HttpBasicExtractor();
 
+        /** The authentication scheme and the space which separates it from the credentials. */
+        private static final String BASIC_SCHEME = "basic ";
+
         private HttpBasicExtractor() { }
 
         @Override
@@ -113,17 +117,23 @@
         }
 
         private Pair<String, String> parseUsernamePassword(String authHeader) {
-            if (authHeader != null && (authHeader.toLowerCase().startsWith("basic"))) {
+            if (authHeader != null && authHeader.regionMatches(true, 0, BASIC_SCHEME, 0, BASIC_SCHEME.length())) {
                 // We received authentication info
                 // Example received header:
                 // "Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ=="
-                final String base64UserCredentials = authHeader.substring("basic".length() + 1);
+                final String base64UserCredentials = authHeader.substring(BASIC_SCHEME.length());
                 // Example usage of base64:
                 // Base64("Aladdin:open sesame") = "QWxhZGRpbjpvcGVuIHNlc2FtZQ=="
-                final String userCredentials = new String(Base64.decode(base64UserCredentials));
-                String[] split = userCredentials.split(":");
-                if (split.length == 2) {
-                    return Pair.of(split[0], split[1]);
+                final byte[] decoded = Base64.decode(base64UserCredentials);
+                if (decoded == null) {
+                    // Not a multiple of 4 characters long once the characters outside base64 are dropped.
+                    return null;
+                }
+                final String userCredentials = new String(decoded);
+                // RFC 7617 section 2: the user-id cannot contain a colon, the password can.
+                final int colon = userCredentials.indexOf(':');
+                if (colon >= 0) {
+                    return Pair.of(userCredentials.substring(0, colon), userCredentials.substring(colon + 1));
                 }
             }
             return null;
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/HttpBasicAuthenticationFilter.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/HttpBasicAuthenticationFilter.java
index 76dd90a..a95a8f3 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/HttpBasicAuthenticationFilter.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/HttpBasicAuthenticationFilter.java
@@ -12,6 +12,7 @@
  * information: "Portions copyright [year] [name of copyright owner]".
  *
  * Copyright 2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
  */
 package org.forgerock.opendj.rest2ldap.authz;
 
@@ -63,7 +64,9 @@
     public Promise<Response, NeverThrowsException> filter(final Context context, final Request request,
             final Handler next) {
         final Pair<String, String> credentials = credentialsExtractor.apply(request.getHeaders());
-        if (credentials == null) {
+        // A simple bind with a DN and an empty password is an unauthenticated bind (RFC 4513 section 5.1.2): a server
+        // which accepts it would let the request run as the named user without checking any password.
+        if (credentials == null || credentials.getSecond().isEmpty()) {
             return asErrorResponse(LdapException.newLdapException(ResultCode.INVALID_CREDENTIALS));
         }
         return authenticationStrategy
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/SaslPlainStrategy.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/SaslPlainStrategy.java
index 58b7b09..a883844 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/SaslPlainStrategy.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/SaslPlainStrategy.java
@@ -12,6 +12,7 @@
  * information: "Portions copyright [year] [name of copyright owner]".
  *
  * Copyright 2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
  */
 package org.forgerock.opendj.rest2ldap.authz;
 
@@ -20,19 +21,17 @@
 import static org.forgerock.services.context.SecurityContext.AUTHZID_ID;
 import static org.forgerock.util.Reject.checkNotNull;
 import static org.forgerock.opendj.rest2ldap.authz.Utils.close;
+import static org.forgerock.opendj.rest2ldap.authz.Utils.formatBindDn;
 
 import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicReference;
 
-import org.forgerock.i18n.LocalizedIllegalArgumentException;
 import org.forgerock.opendj.ldap.Connection;
 import org.forgerock.opendj.ldap.ConnectionFactory;
-import org.forgerock.opendj.ldap.DN;
 import org.forgerock.opendj.ldap.DecodeException;
 import org.forgerock.opendj.ldap.DecodeOptions;
 import org.forgerock.opendj.ldap.LdapException;
-import org.forgerock.opendj.ldap.ResultCode;
 import org.forgerock.opendj.ldap.controls.AuthorizationIdentityRequestControl;
 import org.forgerock.opendj.ldap.controls.AuthorizationIdentityResponseControl;
 import org.forgerock.opendj.ldap.responses.BindResult;
@@ -42,6 +41,7 @@
 import org.forgerock.util.AsyncFunction;
 import org.forgerock.util.Function;
 import org.forgerock.util.promise.Promise;
+import org.forgerock.util.promise.Promises;
 
 /** Bind using a computed DN from a template and the current request/context. */
 final class SaslPlainStrategy implements AuthenticationStrategy {
@@ -56,7 +56,8 @@
      *            Factory used to get {@link Connection} receiving the sasl-bind requests
      * @param authcIdTemplate
      *            Authentication identity template containing a single %s which will be replaced by the authenticating
-     *            user's name. (i.e: (u:%s)
+     *            user's name. (i.e: (u:%s). After a "dn:" prefix the template is a bind DN template: just %s takes the
+     *            user name as the DN, otherwise the user name is escaped as an attribute value.
      * @param schema
      *            Schema used to perform DN validation.
      * @throws NullPointerException
@@ -68,14 +69,12 @@
         checkNotNull(schema, "schema cannot be null");
         checkNotNull(authcIdTemplate, "authcIdTemplate cannot be null");
         if (authcIdTemplate.startsWith("dn:")) {
+            // As AuthzIdTemplate does, ignore spaces after the key: "dn: {username}" is "dn:{username}".
+            final String dnTemplate = authcIdTemplate.substring("dn:".length()).trim();
             formatter = new Function<String, String, LdapException>() {
                 @Override
                 public String apply(String value) throws LdapException {
-                    try {
-                        return DN.format(authcIdTemplate, schema, value).toString();
-                    } catch (LocalizedIllegalArgumentException e) {
-                        throw LdapException.newLdapException(ResultCode.INVALID_DN_SYNTAX, e.getMessageObject(), e);
-                    }
+                    return "dn:" + formatBindDn(dnTemplate, schema, value);
                 }
             };
         } else {
@@ -91,6 +90,12 @@
     @Override
     public Promise<SecurityContext, LdapException> authenticate(final String username, final String password,
             final Context parentContext) {
+        final String authcId;
+        try {
+            authcId = formatter.apply(username);
+        } catch (final LdapException e) {
+            return Promises.newExceptionPromise(e);
+        }
         final AtomicReference<Connection> connectionHolder = new AtomicReference<Connection>();
         return connectionFactory
                 .getConnectionAsync()
@@ -98,15 +103,14 @@
                     @Override
                     public Promise<SecurityContext, LdapException> apply(Connection connection) throws LdapException {
                         connectionHolder.set(connection);
-                        return doSaslPlainBind(connection, parentContext, username, password);
+                        return doSaslPlainBind(connection, parentContext, username, authcId, password);
                     }
                 }).thenFinally(close(connectionHolder));
     }
 
     private Promise<SecurityContext, LdapException> doSaslPlainBind(final Connection connection,
                                                                     final Context parentContext, final String authzId,
-                                                                    final String password) throws LdapException {
-        final String authcId = formatter.apply(authzId);
+                                                                    final String authcId, final String password) {
         return connection
                 .bindAsync(newPlainSASLBindRequest(authcId, password.toCharArray())
                             .addControl(AuthorizationIdentityRequestControl.newControl(true)))
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/SimpleBindStrategy.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/SimpleBindStrategy.java
index dc4dcd3..ab5a5d4 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/SimpleBindStrategy.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/SimpleBindStrategy.java
@@ -12,11 +12,13 @@
  * information: "Portions copyright [year] [name of copyright owner]".
  *
  * Copyright 2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
  */
 package org.forgerock.opendj.rest2ldap.authz;
 
 import static org.forgerock.opendj.ldap.requests.Requests.newSimpleBindRequest;
 import static org.forgerock.opendj.rest2ldap.authz.Utils.close;
+import static org.forgerock.opendj.rest2ldap.authz.Utils.formatBindDn;
 import static org.forgerock.services.context.SecurityContext.AUTHZID_DN;
 import static org.forgerock.services.context.SecurityContext.AUTHZID_ID;
 import static org.forgerock.util.Reject.checkNotNull;
@@ -36,6 +38,7 @@
 import org.forgerock.util.AsyncFunction;
 import org.forgerock.util.Function;
 import org.forgerock.util.promise.Promise;
+import org.forgerock.util.promise.Promises;
 
 /** Bind using a computed DN from a template and the current request/context. */
 final class SimpleBindStrategy implements AuthenticationStrategy {
@@ -53,7 +56,7 @@
      *            Schema used to validate DN
      * @param bindDNTemplate
      *            The template which will be replaced by the authenticating user (i.e:
-     *            uid=%s,ou=People,dc=example,dc=com)
+     *            uid=%s,ou=People,dc=example,dc=com). A template which is just %s takes the user name as the bind DN.
      * @throws NullPointerException
      *             If a parameter is null
      */
@@ -66,11 +69,16 @@
     @Override
     public Promise<SecurityContext, LdapException> authenticate(final String username, final String password,
             final Context parentContext) {
+        final DN bindDN;
+        try {
+            bindDN = formatBindDn(bindDNTemplate, schema, username);
+        } catch (final LdapException e) {
+            return Promises.newExceptionPromise(e);
+        }
         final AtomicReference<Connection> connectionHolder = new AtomicReference<>();
         return connectionFactory
                 .getConnectionAsync()
-                .thenAsync(doSimpleBind(connectionHolder, parentContext, username,
-                           DN.format(bindDNTemplate, schema, username), password))
+                .thenAsync(doSimpleBind(connectionHolder, parentContext, username, bindDN, password))
                 .thenFinally(close(connectionHolder));
     }
 
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/Utils.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/Utils.java
index 92770cf..d57b54a 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/Utils.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/Utils.java
@@ -12,6 +12,7 @@
  * information: "Portions copyright [year] [name of copyright owner]".
  *
  * Copyright 2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
  */
 package org.forgerock.opendj.rest2ldap.authz;
 
@@ -25,8 +26,12 @@
 import org.forgerock.http.protocol.Response;
 import org.forgerock.http.protocol.Status;
 import org.forgerock.i18n.LocalizableMessage;
+import org.forgerock.i18n.LocalizedIllegalArgumentException;
 import org.forgerock.json.resource.ResourceException;
+import org.forgerock.opendj.ldap.DN;
 import org.forgerock.opendj.ldap.LdapException;
+import org.forgerock.opendj.ldap.ResultCode;
+import org.forgerock.opendj.ldap.schema.Schema;
 import org.forgerock.util.AsyncFunction;
 import org.forgerock.util.promise.NeverThrowsException;
 import org.forgerock.util.promise.Promise;
@@ -55,6 +60,31 @@
         return new AccessTokenException(message.toString(), cause);
     }
 
+    /**
+     * Returns the DN which a bind DN template designates for a user name.
+     *
+     * @param dnTemplate
+     *         The template, with {@code %s} in place of the user name. A template which is just {@code %s} takes the
+     *         user name as the DN; otherwise the user name is escaped as an attribute value.
+     * @param schema
+     *         The schema used to parse the DN.
+     * @param username
+     *         The user name.
+     * @return The DN.
+     * @throws LdapException
+     *         With {@link ResultCode#INVALID_CREDENTIALS} if the result is not a valid DN.
+     */
+    static DN formatBindDn(final String dnTemplate, final Schema schema, final String username)
+            throws LdapException {
+        try {
+            return "%s".equals(dnTemplate)
+                    ? DN.valueOf(username, schema)
+                    : DN.format(dnTemplate, schema, username);
+        } catch (final LocalizedIllegalArgumentException e) {
+            throw LdapException.newLdapException(ResultCode.INVALID_CREDENTIALS, e.getMessageObject(), e);
+        }
+    }
+
     static Runnable close(final AtomicReference<? extends Closeable> holder) {
         return new Runnable() {
             @Override
diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicJsonConfigurationTestCase.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicJsonConfigurationTestCase.java
new file mode 100644
index 0000000..6573fb4
--- /dev/null
+++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicJsonConfigurationTestCase.java
@@ -0,0 +1,135 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2026 3A Systems, LLC.
+ */
+package org.forgerock.opendj.rest2ldap;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.forgerock.opendj.ldap.spi.LdapPromises.newSuccessfulLdapPromise;
+import static org.forgerock.opendj.rest2ldap.TestUtils.parseJson;
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.anyString;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import org.forgerock.http.protocol.Headers;
+import org.forgerock.opendj.ldap.Connection;
+import org.forgerock.opendj.ldap.ConnectionFactory;
+import org.forgerock.opendj.ldap.LdapException;
+import org.forgerock.opendj.ldap.ResultCode;
+import org.forgerock.opendj.ldap.requests.BindRequest;
+import org.forgerock.opendj.ldap.requests.PlainSASLBindRequest;
+import org.forgerock.opendj.ldap.requests.SearchRequest;
+import org.forgerock.opendj.ldap.requests.SimpleBindRequest;
+import org.forgerock.opendj.ldap.responses.Responses;
+import org.forgerock.opendj.rest2ldap.authz.AuthenticationStrategy;
+import org.forgerock.services.context.RootContext;
+import org.forgerock.testng.ForgeRockTestCase;
+import org.forgerock.util.Function;
+import org.forgerock.util.Pair;
+import org.forgerock.util.promise.NeverThrowsException;
+import org.forgerock.util.promise.Promises;
+import org.mockito.ArgumentCaptor;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+/** Tests how the "basic" authorization configuration turns the HTTP Basic user name into an LDAP name. */
+@Test
+@SuppressWarnings("javadoc")
+public final class BasicJsonConfigurationTestCase extends ForgeRockTestCase {
+    private static final String USER_DN = "uid=bjensen,ou=People,dc=example,dc=com";
+
+    private Rest2LdapHttpApplication fakeApp;
+    private Connection connection;
+
+    @BeforeMethod
+    public void setUp() throws Exception {
+        connection = mock(Connection.class);
+        when(connection.bindAsync(any(BindRequest.class)))
+                .thenReturn(newSuccessfulLdapPromise(Responses.newBindResult(ResultCode.SUCCESS)));
+        when(connection.searchSingleEntryAsync(any(SearchRequest.class)))
+                .thenReturn(newSuccessfulLdapPromise(Responses.newSearchResultEntry(USER_DN)));
+        final ConnectionFactory factory = mock(ConnectionFactory.class);
+        when(factory.getConnectionAsync())
+                .thenReturn(Promises.<Connection, LdapException> newResultPromise(connection));
+
+        fakeApp = spy(Rest2LdapHttpApplication.class);
+        doReturn(factory).when(fakeApp).getConnectionFactory(anyString());
+    }
+
+    @Test
+    public void testSimpleDefaultTemplateTakesTheUserNameAsTheBindDn() throws Exception {
+        authenticate("{'bind': 'simple', 'simple': {}}", USER_DN);
+
+        assertThat(bindRequest(SimpleBindRequest.class).getName()).isEqualTo(USER_DN);
+    }
+
+    @Test
+    public void testSimpleTemplateKeepsPercentLiterally() throws Exception {
+        authenticate("{'bind': 'simple', 'simple': {'bindDnTemplate': 'uid={username},o=100%,dc=example,dc=com'}}",
+                "bjensen");
+
+        assertThat(bindRequest(SimpleBindRequest.class).getName()).isEqualTo("uid=bjensen,o=100%,dc=example,dc=com");
+    }
+
+    @Test
+    public void testSaslPlainDefaultTemplateIsTheUserId() throws Exception {
+        authenticate("{'bind': 'sasl-plain', 'sasl-plain': {}}", "bjensen");
+
+        assertThat(bindRequest(PlainSASLBindRequest.class).getAuthenticationID()).isEqualTo("u:bjensen");
+    }
+
+    @Test
+    public void testSaslPlainDnTemplateTakesTheUserNameAsTheBindDn() throws Exception {
+        authenticate("{'bind': 'sasl-plain', 'sasl-plain': {'authzIdTemplate': 'dn:{username}'}}", USER_DN);
+
+        assertThat(bindRequest(PlainSASLBindRequest.class).getAuthenticationID()).isEqualTo("dn:" + USER_DN);
+    }
+
+    @Test
+    public void testSaslPlainTemplateKeepsPercentLiterally() throws Exception {
+        authenticate("{'bind': 'sasl-plain', 'sasl-plain': {'authzIdTemplate': 'u:{username}%example'}}", "bjensen");
+
+        assertThat(bindRequest(PlainSASLBindRequest.class).getAuthenticationID()).isEqualTo("u:bjensen%example");
+    }
+
+    @Test
+    public void testSearchFilterTemplateKeepsPercentLiterally() throws Exception {
+        authenticate("{'bind': 'search', 'search': {'baseDn': 'dc=example,dc=com', 'scope': 'sub',"
+                + " 'filterTemplate': '(&(uid={username})(description=100%))'}}", "bjensen");
+
+        final ArgumentCaptor<SearchRequest> request = ArgumentCaptor.forClass(SearchRequest.class);
+        verify(connection).searchSingleEntryAsync(request.capture());
+        assertThat(request.getValue().getFilter().toString()).isEqualTo("(&(uid=bjensen)(description=100%))");
+        assertThat(bindRequest(SimpleBindRequest.class).getName()).isEqualTo(USER_DN);
+    }
+
+    @SuppressWarnings("unchecked")
+    private void authenticate(final String basicConfig, final String username) throws Exception {
+        final ArgumentCaptor<AuthenticationStrategy> strategy = ArgumentCaptor.forClass(AuthenticationStrategy.class);
+        doReturn(null).when(fakeApp).newBasicAuthenticationFilter(strategy.capture(),
+                (Function<Headers, Pair<String, String>, NeverThrowsException>) any(Function.class));
+        fakeApp.buildBasicFilter(parseJson(basicConfig));
+        strategy.getValue().authenticate(username, "secret", new RootContext()).getOrThrow();
+    }
+
+    private <T extends BindRequest> T bindRequest(final Class<T> type) {
+        final ArgumentCaptor<BindRequest> request = ArgumentCaptor.forClass(BindRequest.class);
+        verify(connection).bindAsync(request.capture());
+        return type.cast(request.getValue());
+    }
+}
diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/DnTemplateTest.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/DnTemplateTest.java
index cc847dc..275257a 100644
--- a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/DnTemplateTest.java
+++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/DnTemplateTest.java
@@ -12,6 +12,7 @@
  * information: "Portions copyright [year] [name of copyright owner]".
  *
  * Copyright 2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
  */
 package org.forgerock.opendj.rest2ldap;
 
@@ -51,6 +52,12 @@
             { "dc={subdomain}", "dc=www", "dc=www,dc=example,dc=com" },
             { "dc={subdomain},..", "dc=www,dc=com", "dc=www,dc=com" },
             { "dc={subdomain},dc={tenant},..", "dc=www,dc=acme,dc=com", "dc=www,dc=acme,dc=com" },
+            // A '%' in the fixed part is not a format specifier.
+            { "dc={subdomain},o=100%,dc=x", "dc=www,o=100%,dc=x", "dc=www,o=100%,dc=x,dc=example,dc=com" },
+            // An escaped comma does not start a relative "..", an escaped backslash before the comma does not escape it.
+            { "cn=a\\,..", "cn=a\\,..", "cn=a\\,..,dc=example,dc=com" },
+            { "cn=a\\\\,..", "cn=a\\\\,dc=com", "cn=a\\\\,dc=com" },
+            { "cn={subdomain}\\,..", "cn=www\\,..", "cn=www\\,..,dc=example,dc=com" },
         };
         // @formatter:on
     }
diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplateTest.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplateTest.java
index 0c99ca7..bcd6156 100644
--- a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplateTest.java
+++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplateTest.java
@@ -12,6 +12,7 @@
  * information: "Portions copyright [year] [name of copyright owner]".
  *
  * Copyright 2013-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
  */
 package org.forgerock.opendj.rest2ldap.authz;
 
@@ -51,6 +52,17 @@
                 map("uid", "test.user", "realm", "test+cn=quoting")
             },
             {
+                // A template which is just one placeholder takes the principal as the DN.
+                "dn:{dn}",
+                "uid=test.user,ou=People,dc=example,dc=com",
+                map("dn", "uid=test.user,ou=People,dc=example,dc=com")
+            },
+            {
+                "dn: {dn}",
+                "uid=test.user,ou=People,dc=example,dc=com",
+                map("dn", "uid=test.user,ou=People,dc=example,dc=com")
+            },
+            {
                 "u:{uid}@{realm}.example.com",
                 "test.user@acme.example.com",
                 map("uid", "test.user", "realm", "acme")
@@ -66,6 +78,17 @@
                 "u:{uid}.{numericid}.{testboolean}@{realm}.example.com",
                 "test.42.true@test.example.com",
                 map("uid", "test", "numericid", 42, "testboolean", true, "realm", "test")
+            },
+            {
+                // A '%' in the fixed part is not a format specifier.
+                "dn:uid={uid},o=100%,dc=example,dc=com",
+                "uid=test.user,o=100%,dc=example,dc=com",
+                map("uid", "test.user")
+            },
+            {
+                "u:{uid}%{realm}",
+                "test.user%acme",
+                map("uid", "test.user", "realm", "acme")
             }
         };
         // @formatter:on
diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/CredentialExtractorsTest.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/CredentialExtractorsTest.java
index 54e0209..0e09e73 100644
--- a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/CredentialExtractorsTest.java
+++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/CredentialExtractorsTest.java
@@ -12,6 +12,7 @@
  * information: "Portions copyright [year] [name of copyright owner]".
  *
  * Copyright 2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
  */
 package org.forgerock.opendj.rest2ldap.authz;
 
@@ -24,6 +25,7 @@
 import org.forgerock.testng.ForgeRockTestCase;
 import org.forgerock.util.Pair;
 import org.forgerock.util.encode.Base64;
+import org.testng.annotations.DataProvider;
 import org.testng.annotations.Test;
 
 @Test
@@ -36,11 +38,48 @@
         assertThat(httpBasicExtractor().apply(headers)).isEqualTo(Pair.of("foo", "bar"));
     }
 
-    @Test
-    public void testBasicReturnNullOnInvalidCredentials() {
+    @DataProvider
+    public Object[][] validBasicCredentials() {
+        // @formatter:off
+        return new Object[][] {
+            // RFC 7617 section 2 forbids a colon only in the user-id: the password is everything after the first one.
+            { "bjensen:se:cret", "bjensen", "se:cret" },
+            { "bjensen::", "bjensen", ":" },
+            { "uid=bjensen,ou=People,dc=example,dc=com:secret", "uid=bjensen,ou=People,dc=example,dc=com", "secret" },
+            // An empty password is a well-formed credential; the filter, not the parser, refuses it.
+            { "bjensen:", "bjensen", "" },
+        };
+        // @formatter:on
+    }
+
+    @Test(dataProvider = "validBasicCredentials")
+    public void testBasicSplitsAtTheFirstColon(final String credentials, final String username,
+            final String password) {
         final Headers headers = new Headers();
-        headers.put(HTTP_BASIC_AUTH_HEADER, "*invalid*");
-        assertThat(httpBasicExtractor().apply(new Headers())).isNull();
+        headers.put(HTTP_BASIC_AUTH_HEADER, "Basic " + Base64.encode(credentials.getBytes()));
+        assertThat(httpBasicExtractor().apply(headers)).isEqualTo(Pair.of(username, password));
+    }
+
+    @DataProvider
+    public Object[][] invalidBasicHeaders() {
+        // @formatter:off
+        return new Object[][] {
+            { "*invalid*" },
+            { "Basic " + Base64.encode("bjensen".getBytes()) },
+            { "Basic !!!" },
+            // Base64 which is not a multiple of 4 characters long does not decode at all.
+            { "Basic abc" },
+            { "Basic " + Base64.encode("foo:bar".getBytes()).replace("=", "") },
+            { "Basic" },
+        };
+        // @formatter:on
+    }
+
+    @Test(dataProvider = "invalidBasicHeaders")
+    public void testBasicReturnNullOnInvalidCredentials(final String header) {
+        final Headers headers = new Headers();
+        headers.put(HTTP_BASIC_AUTH_HEADER, header);
+        assertThat(httpBasicExtractor().apply(headers)).isNull();
     }
 
     @Test
diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/HttpBasicAuthenticationFilterTest.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/HttpBasicAuthenticationFilterTest.java
index ef44680..a864b2c 100644
--- a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/HttpBasicAuthenticationFilterTest.java
+++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/HttpBasicAuthenticationFilterTest.java
@@ -12,6 +12,7 @@
  * information: "Portions copyright [year] [name of copyright owner]".
  *
  * Copyright 2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
  */
 package org.forgerock.opendj.rest2ldap.authz;
 
@@ -21,6 +22,7 @@
 import static org.mockito.Matchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyZeroInteractions;
 import static org.mockito.Mockito.when;
 
 import java.io.IOException;
@@ -78,6 +80,25 @@
         verifyUnauthorizedOutputMessage(response);
     }
 
+    /**
+     * An LDAP simple bind with a DN and an empty password is an unauthenticated bind (RFC 4513 section 5.1.2): a
+     * server which accepts it would let the request run as the named user without any password.
+     */
+    @SuppressWarnings("unchecked")
+    @Test
+    public void testRespondUnauthorizedIfPasswordEmpty()
+            throws InterruptedException, ExecutionException, IOException {
+        final Function<Headers, Pair<String, String>, NeverThrowsException> credentials = mock(Function.class);
+        when(credentials.apply(any(Headers.class))).thenReturn(Pair.of("user", ""));
+        final AuthenticationStrategy authStrategy = mock(AuthenticationStrategy.class);
+
+        final Response response = new HttpBasicAuthenticationFilter(authStrategy, credentials)
+                .filter(mock(Context.class), new Request(), mock(Handler.class)).get();
+
+        verifyUnauthorizedOutputMessage(response);
+        verifyZeroInteractions(authStrategy);
+    }
+
     @SuppressWarnings("unchecked")
     @Test
     public void testContinueProcessOnSuccessfullAuthentication() {
diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/SaslPlainStrategyTest.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/SaslPlainStrategyTest.java
new file mode 100644
index 0000000..b19af59
--- /dev/null
+++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/SaslPlainStrategyTest.java
@@ -0,0 +1,108 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2026 3A Systems, LLC.
+ */
+package org.forgerock.opendj.rest2ldap.authz;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.fail;
+import static org.forgerock.opendj.ldap.spi.LdapPromises.newSuccessfulLdapPromise;
+import static org.forgerock.opendj.rest2ldap.authz.AuthenticationStrategies.newSaslPlainStrategy;
+import static org.mockito.Matchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import org.forgerock.opendj.ldap.Connection;
+import org.forgerock.opendj.ldap.ConnectionFactory;
+import org.forgerock.opendj.ldap.LdapException;
+import org.forgerock.opendj.ldap.ResultCode;
+import org.forgerock.opendj.ldap.requests.BindRequest;
+import org.forgerock.opendj.ldap.requests.PlainSASLBindRequest;
+import org.forgerock.opendj.ldap.responses.Responses;
+import org.forgerock.opendj.ldap.schema.Schema;
+import org.forgerock.services.context.RootContext;
+import org.forgerock.services.context.SecurityContext;
+import org.forgerock.testng.ForgeRockTestCase;
+import org.forgerock.util.promise.Promise;
+import org.forgerock.util.promise.Promises;
+import org.mockito.ArgumentCaptor;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+@Test
+@SuppressWarnings("javadoc")
+public final class SaslPlainStrategyTest extends ForgeRockTestCase {
+    private static final String USER_DN = "uid=bjensen,ou=People,dc=example,dc=com";
+
+    private ConnectionFactory factory;
+    private Connection connection;
+
+    @BeforeMethod
+    public void setUp() throws Exception {
+        connection = mock(Connection.class);
+        when(connection.bindAsync(any(BindRequest.class)))
+                .thenReturn(newSuccessfulLdapPromise(Responses.newBindResult(ResultCode.SUCCESS)));
+        factory = mock(ConnectionFactory.class);
+        when(factory.getConnectionAsync())
+                .thenReturn(Promises.<Connection, LdapException> newResultPromise(connection));
+    }
+
+    @DataProvider
+    public Object[][] authcIdTemplates() {
+        // @formatter:off
+        // [template, "{username}" already replaced with "%s"] [user name] [expected SASL authentication ID]
+        return new Object[][] {
+            // The user name is the bind DN.
+            { "dn:%s", USER_DN, "dn:" + USER_DN },
+            // Spaces after "dn:" are not part of the template, as for the OAuth2 authzIdTemplate.
+            { "dn: %s", USER_DN, "dn:" + USER_DN },
+            { "dn:uid=%s,ou=People,dc=example,dc=com", "bjensen", "dn:" + USER_DN },
+            // A user name inside a DN template stays one attribute value.
+            { "dn:uid=%s,ou=People,dc=example,dc=com", "a,ou=x", "dn:uid=a\\,ou\\=x,ou=People,dc=example,dc=com" },
+            { "u:%s", "bjensen", "u:bjensen" },
+        };
+        // @formatter:on
+    }
+
+    @Test(dataProvider = "authcIdTemplates")
+    public void testAuthenticationIdSentToTheServer(final String template, final String username,
+            final String expectedAuthcId) throws Exception {
+        final SecurityContext context = newSaslPlainStrategy(factory, Schema.getDefaultSchema(), template)
+                .authenticate(username, "secret", new RootContext()).getOrThrow();
+
+        final ArgumentCaptor<BindRequest> request = ArgumentCaptor.forClass(BindRequest.class);
+        verify(connection).bindAsync(request.capture());
+        assertThat(((PlainSASLBindRequest) request.getValue()).getAuthenticationID()).isEqualTo(expectedAuthcId);
+        assertThat(context.getAuthenticationId()).isEqualTo(expectedAuthcId);
+    }
+
+    /** A user name which is not a DN fails the returned promise and takes no connection. */
+    @Test
+    public void testUserNameWhichIsNotADnFailsThePromise() throws Exception {
+        final Promise<SecurityContext, LdapException> promise =
+                newSaslPlainStrategy(factory, Schema.getDefaultSchema(), "dn:%s")
+                        .authenticate("bjensen", "secret", new RootContext());
+        try {
+            promise.getOrThrow();
+            fail("The authentication should have failed");
+        } catch (final LdapException e) {
+            assertThat(e.getResult().getResultCode()).isEqualTo(ResultCode.INVALID_CREDENTIALS);
+        }
+        verify(factory, never()).getConnectionAsync();
+        verify(connection, never()).bindAsync(any(BindRequest.class));
+    }
+}
diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/SimpleBindStrategyTest.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/SimpleBindStrategyTest.java
new file mode 100644
index 0000000..6fbecde
--- /dev/null
+++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/SimpleBindStrategyTest.java
@@ -0,0 +1,124 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2026 3A Systems, LLC.
+ */
+package org.forgerock.opendj.rest2ldap.authz;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.fail;
+import static org.forgerock.opendj.ldap.Connections.newInternalConnectionFactory;
+import static org.forgerock.opendj.rest2ldap.authz.AuthenticationStrategies.newSimpleBindStrategy;
+import static org.forgerock.services.context.SecurityContext.AUTHZID_DN;
+import static org.forgerock.services.context.SecurityContext.AUTHZID_ID;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+import org.forgerock.opendj.ldap.ConnectionFactory;
+import org.forgerock.opendj.ldap.LdapException;
+import org.forgerock.opendj.ldap.MemoryBackend;
+import org.forgerock.opendj.ldap.ResultCode;
+import org.forgerock.opendj.ldap.schema.Schema;
+import org.forgerock.opendj.ldif.LDIFEntryReader;
+import org.forgerock.services.context.RootContext;
+import org.forgerock.services.context.SecurityContext;
+import org.forgerock.testng.ForgeRockTestCase;
+import org.forgerock.util.promise.Promise;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+@Test
+@SuppressWarnings("javadoc")
+public final class SimpleBindStrategyTest extends ForgeRockTestCase {
+    private static final String USER_DN = "uid=bjensen,ou=People,dc=example,dc=com";
+
+    private ConnectionFactory factory;
+
+    @BeforeMethod
+    public void setUp() throws Exception {
+        factory = newInternalConnectionFactory(new MemoryBackend(new LDIFEntryReader(
+                "dn: dc=example,dc=com",
+                "objectClass: domain",
+                "dc: example",
+                "",
+                "dn: ou=People,dc=example,dc=com",
+                "objectClass: organizationalUnit",
+                "ou: People",
+                "",
+                "dn: " + USER_DN,
+                "objectClass: inetOrgPerson",
+                "uid: bjensen",
+                "cn: Barbara Jensen",
+                "sn: Jensen",
+                "userPassword: secret")));
+    }
+
+    /** The documented default template, {@code {username}}, takes the user name as the bind DN. */
+    @Test
+    public void testDefaultTemplateTakesTheUserNameAsTheBindDn() throws Exception {
+        final SecurityContext context = newSimpleBindStrategy(factory, "%s", Schema.getDefaultSchema())
+                .authenticate(USER_DN, "secret", new RootContext()).getOrThrow();
+
+        assertThat(context.getAuthorization().get(AUTHZID_DN)).isEqualTo(USER_DN);
+        assertThat(context.getAuthorization().get(AUTHZID_ID)).isEqualTo(USER_DN);
+    }
+
+    @Test
+    public void testTemplateTakesTheUserNameAsAnAttributeValue() throws Exception {
+        final SecurityContext context =
+                newSimpleBindStrategy(factory, "uid=%s,ou=People,dc=example,dc=com", Schema.getDefaultSchema())
+                        .authenticate("bjensen", "secret", new RootContext()).getOrThrow();
+
+        assertThat(context.getAuthorization().get(AUTHZID_DN)).isEqualTo(USER_DN);
+    }
+
+    /** A user name inside a template stays one attribute value: it cannot add RDNs of its own. */
+    @Test
+    public void testTemplateEscapesTheUserName() throws Exception {
+        assertFailsWith(newSimpleBindStrategy(factory, "uid=%s,dc=example,dc=com", Schema.getDefaultSchema())
+                .authenticate("bjensen,ou=People", "secret", new RootContext()), ResultCode.INVALID_CREDENTIALS);
+    }
+
+    /** A user name which is not a DN fails the returned promise instead of being thrown by authenticate(). */
+    @Test
+    public void testUserNameWhichIsNotADnFailsThePromise() throws Exception {
+        assertFailsWith(newSimpleBindStrategy(factory, "%s", Schema.getDefaultSchema())
+                .authenticate("bjensen", "secret", new RootContext()), ResultCode.INVALID_CREDENTIALS);
+    }
+
+    /** A user name which is not a DN is refused before a connection is taken, so none is left open. */
+    @Test
+    public void testUserNameWhichIsNotADnTakesNoConnection() throws Exception {
+        final ConnectionFactory connections = mock(ConnectionFactory.class);
+        assertFailsWith(newSimpleBindStrategy(connections, "%s", Schema.getDefaultSchema())
+                .authenticate("bjensen", "secret", new RootContext()), ResultCode.INVALID_CREDENTIALS);
+        verify(connections, never()).getConnectionAsync();
+    }
+
+    @Test
+    public void testWrongPasswordFails() throws Exception {
+        assertFailsWith(newSimpleBindStrategy(factory, "%s", Schema.getDefaultSchema())
+                .authenticate(USER_DN, "wrong", new RootContext()), ResultCode.INVALID_CREDENTIALS);
+    }
+
+    private static void assertFailsWith(final Promise<SecurityContext, LdapException> promise,
+            final ResultCode expected) throws Exception {
+        try {
+            promise.getOrThrow();
+            fail("The authentication should have failed");
+        } catch (final LdapException e) {
+            assertThat(e.getResult().getResultCode()).isEqualTo(expected);
+        }
+    }
+}

--
Gitblit v1.10.0