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