[#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
| | |
| | | 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!: |
| | |
| | | |
| | | + |
| | | 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\}` |
| | |
| | | |
| | | + |
| | | 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\}` |
| | | |
| | | ======== |
| | | |
| | |
| | | |
| | | + |
| | | 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`. |
| | |
| | | |
| | | + |
| | | 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`. |
| | |
| | | |
| | | + |
| | | 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`. |
| | |
| | | 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++; |
| | | } |
| | |
| | | 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; |
| | |
| | | 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()), |
| | |
| | | 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) { |
| | |
| | | } |
| | | |
| | | 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"); |
| | | } |
| | | } |
| | |
| | | * {@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} |
| | |
| | | * {@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} |
| | |
| | | * 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; |
| | | |
| | |
| | | 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(); |
| | | } |
| | | }; |
| | |
| | | } |
| | | |
| | | 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()); |
| | | } |
| | | |
| | |
| | | * information: "Portions copyright [year] [name of copyright owner]". |
| | | * |
| | | * Copyright 2016 ForgeRock AS. |
| | | * Portions Copyright 2026 3A Systems, LLC. |
| | | */ |
| | | package org.forgerock.opendj.rest2ldap.authz; |
| | | |
| | |
| | | /** 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 |
| | |
| | | } |
| | | |
| | | 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; |
| | |
| | | * information: "Portions copyright [year] [name of copyright owner]". |
| | | * |
| | | * Copyright 2016 ForgeRock AS. |
| | | * Portions Copyright 2026 3A Systems, LLC. |
| | | */ |
| | | package org.forgerock.opendj.rest2ldap.authz; |
| | | |
| | |
| | | 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 |
| | |
| | | * 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.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; |
| | |
| | | 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 { |
| | |
| | | * 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 |
| | |
| | | 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 { |
| | |
| | | @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() |
| | |
| | | @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))) |
| | |
| | | * 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; |
| | |
| | | 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 { |
| | |
| | | * 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 |
| | | */ |
| | |
| | | @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)); |
| | | } |
| | | |
| | |
| | | * 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 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; |
| | |
| | | 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 |
| New file |
| | |
| | | /* |
| | | * 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()); |
| | | } |
| | | } |
| | |
| | | * information: "Portions copyright [year] [name of copyright owner]". |
| | | * |
| | | * Copyright 2016 ForgeRock AS. |
| | | * Portions Copyright 2026 3A Systems, LLC. |
| | | */ |
| | | package org.forgerock.opendj.rest2ldap; |
| | | |
| | |
| | | { "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 |
| | | } |
| | |
| | | * 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; |
| | | |
| | |
| | | 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") |
| | |
| | | "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 |
| | |
| | | * 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 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 |
| | |
| | | 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 |
| | |
| | | * 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.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; |
| | |
| | | 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() { |
| New file |
| | |
| | | /* |
| | | * 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)); |
| | | } |
| | | } |
| New file |
| | |
| | | /* |
| | | * 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); |
| | | } |
| | | } |
| | | } |