This is an automated email from the ASF dual-hosted git repository.
coheigea pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/cxf.git
The following commit(s) were added to refs/heads/main by this push:
new 832021f8e62 Resolve OAuthScopesFilter annotation scopes by method
signature (#3627)
832021f8e62 is described below
commit 832021f8e623721ec9e17e522f173fc915a366ee
Author: Colm O hEigeartaigh <[email protected]>
AuthorDate: Mon Oct 26 08:03:03 2026 +0000
Resolve OAuthScopesFilter annotation scopes by method signature (#3627)
* Resolve OAuthScopesFilter annotation scopes by method signature
* Review comments
---
.../org/apache/cxf/common/util/ReflectionUtil.java | 17 ++
.../security/oauth2/filters/OAuthScopesFilter.java | 87 +++++--
.../oauth2/filters/OAuthScopesFilterTest.java | 275 +++++++++++++++++++++
3 files changed, 363 insertions(+), 16 deletions(-)
diff --git a/core/src/main/java/org/apache/cxf/common/util/ReflectionUtil.java
b/core/src/main/java/org/apache/cxf/common/util/ReflectionUtil.java
index 146d7fc5762..92f89082aa1 100644
--- a/core/src/main/java/org/apache/cxf/common/util/ReflectionUtil.java
+++ b/core/src/main/java/org/apache/cxf/common/util/ReflectionUtil.java
@@ -170,6 +170,23 @@ public final class ReflectionUtil {
}
}
+ /**
+ * Returns the name and parameter types of a method, e.g.
"foo(java.lang.String,int)".
+ * Overloaded methods have distinct signatures, while a method and the
method it
+ * overrides (with the same erased parameter types) share one.
+ */
+ public static String getMethodSignature(final Method method) {
+ StringBuilder sb = new StringBuilder(method.getName()).append('(');
+ Class<?>[] paramTypes = method.getParameterTypes();
+ for (int i = 0; i < paramTypes.length; i++) {
+ if (i > 0) {
+ sb.append(',');
+ }
+ sb.append(paramTypes[i].getName());
+ }
+ return sb.append(')').toString();
+ }
+
public static Field[] getDeclaredFields(final Class<?> cls) {
return AccessController.doPrivileged(new PrivilegedAction<Field[]>() {
public Field[] run() {
diff --git
a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/filters/OAuthScopesFilter.java
b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/filters/OAuthScopesFilter.java
index f3fa59887a5..a43cb02d6c1 100644
---
a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/filters/OAuthScopesFilter.java
+++
b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/filters/OAuthScopesFilter.java
@@ -20,7 +20,9 @@ package org.apache.cxf.rs.security.oauth2.filters;
import java.io.IOException;
import java.lang.reflect.Method;
+import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
@@ -37,6 +39,7 @@ import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.core.Context;
import org.apache.cxf.common.logging.LogUtils;
import org.apache.cxf.common.util.ClassHelper;
+import org.apache.cxf.common.util.ReflectionUtil;
import org.apache.cxf.jaxrs.ext.MessageContext;
import org.apache.cxf.jaxrs.utils.ExceptionUtils;
import org.apache.cxf.rs.security.oauth2.common.OAuthContext;
@@ -52,18 +55,29 @@ public class OAuthScopesFilter implements
ContainerRequestFilter {
@Context
private MessageContext mc;
+ // Scopes configured explicitly via setScopesMap/setScopesStringMap, keyed
by method name
private Map<String, List<String>> scopesMap = new HashMap<>();
private Map<String, Boolean> scopesMatchAllMap = new HashMap<>();
+ // Scopes discovered from @Scopes annotations. Method-level and
class-level annotations
+ // are keyed by method signature so that overloaded methods do not share a
rule and an
+ // annotation on an overriding method is not replaced by one from a
superclass or interface
+ private final Map<String, Scopes> methodScopesBySignature = new
HashMap<>();
+ private final Map<String, Scopes> classScopesBySignature = new HashMap<>();
+ // All the annotation-derived rules for a given method name, enforced
together when the
+ // invoked method's signature is not recorded (e.g. it implements a
generic interface method)
+ private final Map<String, Set<Scopes>> annotationScopesByName = new
HashMap<>();
private Set<String> confidentialClientMethods = new HashSet<>();
public void setSecuredObject(Object object) {
Class<?> cls = ClassHelper.getRealClass(object);
checkSecureClass(cls);
- if (scopesMap.isEmpty()) {
+ if (annotationScopesByName.isEmpty()) {
LOG.warning("The scopes map is empty");
} else if (LOG.isLoggable(Level.FINE)) {
- for (Map.Entry<String, List<String>> entry : scopesMap.entrySet())
{
- LOG.fine("Method: " + entry.getKey() + ", scopes: " +
entry.getValue());
+ for (Map.Entry<String, Set<Scopes>> entry :
annotationScopesByName.entrySet()) {
+ for (Scopes scopes : entry.getValue()) {
+ LOG.fine("Method: " + entry.getKey() + ", scopes: " +
Arrays.asList(scopes.value()));
+ }
}
}
}
@@ -78,11 +92,19 @@ public class OAuthScopesFilter implements
ContainerRequestFilter {
if (SKIP_METHODS.contains(m.getName())) {
continue;
}
+ // Subclasses are scanned before their superclasses and
interfaces, so the most
+ // specific declaration of a given signature is recorded first and
kept
+ String signature = ReflectionUtil.getMethodSignature(m);
Scopes methodScopes = m.getAnnotation(Scopes.class);
- Scopes theScopes = methodScopes == null ? classScopes :
methodScopes;
- if (theScopes != null) {
- scopesMap.put(m.getName(), Arrays.asList(theScopes.value()));
- scopesMatchAllMap.put(m.getName(), theScopes.matchAll());
+ // Each overload has its own signature and so its own rule. The
rules are also
+ // collected by name, but that is only used as a fallback when the
invoked
+ // method's signature was not recorded, and then all of them have
to be met
+ if (methodScopes != null) {
+ methodScopesBySignature.putIfAbsent(signature, methodScopes);
+ annotationScopesByName.computeIfAbsent(m.getName(), k -> new
HashSet<>()).add(methodScopes);
+ } else if (classScopes != null) {
+ classScopesBySignature.putIfAbsent(signature, classScopes);
+ annotationScopesByName.computeIfAbsent(m.getName(), k -> new
HashSet<>()).add(classScopes);
}
ConfidentialClient mConfClient =
m.getAnnotation(ConfidentialClient.class);
@@ -113,27 +135,60 @@ public class OAuthScopesFilter implements
ContainerRequestFilter {
}
}
protected void checkScopes(Method m) {
- List<String> methodScopes = scopesMap.get(m.getName());
- if (methodScopes == null) {
+ List<Scopes> annotationScopes = getAnnotationScopes(m);
+ List<String> configuredScopes = scopesMap.get(m.getName());
+ if (annotationScopes.isEmpty() && configuredScopes == null) {
return;
}
- boolean matchAll = scopesMatchAllMap.get(m.getName());
OAuthContext context = OAuthContextUtils.getContext(mc);
List<String> requestScopes = new LinkedList<>();
for (OAuthPermission perm : context.getPermissions()) {
- if (matchAll) {
- requestScopes.add(perm.getPermission());
- } else if (methodScopes.contains(perm.getPermission())) {
- return;
- }
+ requestScopes.add(perm.getPermission());
}
- if (!requestScopes.containsAll(methodScopes)) {
+ // Every rule which applies to the method has to be satisfied
+ boolean allowed = true;
+ for (Scopes scopes : annotationScopes) {
+ allowed &= isAllowed(requestScopes, Arrays.asList(scopes.value()),
scopes.matchAll());
+ }
+ if (configuredScopes != null) {
+ allowed &= isAllowed(requestScopes, configuredScopes,
+
Boolean.TRUE.equals(scopesMatchAllMap.get(m.getName())));
+ }
+ if (!allowed) {
LOG.warning("Scopes do not match");
throw ExceptionUtils.toForbiddenException(null, null);
}
+ }
+
+ private List<Scopes> getAnnotationScopes(Method m) {
+ String signature = ReflectionUtil.getMethodSignature(m);
+ // A method-level annotation anywhere in the hierarchy takes
precedence over a
+ // class-level one, otherwise an inherited class-level @Scopes would
replace it
+ Scopes scopes = methodScopesBySignature.get(signature);
+ if (scopes == null) {
+ scopes = classScopesBySignature.get(signature);
+ }
+ if (scopes != null) {
+ return Collections.singletonList(scopes);
+ }
+ Set<Scopes> scopesForName = annotationScopesByName.get(m.getName());
+ return scopesForName == null ? Collections.emptyList() : new
ArrayList<>(scopesForName);
+ }
+ private static boolean isAllowed(List<String> requestScopes, List<String>
methodScopes,
+ boolean matchAll) {
+ if (matchAll || methodScopes.isEmpty()) {
+ return requestScopes.containsAll(methodScopes);
+ }
+ for (String scope : requestScopes) {
+ if (methodScopes.contains(scope)) {
+ return true;
+ }
+ }
+ return false;
}
+
protected Method getTargetMethod() {
Method method = (Method)mc.get("org.apache.cxf.resource.method");
if (method != null) {
diff --git
a/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/filters/OAuthScopesFilterTest.java
b/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/filters/OAuthScopesFilterTest.java
new file mode 100644
index 00000000000..ca125bfef26
--- /dev/null
+++
b/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/filters/OAuthScopesFilterTest.java
@@ -0,0 +1,275 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.apache.cxf.rs.security.oauth2.filters;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import jakarta.ws.rs.ForbiddenException;
+import org.apache.cxf.jaxrs.ext.MessageContext;
+import org.apache.cxf.rs.security.oauth2.common.OAuthContext;
+import org.apache.cxf.rs.security.oauth2.common.OAuthPermission;
+import org.apache.cxf.rs.security.oauth2.common.UserSubject;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class OAuthScopesFilterTest {
+
+ @Test
+ public void testOverloadedSubclassMethodKeepsItsOwnScopes() throws
Exception {
+ Method adminMethod = OverloadingService.class.getMethod("operation",
String.class);
+ Method readMethod = OverloadingService.class.getMethod("operation");
+
+ assertAllowed(new OverloadingService(), adminMethod, "admin");
+ assertForbidden(new OverloadingService(), adminMethod, "read");
+
+ assertAllowed(new OverloadingService(), readMethod, "read");
+ assertForbidden(new OverloadingService(), readMethod, "admin");
+ }
+
+ @Test
+ public void testOverloadedMethodsInSameClassKeepTheirOwnScopes() throws
Exception {
+ Method readMethod =
SameClassOverloadingService.class.getMethod("operation");
+ Method adminMethod =
SameClassOverloadingService.class.getMethod("operation", String.class);
+
+ assertAllowed(new SameClassOverloadingService(), readMethod, "read");
+ assertForbidden(new SameClassOverloadingService(), readMethod,
"admin");
+
+ assertAllowed(new SameClassOverloadingService(), adminMethod, "admin");
+ assertForbidden(new SameClassOverloadingService(), adminMethod,
"read");
+ }
+
+ @Test
+ public void testOverridingMethodScopesTakePrecedenceOverSuperclass()
throws Exception {
+ Method method = OverridingService.class.getMethod("operation");
+
+ assertAllowed(new OverridingService(), method, "admin");
+ assertForbidden(new OverridingService(), method, "read");
+ }
+
+ @Test
+ public void
testOverridingMethodScopesTakePrecedenceOverInterfaceClassScopes() throws
Exception {
+ Method method =
InterfaceOverridingService.class.getMethod("operation");
+
+ assertAllowed(new InterfaceOverridingService(), method, "admin");
+ assertForbidden(new InterfaceOverridingService(), method, "read");
+ }
+
+ @Test
+ public void testMatchAllIsResolvedPerSignature() throws Exception {
+ Method matchAllMethod = MatchAllService.class.getMethod("operation",
String.class);
+ Method matchAnyMethod = MatchAllService.class.getMethod("operation");
+
+ assertAllowed(new MatchAllService(), matchAllMethod, "read", "write");
+ assertForbidden(new MatchAllService(), matchAllMethod, "read");
+
+ assertAllowed(new MatchAllService(), matchAnyMethod, "read");
+ }
+
+ @Test
+ public void testInheritedMethodScopesStillApply() throws Exception {
+ Method method = OverridingService.class.getMethod("inherited");
+
+ assertAllowed(new OverridingService(), method, "read");
+ assertForbidden(new OverridingService(), method, "write");
+ }
+
+ @Test
+ public void testUnannotatedOverrideInheritsSuperclassScopes() throws
Exception {
+ Method method =
UnannotatedOverridingService.class.getMethod("operation");
+
+ assertAllowed(new UnannotatedOverridingService(), method, "read");
+ assertForbidden(new UnannotatedOverridingService(), method, "write");
+ }
+
+ @Test
+ public void testScopesConfiguredByMethodName() throws Exception {
+ Method method = UnsecuredService.class.getMethod("operation",
String.class);
+
+ OAuthScopesFilter filter = new OAuthScopesFilter();
+ filter.setScopesStringMap(Collections.singletonMap("operation",
"admin"));
+ filter(filter, method, "admin");
+ assertThrows(ForbiddenException.class, () -> filter(filter, method,
"read"));
+ }
+
+ @Test
+ public void testMethodScopesTakePrecedenceOverInheritedClassScopes()
throws Exception {
+ Method method =
InheritedClassScopesService.class.getMethod("operation");
+
+ assertAllowed(new InheritedClassScopesService(), method, "admin");
+ assertForbidden(new InheritedClassScopesService(), method, "read");
+ }
+
+ @Test
+ public void testGenericInterfaceMethodScopesAreEnforced() throws Exception
{
+ Method updateMethod = StringRepository.class.getMethod("update",
String.class);
+
+ // The erased interface signature does not match, so every rule for
the name applies
+ assertAllowed(new StringRepository(), updateMethod, "admin", "read");
+ assertForbidden(new StringRepository(), updateMethod, "read");
+ assertForbidden(new StringRepository(), updateMethod, "admin");
+
+ Method readMethod = StringRepository.class.getMethod("update");
+ assertAllowed(new StringRepository(), readMethod, "read");
+ }
+
+ @Test
+ public void testConfiguredScopesAreEnforcedTogetherWithAnnotations()
throws Exception {
+ Method method = BaseService.class.getMethod("operation");
+
+ OAuthScopesFilter filter = createFilter(new BaseService());
+ filter.setScopesStringMap(Collections.singletonMap("operation",
"admin"));
+ filter(filter, method, "read", "admin");
+ assertThrows(ForbiddenException.class, () -> filter(filter, method,
"read"));
+ assertThrows(ForbiddenException.class, () -> filter(filter, method,
"admin"));
+ }
+
+ private static void assertAllowed(Object service, Method method, String...
scopes) throws Exception {
+ filter(createFilter(service), method, scopes);
+ }
+
+ private static void assertForbidden(Object service, Method method,
String... scopes) throws Exception {
+ OAuthScopesFilter filter = createFilter(service);
+ assertThrows(ForbiddenException.class, () -> filter(filter, method,
scopes));
+ }
+
+ private static OAuthScopesFilter createFilter(Object service) {
+ OAuthScopesFilter filter = new OAuthScopesFilter();
+ filter.setSecuredObject(service);
+ return filter;
+ }
+
+ private static void filter(OAuthScopesFilter filter, Method method,
String... scopes) throws Exception {
+ List<OAuthPermission> permissions = new ArrayList<>();
+ for (String scope : scopes) {
+ permissions.add(new OAuthPermission(scope));
+ }
+ OAuthContext context = new OAuthContext(new UserSubject("alice"),
null, permissions, "code");
+
+ MessageContext mc = mock(MessageContext.class);
+ when(mc.get("org.apache.cxf.resource.method")).thenReturn(method);
+ when(mc.getContent(OAuthContext.class)).thenReturn(context);
+
+ Field field = OAuthScopesFilter.class.getDeclaredField("mc");
+ field.setAccessible(true);
+ field.set(filter, mc);
+
+ filter.filter(null);
+ }
+
+ public static class BaseService {
+ @Scopes("read")
+ public void operation() {
+ }
+
+ @Scopes("read")
+ public void inherited() {
+ }
+ }
+
+ public static class OverloadingService extends BaseService {
+ @Scopes("admin")
+ public void operation(String value) {
+ }
+ }
+
+ public static class SameClassOverloadingService {
+ @Scopes("read")
+ public void operation() {
+ }
+
+ @Scopes("admin")
+ public void operation(String value) {
+ }
+ }
+
+ public static class OverridingService extends BaseService {
+ @Override
+ @Scopes("admin")
+ public void operation() {
+ }
+ }
+
+ public static class UnannotatedOverridingService extends BaseService {
+ @Override
+ public void operation() {
+ }
+ }
+
+ @Scopes("read")
+ public interface ReadService {
+ void operation();
+ }
+
+ public static class InterfaceOverridingService implements ReadService {
+ @Override
+ @Scopes("admin")
+ public void operation() {
+ }
+ }
+
+ public static class MatchAllService extends BaseService {
+ @Scopes(value = {"read", "write" }, matchAll = true)
+ public void operation(String value) {
+ }
+ }
+
+ @Scopes("read")
+ public static class InheritedClassScopesBaseService {
+ @Scopes("admin")
+ public void operation() {
+ }
+ }
+
+ public static class InheritedClassScopesService extends
InheritedClassScopesBaseService {
+ @Override
+ public void operation() {
+ }
+ }
+
+ public interface Repository<T> {
+ @Scopes("admin")
+ void update(T value);
+
+ @Scopes("read")
+ void update();
+ }
+
+ public static class StringRepository implements Repository<String> {
+ @Override
+ public void update(String value) {
+ }
+
+ @Override
+ public void update() {
+ }
+ }
+
+ public static class UnsecuredService {
+ public void operation(String value) {
+ }
+ }
+}