Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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!:
Expand Down Expand Up @@ -415,6 +415,10 @@ A single occurrence of the string `\{username\}` is replaced in the template wit

+
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\}`
Expand Down Expand Up @@ -445,6 +449,11 @@ If the user name is also the authorization ID, use `u:\{username\}`.

+
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\}`

========

Expand Down Expand Up @@ -579,6 +588,8 @@ A JSON pointer value in braces is replaced in the template with a field value fr

+
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`.
Expand Down Expand Up @@ -629,6 +640,8 @@ A JSON pointer value in braces is replaced in the template with a field value fr

+
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`.
Expand Down Expand Up @@ -666,6 +679,8 @@ A JSON pointer value in braces is replaced in the template with a field value fr

+
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`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,10 @@ private static DnTemplate compile(String template, boolean isRelative) {
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++;
}
Expand All @@ -99,17 +99,33 @@ private static DnTemplate compile(String template, boolean isRelative) {
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,7 @@ protected ConnectionFactory getConnectionFactory(final String name) {
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()),
Expand Down Expand Up @@ -494,14 +494,14 @@ private AuthenticationStrategy buildBindStrategy(final BindStrategy strategy, fi
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) {
Expand All @@ -516,6 +516,7 @@ private AuthenticationStrategy buildSearchThenBindStrategy(JsonValue config) {
}

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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ private AuthenticationStrategies() {
* {@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}
Expand Down Expand Up @@ -85,7 +86,8 @@ public static AuthenticationStrategy newSearchThenBindStrategy(ConnectionFactory
* {@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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -48,6 +49,10 @@ private interface Impl {
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();
}
};
Expand Down Expand Up @@ -126,14 +131,17 @@ private String removeTemplateKey(final String formattedString) {
}

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());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -98,6 +99,9 @@ private static final class HttpBasicExtractor
/** 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
Expand All @@ -113,17 +117,23 @@ public Pair<String, String> apply(Headers headers) {
}

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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -63,7 +64,9 @@ public HttpBasicAuthenticationFilter(AuthenticationStrategy authenticationStrate
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand All @@ -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 {
Expand All @@ -56,7 +56,8 @@ final class SaslPlainStrategy implements AuthenticationStrategy {
* 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
Expand All @@ -68,14 +69,12 @@ public SaslPlainStrategy(final ConnectionFactory connectionFactory, final Schema
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 {
Expand All @@ -91,22 +90,27 @@ public String apply(String value) throws LdapException {
@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()
.thenAsync(new AsyncFunction<Connection, SecurityContext, LdapException>() {
@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)))
Expand Down
Loading
Loading