mirror of https://github.com/OpenIdentityPlatform/OpenDJ.git

Valery Kharseko
yesterday 81bc6cd81b347f178a1a2b85e33b7101d1241c41
[#1155] Make the rest2ldap bind templates and HTTP Basic credentials work as documented (#1165)

## Problem

rest2ldap could not build an LDAP name from HTTP Basic credentials in
several configurations the reference documentation recommends. See
#1155.

- `sasl-plain` with an `authzIdTemplate` starting with `dn:` failed
every bind with `INVALID_DN_SYNTAX`, because the whole `dn:...` string
went to `DN.format()`.
- The `simple` bind with its default `bindDnTemplate`, `{username}`,
never worked. `DN.format()` escaped the whole DN user name as one
attribute value. The parse error was thrown from `authenticate()`
instead of failing the returned promise.
- An HTTP Basic password that contains `:` was dropped: the credentials
were split with `split(":")` and anything but two parts meant "no
credentials". A bare `Basic` header threw
`StringIndexOutOfBoundsException`.
- A `%` in the fixed part of a template was read by `String.format()` as
a format specifier (`o=100%,dc=example` →
`MissingFormatArgumentException: '%,d'`). `DnTemplate` also stripped a
trailing `,..` after an escaped comma.

## Change

- **Bind DN templates** (`authz/Utils.formatBindDn`, used by
`SimpleBindStrategy` and the `dn:` branch of `SaslPlainStrategy`): a
template which is just `{username}` takes the user name as the DN
(`DN.valueOf`); any other template keeps `DN.format()`, which escapes
the user name as one attribute value. A user name which does not give a
DN fails the returned promise with `INVALID_CREDENTIALS` (HTTP 401)
before a connection is taken.
- **`sasl-plain`**: only the part after `dn:` is formatted, and the
authentication ID sent is `dn:<DN>`. Spaces after `dn:` are ignored, as
for the OAuth2 template, so `dn: {username}` reads as `dn:{username}`.
- **OAuth2 `authzIdTemplate`** (`AuthzIdTemplate`): a `dn:` template
which is just one placeholder, such as `dn:{sub}`, takes the field value
as the DN as well. Without this, it failed in the same way as the
default `simple` template.
- **HTTP Basic** (`CredentialExtractors`): the credentials are split at
the first `:`, as [RFC 7617
§2](https://www.rfc-editor.org/rfc/rfc7617#section-2) requires, and the
scheme must be followed by a space. The case-insensitive match no longer
depends on the default locale. Credentials which do not decode as
base64, such as `Basic abc` or unpadded base64, are "no credentials"
instead of a `NullPointerException`.
- **Empty password** (`HttpBasicAuthenticationFilter`): refused with 401
before any bind. A simple bind with a DN and an empty password is an
unauthenticated bind ([RFC 4513
§5.1.2](https://www.rfc-editor.org/rfc/rfc4513#section-5.1.2)). A server
which accepts it would let the request run as the named user through
proxied authorization without checking any password. Before this change,
`user:` from the Basic header was "no credentials" and fell through to
the next authorization mechanism; an empty alternative password header
already reached the bind strategy. The server's own HTTP Basic mechanism
(`HttpBasicAuthorizationMechanism`) uses the same filter and extractor,
so it gets both changes.
- **`%` in templates**: `DnTemplate`, `AuthzIdTemplate` and the
`{username}` templates of the `basic` configuration (`bindDnTemplate`,
`authzIdTemplate`, `filterTemplate`) escape `%` in their fixed parts.
The configuration defaults are now `{username}` and `u:{username}`
instead of the raw format strings `%s` and `u:%s`. A literal `%s`
written in `config.json`, which was never documented, is now kept as
text.
- **`DnTemplate`**: a trailing `,..` is stripped only when its comma is
not escaped (an even number of backslashes before it).
- The reference (`appendix-rest2ldap.adoc`) says how a template which is
just `{username}` or one field is read, and gives the `sasl-plain`
default.

Departures from the issue text: an empty password is parsed but refused
(see above), and a user name which does not give a DN is reported as
`INVALID_CREDENTIALS` (401) rather than `INVALID_DN_SYNTAX`, which maps
to 400.

## Test

- `SimpleBindStrategyTest` (new, in-memory backend): the default
template binds with a DN user name. A template escapes the user name, so
`bjensen,ou=People` cannot add RDNs. A user name which is not a DN fails
the promise with `INVALID_CREDENTIALS` and takes no connection.
- `SaslPlainStrategyTest` (new): the authentication ID sent for `dn:%s`,
`dn: %s`, `dn:uid=%s,...` (escaped) and `u:%s`. A user name which is not
a DN fails without taking a connection or sending a bind.
- `BasicJsonConfigurationTestCase` (new): the `basic` configuration
defaults for `simple` and `sasl-plain`, `dn:{username}`, and `%` in all
three templates, read from the request sent to the server.
- `CredentialExtractorsTest`: `bjensen:se:cret`, `bjensen::`, a DN user
name and `bjensen:`. `testBasicReturnNullOnInvalidCredentials` used to
fill `headers` and then pass `new Headers()`; it now checks the headers
it builds, including a bare `Basic`, `Basic abc` and unpadded base64.
- `HttpBasicAuthenticationFilterTest`: an empty password gives 401 and
the strategy is never called.
- `AuthzIdTemplateTest`, `DnTemplateTest`: `%` in the fixed part,
`dn:{dn}` and `dn: {dn}` with a DN value, `\,..` and `\\,..`.

Results:

- Against master, 27 of the new checks fail, each for the reason the
issue describes.
- With the fix, the whole `opendj-rest2ldap` suite passes (571 tests).
Javadoc doclint passes, and a broken `{@link}` added as a negative
control fails it.
- Nine mutants each turn at least one test red:
`DN.valueOf(String.format(...))` for every template, the empty password
let through, any backslash escaping the comma, the old `%s` default, a
split at the last colon, no `null` check after `Base64.decode`, no trim
after `dn:`, and `getConnectionAsync()` called before the user name is
checked, in `SimpleBindStrategy` and in `SaslPlainStrategy`.

Fixes #1155
14 files modified
3 files added
638 ■■■■■ changed files
opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-rest2ldap.adoc 17 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/DnTemplate.java 26 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapHttpApplication.java 9 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/AuthenticationStrategies.java 6 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplate.java 16 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/CredentialExtractors.java 22 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/HttpBasicAuthenticationFilter.java 5 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/SaslPlainStrategy.java 28 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/SimpleBindStrategy.java 14 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/Utils.java 30 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicJsonConfigurationTestCase.java 135 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/DnTemplateTest.java 7 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplateTest.java 23 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/CredentialExtractorsTest.java 47 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/HttpBasicAuthenticationFilterTest.java 21 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/SaslPlainStrategyTest.java 108 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/SimpleBindStrategyTest.java 124 ●●●●● patch | view | raw | blame | history
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`.
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;
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");
    }
}
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}
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());
    }
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;
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
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)))
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));
    }
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
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicJsonConfigurationTestCase.java
New file
@@ -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());
    }
}
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
    }
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
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
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() {
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/SaslPlainStrategyTest.java
New file
@@ -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));
    }
}
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/SimpleBindStrategyTest.java
New file
@@ -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);
        }
    }
}