diff --git a/changelog.html b/changelog.html
index 09b741d68..2a15c8a70 100644
--- a/changelog.html
+++ b/changelog.html
@@ -47,6 +47,7 @@
1.12.1 (to be determined)
- [#251] - Enable JUnit 5 tests
+ - [#246] - Require custom authenticator plugin to be annotated as an authenticator
- [#244] - Prevent REST API plugin from exposing its own configuration (including authentication) via its own endpoints
- [#242] - Fix individual System Property GETs returning HTTP/404
- [#213] - Improve setting a subject in a chat room
diff --git a/readme.md b/readme.md
index a2d8e9447..969110e59 100644
--- a/readme.md
+++ b/readme.md
@@ -80,6 +80,34 @@ The secret key can be defined in Openfire Admin console under Server > Server Se
E.g.
>**Header:** Authorization: s3cretKey
+### Custom authentication filter
+
+In addition to Basic HTTP Authentication and the shared secret key, the REST API plugin can delegate authentication
+to a custom implementation, for deployments that need to integrate with an external identity provider, a
+different credential store, or additional checks beyond what the built-in mechanisms provide.
+
+This is configured in the Openfire Admin console under Server > Server Settings > REST API, by setting the
+authentication type to "custom" and providing the fully qualified class name of your implementation. The class
+must already be present on Openfire's classpath (e.g. provided as a JAR file in Openfire's LIB folder) before it can be
+selected.
+
+#### Requirements for a custom implementation
+
+A class used as a custom authentication filter must:
+
+1. Implement `javax.ws.rs.container.ContainerRequestFilter`.
+2. Be annotated with `@javax.annotation.Priority(javax.ws.rs.Priorities.AUTHENTICATION)`.
+3. Reject any request that does not carry valid credentials (for example by throwing a `WebApplicationException` with an
+ appropriate `Response.Status`, or by calling `requestContext.abortWith(...)` with an appropriate `4xx` response)
+ before returning from `filter()`.
+
+The first two requirements are enforced by the plugin: a class that does not satisfy both will be rejected when
+you attempt to save the configuration. The REST API will continue using its default authentication filter.
+
+The third requirement cannot be verified automatically and is the implementer's responsibility. A filter that
+implements the interface and carries the annotation, but returns without calling `abortWith(...)` on an
+unauthenticated request, will be loaded successfully and will silently grant unauthenticated access.
+
# User related REST Endpoints
## Retrieve users
diff --git a/src/java/org/jivesoftware/openfire/plugin/rest/AuthFilter.java b/src/java/org/jivesoftware/openfire/plugin/rest/AuthFilter.java
index a3a9084f0..561475930 100644
--- a/src/java/org/jivesoftware/openfire/plugin/rest/AuthFilter.java
+++ b/src/java/org/jivesoftware/openfire/plugin/rest/AuthFilter.java
@@ -41,7 +41,7 @@
* The Class AuthFilter.
*/
@PreMatching
-@Priority(Priorities.AUTHORIZATION)
+@Priority(Priorities.AUTHENTICATION)
public class AuthFilter implements ContainerRequestFilter {
/** The log. */
diff --git a/src/java/org/jivesoftware/openfire/plugin/rest/RESTServicePlugin.java b/src/java/org/jivesoftware/openfire/plugin/rest/RESTServicePlugin.java
index 8dced564d..b0b0d16c4 100644
--- a/src/java/org/jivesoftware/openfire/plugin/rest/RESTServicePlugin.java
+++ b/src/java/org/jivesoftware/openfire/plugin/rest/RESTServicePlugin.java
@@ -149,18 +149,13 @@ public void destroyPlugin() {
}
/**
- * Returns the loading status message.
+ * Validates the custom authentication filter class name.
*
- * @return the loading status message.
+ * @param customAuthFilterClassName the custom authentication filter class name.
+ * @return the validation message, or null when the class name is valid.
*/
- public String getLoadingStatusMessage() {
- return JerseyWrapper.getLoadingStatusMessage();
- }
-
- /**
- * Reloads the Jersey wrapper.
- */
- public String loadAuthenticationFilter(String customAuthFilterClassName) {
- return JerseyWrapper.tryLoadingAuthenticationFilter(customAuthFilterClassName);
+ public String validateCustomAuthenticationFilter(String customAuthFilterClassName)
+ {
+ return JerseyWrapper.validateCustomAuthFilterClassName(customAuthFilterClassName);
}
}
diff --git a/src/java/org/jivesoftware/openfire/plugin/rest/service/JerseyWrapper.java b/src/java/org/jivesoftware/openfire/plugin/rest/service/JerseyWrapper.java
index 84b3dda38..3d9b8b6cd 100644
--- a/src/java/org/jivesoftware/openfire/plugin/rest/service/JerseyWrapper.java
+++ b/src/java/org/jivesoftware/openfire/plugin/rest/service/JerseyWrapper.java
@@ -16,68 +16,135 @@
package org.jivesoftware.openfire.plugin.rest.service;
+import com.google.common.annotations.VisibleForTesting;
import org.glassfish.jersey.server.ResourceConfig;
import org.jivesoftware.openfire.plugin.rest.*;
import org.jivesoftware.openfire.plugin.rest.exceptions.RESTExceptionMapper;
+import javax.annotation.Nonnull;
+import javax.annotation.Priority;
import javax.servlet.ServletConfig;
+import javax.ws.rs.Priorities;
+import javax.ws.rs.container.ContainerRequestFilter;
+import javax.ws.rs.container.DynamicFeature;
import javax.ws.rs.core.Context;
+import javax.ws.rs.core.Feature;
+import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
+import java.util.stream.Collectors;
/**
* The Class JerseyWrapper.
*/
public class JerseyWrapper extends ResourceConfig {
+ private static final org.slf4j.Logger Log = org.slf4j.LoggerFactory.getLogger(JerseyWrapper.class);
+
+ /**
+ * A collection of classes that are acceptable for use as a custom REST API authentication implementation.
+ */
+ public static final Set> CUSTOM_AUTH_ACCEPTABLE_CONTRACT_SHAPES = Set.of(
+ ContainerRequestFilter.class, // Implementations are expected to be a ContainerRequestFilter
+ Feature.class, // Theoretically, Jersey can use this too - allowing/keeping this for backwards compatibility.
+ DynamicFeature.class // Theoretically, Jersey can use this too - allowing/keeping this for backwards compatibility.
+ );
+
/** The Constant SERVLET_URL. */
public static final String SERVLET_URL = "restapi/*";
/** The Constant JERSEY_LOGGER. */
private final static Logger JERSEY_LOGGER = Logger.getLogger("org.glassfish.jersey");
- private static String loadingStatusMessage = null;
-
static {
JERSEY_LOGGER.setLevel(Level.SEVERE);
}
- public static String tryLoadingAuthenticationFilter(String customAuthFilterClassName) {
-
- try {
- if(customAuthFilterClassName != null) {
- Class.forName(customAuthFilterClassName, false, JerseyWrapper.class.getClassLoader());
+ public static String validateCustomAuthFilterClassName(String customAuthFilterClassName)
+ {
+ String loadingStatusMessage;
+ if (customAuthFilterClassName == null || customAuthFilterClassName.isEmpty()) {
+ loadingStatusMessage = "Classname field can't be empty!";
+ } else {
+ try {
+ getCustomAuthFilterClassObject(customAuthFilterClassName);
loadingStatusMessage = null;
+ } catch (IllegalArgumentException e) {
+ loadingStatusMessage = "Unable to use the custom auth filter class name '" + customAuthFilterClassName + "': " + e.getMessage();
}
- } catch (ClassNotFoundException e) {
- loadingStatusMessage = "No custom auth filter found for restAPI plugin with name " + customAuthFilterClassName;
}
-
- if(customAuthFilterClassName == null || customAuthFilterClassName.isEmpty())
- loadingStatusMessage = "Classname field can't be empty!";
+
return loadingStatusMessage;
}
- public String loadAuthenticationFilter() {
-
+ public String loadAuthenticationFilter()
+ {
// Check if custom AuthFilter is available
String customAuthFilterClassName = RESTServicePlugin.CUSTOM_AUTH_FILTER.getValue();
RESTServicePlugin.AuthType restAuthType = RESTServicePlugin.AUTH_TYPE.getValue();
Class> pickedAuthFilter = AuthFilter.class;
-
+
+ String loadingStatusMessage = null;
try {
if(customAuthFilterClassName != null && RESTServicePlugin.AuthType.custom.equals(restAuthType)) {
- pickedAuthFilter = Class.forName(customAuthFilterClassName, false, JerseyWrapper.class.getClassLoader());
+ pickedAuthFilter = getCustomAuthFilterClassObject(customAuthFilterClassName);
loadingStatusMessage = null;
}
- } catch (ClassNotFoundException e) {
- loadingStatusMessage = "No custom auth filter found for restAPI plugin! " + customAuthFilterClassName + " " + restAuthType;
+ } catch (IllegalArgumentException e) {
+ loadingStatusMessage = "Unable to use the custom auth filter class name '" + customAuthFilterClassName + "': " + e.getMessage();
+ Log.error("Unable to load custom auth filter: {}", customAuthFilterClassName, e);
}
register(pickedAuthFilter);
return loadingStatusMessage;
}
-
+
+ /**
+ * Resolves the given class name and validates that it is suitable for use as a custom REST API authentication
+ * filter.
+ *
+ * To be accepted, the resolved class must satisfy both of the following:
+ *
+ * - It must implement one of {@link ContainerRequestFilter}, {@link Feature}, or {@link DynamicFeature}.
+ * {@link ContainerRequestFilter} is the expected, documented contract; {@link Feature} and
+ * {@link DynamicFeature} are accepted only for backwards compatibility with any implementation that wires
+ * up its filter indirectly through one of these.
+ * - It must be annotated with {@code @}{@link Priority}{@code (}{@link Priorities#AUTHENTICATION}{@code )},
+ * signaling that the class is specifically intended to perform authentication. This is a necessary, but not
+ * sufficient, safeguard: it prevents an incidental class (e.g. a logging or metrics filter) from being
+ * mistaken for an authenticator, but it cannot verify that the class actually rejects unauthenticated
+ * requests.
+ *
+ * This method does not instantiate or invoke the resolved class; it only inspects its type and annotations.
+ *
+ * @param className the fully qualified name of the class to resolve and validate. Must not be {@code null}.
+ * @return the resolved, validated class. Never {@code null}.
+ * @throws IllegalArgumentException if the class cannot be resolved on the classpath, or if it is resolved but
+ * does not satisfy both validation requirements above. The exception message identifies the specific
+ * reason for rejection.
+ */
+ @VisibleForTesting
+ static Class> getCustomAuthFilterClassObject(@Nonnull final String className) throws IllegalArgumentException
+ {
+ final Class> candidate;
+ try {
+ candidate = Class.forName(className, false, JerseyWrapper.class.getClassLoader());
+ } catch (ClassNotFoundException e) {
+ throw new IllegalArgumentException("Class not found: " + className, e);
+ }
+
+ if (CUSTOM_AUTH_ACCEPTABLE_CONTRACT_SHAPES.stream().noneMatch(c -> c.isAssignableFrom(candidate))) {
+ throw new IllegalArgumentException("Class " + className + " does not implement an acceptable contract shape (one of " + CUSTOM_AUTH_ACCEPTABLE_CONTRACT_SHAPES.stream().map(Class::getName).collect(Collectors.joining(", ")) + ").");
+ }
+
+ final Priority priority = candidate.getAnnotation(Priority.class);
+ if (priority == null || priority.value() != Priorities.AUTHENTICATION) {
+ throw new IllegalArgumentException("Class " + className + " is not a valid authentication filter (it lacks the @javax.annotation.Priority(javax.ws.rs.Priorities.AUTHENTICATION) annotation).");
+ }
+
+ return candidate;
+ }
+
/**
* Instantiates a new jersey wrapper.
*/
@@ -118,14 +185,4 @@ public JerseyWrapper(@Context ServletConfig servletConfig) {
// Documentation (Swagger)
register( new CustomOpenApiResource() );
}
-
- /*
- * Returns the loading status message.
- *
- * @return the loading status message.
- */
- public static String getLoadingStatusMessage() {
- return loadingStatusMessage;
- }
-
}
diff --git a/src/test/java/org/jivesoftware/openfire/plugin/rest/service/JerseyWrapperTest.java b/src/test/java/org/jivesoftware/openfire/plugin/rest/service/JerseyWrapperTest.java
new file mode 100644
index 000000000..5ba247237
--- /dev/null
+++ b/src/test/java/org/jivesoftware/openfire/plugin/rest/service/JerseyWrapperTest.java
@@ -0,0 +1,230 @@
+/*
+ * Copyright (c) 2026 Ignite Realtime Foundation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.jivesoftware.openfire.plugin.rest.service;
+
+import org.junit.jupiter.api.Test;
+
+import javax.annotation.Priority;
+import javax.ws.rs.Priorities;
+import javax.ws.rs.container.ContainerRequestContext;
+import javax.ws.rs.container.ContainerRequestFilter;
+import javax.ws.rs.container.DynamicFeature;
+import javax.ws.rs.container.ResourceInfo;
+import javax.ws.rs.core.Feature;
+import javax.ws.rs.core.FeatureContext;
+import java.io.IOException;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Unit tests for {@link JerseyWrapper#getCustomAuthFilterClassObject(String)}.
+ */
+public class JerseyWrapperTest
+{
+ /**
+ * A class implementing ContainerRequestFilter and carrying the correct @Priority annotation must be accepted.
+ */
+ @Test
+ public void testValidContainerRequestFilterIsAccepted()
+ {
+ // Setup test fixture.
+ final String className = ValidAuthFilter.class.getName();
+
+ // Execute system under test.
+ final Class> result = JerseyWrapper.getCustomAuthFilterClassObject(className);
+
+ // Verify result.
+ assertEquals(ValidAuthFilter.class, result, "A correctly annotated ContainerRequestFilter should be returned as-is.");
+ }
+
+ /**
+ * A class implementing Feature and carrying the correct @Priority annotation must be accepted, for backwards compatibility.
+ */
+ @Test
+ public void testValidFeatureIsAccepted()
+ {
+ // Setup test fixture.
+ final String className = ValidAuthFeature.class.getName();
+
+ // Execute system under test.
+ final Class> result = JerseyWrapper.getCustomAuthFilterClassObject(className);
+
+ // Verify result.
+ assertEquals(ValidAuthFeature.class, result, "A correctly annotated Feature should be accepted for backwards compatibility.");
+ }
+
+ /**
+ * A class implementing DynamicFeature and carrying the correct @Priority annotation must be accepted, for backwards compatibility.
+ */
+ @Test
+ public void testValidDynamicFeatureIsAccepted()
+ {
+ // Setup test fixture.
+ final String className = ValidAuthDynamicFeature.class.getName();
+
+ // Execute system under test.
+ final Class> result = JerseyWrapper.getCustomAuthFilterClassObject(className);
+
+ // Verify result.
+ assertEquals(ValidAuthDynamicFeature.class, result, "A correctly annotated DynamicFeature should be accepted for backwards compatibility.");
+ }
+
+ /**
+ * A ContainerRequestFilter that lacks the @Priority annotation entirely must be rejected.
+ */
+ @Test
+ public void testUnannotatedFilterIsRejected()
+ {
+ // Setup test fixture.
+ final String className = UnannotatedFilter.class.getName();
+
+ // Execute system under test.
+ final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> JerseyWrapper.getCustomAuthFilterClassObject(className),
+ "A ContainerRequestFilter without @Priority(AUTHENTICATION) must not be accepted as an authentication filter.");
+
+ // Verify result.
+ assertTrue(exception.getMessage().contains(className), "The exception message should identify the rejected class name.");
+ }
+
+ /**
+ * A ContainerRequestFilter carrying a @Priority annotation with a value other than AUTHENTICATION must be rejected.
+ */
+ @Test
+ public void testWronglyAnnotatedFilterIsRejected()
+ {
+ // Setup test fixture.
+ final String className = WronglyAnnotatedFilter.class.getName();
+
+ // Execute system under test.
+ final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> JerseyWrapper.getCustomAuthFilterClassObject(className),
+ "A filter annotated with a priority other than AUTHENTICATION must not be accepted as an authentication filter.");
+
+ // Verify result.
+ assertTrue(exception.getMessage().contains(className), "The exception message should identify the rejected class name.");
+ }
+
+ /**
+ * A class that implements none of the acceptable contract shapes must be rejected, even when correctly annotated.
+ */
+ @Test
+ public void testClassWithoutAcceptableContractShapeIsRejected()
+ {
+ // Setup test fixture.
+ final String className = NotAFilterAtAll.class.getName();
+
+ // Execute system under test.
+ final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> JerseyWrapper.getCustomAuthFilterClassObject(className),
+ "A class that is not a ContainerRequestFilter, Feature, or DynamicFeature must not be accepted, regardless of annotation.");
+
+ // Verify result.
+ assertTrue(exception.getMessage().contains(className), "The exception message should identify the rejected class name.");
+ }
+
+ /**
+ * A class name that cannot be resolved on the classpath must be rejected.
+ */
+ @Test
+ public void testNonExistentClassNameIsRejected()
+ {
+ // Setup test fixture.
+ final String className = "org.jivesoftware.openfire.plugin.rest.service.DoesNotExist";
+
+ // Execute system under test.
+ final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> JerseyWrapper.getCustomAuthFilterClassObject(className),
+ "A class name that does not resolve on the classpath must not be accepted as an authentication filter.");
+
+ // Verify result.
+ assertTrue(exception.getMessage().contains(className), "The exception message should identify the unresolved class name.");
+ }
+
+ /**
+ * An empty class name must be rejected, independent of any caller-side validation for this case.
+ */
+ @Test
+ public void testEmptyClassNameIsRejected()
+ {
+ // Setup test fixture.
+ final String className = "";
+
+ // Execute system under test.
+ final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> JerseyWrapper.getCustomAuthFilterClassObject(className),
+ "An empty class name must not be accepted, even though callers are expected to filter this case out beforehand.");
+
+ // Verify result.
+ assertNotNull(exception.getMessage(), "The exception should carry an explanatory message even for an empty class name.");
+ }
+
+ @Priority(Priorities.AUTHENTICATION)
+ public static class ValidAuthFilter implements ContainerRequestFilter
+ {
+ @Override
+ public void filter(ContainerRequestContext requestContext)
+ {
+ // No-op: test fixture only, never invoked.
+ }
+ }
+
+ public static class UnannotatedFilter implements ContainerRequestFilter
+ {
+ @Override
+ public void filter(ContainerRequestContext requestContext)
+ {
+ // No-op: test fixture only, never invoked.
+ }
+ }
+
+ @Priority(Priorities.AUTHORIZATION)
+ public static class WronglyAnnotatedFilter implements ContainerRequestFilter
+ {
+ @Override
+ public void filter(ContainerRequestContext requestContext)
+ {
+ // No-op: test fixture only, never invoked.
+ }
+ }
+
+ @Priority(Priorities.AUTHENTICATION)
+ public static class ValidAuthFeature implements Feature
+ {
+ @Override
+ public boolean configure(FeatureContext context)
+ {
+ return true; // No-op: test fixture only, never invoked.
+ }
+ }
+
+ @Priority(Priorities.AUTHENTICATION)
+ public static class ValidAuthDynamicFeature implements DynamicFeature
+ {
+ @Override
+ public void configure(ResourceInfo resourceInfo, FeatureContext context)
+ {
+ // No-op: test fixture only, never invoked.
+ }
+ }
+
+ @Priority(Priorities.AUTHENTICATION)
+ public static class NotAFilterAtAll
+ {
+ // Deliberately implements none of the acceptable contract shapes.
+ }
+}
diff --git a/src/web/rest-api.jsp b/src/web/rest-api.jsp
index 4d0ded260..d4004243e 100644
--- a/src/web/rest-api.jsp
+++ b/src/web/rest-api.jsp
@@ -65,7 +65,7 @@
}
if (RESTServicePlugin.AuthType.custom.equals(authType)) {
- loadingStatus = plugin.loadAuthenticationFilter(customAuthFilterClassName);
+ loadingStatus = plugin.validateCustomAuthenticationFilter(customAuthFilterClassName);
}
if (loadingStatus != null) {
errors.put("loadingStatus", loadingStatus);