SPDX-License-Identifier: Apache-2.0 ********************************************************************************/ -package org.eclipse.ecsp.restclient; +package org.eclipse.ecsp.config; +import org.eclipse.ecsp.restclient.RestTemplateErrorHandler; +import org.eclipse.ecsp.restclient.RestTemplateLogInterceptor; +import org.eclipse.ecsp.restclient.RestTemplateTokenInterceptor; import org.eclipse.ecsp.utils.logger.IgniteLogger; import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -44,16 +48,24 @@ public RestTemplateConfig() { /** * Create and returns the object of RestTemplate. * + *
If a {@link RestTemplateTokenInterceptor} bean is available it is added to the
+ * interceptor chain so that Bearer tokens are forwarded to downstream services.
+ *
+ * @param tokenInterceptorProvider provider for the optional token propagation interceptor
* @return RestTemplate Object
*/
@Bean
@ConditionalOnMissingBean
- public RestTemplate restTemplate() {
+ public RestTemplate restTemplate(ObjectProvider 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+package org.eclipse.ecsp.config;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.eclipse.ecsp.interceptors.SecurityRequirementCache;
+import org.eclipse.ecsp.interceptors.TokenValidationInterceptor;
+import org.eclipse.ecsp.restclient.RestClientTokenInterceptor;
+import org.eclipse.ecsp.restclient.RestTemplateTokenInterceptor;
+import org.eclipse.ecsp.security.ScopeOverrideProperties;
+import org.eclipse.ecsp.security.ValidationConfigProperties;
+import org.eclipse.ecsp.tokenvalidator.TokenValidator;
+import org.eclipse.ecsp.tokenvalidator.config.TokenValidatorAutoConfiguration;
+import org.eclipse.ecsp.utils.RegistryCommonConstants;
+import org.eclipse.ecsp.utils.logger.IgniteLogger;
+import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory;
+import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Auto-configuration that wires together the JWT token validation and token propagation
+ * beans for the {@code api-registry-common} library.
+ *
+ * Activated by the presence of the library on the classpath — no additional user
+ * configuration is required unless behaviour needs to be overridden.
+ */
+@Configuration
+@ConditionalOnProperty(
+ prefix = RegistryCommonConstants.API_REGISTRY_SECURITY_PREFIX,
+ name = "enabled",
+ havingValue = "true"
+)
+@ImportAutoConfiguration(TokenValidatorAutoConfiguration.class)
+@EnableConfigurationProperties(ValidationConfigProperties.class)
+public class TokenValidationConfiguration {
+ private static final IgniteLogger LOGGER = IgniteLoggerFactory.getLogger(TokenValidationConfiguration.class);
+
+
+ /**
+ * Default constructor.
+ */
+ public TokenValidationConfiguration() {
+ LOGGER.debug("security is enabled, configuring the token validation configuration");
+ }
+
+ /**
+ * Creates the annotation-lookup cache bean when security is enabled.
+ *
+ * @return the cache
+ */
+ @Bean
+ public SecurityRequirementCache securityRequirementCache() {
+ LOGGER.debug("Creating SecurityRequirementCache bean");
+ return new SecurityRequirementCache();
+ }
+
+ /**
+ * Creates the token-validation interceptor when security is enabled.
+ *
+ * @param tokenValidator the JWT validator
+ * @param config the validation configuration properties
+ * @param securityRequirementCache the annotation-lookup cache
+ * @param objectMapper the object mapper for JSON serialization
+ * @param scopeOverrideProperties the scope-override configuration properties
+ * @return the interceptor
+ */
+ @Bean
+ public TokenValidationInterceptor tokenValidationInterceptor(
+ TokenValidator tokenValidator,
+ ValidationConfigProperties config,
+ SecurityRequirementCache securityRequirementCache,
+ ObjectMapper objectMapper,
+ ScopeOverrideProperties scopeOverrideProperties) {
+ LOGGER.debug("Creating TokenValidationInterceptor bean");
+ return new TokenValidationInterceptor(tokenValidator, config, securityRequirementCache, objectMapper,
+ scopeOverrideProperties);
+ }
+
+ /**
+ * Creates the RestTemplate token propagation interceptor when enabled.
+ *
+ * @param config the validation / propagation configuration properties
+ * @return the interceptor
+ */
+ @Bean
+ @ConditionalOnProperty(
+ prefix = RegistryCommonConstants.API_REGISTRY_REST_TEMPLATE_PROPAGATION_PREFIX,
+ name = "enabled",
+ havingValue = "true",
+ matchIfMissing = true
+ )
+ public RestTemplateTokenInterceptor restTemplateTokenInterceptor(ValidationConfigProperties config) {
+ LOGGER.debug("Creating RestTemplateTokenInterceptor bean");
+ return new RestTemplateTokenInterceptor(config);
+ }
+
+ /**
+ * Creates a {@link RestClientTokenInterceptor} bean for token propagation.
+ *
+ * @param config the validation / propagation configuration properties
+ * @return the interceptor
+ */
+ @Bean
+ @ConditionalOnProperty(
+ prefix = RegistryCommonConstants.API_REGISTRY_REST_CLIENT_PROPAGATION_PREFIX,
+ name = "enabled",
+ havingValue = "true",
+ matchIfMissing = true
+ )
+ @ConditionalOnClass(org.springframework.web.client.RestClient.class)
+ public RestClientTokenInterceptor restClientTokenInterceptor(ValidationConfigProperties config) {
+ LOGGER.debug("Creating RestClientTokenInterceptor bean");
+ return new RestClientTokenInterceptor(config);
+ }
+
+ /**
+ * Creates a default ObjectMapper bean if one is not already defined.
+ *
+ * @return the object mapper
+ */
+ @Bean
+ @ConditionalOnMissingBean(ObjectMapper.class)
+ public ObjectMapper objectMapper() {
+ LOGGER.debug("Creating ObjectMapper bean");
+ ObjectMapper om = new ObjectMapper();
+ om.findAndRegisterModules();
+ return om;
+ }
+}
diff --git a/api-registry-common/src/main/java/org/eclipse/ecsp/interceptors/HeaderInterceptor.java b/api-registry-common/src/main/java/org/eclipse/ecsp/interceptors/HeaderInterceptor.java
index 2dd1c8ef..4eb89731 100644
--- a/api-registry-common/src/main/java/org/eclipse/ecsp/interceptors/HeaderInterceptor.java
+++ b/api-registry-common/src/main/java/org/eclipse/ecsp/interceptors/HeaderInterceptor.java
@@ -69,4 +69,20 @@ public boolean preHandle(@NotNull HttpServletRequest request,
}
return true;
}
+
+ /**
+ * Clears the {@link HeaderContext} ThreadLocal after every request, preventing
+ * memory leaks caused by servlet-container thread reuse.
+ *
+ * @param request the current HTTP request
+ * @param response the current HTTP response
+ * @param handler the chosen handler
+ * @param ex any exception thrown during handler execution, or {@code null}
+ */
+ @Override
+ public void afterCompletion(@NotNull HttpServletRequest request,
+ @NotNull HttpServletResponse response,
+ @NotNull Object handler, Exception ex) {
+ HeaderContext.clear();
+ }
}
\ No newline at end of file
diff --git a/api-registry-common/src/main/java/org/eclipse/ecsp/interceptors/SecurityRequirementCache.java b/api-registry-common/src/main/java/org/eclipse/ecsp/interceptors/SecurityRequirementCache.java
new file mode 100644
index 00000000..f2169c24
--- /dev/null
+++ b/api-registry-common/src/main/java/org/eclipse/ecsp/interceptors/SecurityRequirementCache.java
@@ -0,0 +1,84 @@
+/********************************************************************************
+ * Copyright (c) 2023-24 Harman International
+ *
+ * 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+package org.eclipse.ecsp.interceptors;
+
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import org.springframework.web.method.HandlerMethod;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Caches the result of inspecting whether a controller method is annotated with
+ * {@link SecurityRequirement}, eliminating repeated reflection calls on hot paths.
+ *
+ * Routes are fixed at startup, so the cache never needs to be evicted.
+ * {@link ConcurrentHashMap#computeIfAbsent} ensures the reflection call is performed
+ * at most once per {@link HandlerMethod}, even under concurrent first-requests.
+ *
+ * This class is not annotated with {@code @Component} — it is registered exclusively
+ * by {@link org.eclipse.ecsp.config.TokenValidationConfiguration} to prevent
+ * duplicate-bean conflicts if a consuming application scans the library packages.
+ */
+public class SecurityRequirementCache {
+
+ private final ConcurrentHashMap The result is cached after the first call for each method, making subsequent
+ * calls an O(1) map lookup.
+ *
+ * @param handlerMethod the resolved handler method
+ * @return {@code true} if the endpoint requires JWT authentication; {@code false} if public
+ */
+ public boolean isSecured(HandlerMethod handlerMethod) {
+ return cache.computeIfAbsent(handlerMethod,
+ m -> m.getMethodAnnotation(SecurityRequirement.class) != null);
+ }
+
+ /**
+ * Returns the scopes required by the handler method's {@link SecurityRequirement} annotation.
+ *
+ * @param handlerMethod the resolved handler method
+ * @return a list of required scopes, or an empty list if none are specified
+ */
+ public List 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+package org.eclipse.ecsp.interceptors;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.eclipse.ecsp.security.ScopeOverrideProperties;
+import org.eclipse.ecsp.security.SecurityContext;
+import org.eclipse.ecsp.security.ValidationConfigProperties;
+import org.eclipse.ecsp.tokenvalidator.ScopeMatchMode;
+import org.eclipse.ecsp.tokenvalidator.ScopeValidator;
+import org.eclipse.ecsp.tokenvalidator.TokenValidator;
+import org.eclipse.ecsp.tokenvalidator.exception.InvalidClaimException;
+import org.eclipse.ecsp.tokenvalidator.exception.TokenValidatorException;
+import org.eclipse.ecsp.tokenvalidator.impl.DefaultScopeValidator;
+import org.eclipse.ecsp.tokenvalidator.model.TokenClaim;
+import org.eclipse.ecsp.utils.logger.IgniteLogger;
+import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory;
+import org.jetbrains.annotations.NotNull;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.web.method.HandlerMethod;
+import org.springframework.web.servlet.HandlerInterceptor;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Spring MVC interceptor that validates JWT Bearer tokens on secured endpoints.
+ *
+ * An endpoint is considered secured when its handler method carries the
+ * {@code @SecurityRequirement} annotation. Public endpoints (no annotation) are
+ * passed through without any token inspection.
+ *
+ * All {@link TokenValidatorException} sub-types are mapped to HTTP 401 to avoid
+ * catch-order bugs arising from the {@code TokenExpiredException} /
+ * {@code InvalidIssuerException} inheritance chain.
+ *
+ * On successful validation the verified claims are stored in {@link SecurityContext}.
+ * {@link SecurityContext#clear()} is always called in {@code afterCompletion} to
+ * prevent ThreadLocal memory leaks.
+ */
+public class TokenValidationInterceptor implements HandlerInterceptor {
+
+ private static final IgniteLogger LOGGER = IgniteLoggerFactory.getLogger(TokenValidationInterceptor.class);
+
+ private static final String BEARER_PREFIX = "Bearer ";
+ private static final int BEARER_PREFIX_LENGTH = BEARER_PREFIX.length();
+ private static final String ERROR_FIELD = "error";
+
+ private final TokenValidator tokenValidator;
+ private final ValidationConfigProperties config;
+ private final SecurityRequirementCache securityRequirementCache;
+ private final ObjectMapper objectMapper;
+ private final ScopeValidator scopeValidator = new DefaultScopeValidator(Set. When {@code scopes.override.enabled} is {@code true} and the route ID is present
+ * in the {@code scopesMap}, the effective allowed scope set is the
+ * configured override scopes.
+ * This makes scope validation self-contained: no dependency on the {@code override-scope} header
+ * forwarded by the API Gateway.
+ *
+ * @param handlerMethod the resolved handler method for the current request
+ * @param annotationScopes the scopes declared on the {@code @SecurityRequirement} annotation
+ * @return the effective list of allowed scopes
+ */
+ private List The tag is the controller class simple name converted from CamelCase to
+ * kebab-case and lowercased — matching the OpenAPI tag that SpringDoc generates,
+ * which retains the full class name including the {@code Controller} suffix.
+ * The operation ID is the method name with underscores replaced by hyphens.
+ *
+ * @param handlerMethod the resolved handler method
+ * @return the derived route ID
+ */
+ private String resolveRouteId(HandlerMethod handlerMethod) {
+ String className = handlerMethod.getBeanType().getSimpleName();
+ String tag = className.replaceAll("([a-z])([A-Z])", "$1-$2").toLowerCase();
+ String operationId = handlerMethod.getMethod().getName().replace("_", "-");
+ return tag + "-" + operationId;
+ }
+
+ private boolean writeUnauthorized(HttpServletResponse response, String message) throws IOException {
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.setContentType(MediaType.APPLICATION_JSON_VALUE);
+ Map 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+package org.eclipse.ecsp.restclient;
+
+import org.eclipse.ecsp.security.SecurityContext;
+import org.eclipse.ecsp.security.ValidationConfigProperties;
+import org.eclipse.ecsp.utils.logger.IgniteLogger;
+import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory;
+import java.net.URI;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Base class for token-propagation interceptors and filters.
+ *
+ * Provides shared host-classification logic and token-retrieval from
+ * {@link SecurityContext}. Subclasses implement the HTTP-client-specific
+ * {@code intercept} / {@code filter} method.
+ *
+ * Thread-safety note: {@link #resolveToken(URI)} reads from the
+ * {@link SecurityContext} ThreadLocal. For {@code RestTemplate} and {@code RestClient}
+ * interceptors this is always called on the same servlet thread as the original request,
+ * so no additional synchronisation is needed.
+ */
+public abstract class AbstractTokenPropagationInterceptor {
+
+ private static final IgniteLogger LOGGER =
+ IgniteLoggerFactory.getLogger(AbstractTokenPropagationInterceptor.class);
+
+ private final ValidationConfigProperties config;
+
+ /**
+ * Constructs an interceptor with the given configuration.
+ *
+ * @param config the validation / propagation configuration properties
+ */
+ protected AbstractTokenPropagationInterceptor(ValidationConfigProperties config) {
+ this.config = config;
+ }
+
+ /**
+ * Returns {@code true} when the Bearer token must NOT be forwarded to the given target.
+ *
+ * Token propagation is skipped when:
+ * Returns {@link Optional#empty()} and logs a warning if the token is expired.
+ *
+ * @param targetUri the URI of the downstream service (used only for logging)
+ * @return the token, or empty if absent or expired
+ */
+ protected Optional 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+package org.eclipse.ecsp.restclient;
+
+import org.eclipse.ecsp.security.ValidationConfigProperties;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpRequest;
+import org.springframework.http.client.ClientHttpRequestExecution;
+import org.springframework.http.client.ClientHttpRequestInterceptor;
+import org.springframework.http.client.ClientHttpResponse;
+import java.io.IOException;
+import java.util.Optional;
+
+/**
+ * {@link ClientHttpRequestInterceptor} for {@code RestClient} that propagates the
+ * current thread's Bearer token to downstream service calls.
+ *
+ * Separate from {@link RestTemplateTokenInterceptor} to allow independent
+ * bean-lifecycle management for the {@code RestClient} integration.
+ */
+public class RestClientTokenInterceptor extends AbstractTokenPropagationInterceptor
+ implements ClientHttpRequestInterceptor {
+
+ /**
+ * Constructs an interceptor with the given configuration.
+ *
+ * @param config the validation / propagation configuration properties
+ */
+ public RestClientTokenInterceptor(ValidationConfigProperties config) {
+ super(config);
+ }
+
+ @Override
+ public ClientHttpResponse intercept(HttpRequest request, byte[] body,
+ ClientHttpRequestExecution execution) throws IOException {
+ if (request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION) != null) {
+ return execution.execute(request, body);
+ }
+ Optional 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+package org.eclipse.ecsp.restclient;
+
+import org.eclipse.ecsp.security.ValidationConfigProperties;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpRequest;
+import org.springframework.http.client.ClientHttpRequestExecution;
+import org.springframework.http.client.ClientHttpRequestInterceptor;
+import org.springframework.http.client.ClientHttpResponse;
+import java.io.IOException;
+import java.util.Optional;
+
+/**
+ * {@link ClientHttpRequestInterceptor} for {@code RestTemplate} that propagates the
+ * current thread's Bearer token to downstream service calls.
+ *
+ * The token is read from {@link org.eclipse.ecsp.security.SecurityContext} on the same
+ * servlet thread as the original request — no reactive or cross-thread concerns apply.
+ */
+public class RestTemplateTokenInterceptor extends AbstractTokenPropagationInterceptor
+ implements ClientHttpRequestInterceptor {
+
+ /**
+ * Constructs an interceptor with the given configuration.
+ *
+ * @param config the validation / propagation configuration properties
+ */
+ public RestTemplateTokenInterceptor(ValidationConfigProperties config) {
+ super(config);
+ }
+
+ @Override
+ public ClientHttpResponse intercept(HttpRequest request, byte[] body,
+ ClientHttpRequestExecution execution) throws IOException {
+ if (request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION) != null) {
+ return execution.execute(request, body);
+ }
+ Optional 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+package org.eclipse.ecsp.security;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Configuration properties for scope-override behaviour.
+ *
+ * Binds to the {@code scopes} prefix and centralises the two scope-related
+ * settings that were previously duplicated across {@code ScopeTagger},
+ * {@code ApiRoutesLoader}, {@code ScopeValidator}, and
+ * {@code TokenValidationInterceptor}:
+ *
+ * Registered as a {@code @Component} so it is available in any application context
+ * regardless of which optional features are enabled.
+ */
+@Component
+@ConfigurationProperties(prefix = "scopes")
+public class ScopeOverrideProperties {
+
+ /**
+ * Default constructor.
+ */
+ public ScopeOverrideProperties() {
+ // Default constructor
+ }
+
+ private Override override = new Override();
+
+ /**
+ * Per-route override scope lists.
+ *
+ * Keys are route IDs in the form {@code SCOPE: EMPTY 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+package org.eclipse.ecsp.security;
+
+import org.eclipse.ecsp.tokenvalidator.model.TokenClaim;
+import org.eclipse.ecsp.utils.logger.IgniteLogger;
+import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Thread-local holder for JWT-validated security data.
+ *
+ * Populated exclusively by {@code TokenValidationInterceptor} after successful JWT
+ * validation. Completely separate from {@link HeaderContext}, which holds raw HTTP
+ * header values that may or may not be JWT-validated.
+ *
+ * All storage is per-thread. {@link #clear()} must be called in
+ * {@code afterCompletion} to prevent ThreadLocal memory leaks in servlet-container
+ * thread pools.
+ */
+public abstract class SecurityContext {
+
+ private static final IgniteLogger LOGGER = IgniteLoggerFactory.getLogger(SecurityContext.class);
+
+ private static final ThreadLocal Parses the {@code exp}, {@code sub}, and {@code scope} claims from
+ * {@code claims} and stores the result alongside the raw token string.
+ *
+ * @param rawToken the Bearer token string (without the "Bearer " prefix)
+ * @param claims the verified claims returned by the token validator
+ */
+ public static void set(String rawToken, List Returns {@code true} if no context is set (treat absent token as expired).
+ *
+ * @return {@code true} if the token is expired or absent
+ */
+ public static boolean isTokenExpired() {
+ SecurityDetails details = SECURITY_CONTEXT.get();
+ if (details == null || details.expiry() == null) {
+ return true;
+ }
+ return Instant.now().isAfter(details.expiry());
+ }
+
+ /**
+ * Clears the security context for the current thread.
+ *
+ * Must be called in {@code afterCompletion} of the interceptor to prevent
+ * ThreadLocal memory leaks in servlet-container thread pools.
+ */
+ public static void clear() {
+ SECURITY_CONTEXT.remove();
+ }
+
+ private static Instant parseExpiry(List 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+package org.eclipse.ecsp.security;
+
+import org.eclipse.ecsp.utils.RegistryCommonConstants;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Configuration properties for token validation and token propagation.
+ *
+ * Bound to the {@code api.registry} prefix. Controls whether JWT validation
+ * is active and how tokens are forwarded to downstream services.
+ */
+@ConfigurationProperties(prefix = RegistryCommonConstants.API_REGISTRY_PREFIX)
+public class ValidationConfigProperties {
+
+ private Security security = new Security();
+ private TokenPropagation tokenPropagation = new TokenPropagation();
+
+ /**
+ * Default constructor.
+ */
+ public ValidationConfigProperties() {
+ // Default constructor
+ }
+
+ /**
+ * Returns the security sub-properties.
+ *
+ * @return the security configuration
+ */
+ public Security getSecurity() {
+ return security;
+ }
+
+ /**
+ * Sets the security sub-properties.
+ *
+ * @param security the security configuration
+ */
+ public void setSecurity(Security security) {
+ this.security = security;
+ }
+
+ /**
+ * Returns the token-propagation sub-properties.
+ *
+ * @return the token-propagation configuration
+ */
+ public TokenPropagation getTokenPropagation() {
+ return tokenPropagation;
+ }
+
+ /**
+ * Sets the token-propagation sub-properties.
+ *
+ * @param tokenPropagation the token-propagation configuration
+ */
+ public void setTokenPropagation(TokenPropagation tokenPropagation) {
+ this.tokenPropagation = tokenPropagation;
+ }
+
+ /**
+ * Security sub-properties controlling whether JWT validation is enabled.
+ */
+ public static class Security {
+
+ private boolean enabled = false;
+
+ /**
+ * Default constructor.
+ */
+ public Security() {
+ // Default constructor
+ }
+
+ /**
+ * Returns whether JWT token validation is enabled.
+ *
+ * @return {@code true} if validation is active
+ */
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ /**
+ * Sets whether JWT token validation is enabled.
+ *
+ * @param enabled {@code true} to activate validation
+ */
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+ }
+
+ /**
+ * Token-propagation sub-properties controlling how Bearer tokens are forwarded
+ * to downstream services via RestTemplate, WebClient, and RestClient.
+ */
+ public static class TokenPropagation {
+
+ private RestTemplate restTemplate = new RestTemplate();
+ private WebClient webClient = new WebClient();
+ private RestClient restClient = new RestClient();
+ private List 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+package org.eclipse.ecsp.webclient;
+
+import org.eclipse.ecsp.security.ValidationConfigProperties;
+import org.eclipse.ecsp.utils.RegistryCommonConstants;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.reactive.function.client.WebClient;
+
+/**
+ * Auto-configuration for WebClient bean with token propagation support.
+ *
+ * Creates a {@link WebClient.Builder} bean pre-configured with
+ * {@link WebClientTokenFilter} when {@code spring-webflux} is on the classpath and
+ * {@code api.registry.token-propagation.web-client.enabled=true}.
+ */
+@Configuration
+@ConditionalOnClass(WebClient.class)
+@EnableConfigurationProperties(ValidationConfigProperties.class)
+public class WebClientConfig {
+
+ /**
+ * Default constructor.
+ */
+ public WebClientConfig() {
+ // Default constructor
+ }
+
+ /**
+ * Creates a {@link WebClientTokenFilter} bean for token propagation.
+ *
+ * @param config the validation / propagation configuration properties
+ * @return the filter
+ */
+ @Bean
+ @ConditionalOnProperty(
+ prefix = RegistryCommonConstants.API_REGISTRY_WEB_CLIENT_PROPAGATION_PREFIX,
+ name = "enabled",
+ havingValue = "true",
+ matchIfMissing = true
+ )
+ public WebClientTokenFilter webClientTokenFilter(ValidationConfigProperties config) {
+ return new WebClientTokenFilter(config);
+ }
+}
diff --git a/api-registry-common/src/main/java/org/eclipse/ecsp/webclient/WebClientTokenFilter.java b/api-registry-common/src/main/java/org/eclipse/ecsp/webclient/WebClientTokenFilter.java
new file mode 100644
index 00000000..96b81617
--- /dev/null
+++ b/api-registry-common/src/main/java/org/eclipse/ecsp/webclient/WebClientTokenFilter.java
@@ -0,0 +1,115 @@
+/********************************************************************************
+ * Copyright (c) 2023-24 Harman International
+ *
+ * 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+package org.eclipse.ecsp.webclient;
+
+import org.eclipse.ecsp.security.SecurityContext;
+import org.eclipse.ecsp.security.ValidationConfigProperties;
+import org.eclipse.ecsp.utils.logger.IgniteLogger;
+import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory;
+import org.springframework.http.HttpHeaders;
+import org.springframework.web.reactive.function.client.ClientRequest;
+import org.springframework.web.reactive.function.client.ClientResponse;
+import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
+import org.springframework.web.reactive.function.client.ExchangeFunction;
+import reactor.core.publisher.Mono;
+import java.net.URI;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Reactive {@link ExchangeFilterFunction} that propagates the current thread's Bearer token
+ * to outbound WebClient calls.
+ *
+ * Thread-safety: The token is captured synchronously from the calling
+ * thread before any reactive operator is applied. This ensures the ThreadLocal value is
+ * read on the correct (servlet) thread rather than a scheduler thread.
+ */
+public class WebClientTokenFilter implements ExchangeFilterFunction {
+
+ private static final IgniteLogger LOGGER = IgniteLoggerFactory.getLogger(WebClientTokenFilter.class);
+
+ private final ValidationConfigProperties config;
+
+ /**
+ * Constructs the filter with the given configuration.
+ *
+ * @param config the validation / propagation configuration properties
+ */
+ public WebClientTokenFilter(ValidationConfigProperties config) {
+ this.config = config;
+ }
+
+ @Override
+ public Mono 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+package org.eclipse.ecsp.restclient;
+
+import org.eclipse.ecsp.security.SecurityContext;
+import org.eclipse.ecsp.security.ValidationConfigProperties;
+import org.eclipse.ecsp.tokenvalidator.model.TokenClaim;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpRequest;
+import org.springframework.http.client.ClientHttpRequestExecution;
+import org.springframework.http.client.ClientHttpResponse;
+import java.net.URI;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Tests for {@link RestClientTokenInterceptor}.
+ */
+@ExtendWith(MockitoExtension.class)
+class RestClientTokenInterceptorTest {
+
+ @Mock
+ private HttpRequest httpRequest;
+
+ @Mock
+ private ClientHttpRequestExecution execution;
+
+ @Mock
+ private ClientHttpResponse httpResponse;
+
+ private HttpHeaders headers;
+ private ValidationConfigProperties config;
+ private RestClientTokenInterceptor underTest;
+
+ @BeforeEach
+ void beforeEach() {
+ config = new ValidationConfigProperties();
+ underTest = new RestClientTokenInterceptor(config);
+ headers = new HttpHeaders();
+ Mockito.when(httpRequest.getHeaders()).thenReturn(headers);
+ }
+
+ @AfterEach
+ void tearDown() {
+ SecurityContext.clear();
+ }
+
+ @Test
+ void shouldAddAuthHeaderWhenTokenPresent() throws Exception {
+ setValidToken("valid-token");
+ Mockito.when(httpRequest.getURI()).thenReturn(URI.create("http://internal-svc/api"));
+ Mockito.when(execution.execute(Mockito.any(), Mockito.any())).thenReturn(httpResponse);
+
+ ClientHttpResponse result = underTest.intercept(httpRequest, new byte[0], execution);
+
+ Assertions.assertNotNull(result);
+ Assertions.assertEquals("Bearer valid-token", headers.getFirst(HttpHeaders.AUTHORIZATION));
+ }
+
+ @Test
+ void shouldSkipWhenAuthHeaderAlreadyPresent() throws Exception {
+ setValidToken("valid-token");
+ headers.add(HttpHeaders.AUTHORIZATION, "Bearer existing-token");
+ Mockito.when(execution.execute(Mockito.any(), Mockito.any())).thenReturn(httpResponse);
+
+ underTest.intercept(httpRequest, new byte[0], execution);
+
+ Assertions.assertEquals("Bearer existing-token", headers.getFirst(HttpHeaders.AUTHORIZATION));
+ }
+
+ @Test
+ void shouldSkipWhenTokenExpired() throws Exception {
+ setExpiredToken("expired-token");
+ Mockito.when(httpRequest.getURI()).thenReturn(URI.create("http://internal-svc/api"));
+ Mockito.when(execution.execute(Mockito.any(), Mockito.any())).thenReturn(httpResponse);
+
+ underTest.intercept(httpRequest, new byte[0], execution);
+
+ Assertions.assertNull(headers.getFirst(HttpHeaders.AUTHORIZATION));
+ }
+
+ @Test
+ void shouldSkipWhenHostIsExcluded() throws Exception {
+ setValidToken("valid-token");
+ config.getTokenPropagation().setExcludeHosts(Collections.singletonList("external-api.com"));
+ Mockito.when(httpRequest.getURI()).thenReturn(URI.create("http://external-api.com/resource"));
+ Mockito.when(execution.execute(Mockito.any(), Mockito.any())).thenReturn(httpResponse);
+
+ underTest.intercept(httpRequest, new byte[0], execution);
+
+ Assertions.assertNull(headers.getFirst(HttpHeaders.AUTHORIZATION));
+ }
+
+ @Test
+ void shouldSkipWhenHostIsExternalAndExternalDisabled() throws Exception {
+ setValidToken("valid-token");
+ config.getTokenPropagation().setAllowExternalHosts(false);
+ config.getTokenPropagation().setIncludeHosts(Collections.singletonList("internal.svc"));
+ Mockito.when(httpRequest.getURI()).thenReturn(URI.create("http://unknown-external.com/resource"));
+ Mockito.when(execution.execute(Mockito.any(), Mockito.any())).thenReturn(httpResponse);
+
+ underTest.intercept(httpRequest, new byte[0], execution);
+
+ Assertions.assertNull(headers.getFirst(HttpHeaders.AUTHORIZATION));
+ }
+
+ @Test
+ void shouldForwardWhenHostIsInIncludeList() throws Exception {
+ setValidToken("valid-token");
+ config.getTokenPropagation().setAllowExternalHosts(false);
+ config.getTokenPropagation().setIncludeHosts(Collections.singletonList("internal.svc"));
+ Mockito.when(httpRequest.getURI()).thenReturn(URI.create("http://internal.svc/resource"));
+ Mockito.when(execution.execute(Mockito.any(), Mockito.any())).thenReturn(httpResponse);
+
+ underTest.intercept(httpRequest, new byte[0], execution);
+
+ Assertions.assertEquals("Bearer valid-token", headers.getFirst(HttpHeaders.AUTHORIZATION));
+ }
+
+ private void setValidToken(String token) {
+ long futureEpoch = Instant.now().plusSeconds(3600).getEpochSecond();
+ List 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+package org.eclipse.ecsp.restclient;
+
+import org.eclipse.ecsp.security.SecurityContext;
+import org.eclipse.ecsp.security.ValidationConfigProperties;
+import org.eclipse.ecsp.tokenvalidator.model.TokenClaim;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpRequest;
+import org.springframework.http.client.ClientHttpRequestExecution;
+import org.springframework.http.client.ClientHttpResponse;
+import java.net.URI;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Tests for {@link RestTemplateTokenInterceptor}.
+ */
+@ExtendWith(MockitoExtension.class)
+class RestTemplateTokenInterceptorTest {
+
+ @Mock
+ private HttpRequest httpRequest;
+
+ @Mock
+ private ClientHttpRequestExecution execution;
+
+ @Mock
+ private ClientHttpResponse httpResponse;
+
+ private HttpHeaders headers;
+ private ValidationConfigProperties config;
+ private RestTemplateTokenInterceptor underTest;
+
+ @BeforeEach
+ void beforeEach() {
+ config = new ValidationConfigProperties();
+ underTest = new RestTemplateTokenInterceptor(config);
+ headers = new HttpHeaders();
+ Mockito.when(httpRequest.getHeaders()).thenReturn(headers);
+ }
+
+ @AfterEach
+ void tearDown() {
+ SecurityContext.clear();
+ }
+
+ @Test
+ void shouldAddAuthHeaderWhenTokenPresent() throws Exception {
+ setValidToken("valid-token");
+ Mockito.when(httpRequest.getURI()).thenReturn(URI.create("http://internal-svc/api"));
+ Mockito.when(execution.execute(Mockito.any(), Mockito.any())).thenReturn(httpResponse);
+
+ ClientHttpResponse result = underTest.intercept(httpRequest, new byte[0], execution);
+
+ Assertions.assertNotNull(result);
+ Assertions.assertEquals("Bearer valid-token", headers.getFirst(HttpHeaders.AUTHORIZATION));
+ }
+
+ @Test
+ void shouldSkipWhenAuthHeaderAlreadyPresent() throws Exception {
+ setValidToken("valid-token");
+ headers.add(HttpHeaders.AUTHORIZATION, "Bearer existing-token");
+ Mockito.when(execution.execute(Mockito.any(), Mockito.any())).thenReturn(httpResponse);
+
+ underTest.intercept(httpRequest, new byte[0], execution);
+
+ // Should not have overwritten the header
+ Assertions.assertEquals("Bearer existing-token", headers.getFirst(HttpHeaders.AUTHORIZATION));
+ }
+
+ @Test
+ void shouldSkipWhenTokenExpired() throws Exception {
+ setExpiredToken("expired-token");
+ Mockito.when(httpRequest.getURI()).thenReturn(URI.create("http://internal-svc/api"));
+ Mockito.when(execution.execute(Mockito.any(), Mockito.any())).thenReturn(httpResponse);
+
+ underTest.intercept(httpRequest, new byte[0], execution);
+
+ Assertions.assertNull(headers.getFirst(HttpHeaders.AUTHORIZATION));
+ }
+
+ @Test
+ void shouldSkipWhenHostIsExcluded() throws Exception {
+ setValidToken("valid-token");
+ config.getTokenPropagation().setExcludeHosts(Collections.singletonList("external-api.com"));
+ Mockito.when(httpRequest.getURI()).thenReturn(URI.create("http://external-api.com/resource"));
+ Mockito.when(execution.execute(Mockito.any(), Mockito.any())).thenReturn(httpResponse);
+
+ underTest.intercept(httpRequest, new byte[0], execution);
+
+ Assertions.assertNull(headers.getFirst(HttpHeaders.AUTHORIZATION));
+ }
+
+ @Test
+ void shouldSkipWhenHostIsExternalAndExternalDisabled() throws Exception {
+ setValidToken("valid-token");
+ config.getTokenPropagation().setAllowExternalHosts(false);
+ config.getTokenPropagation().setIncludeHosts(Collections.singletonList("internal.svc"));
+ Mockito.when(httpRequest.getURI()).thenReturn(URI.create("http://unknown-external.com/resource"));
+ Mockito.when(execution.execute(Mockito.any(), Mockito.any())).thenReturn(httpResponse);
+
+ underTest.intercept(httpRequest, new byte[0], execution);
+
+ Assertions.assertNull(headers.getFirst(HttpHeaders.AUTHORIZATION));
+ }
+
+ @Test
+ void shouldForwardWhenHostIsInIncludeList() throws Exception {
+ setValidToken("valid-token");
+ config.getTokenPropagation().setAllowExternalHosts(false);
+ config.getTokenPropagation().setIncludeHosts(Collections.singletonList("internal.svc"));
+ Mockito.when(httpRequest.getURI()).thenReturn(URI.create("http://internal.svc/resource"));
+ Mockito.when(execution.execute(Mockito.any(), Mockito.any())).thenReturn(httpResponse);
+
+ underTest.intercept(httpRequest, new byte[0], execution);
+
+ Assertions.assertEquals("Bearer valid-token", headers.getFirst(HttpHeaders.AUTHORIZATION));
+ }
+
+ private void setValidToken(String token) {
+ long futureEpoch = Instant.now().plusSeconds(3600).getEpochSecond();
+ List 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+package org.eclipse.ecsp.security;
+
+import org.eclipse.ecsp.tokenvalidator.model.TokenClaim;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Tests for {@link SecurityContext}.
+ */
+@ExtendWith(MockitoExtension.class)
+class SecurityContextTest {
+
+ @AfterEach
+ void tearDown() {
+ SecurityContext.clear();
+ }
+
+ @Test
+ void shouldStoreAndRetrieveToken() {
+ List 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+package org.eclipse.ecsp.security;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Tests for {@link ValidationConfigProperties}.
+ */
+@ExtendWith(MockitoExtension.class)
+class ValidationConfigPropertiesTest {
+
+ @Configuration
+ @EnableConfigurationProperties(ValidationConfigProperties.class)
+ static class TestConfig {
+ }
+
+ @Test
+ void shouldBindDefaultValues() {
+ ValidationConfigProperties props = new ValidationConfigProperties();
+ Assertions.assertFalse(props.getSecurity().isEnabled());
+ Assertions.assertTrue(props.getTokenPropagation().getRestTemplate().isEnabled());
+ Assertions.assertTrue(props.getTokenPropagation().getWebClient().isEnabled());
+ Assertions.assertTrue(props.getTokenPropagation().getRestClient().isEnabled());
+ Assertions.assertFalse(props.getTokenPropagation().isAllowExternalHosts());
+ Assertions.assertTrue(props.getTokenPropagation().getIncludeHosts().isEmpty());
+ Assertions.assertTrue(props.getTokenPropagation().getExcludeHosts().isEmpty());
+ }
+
+ @Test
+ void shouldBindCustomValues() {
+ ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+ .withPropertyValues(
+ "api.registry.security.enabled=true",
+ "api.registry.token-propagation.rest-template.enabled=false",
+ "api.registry.token-propagation.web-client.enabled=false",
+ "api.registry.token-propagation.rest-client.enabled=false",
+ "api.registry.token-propagation.allow-external-hosts=true",
+ "api.registry.token-propagation.include-hosts=internal.svc",
+ "api.registry.token-propagation.exclude-hosts=external.com"
+ )
+ .withUserConfiguration(TestConfig.class);
+
+ contextRunner.run(ctx -> {
+ ValidationConfigProperties props = ctx.getBean(ValidationConfigProperties.class);
+ Assertions.assertTrue(props.getSecurity().isEnabled());
+ Assertions.assertFalse(props.getTokenPropagation().getRestTemplate().isEnabled());
+ Assertions.assertFalse(props.getTokenPropagation().getWebClient().isEnabled());
+ Assertions.assertFalse(props.getTokenPropagation().getRestClient().isEnabled());
+ Assertions.assertTrue(props.getTokenPropagation().isAllowExternalHosts());
+ Assertions.assertFalse(props.getTokenPropagation().getIncludeHosts().isEmpty());
+ Assertions.assertFalse(props.getTokenPropagation().getExcludeHosts().isEmpty());
+ });
+ }
+}
diff --git a/api-registry-common/src/test/java/org/eclipse/ecsp/security/validator/SecurityRequirementCacheTest.java b/api-registry-common/src/test/java/org/eclipse/ecsp/security/validator/SecurityRequirementCacheTest.java
new file mode 100644
index 00000000..45dd842f
--- /dev/null
+++ b/api-registry-common/src/test/java/org/eclipse/ecsp/security/validator/SecurityRequirementCacheTest.java
@@ -0,0 +1,124 @@
+/********************************************************************************
+ * Copyright (c) 2023-24 Harman International
+ *
+ * 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+package org.eclipse.ecsp.security.validator;
+
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import org.eclipse.ecsp.interceptors.SecurityRequirementCache;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.web.method.HandlerMethod;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+
+/**
+ * Tests for {@link SecurityRequirementCache}.
+ */
+@ExtendWith(MockitoExtension.class)
+class SecurityRequirementCacheTest {
+
+ private SecurityRequirementCache underTest;
+
+ @BeforeEach
+ void beforeEach() {
+ underTest = new SecurityRequirementCache();
+ }
+
+ @Test
+ void shouldReturnFalseWhenAnnotationAbsent() throws Exception {
+ Method method = SampleController.class.getMethod("publicEndpoint");
+ HandlerMethod handlerMethod = Mockito.mock(HandlerMethod.class);
+ Mockito.when(handlerMethod.getMethodAnnotation(SecurityRequirement.class)).thenReturn(null);
+
+ boolean result = underTest.isSecured(handlerMethod);
+
+ Assertions.assertFalse(result);
+ }
+
+ @Test
+ void shouldReturnTrueWhenAnnotationPresent() throws Exception {
+ Method method = SampleController.class.getMethod("securedEndpoint");
+ SecurityRequirement annotation = method.getAnnotation(SecurityRequirement.class);
+
+ HandlerMethod handlerMethod = Mockito.mock(HandlerMethod.class);
+ Mockito.when(handlerMethod.getMethodAnnotation(SecurityRequirement.class)).thenReturn(annotation);
+
+ boolean result = underTest.isSecured(handlerMethod);
+
+ Assertions.assertTrue(result);
+ }
+
+ @Test
+ void shouldReturnCachedResultOnSecondCall() {
+ HandlerMethod handlerMethod = Mockito.mock(HandlerMethod.class);
+ Mockito.when(handlerMethod.getMethodAnnotation(SecurityRequirement.class)).thenReturn(null);
+
+ underTest.isSecured(handlerMethod);
+ underTest.isSecured(handlerMethod);
+
+ // getMethodAnnotation should be called exactly once due to caching
+ Mockito.verify(handlerMethod, Mockito.times(1)).getMethodAnnotation(SecurityRequirement.class);
+ }
+
+ @Test
+ void shouldHandleConcurrentFirstCalls() throws Exception {
+ HandlerMethod handlerMethod = Mockito.mock(HandlerMethod.class);
+ Mockito.when(handlerMethod.getMethodAnnotation(SecurityRequirement.class)).thenReturn(null);
+
+ int threadCount = 10;
+ CountDownLatch startLatch = new CountDownLatch(1);
+ ExecutorService executor = Executors.newFixedThreadPool(threadCount);
+ List 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+package org.eclipse.ecsp.security.validator;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.eclipse.ecsp.interceptors.SecurityRequirementCache;
+import org.eclipse.ecsp.interceptors.TokenValidationInterceptor;
+import org.eclipse.ecsp.security.ScopeOverrideProperties;
+import org.eclipse.ecsp.security.SecurityContext;
+import org.eclipse.ecsp.security.ValidationConfigProperties;
+import org.eclipse.ecsp.tokenvalidator.TokenValidator;
+import org.eclipse.ecsp.tokenvalidator.exception.InvalidSignatureException;
+import org.eclipse.ecsp.tokenvalidator.exception.TokenExpiredException;
+import org.eclipse.ecsp.tokenvalidator.model.TokenClaim;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.HttpHeaders;
+import org.springframework.web.method.HandlerMethod;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Tests for {@link TokenValidationInterceptor}.
+ */
+@ExtendWith(MockitoExtension.class)
+class TokenValidationInterceptorTest {
+
+ @Mock
+ private TokenValidator tokenValidator;
+
+ @Mock
+ private SecurityRequirementCache securityRequirementCache;
+
+ @Mock
+ private HttpServletRequest request;
+
+ @Mock
+ private HttpServletResponse response;
+
+ @Mock
+ private HandlerMethod handlerMethod;
+
+ private ScopeOverrideProperties scopeOverrideProperties;
+
+ private ValidationConfigProperties config;
+ private TokenValidationInterceptor underTest;
+ private StringWriter responseWriter;
+
+ @BeforeEach
+ void beforeEach() throws Exception {
+ config = new ValidationConfigProperties();
+ config.getSecurity().setEnabled(true);
+ scopeOverrideProperties = new ScopeOverrideProperties();
+ underTest = new TokenValidationInterceptor(tokenValidator, config, securityRequirementCache, new ObjectMapper(),
+ scopeOverrideProperties);
+ responseWriter = new StringWriter();
+ Mockito.lenient().when(response.getWriter()).thenReturn(new PrintWriter(responseWriter));
+ }
+
+ @AfterEach
+ void tearDown() {
+ SecurityContext.clear();
+ }
+
+ @Test
+ void shouldReturnTrueWhenSecurityDisabled() throws Exception {
+ config.getSecurity().setEnabled(false);
+
+ boolean result = underTest.preHandle(request, response, handlerMethod);
+
+ Assertions.assertTrue(result);
+ Mockito.verifyNoInteractions(tokenValidator, securityRequirementCache);
+ }
+
+ @Test
+ void shouldReturnTrueWhenHandlerIsNotHandlerMethod() throws Exception {
+ boolean result = underTest.preHandle(request, response, new Object());
+
+ Assertions.assertTrue(result);
+ Mockito.verifyNoInteractions(tokenValidator, securityRequirementCache);
+ }
+
+ @Test
+ void shouldReturnTrueWhenCacheReportsNotSecured() throws Exception {
+ Mockito.when(securityRequirementCache.isSecured(handlerMethod)).thenReturn(false);
+
+ boolean result = underTest.preHandle(request, response, handlerMethod);
+
+ Assertions.assertTrue(result);
+ Mockito.verifyNoInteractions(tokenValidator);
+ }
+
+ @Test
+ void shouldReturn401WhenAuthorizationHeaderMissing() throws Exception {
+ Mockito.when(securityRequirementCache.isSecured(handlerMethod)).thenReturn(true);
+ Mockito.when(request.getHeader(HttpHeaders.AUTHORIZATION)).thenReturn(null);
+
+ boolean result = underTest.preHandle(request, response, handlerMethod);
+
+ Assertions.assertFalse(result);
+ Mockito.verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ }
+
+ @Test
+ void shouldReturn401WhenAuthorizationHeaderMalformed() throws Exception {
+ Mockito.when(securityRequirementCache.isSecured(handlerMethod)).thenReturn(true);
+ Mockito.when(request.getHeader(HttpHeaders.AUTHORIZATION)).thenReturn("Basic dXNlcjpwYXNz");
+
+ boolean result = underTest.preHandle(request, response, handlerMethod);
+
+ Assertions.assertFalse(result);
+ Mockito.verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ }
+
+ @Test
+ void shouldReturn401WhenTokenValidationFails() throws Exception {
+ Mockito.when(securityRequirementCache.isSecured(handlerMethod)).thenReturn(true);
+ Mockito.when(request.getHeader(HttpHeaders.AUTHORIZATION)).thenReturn("Bearer bad-token");
+ Mockito.when(tokenValidator.validate("bad-token"))
+ .thenThrow(new InvalidSignatureException("invalid signature"));
+
+ boolean result = underTest.preHandle(request, response, handlerMethod);
+
+ Assertions.assertFalse(result);
+ Mockito.verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ }
+
+ @Test
+ void shouldReturn401WhenTokenExpired() throws Exception {
+ Mockito.when(securityRequirementCache.isSecured(handlerMethod)).thenReturn(true);
+ Mockito.when(request.getHeader(HttpHeaders.AUTHORIZATION)).thenReturn("Bearer expired-token");
+ Mockito.when(tokenValidator.validate("expired-token"))
+ .thenThrow(new TokenExpiredException("token is expired"));
+
+ boolean result = underTest.preHandle(request, response, handlerMethod);
+
+ Assertions.assertFalse(result);
+ Mockito.verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ }
+
+ @Test
+ void shouldStoreClaimsInSecurityContextWhenTokenIsValid() throws Exception {
+ List 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+package org.eclipse.ecsp.webclient;
+
+import org.eclipse.ecsp.security.SecurityContext;
+import org.eclipse.ecsp.security.ValidationConfigProperties;
+import org.eclipse.ecsp.tokenvalidator.model.TokenClaim;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.HttpHeaders;
+import org.springframework.web.reactive.function.client.ClientRequest;
+import org.springframework.web.reactive.function.client.ClientResponse;
+import org.springframework.web.reactive.function.client.ExchangeFunction;
+import reactor.core.publisher.Mono;
+import java.net.URI;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Tests for {@link WebClientTokenFilter}.
+ */
+@ExtendWith(MockitoExtension.class)
+class WebClientTokenFilterTest {
+
+ @Mock
+ private ExchangeFunction exchangeFunction;
+
+ @Mock
+ private ClientResponse clientResponse;
+
+ private ValidationConfigProperties config;
+ private WebClientTokenFilter underTest;
+
+ @BeforeEach
+ void beforeEach() {
+ config = new ValidationConfigProperties();
+ underTest = new WebClientTokenFilter(config);
+ Mockito.when(exchangeFunction.exchange(Mockito.any())).thenReturn(Mono.just(clientResponse));
+ }
+
+ @AfterEach
+ void tearDown() {
+ SecurityContext.clear();
+ }
+
+ @Test
+ void shouldAddAuthHeaderWhenTokenPresent() {
+ setValidToken("valid-token");
+ ClientRequest request = ClientRequest.create(
+ org.springframework.http.HttpMethod.GET, URI.create("http://internal-svc/api"))
+ .build();
+
+ underTest.filter(request, exchangeFunction).block();
+
+ ArgumentCaptor
+ *
+ *
+ * @param targetUri the URI of the downstream service
+ * @return {@code true} if propagation should be skipped
+ */
+ protected boolean shouldSkipPropagation(URI targetUri) {
+ String host = targetUri.getHost();
+ if (host == null) {
+ return false;
+ }
+ List
+ *
+ *
+ *