Create spring-boot-cloudfoundry module

This commit is contained in:
Andy Wilkinson
2025-06-05 17:37:50 +01:00
committed by Phillip Webb
parent 1d2604602c
commit f680582019
59 changed files with 279 additions and 168 deletions

View File

@@ -0,0 +1,61 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure;
import java.util.Arrays;
import java.util.List;
/**
* The specific access level granted to the cloud foundry user that's calling the
* endpoints.
*
* @author Madhura Bhave
* @since 4.0.0
*/
public enum AccessLevel {
/**
* Restricted access to a limited set of endpoints.
*/
RESTRICTED("", "health", "info"),
/**
* Full access to all endpoints.
*/
FULL;
/**
* The request attribute used to store the {@link AccessLevel}.
*/
public static final String REQUEST_ATTRIBUTE = "cloudFoundryAccessLevel";
private final List<String> ids;
AccessLevel(String... ids) {
this.ids = Arrays.asList(ids);
}
/**
* Returns if the access level should allow access to the specified ID.
* @param id the ID to check
* @return {@code true} if access is allowed
*/
public boolean isAccessAllowed(String id) {
return this.ids.isEmpty() || this.ids.contains(id);
}
}

View File

@@ -0,0 +1,123 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure;
import org.springframework.http.HttpStatus;
/**
* Authorization exceptions thrown to limit access to the endpoints.
*
* @author Madhura Bhave
* @since 4.0.0
*/
public class CloudFoundryAuthorizationException extends RuntimeException {
private final Reason reason;
public CloudFoundryAuthorizationException(Reason reason, String message) {
this(reason, message, null);
}
public CloudFoundryAuthorizationException(Reason reason, String message, Throwable cause) {
super(message, cause);
this.reason = reason;
}
/**
* Return the status code that should be returned to the client.
* @return the HTTP status code
*/
public HttpStatus getStatusCode() {
return getReason().getStatus();
}
/**
* Return the reason why the authorization exception was thrown.
* @return the reason
*/
public Reason getReason() {
return this.reason;
}
/**
* Reasons why the exception can be thrown.
*/
public enum Reason {
/**
* Access Denied.
*/
ACCESS_DENIED(HttpStatus.FORBIDDEN),
/**
* Invalid Audience.
*/
INVALID_AUDIENCE(HttpStatus.UNAUTHORIZED),
/**
* Invalid Issuer.
*/
INVALID_ISSUER(HttpStatus.UNAUTHORIZED),
/**
* Invalid Key ID.
*/
INVALID_KEY_ID(HttpStatus.UNAUTHORIZED),
/**
* Invalid Signature.
*/
INVALID_SIGNATURE(HttpStatus.UNAUTHORIZED),
/**
* Invalid Token.
*/
INVALID_TOKEN(HttpStatus.UNAUTHORIZED),
/**
* Missing Authorization.
*/
MISSING_AUTHORIZATION(HttpStatus.UNAUTHORIZED),
/**
* Token Expired.
*/
TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED),
/**
* Unsupported Token Signing Algorithm.
*/
UNSUPPORTED_TOKEN_SIGNING_ALGORITHM(HttpStatus.UNAUTHORIZED),
/**
* Service Unavailable.
*/
SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE);
private final HttpStatus status;
Reason(HttpStatus status) {
this.status = status;
}
public HttpStatus getStatus() {
return this.status;
}
}
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure;
import java.util.Set;
import org.springframework.boot.actuate.autoconfigure.endpoint.condition.EndpointExposureOutcomeContributor;
import org.springframework.boot.actuate.autoconfigure.endpoint.expose.EndpointExposure;
import org.springframework.boot.actuate.autoconfigure.endpoint.expose.IncludeExcludeEndpointFilter;
import org.springframework.boot.actuate.endpoint.EndpointId;
import org.springframework.boot.actuate.endpoint.ExposableEndpoint;
import org.springframework.boot.autoconfigure.condition.ConditionMessage.Builder;
import org.springframework.boot.autoconfigure.condition.ConditionOutcome;
import org.springframework.boot.cloud.CloudPlatform;
import org.springframework.core.env.Environment;
/**
* {@link EndpointExposureOutcomeContributor} to expose {@link EndpointExposure#WEB web}
* endpoints for Cloud Foundry.
*
* @author Phillip Webb
*/
class CloudFoundryEndpointExposureOutcomeContributor implements EndpointExposureOutcomeContributor {
private static final String PROPERTY = "management.endpoints.cloud-foundry.exposure";
private final IncludeExcludeEndpointFilter<?> filter;
CloudFoundryEndpointExposureOutcomeContributor(Environment environment) {
this.filter = (!CloudPlatform.CLOUD_FOUNDRY.isActive(environment)) ? null
: new IncludeExcludeEndpointFilter<>(ExposableEndpoint.class, environment, PROPERTY, "*");
}
@Override
public ConditionOutcome getExposureOutcome(EndpointId endpointId, Set<EndpointExposure> exposures,
Builder message) {
if (exposures.contains(EndpointExposure.WEB) && this.filter != null && this.filter.match(endpointId)) {
return ConditionOutcome.match(message.because("marked as exposed by a '" + PROPERTY + "' property"));
}
return null;
}
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure;
import org.springframework.boot.actuate.endpoint.EndpointFilter;
import org.springframework.boot.actuate.endpoint.annotation.DiscovererEndpointFilter;
/**
* {@link EndpointFilter} for endpoints discovered by
* {@link CloudFoundryWebEndpointDiscoverer}.
*
* @author Madhura Bhave
*/
class CloudFoundryEndpointFilter extends DiscovererEndpointFilter {
protected CloudFoundryEndpointFilter() {
super(CloudFoundryWebEndpointDiscoverer.class);
}
}

View File

@@ -0,0 +1,99 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure;
import java.util.Collection;
import java.util.List;
import org.springframework.aot.hint.MemberCategory;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.boot.actuate.endpoint.EndpointFilter;
import org.springframework.boot.actuate.endpoint.OperationFilter;
import org.springframework.boot.actuate.endpoint.invoke.OperationInvokerAdvisor;
import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper;
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
import org.springframework.boot.actuate.endpoint.web.PathMapper;
import org.springframework.boot.actuate.endpoint.web.WebOperation;
import org.springframework.boot.actuate.endpoint.web.annotation.EndpointWebExtension;
import org.springframework.boot.actuate.endpoint.web.annotation.WebEndpointDiscoverer;
import org.springframework.boot.actuate.health.HealthEndpoint;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryWebEndpointDiscoverer.CloudFoundryWebEndpointDiscovererRuntimeHints;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.ImportRuntimeHints;
import org.springframework.core.annotation.MergedAnnotations;
/**
* {@link WebEndpointDiscoverer} for Cloud Foundry that uses Cloud Foundry specific
* extensions for the {@link HealthEndpoint}.
*
* @author Madhura Bhave
* @since 4.0.0
*/
@ImportRuntimeHints(CloudFoundryWebEndpointDiscovererRuntimeHints.class)
public class CloudFoundryWebEndpointDiscoverer extends WebEndpointDiscoverer {
/**
* Create a new {@link WebEndpointDiscoverer} instance.
* @param applicationContext the source application context
* @param parameterValueMapper the parameter value mapper
* @param endpointMediaTypes the endpoint media types
* @param endpointPathMappers the endpoint path mappers
* @param invokerAdvisors invoker advisors to apply
* @param endpointFilters endpoint filters to apply
* @param operationFilters operation filters to apply
*/
public CloudFoundryWebEndpointDiscoverer(ApplicationContext applicationContext,
ParameterValueMapper parameterValueMapper, EndpointMediaTypes endpointMediaTypes,
List<PathMapper> endpointPathMappers, Collection<OperationInvokerAdvisor> invokerAdvisors,
Collection<EndpointFilter<ExposableWebEndpoint>> endpointFilters,
Collection<OperationFilter<WebOperation>> operationFilters) {
super(applicationContext, parameterValueMapper, endpointMediaTypes, endpointPathMappers, null, invokerAdvisors,
endpointFilters, operationFilters);
}
@Override
protected boolean isExtensionTypeExposed(Class<?> extensionBeanType) {
// Filter regular health endpoint extensions so a CF version can replace them
return !isHealthEndpointExtension(extensionBeanType)
|| isCloudFoundryHealthEndpointExtension(extensionBeanType);
}
private boolean isHealthEndpointExtension(Class<?> extensionBeanType) {
return MergedAnnotations.from(extensionBeanType)
.get(EndpointWebExtension.class)
.getValue("endpoint", Class.class)
.map(HealthEndpoint.class::isAssignableFrom)
.orElse(false);
}
private boolean isCloudFoundryHealthEndpointExtension(Class<?> extensionBeanType) {
return MergedAnnotations.from(extensionBeanType).isPresent(EndpointCloudFoundryExtension.class);
}
static class CloudFoundryWebEndpointDiscovererRuntimeHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
hints.reflection()
.registerType(CloudFoundryEndpointFilter.class, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS);
}
}
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.EndpointExtension;
import org.springframework.core.annotation.AliasFor;
/**
* Identifies a type as being a Cloud Foundry specific extension for an
* {@link Endpoint @Endpoint}.
*
* @author Phillip Webb
* @author Madhura Bhave
* @since 4.0.0
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@EndpointExtension(filter = CloudFoundryEndpointFilter.class)
public @interface EndpointCloudFoundryExtension {
/**
* The class of the endpoint to provide a Cloud Foundry specific extension for.
* @return the class of the endpoint to extend
*/
@AliasFor(annotation = EndpointExtension.class, attribute = "endpoint")
Class<?> endpoint();
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure;
import org.springframework.http.HttpStatus;
/**
* Response from the Cloud Foundry security interceptors.
*
* @author Madhura Bhave
* @since 4.0.0
*/
public class SecurityResponse {
private final HttpStatus status;
private final String message;
public SecurityResponse(HttpStatus status) {
this(status, null);
}
public SecurityResponse(HttpStatus status, String message) {
this.status = status;
this.message = message;
}
public HttpStatus getStatus() {
return this.status;
}
public String getMessage() {
return this.message;
}
public static SecurityResponse success() {
return new SecurityResponse(HttpStatus.OK);
}
}

View File

@@ -0,0 +1,118 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
import org.springframework.boot.json.JsonParserFactory;
import org.springframework.util.StringUtils;
/**
* The JSON web token provided with each request that originates from Cloud Foundry.
*
* @author Madhura Bhave
* @since 4.0.0
*/
public class Token {
private final String encoded;
private final String signature;
private final Map<String, Object> header;
private final Map<String, Object> claims;
public Token(String encoded) {
this.encoded = encoded;
int firstPeriod = encoded.indexOf('.');
int lastPeriod = encoded.lastIndexOf('.');
if (firstPeriod <= 0 || lastPeriod <= firstPeriod) {
throw new CloudFoundryAuthorizationException(Reason.INVALID_TOKEN,
"JWT must have header, body and signature");
}
this.header = parseJson(encoded.substring(0, firstPeriod));
this.claims = parseJson(encoded.substring(firstPeriod + 1, lastPeriod));
this.signature = encoded.substring(lastPeriod + 1);
if (!StringUtils.hasLength(this.signature)) {
throw new CloudFoundryAuthorizationException(Reason.INVALID_TOKEN,
"Token must have non-empty crypto segment");
}
}
private Map<String, Object> parseJson(String base64) {
try {
byte[] bytes = Base64.getUrlDecoder().decode(base64);
return JsonParserFactory.getJsonParser().parseMap(new String(bytes, StandardCharsets.UTF_8));
}
catch (RuntimeException ex) {
throw new CloudFoundryAuthorizationException(Reason.INVALID_TOKEN, "Token could not be parsed", ex);
}
}
public byte[] getContent() {
return this.encoded.substring(0, this.encoded.lastIndexOf('.')).getBytes();
}
public byte[] getSignature() {
return Base64.getUrlDecoder().decode(this.signature);
}
public String getSignatureAlgorithm() {
return getRequired(this.header, "alg", String.class);
}
public String getIssuer() {
return getRequired(this.claims, "iss", String.class);
}
public long getExpiry() {
return getRequired(this.claims, "exp", Integer.class).longValue();
}
@SuppressWarnings("unchecked")
public List<String> getScope() {
return getRequired(this.claims, "scope", List.class);
}
public String getKeyId() {
return getRequired(this.header, "kid", String.class);
}
@SuppressWarnings("unchecked")
private <T> T getRequired(Map<String, Object> map, String key, Class<T> type) {
Object value = map.get(key);
if (value == null) {
throw new CloudFoundryAuthorizationException(Reason.INVALID_TOKEN, "Unable to get value from key " + key);
}
if (!type.isInstance(value)) {
throw new CloudFoundryAuthorizationException(Reason.INVALID_TOKEN,
"Unexpected value type from key " + key + " value " + value);
}
return (T) value;
}
@Override
public String toString() {
return this.encoded;
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.
*/
/**
* Auto-configuration for actuator Cloud Foundry concerns.
*/
package org.springframework.boot.cloudfoundry.actuate.autoconfigure;

View File

@@ -0,0 +1,60 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.reactive;
import reactor.core.publisher.Mono;
import org.springframework.boot.actuate.endpoint.ApiVersion;
import org.springframework.boot.actuate.endpoint.SecurityContext;
import org.springframework.boot.actuate.endpoint.annotation.EndpointExtension;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.endpoint.annotation.Selector;
import org.springframework.boot.actuate.endpoint.annotation.Selector.Match;
import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse;
import org.springframework.boot.actuate.health.HealthComponent;
import org.springframework.boot.actuate.health.HealthEndpoint;
import org.springframework.boot.actuate.health.ReactiveHealthEndpointWebExtension;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.EndpointCloudFoundryExtension;
/**
* Reactive {@link EndpointExtension @EndpointExtension} for the {@link HealthEndpoint}
* that always exposes full health details.
*
* @author Madhura Bhave
* @since 4.0.0
*/
@EndpointCloudFoundryExtension(endpoint = HealthEndpoint.class)
public class CloudFoundryReactiveHealthEndpointWebExtension {
private final ReactiveHealthEndpointWebExtension delegate;
public CloudFoundryReactiveHealthEndpointWebExtension(ReactiveHealthEndpointWebExtension delegate) {
this.delegate = delegate;
}
@ReadOperation
public Mono<WebEndpointResponse<? extends HealthComponent>> health(ApiVersion apiVersion) {
return this.delegate.health(apiVersion, null, SecurityContext.NONE, true);
}
@ReadOperation
public Mono<WebEndpointResponse<? extends HealthComponent>> health(ApiVersion apiVersion,
@Selector(match = Match.ALL_REMAINING) String... path) {
return this.delegate.health(apiVersion, null, SecurityContext.NONE, true, path);
}
}

View File

@@ -0,0 +1,113 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.reactive;
import java.util.Locale;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import reactor.core.publisher.Mono;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.SecurityResponse;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.Token;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.util.StringUtils;
import org.springframework.web.cors.reactive.CorsUtils;
import org.springframework.web.server.ServerWebExchange;
/**
* Security interceptor to validate the cloud foundry token.
*
* @author Madhura Bhave
*/
class CloudFoundrySecurityInterceptor {
private static final Log logger = LogFactory.getLog(CloudFoundrySecurityInterceptor.class);
private final ReactiveTokenValidator tokenValidator;
private final ReactiveCloudFoundrySecurityService cloudFoundrySecurityService;
private final String applicationId;
private static final Mono<SecurityResponse> SUCCESS = Mono.just(SecurityResponse.success());
CloudFoundrySecurityInterceptor(ReactiveTokenValidator tokenValidator,
ReactiveCloudFoundrySecurityService cloudFoundrySecurityService, String applicationId) {
this.tokenValidator = tokenValidator;
this.cloudFoundrySecurityService = cloudFoundrySecurityService;
this.applicationId = applicationId;
}
Mono<SecurityResponse> preHandle(ServerWebExchange exchange, String id) {
ServerHttpRequest request = exchange.getRequest();
if (CorsUtils.isPreFlightRequest(request)) {
return SUCCESS;
}
if (!StringUtils.hasText(this.applicationId)) {
return Mono.error(new CloudFoundryAuthorizationException(Reason.SERVICE_UNAVAILABLE,
"Application id is not available"));
}
if (this.cloudFoundrySecurityService == null) {
return Mono.error(new CloudFoundryAuthorizationException(Reason.SERVICE_UNAVAILABLE,
"Cloud controller URL is not available"));
}
return check(exchange, id).then(SUCCESS).doOnError(this::logError).onErrorResume(this::getErrorResponse);
}
private void logError(Throwable ex) {
logger.error(ex.getMessage(), ex);
}
private Mono<Void> check(ServerWebExchange exchange, String id) {
try {
Token token = getToken(exchange.getRequest());
return this.tokenValidator.validate(token)
.then(this.cloudFoundrySecurityService.getAccessLevel(token.toString(), this.applicationId))
.filter((accessLevel) -> accessLevel.isAccessAllowed(id))
.switchIfEmpty(
Mono.error(new CloudFoundryAuthorizationException(Reason.ACCESS_DENIED, "Access denied")))
.doOnSuccess((accessLevel) -> exchange.getAttributes().put("cloudFoundryAccessLevel", accessLevel))
.then();
}
catch (CloudFoundryAuthorizationException ex) {
return Mono.error(ex);
}
}
private Mono<SecurityResponse> getErrorResponse(Throwable throwable) {
if (throwable instanceof CloudFoundryAuthorizationException cfException) {
return Mono.just(new SecurityResponse(cfException.getStatusCode(),
"{\"security_error\":\"" + cfException.getMessage() + "\"}"));
}
return Mono.just(new SecurityResponse(HttpStatus.INTERNAL_SERVER_ERROR, throwable.getMessage()));
}
private Token getToken(ServerHttpRequest request) {
String authorization = request.getHeaders().getFirst("Authorization");
String bearerPrefix = "bearer ";
if (authorization == null || !authorization.toLowerCase(Locale.ENGLISH).startsWith(bearerPrefix)) {
throw new CloudFoundryAuthorizationException(Reason.MISSING_AUTHORIZATION,
"Authorization header is missing or invalid");
}
return new Token(authorization.substring(bearerPrefix.length()));
}
}

View File

@@ -0,0 +1,179 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.reactive;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Mono;
import org.springframework.aot.hint.BindingReflectionHintsRegistrar;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.aot.hint.annotation.Reflective;
import org.springframework.aot.hint.annotation.ReflectiveRuntimeHintsRegistrar;
import org.springframework.boot.actuate.endpoint.EndpointId;
import org.springframework.boot.actuate.endpoint.ExposableEndpoint;
import org.springframework.boot.actuate.endpoint.web.EndpointLinksResolver;
import org.springframework.boot.actuate.endpoint.web.EndpointMapping;
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
import org.springframework.boot.actuate.endpoint.web.Link;
import org.springframework.boot.actuate.endpoint.web.WebOperation;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.AccessLevel;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.SecurityResponse;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.reactive.CloudFoundryWebFluxEndpointHandlerMapping.CloudFoundryWebFluxEndpointHandlerMappingRuntimeHints;
import org.springframework.boot.webflux.actuate.endpoint.web.AbstractWebFluxEndpointHandlerMapping;
import org.springframework.context.annotation.ImportRuntimeHints;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.reactive.result.method.RequestMappingInfoHandlerMapping;
import org.springframework.web.server.ServerWebExchange;
/**
* A custom {@link RequestMappingInfoHandlerMapping} that makes web endpoints available on
* Cloud Foundry specific URLs over HTTP using Spring WebFlux.
*
* @author Madhura Bhave
* @author Phillip Webb
* @author Brian Clozel
*/
@ImportRuntimeHints(CloudFoundryWebFluxEndpointHandlerMappingRuntimeHints.class)
class CloudFoundryWebFluxEndpointHandlerMapping extends AbstractWebFluxEndpointHandlerMapping {
private final CloudFoundrySecurityInterceptor securityInterceptor;
private final EndpointLinksResolver linksResolver;
private final Collection<ExposableEndpoint<?>> allEndpoints;
CloudFoundryWebFluxEndpointHandlerMapping(EndpointMapping endpointMapping,
Collection<ExposableWebEndpoint> endpoints, EndpointMediaTypes endpointMediaTypes,
CorsConfiguration corsConfiguration, CloudFoundrySecurityInterceptor securityInterceptor,
Collection<ExposableEndpoint<?>> allEndpoints) {
super(endpointMapping, endpoints, endpointMediaTypes, corsConfiguration, true);
this.linksResolver = new EndpointLinksResolver(allEndpoints);
this.allEndpoints = allEndpoints;
this.securityInterceptor = securityInterceptor;
}
@Override
protected ReactiveWebOperation wrapReactiveWebOperation(ExposableWebEndpoint endpoint, WebOperation operation,
ReactiveWebOperation reactiveWebOperation) {
return new SecureReactiveWebOperation(reactiveWebOperation, this.securityInterceptor, endpoint.getEndpointId());
}
@Override
protected LinksHandler getLinksHandler() {
return new CloudFoundryLinksHandler();
}
Collection<ExposableEndpoint<?>> getAllEndpoints() {
return this.allEndpoints;
}
class CloudFoundryLinksHandler implements LinksHandler {
@Override
@Reflective
public Publisher<ResponseEntity<Object>> links(ServerWebExchange exchange) {
ServerHttpRequest request = exchange.getRequest();
return CloudFoundryWebFluxEndpointHandlerMapping.this.securityInterceptor.preHandle(exchange, "")
.map((securityResponse) -> {
if (!securityResponse.getStatus().equals(HttpStatus.OK)) {
return new ResponseEntity<>(securityResponse.getStatus());
}
AccessLevel accessLevel = exchange.getAttribute(AccessLevel.REQUEST_ATTRIBUTE);
Map<String, Link> links = CloudFoundryWebFluxEndpointHandlerMapping.this.linksResolver
.resolveLinks(request.getURI().toString());
return new ResponseEntity<>(
Collections.singletonMap("_links", getAccessibleLinks(accessLevel, links)), HttpStatus.OK);
});
}
private Map<String, Link> getAccessibleLinks(AccessLevel accessLevel, Map<String, Link> links) {
if (accessLevel == null) {
return new LinkedHashMap<>();
}
return links.entrySet()
.stream()
.filter((entry) -> entry.getKey().equals("self") || accessLevel.isAccessAllowed(entry.getKey()))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}
@Override
public String toString() {
return "Actuator root web endpoint";
}
}
/**
* {@link ReactiveWebOperation} wrapper to add security.
*/
private static class SecureReactiveWebOperation implements ReactiveWebOperation {
private final ReactiveWebOperation delegate;
private final CloudFoundrySecurityInterceptor securityInterceptor;
private final EndpointId endpointId;
SecureReactiveWebOperation(ReactiveWebOperation delegate, CloudFoundrySecurityInterceptor securityInterceptor,
EndpointId endpointId) {
this.delegate = delegate;
this.securityInterceptor = securityInterceptor;
this.endpointId = endpointId;
}
@Override
public Mono<ResponseEntity<Object>> handle(ServerWebExchange exchange, Map<String, String> body) {
return this.securityInterceptor.preHandle(exchange, this.endpointId.toLowerCaseString())
.flatMap((securityResponse) -> flatMapResponse(exchange, body, securityResponse));
}
private Mono<ResponseEntity<Object>> flatMapResponse(ServerWebExchange exchange, Map<String, String> body,
SecurityResponse securityResponse) {
if (!securityResponse.getStatus().equals(HttpStatus.OK)) {
return Mono.just(new ResponseEntity<>(securityResponse.getStatus()));
}
return this.delegate.handle(exchange, body);
}
}
static class CloudFoundryWebFluxEndpointHandlerMappingRuntimeHints implements RuntimeHintsRegistrar {
private final ReflectiveRuntimeHintsRegistrar reflectiveRegistrar = new ReflectiveRuntimeHintsRegistrar();
private final BindingReflectionHintsRegistrar bindingRegistrar = new BindingReflectionHintsRegistrar();
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
this.reflectiveRegistrar.registerRuntimeHints(hints, CloudFoundryLinksHandler.class);
this.bindingRegistrar.registerReflectionHints(hints.reflection(), Link.class);
}
}
}

View File

@@ -0,0 +1,207 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.reactive;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.function.Supplier;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint;
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.info.InfoEndpointAutoConfiguration;
import org.springframework.boot.actuate.endpoint.ExposableEndpoint;
import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper;
import org.springframework.boot.actuate.endpoint.web.EndpointMapping;
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
import org.springframework.boot.actuate.endpoint.web.PathMappedEndpoints;
import org.springframework.boot.actuate.health.HealthEndpoint;
import org.springframework.boot.actuate.health.ReactiveHealthEndpointWebExtension;
import org.springframework.boot.actuate.info.GitInfoContributor;
import org.springframework.boot.actuate.info.InfoContributor;
import org.springframework.boot.actuate.info.InfoEndpoint;
import org.springframework.boot.actuate.info.InfoPropertiesInfoContributor;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnCloudPlatform;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.cloud.CloudPlatform;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryWebEndpointDiscoverer;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet.CloudFoundryInfoEndpointWebExtension;
import org.springframework.boot.info.GitProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.security.web.server.MatcherSecurityWebFilterChain;
import org.springframework.security.web.server.WebFilterChainProxy;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
import org.springframework.util.function.SingletonSupplier;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.server.WebFilter;
/**
* {@link EnableAutoConfiguration Auto-configuration} to expose actuator endpoints for
* Cloud Foundry to use in a reactive environment.
*
* @author Madhura Bhave
* @since 4.0.0
*/
@AutoConfiguration(after = { HealthEndpointAutoConfiguration.class, InfoEndpointAutoConfiguration.class })
@ConditionalOnBooleanProperty(name = "management.cloudfoundry.enabled", matchIfMissing = true)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
@ConditionalOnCloudPlatform(CloudPlatform.CLOUD_FOUNDRY)
public class ReactiveCloudFoundryActuatorAutoConfiguration {
private static final String BASE_PATH = "/cloudfoundryapplication";
@Bean
@ConditionalOnMissingBean
@ConditionalOnAvailableEndpoint
@ConditionalOnBean({ HealthEndpoint.class, ReactiveHealthEndpointWebExtension.class })
public CloudFoundryReactiveHealthEndpointWebExtension cloudFoundryReactiveHealthEndpointWebExtension(
ReactiveHealthEndpointWebExtension reactiveHealthEndpointWebExtension) {
return new CloudFoundryReactiveHealthEndpointWebExtension(reactiveHealthEndpointWebExtension);
}
@Bean
@ConditionalOnMissingBean
@ConditionalOnAvailableEndpoint
@ConditionalOnBean({ InfoEndpoint.class, GitProperties.class })
public CloudFoundryInfoEndpointWebExtension cloudFoundryInfoEndpointWebExtension(GitProperties properties,
ObjectProvider<InfoContributor> infoContributors) {
List<InfoContributor> contributors = infoContributors.orderedStream()
.map((infoContributor) -> (infoContributor instanceof GitInfoContributor)
? new GitInfoContributor(properties, InfoPropertiesInfoContributor.Mode.FULL) : infoContributor)
.toList();
return new CloudFoundryInfoEndpointWebExtension(new InfoEndpoint(contributors));
}
@Bean
@SuppressWarnings("removal")
public CloudFoundryWebFluxEndpointHandlerMapping cloudFoundryWebFluxEndpointHandlerMapping(
ParameterValueMapper parameterMapper, EndpointMediaTypes endpointMediaTypes,
WebClient.Builder webClientBuilder,
org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpointsSupplier controllerEndpointsSupplier,
ApplicationContext applicationContext) {
CloudFoundryWebEndpointDiscoverer endpointDiscoverer = new CloudFoundryWebEndpointDiscoverer(applicationContext,
parameterMapper, endpointMediaTypes, null, Collections.emptyList(), Collections.emptyList(),
Collections.emptyList());
CloudFoundrySecurityInterceptor securityInterceptor = getSecurityInterceptor(webClientBuilder,
applicationContext.getEnvironment());
Collection<ExposableWebEndpoint> webEndpoints = endpointDiscoverer.getEndpoints();
List<ExposableEndpoint<?>> allEndpoints = new ArrayList<>();
allEndpoints.addAll(webEndpoints);
allEndpoints.addAll(controllerEndpointsSupplier.getEndpoints());
return new CloudFoundryWebFluxEndpointHandlerMapping(new EndpointMapping(BASE_PATH), webEndpoints,
endpointMediaTypes, getCorsConfiguration(), securityInterceptor, allEndpoints);
}
private CloudFoundrySecurityInterceptor getSecurityInterceptor(WebClient.Builder webClientBuilder,
Environment environment) {
ReactiveCloudFoundrySecurityService cloudfoundrySecurityService = getCloudFoundrySecurityService(
webClientBuilder, environment);
ReactiveTokenValidator tokenValidator = new ReactiveTokenValidator(cloudfoundrySecurityService);
return new CloudFoundrySecurityInterceptor(tokenValidator, cloudfoundrySecurityService,
environment.getProperty("vcap.application.application_id"));
}
private ReactiveCloudFoundrySecurityService getCloudFoundrySecurityService(WebClient.Builder webClientBuilder,
Environment environment) {
String cloudControllerUrl = environment.getProperty("vcap.application.cf_api");
boolean skipSslValidation = environment.getProperty("management.cloudfoundry.skip-ssl-validation",
Boolean.class, false);
return (cloudControllerUrl != null)
? new ReactiveCloudFoundrySecurityService(webClientBuilder, cloudControllerUrl, skipSslValidation)
: null;
}
private CorsConfiguration getCorsConfiguration() {
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.addAllowedOrigin(CorsConfiguration.ALL);
corsConfiguration.setAllowedMethods(Arrays.asList(HttpMethod.GET.name(), HttpMethod.POST.name()));
corsConfiguration
.setAllowedHeaders(Arrays.asList(HttpHeaders.AUTHORIZATION, "X-Cf-App-Instance", HttpHeaders.CONTENT_TYPE));
return corsConfiguration;
}
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(MatcherSecurityWebFilterChain.class)
static class IgnoredPathsSecurityConfiguration {
@Bean
static WebFilterChainPostProcessor webFilterChainPostProcessor(
ObjectProvider<CloudFoundryWebFluxEndpointHandlerMapping> handlerMapping) {
return new WebFilterChainPostProcessor(handlerMapping);
}
}
static class WebFilterChainPostProcessor implements BeanPostProcessor {
private final Supplier<PathMappedEndpoints> pathMappedEndpoints;
WebFilterChainPostProcessor(ObjectProvider<CloudFoundryWebFluxEndpointHandlerMapping> handlerMapping) {
this.pathMappedEndpoints = SingletonSupplier
.of(() -> new PathMappedEndpoints(BASE_PATH, () -> handlerMapping.getObject().getAllEndpoints()));
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
if (bean instanceof WebFilterChainProxy webFilterChainProxy) {
return postProcess(webFilterChainProxy);
}
return bean;
}
private WebFilterChainProxy postProcess(WebFilterChainProxy existing) {
List<String> paths = getPaths(this.pathMappedEndpoints.get());
ServerWebExchangeMatcher cloudFoundryRequestMatcher = ServerWebExchangeMatchers
.pathMatchers(paths.toArray(new String[] {}));
WebFilter noOpFilter = (exchange, chain) -> chain.filter(exchange);
MatcherSecurityWebFilterChain ignoredRequestFilterChain = new MatcherSecurityWebFilterChain(
cloudFoundryRequestMatcher, Collections.singletonList(noOpFilter));
MatcherSecurityWebFilterChain allRequestsFilterChain = new MatcherSecurityWebFilterChain(
ServerWebExchangeMatchers.anyExchange(), Collections.singletonList(existing));
return new WebFilterChainProxy(ignoredRequestFilterChain, allRequestsFilterChain);
}
private static List<String> getPaths(PathMappedEndpoints pathMappedEndpoints) {
List<String> paths = new ArrayList<>();
pathMappedEndpoints.getAllPaths().forEach((path) -> paths.add(path + "/**"));
paths.add(BASE_PATH);
paths.add(BASE_PATH + "/");
return paths;
}
}
}

View File

@@ -0,0 +1,161 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.reactive;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import io.netty.handler.ssl.SslProvider;
import io.netty.handler.ssl.util.InsecureTrustManagerFactory;
import reactor.core.publisher.Mono;
import reactor.netty.http.Http11SslContextSpec;
import reactor.netty.http.client.HttpClient;
import reactor.netty.tcp.SslProvider.GenericSslContextSpec;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.AccessLevel;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.util.Assert;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClient.RequestHeadersSpec;
import org.springframework.web.reactive.function.client.WebClientResponseException;
/**
* Reactive Cloud Foundry security service to handle REST calls to the cloud controller
* and UAA.
*
* @author Madhura Bhave
*/
class ReactiveCloudFoundrySecurityService {
private static final ParameterizedTypeReference<Map<String, Object>> STRING_OBJECT_MAP = new ParameterizedTypeReference<>() {
};
private final WebClient webClient;
private final String cloudControllerUrl;
ReactiveCloudFoundrySecurityService(WebClient.Builder webClientBuilder, String cloudControllerUrl,
boolean skipSslValidation) {
Assert.notNull(webClientBuilder, "'webClientBuilder' must not be null");
Assert.notNull(cloudControllerUrl, "'cloudControllerUrl' must not be null");
if (skipSslValidation) {
webClientBuilder.clientConnector(buildTrustAllSslConnector());
}
this.webClient = webClientBuilder.build();
this.cloudControllerUrl = cloudControllerUrl;
}
protected ReactorClientHttpConnector buildTrustAllSslConnector() {
HttpClient client = HttpClient.create().secure((spec) -> spec.sslContext(createSslContextSpec()));
return new ReactorClientHttpConnector(client);
}
private GenericSslContextSpec<?> createSslContextSpec() {
return Http11SslContextSpec.forClient()
.configure((builder) -> builder.sslProvider(SslProvider.JDK)
.trustManager(InsecureTrustManagerFactory.INSTANCE));
}
/**
* Return a Mono of the access level that should be granted to the given token.
* @param token the token
* @param applicationId the cloud foundry application ID
* @return a Mono of the access level that should be granted
* @throws CloudFoundryAuthorizationException if the token is not authorized
*/
Mono<AccessLevel> getAccessLevel(String token, String applicationId) throws CloudFoundryAuthorizationException {
String uri = getPermissionsUri(applicationId);
return this.webClient.get()
.uri(uri)
.header("Authorization", "bearer " + token)
.retrieve()
.bodyToMono(Map.class)
.map(this::getAccessLevel)
.onErrorMap(this::mapError);
}
private Throwable mapError(Throwable throwable) {
if (throwable instanceof WebClientResponseException webClientResponseException) {
HttpStatusCode statusCode = webClientResponseException.getStatusCode();
if (statusCode.equals(HttpStatus.FORBIDDEN)) {
return new CloudFoundryAuthorizationException(Reason.ACCESS_DENIED, "Access denied");
}
if (statusCode.is4xxClientError()) {
return new CloudFoundryAuthorizationException(Reason.INVALID_TOKEN, "Invalid token", throwable);
}
}
return new CloudFoundryAuthorizationException(Reason.SERVICE_UNAVAILABLE, "Cloud controller not reachable");
}
private AccessLevel getAccessLevel(Map<?, ?> body) {
if (Boolean.TRUE.equals(body.get("read_sensitive_data"))) {
return AccessLevel.FULL;
}
return AccessLevel.RESTRICTED;
}
private String getPermissionsUri(String applicationId) {
return this.cloudControllerUrl + "/v2/apps/" + applicationId + "/permissions";
}
/**
* Return a Mono of all token keys known by the UAA.
* @return a Mono of token keys
*/
Mono<Map<String, String>> fetchTokenKeys() {
return getUaaUrl().flatMap(this::fetchTokenKeys);
}
private Mono<? extends Map<String, String>> fetchTokenKeys(String url) {
RequestHeadersSpec<?> uri = this.webClient.get().uri(url + "/token_keys");
return uri.retrieve()
.bodyToMono(STRING_OBJECT_MAP)
.map(this::extractTokenKeys)
.onErrorMap(((ex) -> new CloudFoundryAuthorizationException(Reason.SERVICE_UNAVAILABLE, ex.getMessage())));
}
private Map<String, String> extractTokenKeys(Map<String, Object> response) {
Map<String, String> tokenKeys = new HashMap<>();
for (Object key : (List<?>) response.get("keys")) {
Map<?, ?> tokenKey = (Map<?, ?>) key;
tokenKeys.put((String) tokenKey.get("kid"), (String) tokenKey.get("value"));
}
return tokenKeys;
}
/**
* Return a Mono of URL of the UAA.
* @return the UAA url Mono
*/
Mono<String> getUaaUrl() {
return this.webClient.get()
.uri(this.cloudControllerUrl + "/info")
.retrieve()
.bodyToMono(Map.class)
.map((response) -> (String) response.get("token_endpoint"))
.cache()
.onErrorMap((ex) -> new CloudFoundryAuthorizationException(Reason.SERVICE_UNAVAILABLE,
"Unable to fetch token keys from UAA."));
}
}

View File

@@ -0,0 +1,144 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.reactive;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import reactor.core.publisher.Mono;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.Token;
/**
* Validator used to ensure that a signed {@link Token} has not been tampered with.
*
* @author Madhura Bhave
*/
class ReactiveTokenValidator {
private final ReactiveCloudFoundrySecurityService securityService;
private volatile Map<String, String> cachedTokenKeys = Collections.emptyMap();
ReactiveTokenValidator(ReactiveCloudFoundrySecurityService securityService) {
this.securityService = securityService;
}
Mono<Void> validate(Token token) {
return validateAlgorithm(token).then(validateKeyIdAndSignature(token))
.then(validateExpiry(token))
.then(validateIssuer(token))
.then(validateAudience(token));
}
private Mono<Void> validateAlgorithm(Token token) {
String algorithm = token.getSignatureAlgorithm();
if (algorithm == null) {
return Mono.error(new CloudFoundryAuthorizationException(Reason.INVALID_SIGNATURE,
"Signing algorithm cannot be null"));
}
if (!algorithm.equals("RS256")) {
return Mono.error(new CloudFoundryAuthorizationException(Reason.UNSUPPORTED_TOKEN_SIGNING_ALGORITHM,
"Signing algorithm " + algorithm + " not supported"));
}
return Mono.empty();
}
private Mono<Void> validateKeyIdAndSignature(Token token) {
return getTokenKey(token).filter((tokenKey) -> hasValidSignature(token, tokenKey))
.switchIfEmpty(Mono.error(new CloudFoundryAuthorizationException(Reason.INVALID_SIGNATURE,
"RSA Signature did not match content")))
.then();
}
private Mono<String> getTokenKey(Token token) {
String keyId = token.getKeyId();
String cached = this.cachedTokenKeys.get(keyId);
if (cached != null) {
return Mono.just(cached);
}
return this.securityService.fetchTokenKeys()
.doOnSuccess(this::cacheTokenKeys)
.filter((tokenKeys) -> tokenKeys.containsKey(keyId))
.map((tokenKeys) -> tokenKeys.get(keyId))
.switchIfEmpty(Mono.error(new CloudFoundryAuthorizationException(Reason.INVALID_KEY_ID,
"Key Id present in token header does not match")));
}
private void cacheTokenKeys(Map<String, String> tokenKeys) {
this.cachedTokenKeys = Map.copyOf(tokenKeys);
}
private boolean hasValidSignature(Token token, String key) {
try {
PublicKey publicKey = getPublicKey(key);
Signature signature = Signature.getInstance("SHA256withRSA");
signature.initVerify(publicKey);
signature.update(token.getContent());
return signature.verify(token.getSignature());
}
catch (GeneralSecurityException ex) {
return false;
}
}
private PublicKey getPublicKey(String key) throws NoSuchAlgorithmException, InvalidKeySpecException {
key = key.replace("-----BEGIN PUBLIC KEY-----\n", "");
key = key.replace("-----END PUBLIC KEY-----", "");
key = key.trim().replace("\n", "");
byte[] bytes = Base64.getDecoder().decode(key);
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(bytes);
return KeyFactory.getInstance("RSA").generatePublic(keySpec);
}
private Mono<Void> validateExpiry(Token token) {
long currentTime = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis());
if (currentTime > token.getExpiry()) {
return Mono.error(new CloudFoundryAuthorizationException(Reason.TOKEN_EXPIRED, "Token expired"));
}
return Mono.empty();
}
private Mono<Void> validateIssuer(Token token) {
return this.securityService.getUaaUrl()
.map((uaaUrl) -> String.format("%s/oauth/token", uaaUrl))
.filter((issuerUri) -> issuerUri.equals(token.getIssuer()))
.switchIfEmpty(Mono
.error(new CloudFoundryAuthorizationException(Reason.INVALID_ISSUER, "Token issuer does not match")))
.then();
}
private Mono<Void> validateAudience(Token token) {
if (!token.getScope().contains("actuator.read")) {
return Mono.error(new CloudFoundryAuthorizationException(Reason.INVALID_AUDIENCE,
"Token does not have audience actuator"));
}
return Mono.empty();
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.
*/
/**
* Auto-configuration for actuator Cloud Foundry concerns using WebFlux.
*/
package org.springframework.boot.cloudfoundry.actuate.autoconfigure.reactive;

View File

@@ -0,0 +1,197 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint;
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.info.InfoEndpointAutoConfiguration;
import org.springframework.boot.actuate.endpoint.ExposableEndpoint;
import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper;
import org.springframework.boot.actuate.endpoint.web.EndpointMapping;
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
import org.springframework.boot.actuate.endpoint.web.PathMappedEndpoints;
import org.springframework.boot.actuate.health.HealthEndpoint;
import org.springframework.boot.actuate.health.HealthEndpointWebExtension;
import org.springframework.boot.actuate.info.GitInfoContributor;
import org.springframework.boot.actuate.info.InfoContributor;
import org.springframework.boot.actuate.info.InfoEndpoint;
import org.springframework.boot.actuate.info.InfoPropertiesInfoContributor;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnCloudPlatform;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.cloud.CloudPlatform;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryWebEndpointDiscoverer;
import org.springframework.boot.info.GitProperties;
import org.springframework.boot.restclient.RestTemplateBuilder;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.WebSecurityConfigurer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.security.web.util.matcher.OrRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.servlet.DispatcherServlet;
/**
* {@link EnableAutoConfiguration Auto-configuration} to expose actuator endpoints for
* Cloud Foundry to use.
*
* @author Madhura Bhave
* @since 4.0.0
*/
@AutoConfiguration(after = { HealthEndpointAutoConfiguration.class, InfoEndpointAutoConfiguration.class },
afterName = "org.springframework.boot.servlet.actuate.autoconfigure.ServletManagementContextAutoConfiguration")
@ConditionalOnBooleanProperty(name = "management.cloudfoundry.enabled", matchIfMissing = true)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@ConditionalOnClass(DispatcherServlet.class)
@ConditionalOnBean(DispatcherServlet.class)
@ConditionalOnCloudPlatform(CloudPlatform.CLOUD_FOUNDRY)
public class CloudFoundryActuatorAutoConfiguration {
private static final String BASE_PATH = "/cloudfoundryapplication";
@Bean
@ConditionalOnMissingBean
@ConditionalOnAvailableEndpoint
@ConditionalOnBean({ HealthEndpoint.class, HealthEndpointWebExtension.class })
public CloudFoundryHealthEndpointWebExtension cloudFoundryHealthEndpointWebExtension(
HealthEndpointWebExtension healthEndpointWebExtension) {
return new CloudFoundryHealthEndpointWebExtension(healthEndpointWebExtension);
}
@Bean
@ConditionalOnMissingBean
@ConditionalOnAvailableEndpoint
@ConditionalOnBean({ InfoEndpoint.class, GitProperties.class })
public CloudFoundryInfoEndpointWebExtension cloudFoundryInfoEndpointWebExtension(GitProperties properties,
ObjectProvider<InfoContributor> infoContributors) {
List<InfoContributor> contributors = infoContributors.orderedStream()
.map((infoContributor) -> (infoContributor instanceof GitInfoContributor)
? new GitInfoContributor(properties, InfoPropertiesInfoContributor.Mode.FULL) : infoContributor)
.toList();
return new CloudFoundryInfoEndpointWebExtension(new InfoEndpoint(contributors));
}
@Bean
@SuppressWarnings("removal")
public CloudFoundryWebEndpointServletHandlerMapping cloudFoundryWebEndpointServletHandlerMapping(
ParameterValueMapper parameterMapper, EndpointMediaTypes endpointMediaTypes,
RestTemplateBuilder restTemplateBuilder,
org.springframework.boot.actuate.endpoint.web.annotation.ServletEndpointsSupplier servletEndpointsSupplier,
org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpointsSupplier controllerEndpointsSupplier,
ApplicationContext applicationContext) {
CloudFoundryWebEndpointDiscoverer discoverer = new CloudFoundryWebEndpointDiscoverer(applicationContext,
parameterMapper, endpointMediaTypes, null, Collections.emptyList(), Collections.emptyList(),
Collections.emptyList());
CloudFoundrySecurityInterceptor securityInterceptor = getSecurityInterceptor(restTemplateBuilder,
applicationContext.getEnvironment());
Collection<ExposableWebEndpoint> webEndpoints = discoverer.getEndpoints();
List<ExposableEndpoint<?>> allEndpoints = new ArrayList<>();
allEndpoints.addAll(webEndpoints);
allEndpoints.addAll(servletEndpointsSupplier.getEndpoints());
allEndpoints.addAll(controllerEndpointsSupplier.getEndpoints());
return new CloudFoundryWebEndpointServletHandlerMapping(new EndpointMapping(BASE_PATH), webEndpoints,
endpointMediaTypes, getCorsConfiguration(), securityInterceptor, allEndpoints);
}
private CloudFoundrySecurityInterceptor getSecurityInterceptor(RestTemplateBuilder restTemplateBuilder,
Environment environment) {
CloudFoundrySecurityService cloudfoundrySecurityService = getCloudFoundrySecurityService(restTemplateBuilder,
environment);
TokenValidator tokenValidator = new TokenValidator(cloudfoundrySecurityService);
return new CloudFoundrySecurityInterceptor(tokenValidator, cloudfoundrySecurityService,
environment.getProperty("vcap.application.application_id"));
}
private CloudFoundrySecurityService getCloudFoundrySecurityService(RestTemplateBuilder restTemplateBuilder,
Environment environment) {
String cloudControllerUrl = environment.getProperty("vcap.application.cf_api");
boolean skipSslValidation = environment.getProperty("management.cloudfoundry.skip-ssl-validation",
Boolean.class, false);
return (cloudControllerUrl != null)
? new CloudFoundrySecurityService(restTemplateBuilder, cloudControllerUrl, skipSslValidation) : null;
}
private CorsConfiguration getCorsConfiguration() {
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.addAllowedOrigin(CorsConfiguration.ALL);
corsConfiguration.setAllowedMethods(Arrays.asList(HttpMethod.GET.name(), HttpMethod.POST.name()));
corsConfiguration
.setAllowedHeaders(Arrays.asList(HttpHeaders.AUTHORIZATION, "X-Cf-App-Instance", HttpHeaders.CONTENT_TYPE));
return corsConfiguration;
}
/**
* {@link WebSecurityConfigurer} to tell Spring Security to permit cloudfoundry
* specific paths. The Cloud foundry endpoints are protected by their own security
* interceptor.
*/
@ConditionalOnClass({ WebSecurityCustomizer.class, WebSecurity.class })
@Configuration(proxyBeanMethods = false)
public static class IgnoredCloudFoundryPathsWebSecurityConfiguration {
private static final int FILTER_CHAIN_ORDER = -1;
@Bean
@Order(FILTER_CHAIN_ORDER)
SecurityFilterChain cloudFoundrySecurityFilterChain(HttpSecurity http,
CloudFoundryWebEndpointServletHandlerMapping handlerMapping) throws Exception {
RequestMatcher cloudFoundryRequest = getRequestMatcher(handlerMapping);
http.csrf((csrf) -> csrf.ignoringRequestMatchers(cloudFoundryRequest));
http.securityMatchers((matches) -> matches.requestMatchers(cloudFoundryRequest))
.authorizeHttpRequests((authorize) -> authorize.anyRequest().permitAll());
return http.build();
}
private RequestMatcher getRequestMatcher(CloudFoundryWebEndpointServletHandlerMapping handlerMapping) {
PathMappedEndpoints endpoints = new PathMappedEndpoints(BASE_PATH, handlerMapping::getAllEndpoints);
List<RequestMatcher> matchers = new ArrayList<>();
endpoints.getAllPaths().forEach((path) -> matchers.add(pathMatcher(path + "/**")));
matchers.add(pathMatcher(BASE_PATH));
matchers.add(pathMatcher(BASE_PATH + "/"));
return new OrRequestMatcher(matchers);
}
private PathPatternRequestMatcher pathMatcher(String path) {
return PathPatternRequestMatcher.withDefaults().matcher(path);
}
}
}

View File

@@ -0,0 +1,58 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet;
import org.springframework.boot.actuate.endpoint.ApiVersion;
import org.springframework.boot.actuate.endpoint.SecurityContext;
import org.springframework.boot.actuate.endpoint.annotation.EndpointExtension;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.endpoint.annotation.Selector;
import org.springframework.boot.actuate.endpoint.annotation.Selector.Match;
import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse;
import org.springframework.boot.actuate.health.HealthComponent;
import org.springframework.boot.actuate.health.HealthEndpoint;
import org.springframework.boot.actuate.health.HealthEndpointWebExtension;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.EndpointCloudFoundryExtension;
/**
* {@link EndpointExtension @EndpointExtension} for the {@link HealthEndpoint} that always
* exposes full health details.
*
* @author Madhura Bhave
* @since 4.0.0
*/
@EndpointCloudFoundryExtension(endpoint = HealthEndpoint.class)
public class CloudFoundryHealthEndpointWebExtension {
private final HealthEndpointWebExtension delegate;
public CloudFoundryHealthEndpointWebExtension(HealthEndpointWebExtension delegate) {
this.delegate = delegate;
}
@ReadOperation
public WebEndpointResponse<HealthComponent> health(ApiVersion apiVersion) {
return this.delegate.health(apiVersion, null, SecurityContext.NONE, true);
}
@ReadOperation
public WebEndpointResponse<HealthComponent> health(ApiVersion apiVersion,
@Selector(match = Match.ALL_REMAINING) String... path) {
return this.delegate.health(apiVersion, null, SecurityContext.NONE, true, path);
}
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet;
import java.util.Map;
import org.springframework.boot.actuate.endpoint.annotation.EndpointExtension;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.info.InfoEndpoint;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.EndpointCloudFoundryExtension;
/**
* {@link EndpointExtension @EndpointExtension} for the {@link InfoEndpoint} that always
* exposes full git details.
*
* @author Madhura Bhave
* @since 4.0.0
*/
@EndpointCloudFoundryExtension(endpoint = InfoEndpoint.class)
public class CloudFoundryInfoEndpointWebExtension {
private final InfoEndpoint delegate;
public CloudFoundryInfoEndpointWebExtension(InfoEndpoint delegate) {
this.delegate = delegate;
}
@ReadOperation
public Map<String, Object> info() {
return this.delegate.info();
}
}

View File

@@ -0,0 +1,109 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet;
import java.util.Locale;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.boot.actuate.endpoint.EndpointId;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.AccessLevel;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.SecurityResponse;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.Token;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.util.StringUtils;
import org.springframework.web.cors.CorsUtils;
/**
* Security interceptor to validate the cloud foundry token.
*
* @author Madhura Bhave
*/
class CloudFoundrySecurityInterceptor {
private static final Log logger = LogFactory.getLog(CloudFoundrySecurityInterceptor.class);
private final TokenValidator tokenValidator;
private final CloudFoundrySecurityService cloudFoundrySecurityService;
private final String applicationId;
private static final SecurityResponse SUCCESS = SecurityResponse.success();
CloudFoundrySecurityInterceptor(TokenValidator tokenValidator,
CloudFoundrySecurityService cloudFoundrySecurityService, String applicationId) {
this.tokenValidator = tokenValidator;
this.cloudFoundrySecurityService = cloudFoundrySecurityService;
this.applicationId = applicationId;
}
SecurityResponse preHandle(HttpServletRequest request, EndpointId endpointId) {
if (CorsUtils.isPreFlightRequest(request)) {
return SecurityResponse.success();
}
try {
if (!StringUtils.hasText(this.applicationId)) {
throw new CloudFoundryAuthorizationException(Reason.SERVICE_UNAVAILABLE,
"Application id is not available");
}
if (this.cloudFoundrySecurityService == null) {
throw new CloudFoundryAuthorizationException(Reason.SERVICE_UNAVAILABLE,
"Cloud controller URL is not available");
}
if (HttpMethod.OPTIONS.matches(request.getMethod())) {
return SUCCESS;
}
check(request, endpointId);
}
catch (Exception ex) {
logger.error(ex);
if (ex instanceof CloudFoundryAuthorizationException cfException) {
return new SecurityResponse(cfException.getStatusCode(),
"{\"security_error\":\"" + cfException.getMessage() + "\"}");
}
return new SecurityResponse(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage());
}
return SecurityResponse.success();
}
private void check(HttpServletRequest request, EndpointId endpointId) {
Token token = getToken(request);
this.tokenValidator.validate(token);
AccessLevel accessLevel = this.cloudFoundrySecurityService.getAccessLevel(token.toString(), this.applicationId);
if (!accessLevel.isAccessAllowed((endpointId != null) ? endpointId.toLowerCaseString() : "")) {
throw new CloudFoundryAuthorizationException(Reason.ACCESS_DENIED, "Access denied");
}
request.setAttribute(AccessLevel.REQUEST_ATTRIBUTE, accessLevel);
}
private Token getToken(HttpServletRequest request) {
String authorization = request.getHeader("Authorization");
String bearerPrefix = "bearer ";
if (authorization == null || !authorization.toLowerCase(Locale.ENGLISH).startsWith(bearerPrefix)) {
throw new CloudFoundryAuthorizationException(Reason.MISSING_AUTHORIZATION,
"Authorization header is missing or invalid");
}
return new Token(authorization.substring(bearerPrefix.length()));
}
}

View File

@@ -0,0 +1,138 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.AccessLevel;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
import org.springframework.boot.restclient.RestTemplateBuilder;
import org.springframework.http.HttpStatus;
import org.springframework.http.RequestEntity;
import org.springframework.util.Assert;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.RestTemplate;
/**
* Cloud Foundry security service to handle REST calls to the cloud controller and UAA.
*
* @author Madhura Bhave
*/
class CloudFoundrySecurityService {
private final RestTemplate restTemplate;
private final String cloudControllerUrl;
private String uaaUrl;
CloudFoundrySecurityService(RestTemplateBuilder restTemplateBuilder, String cloudControllerUrl,
boolean skipSslValidation) {
Assert.notNull(restTemplateBuilder, "'restTemplateBuilder' must not be null");
Assert.notNull(cloudControllerUrl, "'cloudControllerUrl' must not be null");
if (skipSslValidation) {
restTemplateBuilder = restTemplateBuilder.requestFactory(SkipSslVerificationHttpRequestFactory.class);
}
this.restTemplate = restTemplateBuilder.build();
this.cloudControllerUrl = cloudControllerUrl;
}
/**
* Return the access level that should be granted to the given token.
* @param token the token
* @param applicationId the cloud foundry application ID
* @return the access level that should be granted
* @throws CloudFoundryAuthorizationException if the token is not authorized
*/
AccessLevel getAccessLevel(String token, String applicationId) throws CloudFoundryAuthorizationException {
try {
URI uri = getPermissionsUri(applicationId);
RequestEntity<?> request = RequestEntity.get(uri).header("Authorization", "bearer " + token).build();
Map<?, ?> body = this.restTemplate.exchange(request, Map.class).getBody();
if (Boolean.TRUE.equals(body.get("read_sensitive_data"))) {
return AccessLevel.FULL;
}
return AccessLevel.RESTRICTED;
}
catch (HttpClientErrorException ex) {
if (ex.getStatusCode().equals(HttpStatus.FORBIDDEN)) {
throw new CloudFoundryAuthorizationException(Reason.ACCESS_DENIED, "Access denied");
}
throw new CloudFoundryAuthorizationException(Reason.INVALID_TOKEN, "Invalid token", ex);
}
catch (HttpServerErrorException ex) {
throw new CloudFoundryAuthorizationException(Reason.SERVICE_UNAVAILABLE, "Cloud controller not reachable");
}
}
private URI getPermissionsUri(String applicationId) {
try {
return new URI(this.cloudControllerUrl + "/v2/apps/" + applicationId + "/permissions");
}
catch (URISyntaxException ex) {
throw new IllegalStateException(ex);
}
}
/**
* Return all token keys known by the UAA.
* @return a map of token keys
*/
Map<String, String> fetchTokenKeys() {
try {
return extractTokenKeys(this.restTemplate.getForObject(getUaaUrl() + "/token_keys", Map.class));
}
catch (HttpStatusCodeException ex) {
throw new CloudFoundryAuthorizationException(Reason.SERVICE_UNAVAILABLE, "UAA not reachable");
}
}
private Map<String, String> extractTokenKeys(Map<?, ?> response) {
Map<String, String> tokenKeys = new HashMap<>();
for (Object key : (List<?>) response.get("keys")) {
Map<?, ?> tokenKey = (Map<?, ?>) key;
tokenKeys.put((String) tokenKey.get("kid"), (String) tokenKey.get("value"));
}
return tokenKeys;
}
/**
* Return the URL of the UAA.
* @return the UAA url
*/
String getUaaUrl() {
if (this.uaaUrl == null) {
try {
Map<?, ?> response = this.restTemplate.getForObject(this.cloudControllerUrl + "/info", Map.class);
this.uaaUrl = (String) response.get("token_endpoint");
}
catch (HttpStatusCodeException ex) {
throw new CloudFoundryAuthorizationException(Reason.SERVICE_UNAVAILABLE,
"Unable to fetch token keys from UAA");
}
}
return this.uaaUrl;
}
}

View File

@@ -0,0 +1,182 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.aot.hint.BindingReflectionHintsRegistrar;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.aot.hint.annotation.Reflective;
import org.springframework.aot.hint.annotation.ReflectiveRuntimeHintsRegistrar;
import org.springframework.boot.actuate.endpoint.EndpointId;
import org.springframework.boot.actuate.endpoint.ExposableEndpoint;
import org.springframework.boot.actuate.endpoint.web.EndpointLinksResolver;
import org.springframework.boot.actuate.endpoint.web.EndpointMapping;
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
import org.springframework.boot.actuate.endpoint.web.Link;
import org.springframework.boot.actuate.endpoint.web.WebOperation;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.AccessLevel;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.SecurityResponse;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet.CloudFoundryWebEndpointServletHandlerMapping.CloudFoundryWebEndpointServletHandlerMappingRuntimeHints;
import org.springframework.boot.webmvc.actuate.endpoint.web.AbstractWebMvcEndpointHandlerMapping;
import org.springframework.context.annotation.ImportRuntimeHints;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping;
/**
* A custom {@link RequestMappingInfoHandlerMapping} that makes web endpoints available on
* Cloud Foundry specific URLs over HTTP using Spring MVC.
*
* @author Madhura Bhave
* @author Phillip Webb
* @author Brian Clozel
*/
@ImportRuntimeHints(CloudFoundryWebEndpointServletHandlerMappingRuntimeHints.class)
class CloudFoundryWebEndpointServletHandlerMapping extends AbstractWebMvcEndpointHandlerMapping {
private static final Log logger = LogFactory.getLog(CloudFoundryWebEndpointServletHandlerMapping.class);
private final CloudFoundrySecurityInterceptor securityInterceptor;
private final EndpointLinksResolver linksResolver;
private final Collection<ExposableEndpoint<?>> allEndpoints;
CloudFoundryWebEndpointServletHandlerMapping(EndpointMapping endpointMapping,
Collection<ExposableWebEndpoint> endpoints, EndpointMediaTypes endpointMediaTypes,
CorsConfiguration corsConfiguration, CloudFoundrySecurityInterceptor securityInterceptor,
Collection<ExposableEndpoint<?>> allEndpoints) {
super(endpointMapping, endpoints, endpointMediaTypes, corsConfiguration, true);
this.securityInterceptor = securityInterceptor;
this.linksResolver = new EndpointLinksResolver(allEndpoints);
this.allEndpoints = allEndpoints;
}
@Override
protected ServletWebOperation wrapServletWebOperation(ExposableWebEndpoint endpoint, WebOperation operation,
ServletWebOperation servletWebOperation) {
return new SecureServletWebOperation(servletWebOperation, this.securityInterceptor, endpoint.getEndpointId());
}
@Override
protected LinksHandler getLinksHandler() {
return new CloudFoundryLinksHandler();
}
Collection<ExposableEndpoint<?>> getAllEndpoints() {
return this.allEndpoints;
}
class CloudFoundryLinksHandler implements LinksHandler {
@Override
@ResponseBody
@Reflective
public Map<String, Map<String, Link>> links(HttpServletRequest request, HttpServletResponse response) {
SecurityResponse securityResponse = CloudFoundryWebEndpointServletHandlerMapping.this.securityInterceptor
.preHandle(request, null);
if (!securityResponse.getStatus().equals(HttpStatus.OK)) {
sendFailureResponse(response, securityResponse);
}
AccessLevel accessLevel = (AccessLevel) request.getAttribute(AccessLevel.REQUEST_ATTRIBUTE);
Map<String, Link> filteredLinks = new LinkedHashMap<>();
if (accessLevel == null) {
return Collections.singletonMap("_links", filteredLinks);
}
Map<String, Link> links = CloudFoundryWebEndpointServletHandlerMapping.this.linksResolver
.resolveLinks(request.getRequestURL().toString());
filteredLinks = links.entrySet()
.stream()
.filter((e) -> e.getKey().equals("self") || accessLevel.isAccessAllowed(e.getKey()))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
return Collections.singletonMap("_links", filteredLinks);
}
@Override
public String toString() {
return "Actuator root web endpoint";
}
private void sendFailureResponse(HttpServletResponse response, SecurityResponse securityResponse) {
try {
response.sendError(securityResponse.getStatus().value(), securityResponse.getMessage());
}
catch (Exception ex) {
logger.debug("Failed to send error response", ex);
}
}
}
/**
* {@link ServletWebOperation} wrapper to add security.
*/
private static class SecureServletWebOperation implements ServletWebOperation {
private final ServletWebOperation delegate;
private final CloudFoundrySecurityInterceptor securityInterceptor;
private final EndpointId endpointId;
SecureServletWebOperation(ServletWebOperation delegate, CloudFoundrySecurityInterceptor securityInterceptor,
EndpointId endpointId) {
this.delegate = delegate;
this.securityInterceptor = securityInterceptor;
this.endpointId = endpointId;
}
@Override
public Object handle(HttpServletRequest request, Map<String, String> body) {
SecurityResponse securityResponse = this.securityInterceptor.preHandle(request, this.endpointId);
if (!securityResponse.getStatus().equals(HttpStatus.OK)) {
return new ResponseEntity<Object>(securityResponse.getMessage(), securityResponse.getStatus());
}
return this.delegate.handle(request, body);
}
}
static class CloudFoundryWebEndpointServletHandlerMappingRuntimeHints implements RuntimeHintsRegistrar {
private final ReflectiveRuntimeHintsRegistrar reflectiveRegistrar = new ReflectiveRuntimeHintsRegistrar();
private final BindingReflectionHintsRegistrar bindingRegistrar = new BindingReflectionHintsRegistrar();
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
this.reflectiveRegistrar.registerRuntimeHints(hints, CloudFoundryLinksHandler.class);
this.bindingRegistrar.registerReflectionHints(hints.reflection(), Link.class);
}
}
}

View File

@@ -0,0 +1,91 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
/**
* {@link SimpleClientHttpRequestFactory} that skips SSL certificate verification.
*
* @author Madhura Bhave
*/
class SkipSslVerificationHttpRequestFactory extends SimpleClientHttpRequestFactory {
@Override
protected void prepareConnection(HttpURLConnection connection, String httpMethod) throws IOException {
if (connection instanceof HttpsURLConnection httpsURLConnection) {
prepareHttpsConnection(httpsURLConnection);
}
super.prepareConnection(connection, httpMethod);
}
private void prepareHttpsConnection(HttpsURLConnection connection) {
connection.setHostnameVerifier(new SkipHostnameVerifier());
try {
connection.setSSLSocketFactory(createSslSocketFactory());
}
catch (Exception ex) {
// Ignore
}
}
private SSLSocketFactory createSslSocketFactory() throws Exception {
SSLContext context = SSLContext.getInstance("TLS");
context.init(null, new TrustManager[] { new SkipX509TrustManager() }, new SecureRandom());
return context.getSocketFactory();
}
private static final class SkipHostnameVerifier implements HostnameVerifier {
@Override
public boolean verify(String s, SSLSession sslSession) {
return true;
}
}
private static final class SkipX509TrustManager implements X509TrustManager {
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) {
}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) {
}
}
}

View File

@@ -0,0 +1,134 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.Token;
/**
* Validator used to ensure that a signed {@link Token} has not been tampered with.
*
* @author Madhura Bhave
*/
class TokenValidator {
private final CloudFoundrySecurityService securityService;
private Map<String, String> tokenKeys;
TokenValidator(CloudFoundrySecurityService cloudFoundrySecurityService) {
this.securityService = cloudFoundrySecurityService;
}
void validate(Token token) {
validateAlgorithm(token);
validateKeyIdAndSignature(token);
validateExpiry(token);
validateIssuer(token);
validateAudience(token);
}
private void validateAlgorithm(Token token) {
String algorithm = token.getSignatureAlgorithm();
if (algorithm == null) {
throw new CloudFoundryAuthorizationException(Reason.INVALID_SIGNATURE, "Signing algorithm cannot be null");
}
if (!algorithm.equals("RS256")) {
throw new CloudFoundryAuthorizationException(Reason.UNSUPPORTED_TOKEN_SIGNING_ALGORITHM,
"Signing algorithm " + algorithm + " not supported");
}
}
private void validateKeyIdAndSignature(Token token) {
String keyId = token.getKeyId();
if (this.tokenKeys == null || !hasValidKeyId(keyId)) {
this.tokenKeys = this.securityService.fetchTokenKeys();
if (!hasValidKeyId(keyId)) {
throw new CloudFoundryAuthorizationException(Reason.INVALID_KEY_ID,
"Key Id present in token header does not match");
}
}
if (!hasValidSignature(token, this.tokenKeys.get(keyId))) {
throw new CloudFoundryAuthorizationException(Reason.INVALID_SIGNATURE,
"RSA Signature did not match content");
}
}
private boolean hasValidKeyId(String tokenKey) {
return this.tokenKeys.containsKey(tokenKey);
}
private boolean hasValidSignature(Token token, String key) {
try {
PublicKey publicKey = getPublicKey(key);
Signature signature = Signature.getInstance("SHA256withRSA");
signature.initVerify(publicKey);
signature.update(token.getContent());
return signature.verify(token.getSignature());
}
catch (GeneralSecurityException ex) {
return false;
}
}
private PublicKey getPublicKey(String key) throws NoSuchAlgorithmException, InvalidKeySpecException {
key = key.replace("-----BEGIN PUBLIC KEY-----\n", "");
key = key.replace("-----END PUBLIC KEY-----", "");
key = key.trim().replace("\n", "");
byte[] bytes = Base64.getDecoder().decode(key);
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(bytes);
return KeyFactory.getInstance("RSA").generatePublic(keySpec);
}
private void validateExpiry(Token token) {
long currentTime = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis());
if (currentTime > token.getExpiry()) {
throw new CloudFoundryAuthorizationException(Reason.TOKEN_EXPIRED, "Token expired");
}
}
private void validateIssuer(Token token) {
String uaaUrl = this.securityService.getUaaUrl();
String issuerUri = String.format("%s/oauth/token", uaaUrl);
if (!issuerUri.equals(token.getIssuer())) {
throw new CloudFoundryAuthorizationException(Reason.INVALID_ISSUER,
"Token issuer does not match " + uaaUrl + "/oauth/token");
}
}
private void validateAudience(Token token) {
if (!token.getScope().contains("actuator.read")) {
throw new CloudFoundryAuthorizationException(Reason.INVALID_AUDIENCE,
"Token does not have audience actuator");
}
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.
*/
/**
* Auto-configuration for actuator Cloud Foundry concerns using Spring MVC.
*/
package org.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet;

View File

@@ -0,0 +1,3 @@
# Endpoint Exposure Outcome Contributors
org.springframework.boot.actuate.autoconfigure.endpoint.condition.EndpointExposureOutcomeContributor=\
org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryEndpointExposureOutcomeContributor

View File

@@ -0,0 +1,2 @@
org.springframework.boot.cloudfoundry.actuate.autoconfigure.reactive.ReactiveCloudFoundryActuatorAutoConfiguration
org.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet.CloudFoundryActuatorAutoConfiguration

View File

@@ -0,0 +1,54 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link AccessLevel}.
*
* @author Madhura Bhave
*/
class AccessLevelTests {
@Test
void accessToHealthEndpointShouldNotBeRestricted() {
assertThat(AccessLevel.RESTRICTED.isAccessAllowed("health")).isTrue();
assertThat(AccessLevel.FULL.isAccessAllowed("health")).isTrue();
}
@Test
void accessToInfoEndpointShouldNotBeRestricted() {
assertThat(AccessLevel.RESTRICTED.isAccessAllowed("info")).isTrue();
assertThat(AccessLevel.FULL.isAccessAllowed("info")).isTrue();
}
@Test
void accessToDiscoveryEndpointShouldNotBeRestricted() {
assertThat(AccessLevel.RESTRICTED.isAccessAllowed("")).isTrue();
assertThat(AccessLevel.FULL.isAccessAllowed("")).isTrue();
}
@Test
void accessToAnyOtherEndpointShouldBeRestricted() {
assertThat(AccessLevel.RESTRICTED.isAccessAllowed("env")).isFalse();
assertThat(AccessLevel.FULL.isAccessAllowed("")).isTrue();
}
}

View File

@@ -0,0 +1,84 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure;
import org.junit.jupiter.api.Test;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
import org.springframework.http.HttpStatus;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link CloudFoundryAuthorizationException}.
*
* @author Madhura Bhave
*/
class CloudFoundryAuthorizationExceptionTests {
@Test
void statusCodeForInvalidTokenReasonShouldBe401() {
assertThat(createException(Reason.INVALID_TOKEN).getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
void statusCodeForInvalidIssuerReasonShouldBe401() {
assertThat(createException(Reason.INVALID_ISSUER).getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
void statusCodeForInvalidAudienceReasonShouldBe401() {
assertThat(createException(Reason.INVALID_AUDIENCE).getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
void statusCodeForInvalidSignatureReasonShouldBe401() {
assertThat(createException(Reason.INVALID_SIGNATURE).getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
void statusCodeForMissingAuthorizationReasonShouldBe401() {
assertThat(createException(Reason.MISSING_AUTHORIZATION).getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
void statusCodeForUnsupportedSignatureAlgorithmReasonShouldBe401() {
assertThat(createException(Reason.UNSUPPORTED_TOKEN_SIGNING_ALGORITHM).getStatusCode())
.isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
void statusCodeForTokenExpiredReasonShouldBe401() {
assertThat(createException(Reason.TOKEN_EXPIRED).getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
void statusCodeForAccessDeniedReasonShouldBe403() {
assertThat(createException(Reason.ACCESS_DENIED).getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void statusCodeForServiceUnavailableReasonShouldBe503() {
assertThat(createException(Reason.SERVICE_UNAVAILABLE).getStatusCode())
.isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
}
private CloudFoundryAuthorizationException createException(Reason reason) {
return new CloudFoundryAuthorizationException(reason, "message");
}
}

View File

@@ -0,0 +1,110 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure;
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint;
import org.springframework.boot.actuate.endpoint.Access;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.convert.ApplicationConversionService;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link ConditionalOnAvailableEndpoint @ConditionalOnAvailableEndpoint} when
* running on Cloud Foundry.
*
* @author Brian Clozel
*/
class CloudFoundryConditionalOnAvailableEndpointTests {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withUserConfiguration(AllEndpointsConfiguration.class)
.withInitializer(
(context) -> context.getEnvironment().setConversionService(new ApplicationConversionService()));
@Test
void outcomeOnCloudFoundryShouldMatchAll() {
this.contextRunner.withPropertyValues("VCAP_APPLICATION:---")
.run((context) -> assertThat(context).hasBean("info").hasBean("health").hasBean("spring").hasBean("test"));
}
@Endpoint(id = "health")
static class HealthEndpoint {
}
@Endpoint(id = "info")
static class InfoEndpoint {
}
@Endpoint(id = "spring")
static class SpringEndpoint {
}
@Endpoint(id = "test")
static class TestEndpoint {
}
@Endpoint(id = "shutdown", defaultAccess = Access.NONE)
static class ShutdownEndpoint {
}
@Configuration(proxyBeanMethods = false)
static class AllEndpointsConfiguration {
@Bean
@ConditionalOnAvailableEndpoint
HealthEndpoint health() {
return new HealthEndpoint();
}
@Bean
@ConditionalOnAvailableEndpoint
InfoEndpoint info() {
return new InfoEndpoint();
}
@Bean
@ConditionalOnAvailableEndpoint
SpringEndpoint spring() {
return new SpringEndpoint();
}
@Bean
@ConditionalOnAvailableEndpoint
TestEndpoint test() {
return new TestEndpoint();
}
@Bean
@ConditionalOnAvailableEndpoint
ShutdownEndpoint shutdown() {
return new ShutdownEndpoint();
}
}
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure;
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.endpoint.annotation.DiscoveredEndpoint;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link CloudFoundryEndpointFilter}.
*
* @author Madhura Bhave
*/
class CloudFoundryEndpointFilterTests {
private final CloudFoundryEndpointFilter filter = new CloudFoundryEndpointFilter();
@Test
void matchIfDiscovererCloudFoundryShouldReturnFalse() {
DiscoveredEndpoint<?> endpoint = mock(DiscoveredEndpoint.class);
given(endpoint.wasDiscoveredBy(CloudFoundryWebEndpointDiscoverer.class)).willReturn(true);
assertThat(this.filter.match(endpoint)).isTrue();
}
@Test
void matchIfDiscovererNotCloudFoundryShouldReturnFalse() {
DiscoveredEndpoint<?> endpoint = mock(DiscoveredEndpoint.class);
given(endpoint.wasDiscoveredBy(CloudFoundryWebEndpointDiscoverer.class)).willReturn(false);
assertThat(this.filter.match(endpoint)).isFalse();
}
}

View File

@@ -0,0 +1,186 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure;
import java.util.Collection;
import java.util.Collections;
import java.util.function.Consumer;
import java.util.function.Function;
import org.junit.jupiter.api.Test;
import org.springframework.aot.hint.MemberCategory;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
import org.springframework.boot.actuate.endpoint.EndpointId;
import org.springframework.boot.actuate.endpoint.InvocationContext;
import org.springframework.boot.actuate.endpoint.SecurityContext;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.endpoint.invoke.convert.ConversionServiceParameterValueMapper;
import org.springframework.boot.actuate.endpoint.invoker.cache.CachingOperationInvokerAdvisor;
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
import org.springframework.boot.actuate.endpoint.web.PathMapper;
import org.springframework.boot.actuate.endpoint.web.WebOperation;
import org.springframework.boot.actuate.endpoint.web.annotation.EndpointWebExtension;
import org.springframework.boot.actuate.health.HealthContributorRegistry;
import org.springframework.boot.actuate.health.HealthEndpoint;
import org.springframework.boot.actuate.health.HealthEndpointGroups;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryWebEndpointDiscoverer.CloudFoundryWebEndpointDiscovererRuntimeHints;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.support.DefaultConversionService;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link CloudFoundryWebEndpointDiscoverer}.
*
* @author Madhura Bhave
* @author Moritz Halbritter
*/
class CloudFoundryWebEndpointDiscovererTests {
@Test
void getEndpointsShouldAddCloudFoundryHealthExtension() {
load(TestConfiguration.class, (discoverer) -> {
Collection<ExposableWebEndpoint> endpoints = discoverer.getEndpoints();
assertThat(endpoints).hasSize(2);
for (ExposableWebEndpoint endpoint : endpoints) {
if (endpoint.getEndpointId().equals(EndpointId.of("health"))) {
WebOperation operation = findMainReadOperation(endpoint);
assertThat(operation
.invoke(new InvocationContext(mock(SecurityContext.class), Collections.emptyMap())))
.isEqualTo("cf");
}
}
});
}
@Test
void shouldRegisterHints() {
RuntimeHints runtimeHints = new RuntimeHints();
new CloudFoundryWebEndpointDiscovererRuntimeHints().registerHints(runtimeHints, getClass().getClassLoader());
assertThat(RuntimeHintsPredicates.reflection()
.onType(CloudFoundryEndpointFilter.class)
.withMemberCategories(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS)).accepts(runtimeHints);
}
private WebOperation findMainReadOperation(ExposableWebEndpoint endpoint) {
for (WebOperation operation : endpoint.getOperations()) {
if (operation.getRequestPredicate().getPath().equals("health")) {
return operation;
}
}
throw new IllegalStateException("No main read operation found from " + endpoint.getOperations());
}
private void load(Class<?> configuration, Consumer<CloudFoundryWebEndpointDiscoverer> consumer) {
load((id) -> null, EndpointId::toString, configuration, consumer);
}
private void load(Function<EndpointId, Long> timeToLive, PathMapper endpointPathMapper, Class<?> configuration,
Consumer<CloudFoundryWebEndpointDiscoverer> consumer) {
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(configuration)) {
ConversionServiceParameterValueMapper parameterMapper = new ConversionServiceParameterValueMapper(
DefaultConversionService.getSharedInstance());
EndpointMediaTypes mediaTypes = new EndpointMediaTypes(Collections.singletonList("application/json"),
Collections.singletonList("application/json"));
CloudFoundryWebEndpointDiscoverer discoverer = new CloudFoundryWebEndpointDiscoverer(context,
parameterMapper, mediaTypes, Collections.singletonList(endpointPathMapper),
Collections.singleton(new CachingOperationInvokerAdvisor(timeToLive)), Collections.emptyList(),
Collections.emptyList());
consumer.accept(discoverer);
}
}
@Configuration(proxyBeanMethods = false)
static class TestConfiguration {
@Bean
TestEndpoint testEndpoint() {
return new TestEndpoint();
}
@Bean
TestEndpointWebExtension testEndpointWebExtension() {
return new TestEndpointWebExtension();
}
@Bean
HealthEndpoint healthEndpoint() {
HealthContributorRegistry registry = mock(HealthContributorRegistry.class);
HealthEndpointGroups groups = mock(HealthEndpointGroups.class);
return new HealthEndpoint(registry, groups, null);
}
@Bean
HealthEndpointWebExtension healthEndpointWebExtension() {
return new HealthEndpointWebExtension();
}
@Bean
TestHealthEndpointCloudFoundryExtension testHealthEndpointCloudFoundryExtension() {
return new TestHealthEndpointCloudFoundryExtension();
}
}
@Endpoint(id = "test")
static class TestEndpoint {
@ReadOperation
Object getAll() {
return null;
}
}
@EndpointWebExtension(endpoint = TestEndpoint.class)
static class TestEndpointWebExtension {
@ReadOperation
Object getAll() {
return null;
}
}
@EndpointWebExtension(endpoint = HealthEndpoint.class)
static class HealthEndpointWebExtension {
@ReadOperation
Object getAll() {
return null;
}
}
@EndpointCloudFoundryExtension(endpoint = HealthEndpoint.class)
static class TestHealthEndpointCloudFoundryExtension {
@ReadOperation
Object getAll() {
return "cf";
}
}
}

View File

@@ -0,0 +1,133 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure;
import java.util.Base64;
import java.util.function.Consumer;
import org.junit.jupiter.api.Test;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
/**
* Tests for {@link Token}.
*
* @author Madhura Bhave
*/
class TokenTests {
@Test
void invalidJwtShouldThrowException() {
assertThatExceptionOfType(CloudFoundryAuthorizationException.class).isThrownBy(() -> new Token("invalid-token"))
.satisfies(reasonRequirement(Reason.INVALID_TOKEN));
}
@Test
void invalidJwtClaimsShouldThrowException() {
String header = "{\"alg\": \"RS256\", \"kid\": \"key-id\", \"typ\": \"JWT\"}";
String claims = "invalid-claims";
assertThatExceptionOfType(CloudFoundryAuthorizationException.class)
.isThrownBy(() -> new Token(Base64.getEncoder().encodeToString(header.getBytes()) + "."
+ Base64.getEncoder().encodeToString(claims.getBytes())))
.satisfies(reasonRequirement(Reason.INVALID_TOKEN));
}
@Test
void invalidJwtHeaderShouldThrowException() {
String header = "invalid-header";
String claims = "{\"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\"}";
assertThatExceptionOfType(CloudFoundryAuthorizationException.class)
.isThrownBy(() -> new Token(Base64.getEncoder().encodeToString(header.getBytes()) + "."
+ Base64.getEncoder().encodeToString(claims.getBytes())))
.satisfies(reasonRequirement(Reason.INVALID_TOKEN));
}
@Test
void emptyJwtSignatureShouldThrowException() {
String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b3B0YWwu"
+ "Y29tIiwiZXhwIjoxNDI2NDIwODAwLCJhd2Vzb21lIjp0cnVlfQ.";
assertThatExceptionOfType(CloudFoundryAuthorizationException.class).isThrownBy(() -> new Token(token))
.satisfies(reasonRequirement(Reason.INVALID_TOKEN));
}
@Test
void validJwt() {
String header = "{\"alg\": \"RS256\", \"kid\": \"key-id\", \"typ\": \"JWT\"}";
String claims = "{\"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\"}";
String content = Base64.getEncoder().encodeToString(header.getBytes()) + "."
+ Base64.getEncoder().encodeToString(claims.getBytes());
String signature = Base64.getEncoder().encodeToString("signature".getBytes());
Token token = new Token(content + "." + signature);
assertThat(token.getExpiry()).isEqualTo(2147483647);
assertThat(token.getIssuer()).isEqualTo("http://localhost:8080/uaa/oauth/token");
assertThat(token.getSignatureAlgorithm()).isEqualTo("RS256");
assertThat(token.getKeyId()).isEqualTo("key-id");
assertThat(token.getContent()).isEqualTo(content.getBytes());
assertThat(token.getSignature()).isEqualTo(Base64.getDecoder().decode(signature));
}
@Test
void getSignatureAlgorithmWhenAlgIsNullShouldThrowException() {
String header = "{\"kid\": \"key-id\", \"typ\": \"JWT\"}";
String claims = "{\"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\"}";
Token token = createToken(header, claims);
assertThatExceptionOfType(CloudFoundryAuthorizationException.class).isThrownBy(token::getSignatureAlgorithm)
.satisfies(reasonRequirement(Reason.INVALID_TOKEN));
}
@Test
void getIssuerWhenIssIsNullShouldThrowException() {
String header = "{\"alg\": \"RS256\", \"kid\": \"key-id\", \"typ\": \"JWT\"}";
String claims = "{\"exp\": 2147483647}";
Token token = createToken(header, claims);
assertThatExceptionOfType(CloudFoundryAuthorizationException.class).isThrownBy(token::getIssuer)
.satisfies(reasonRequirement(Reason.INVALID_TOKEN));
}
@Test
void getKidWhenKidIsNullShouldThrowException() {
String header = "{\"alg\": \"RS256\", \"typ\": \"JWT\"}";
String claims = "{\"exp\": 2147483647}";
Token token = createToken(header, claims);
assertThatExceptionOfType(CloudFoundryAuthorizationException.class).isThrownBy(token::getKeyId)
.satisfies(reasonRequirement(Reason.INVALID_TOKEN));
}
@Test
void getExpiryWhenExpIsNullShouldThrowException() {
String header = "{\"alg\": \"RS256\", \"kid\": \"key-id\", \"typ\": \"JWT\"}";
String claims = "{\"iss\": \"http://localhost:8080/uaa/oauth/token\"}";
Token token = createToken(header, claims);
assertThatExceptionOfType(CloudFoundryAuthorizationException.class).isThrownBy(token::getExpiry)
.satisfies(reasonRequirement(Reason.INVALID_TOKEN));
}
private Token createToken(String header, String claims) {
Token token = new Token(Base64.getEncoder().encodeToString(header.getBytes()) + "."
+ Base64.getEncoder().encodeToString(claims.getBytes()) + "."
+ Base64.getEncoder().encodeToString("signature".getBytes()));
return token;
}
private Consumer<CloudFoundryAuthorizationException> reasonRequirement(Reason reason) {
return (ex) -> assertThat(ex.getReason()).isEqualTo(reason);
}
}

View File

@@ -0,0 +1,98 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.reactive;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.health.HealthContributorAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration;
import org.springframework.boot.actuate.endpoint.ApiVersion;
import org.springframework.boot.actuate.health.CompositeHealth;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthComponent;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
import org.springframework.boot.http.converter.autoconfigure.HttpMessageConvertersAutoConfiguration;
import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration;
import org.springframework.boot.security.autoconfigure.reactive.ReactiveSecurityAutoConfiguration;
import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner;
import org.springframework.boot.webclient.autoconfigure.WebClientAutoConfiguration;
import org.springframework.boot.webflux.autoconfigure.WebFluxAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.MapReactiveUserDetailsService;
import org.springframework.security.core.userdetails.User;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link CloudFoundryReactiveHealthEndpointWebExtension}.
*
* @author Madhura Bhave
*/
class CloudFoundryReactiveHealthEndpointWebExtensionTests {
private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner()
.withPropertyValues("VCAP_APPLICATION={}")
.withConfiguration(AutoConfigurations.of(ReactiveSecurityAutoConfiguration.class,
WebFluxAutoConfiguration.class, JacksonAutoConfiguration.class,
HttpMessageConvertersAutoConfiguration.class, PropertyPlaceholderAutoConfiguration.class,
ReactiveCloudFoundryActuatorAutoConfigurationTests.WebClientCustomizerConfig.class,
WebClientAutoConfiguration.class, ManagementContextAutoConfiguration.class,
EndpointAutoConfiguration.class, WebEndpointAutoConfiguration.class,
HealthContributorAutoConfiguration.class, HealthEndpointAutoConfiguration.class,
ReactiveCloudFoundryActuatorAutoConfiguration.class))
.withUserConfiguration(TestHealthIndicator.class, UserDetailsServiceConfiguration.class);
@Test
void healthComponentsAlwaysPresent() {
this.contextRunner.run((context) -> {
CloudFoundryReactiveHealthEndpointWebExtension extension = context
.getBean(CloudFoundryReactiveHealthEndpointWebExtension.class);
HealthComponent body = extension.health(ApiVersion.V3).block(Duration.ofSeconds(30)).getBody();
HealthComponent health = ((CompositeHealth) body).getComponents().entrySet().iterator().next().getValue();
assertThat(((Health) health).getDetails()).containsEntry("spring", "boot");
});
}
private static final class TestHealthIndicator implements HealthIndicator {
@Override
public Health health() {
return Health.up().withDetail("spring", "boot").build();
}
}
@Configuration(proxyBeanMethods = false)
static class UserDetailsServiceConfiguration {
@Bean
MapReactiveUserDetailsService userDetailsService() {
return new MapReactiveUserDetailsService(
User.withUsername("alice").password("secret").roles("admin").build());
}
}
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.reactive;
import org.junit.jupiter.api.Test;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
import org.springframework.boot.actuate.endpoint.web.Link;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.reactive.CloudFoundryWebFluxEndpointHandlerMapping.CloudFoundryLinksHandler;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.reactive.CloudFoundryWebFluxEndpointHandlerMapping.CloudFoundryWebFluxEndpointHandlerMappingRuntimeHints;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link CloudFoundryWebFluxEndpointHandlerMapping}.
*
* @author Moritz Halbritter
*/
class CloudFoundryWebFluxEndpointHandlerMappingTests {
@Test
void shouldRegisterHints() {
RuntimeHints runtimeHints = new RuntimeHints();
new CloudFoundryWebFluxEndpointHandlerMappingRuntimeHints().registerHints(runtimeHints,
getClass().getClassLoader());
assertThat(RuntimeHintsPredicates.reflection().onMethodInvocation(CloudFoundryLinksHandler.class, "links"))
.accepts(runtimeHints);
assertThat(RuntimeHintsPredicates.reflection().onType(Link.class)).accepts(runtimeHints);
}
}

View File

@@ -0,0 +1,351 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.reactive;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import org.springframework.boot.actuate.endpoint.ExposableEndpoint;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.endpoint.annotation.Selector;
import org.springframework.boot.actuate.endpoint.annotation.WriteOperation;
import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper;
import org.springframework.boot.actuate.endpoint.invoke.convert.ConversionServiceParameterValueMapper;
import org.springframework.boot.actuate.endpoint.web.EndpointMapping;
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
import org.springframework.boot.actuate.endpoint.web.annotation.WebEndpointDiscoverer;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.AccessLevel;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
import org.springframework.boot.reactor.netty.autoconfigure.NettyReactiveWebServerAutoConfiguration;
import org.springframework.boot.test.context.assertj.AssertableReactiveWebApplicationContext;
import org.springframework.boot.test.context.runner.ContextConsumer;
import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner;
import org.springframework.boot.web.server.reactive.context.AnnotationConfigReactiveWebServerApplicationContext;
import org.springframework.boot.webflux.autoconfigure.HttpHandlerAutoConfiguration;
import org.springframework.boot.webflux.autoconfigure.WebFluxAutoConfiguration;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.cors.CorsConfiguration;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.willThrow;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link CloudFoundryWebFluxEndpointHandlerMapping}.
*
* @author Madhura Bhave
* @author Stephane Nicoll
*/
class CloudFoundryWebFluxEndpointIntegrationTests {
private final ReactiveTokenValidator tokenValidator = mock(ReactiveTokenValidator.class);
private final ReactiveCloudFoundrySecurityService securityService = mock(ReactiveCloudFoundrySecurityService.class);
private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner(
AnnotationConfigReactiveWebServerApplicationContext::new)
.withConfiguration(AutoConfigurations.of(WebFluxAutoConfiguration.class, HttpHandlerAutoConfiguration.class,
NettyReactiveWebServerAutoConfiguration.class))
.withUserConfiguration(TestEndpointConfiguration.class)
.withBean(ReactiveTokenValidator.class, () -> this.tokenValidator)
.withBean(ReactiveCloudFoundrySecurityService.class, () -> this.securityService)
.withPropertyValues("server.port=0");
@Test
void operationWithSecurityInterceptorForbidden() {
given(this.tokenValidator.validate(any())).willReturn(Mono.empty());
given(this.securityService.getAccessLevel(any(), eq("app-id"))).willReturn(Mono.just(AccessLevel.RESTRICTED));
this.contextRunner.run(withWebTestClient((client) -> client.get()
.uri("/cfApplication/test")
.accept(MediaType.APPLICATION_JSON)
.header("Authorization", "bearer " + mockAccessToken())
.exchange()
.expectStatus()
.isEqualTo(HttpStatus.FORBIDDEN)));
}
@Test
void operationWithSecurityInterceptorSuccess() {
given(this.tokenValidator.validate(any())).willReturn(Mono.empty());
given(this.securityService.getAccessLevel(any(), eq("app-id"))).willReturn(Mono.just(AccessLevel.FULL));
this.contextRunner.run(withWebTestClient((client) -> client.get()
.uri("/cfApplication/test")
.accept(MediaType.APPLICATION_JSON)
.header("Authorization", "bearer " + mockAccessToken())
.exchange()
.expectStatus()
.isEqualTo(HttpStatus.OK)));
}
@Test
void responseToOptionsRequestIncludesCorsHeaders() {
this.contextRunner.run(withWebTestClient((client) -> client.options()
.uri("/cfApplication/test")
.accept(MediaType.APPLICATION_JSON)
.header("Access-Control-Request-Method", "POST")
.header("Origin", "https://example.com")
.exchange()
.expectStatus()
.isOk()
.expectHeader()
.valueEquals("Access-Control-Allow-Origin", "https://example.com")
.expectHeader()
.valueEquals("Access-Control-Allow-Methods", "GET,POST")));
}
@Test
void linksToOtherEndpointsWithFullAccess() {
given(this.tokenValidator.validate(any())).willReturn(Mono.empty());
given(this.securityService.getAccessLevel(any(), eq("app-id"))).willReturn(Mono.just(AccessLevel.FULL));
this.contextRunner.run(withWebTestClient((client) -> client.get()
.uri("/cfApplication")
.accept(MediaType.APPLICATION_JSON)
.header("Authorization", "bearer " + mockAccessToken())
.exchange()
.expectStatus()
.isOk()
.expectBody()
.jsonPath("_links.length()")
.isEqualTo(5)
.jsonPath("_links.self.href")
.isNotEmpty()
.jsonPath("_links.self.templated")
.isEqualTo(false)
.jsonPath("_links.info.href")
.isNotEmpty()
.jsonPath("_links.info.templated")
.isEqualTo(false)
.jsonPath("_links.env.href")
.isNotEmpty()
.jsonPath("_links.env.templated")
.isEqualTo(false)
.jsonPath("_links.test.href")
.isNotEmpty()
.jsonPath("_links.test.templated")
.isEqualTo(false)
.jsonPath("_links.test-part.href")
.isNotEmpty()
.jsonPath("_links.test-part.templated")
.isEqualTo(true)));
}
@Test
void linksToOtherEndpointsForbidden() {
CloudFoundryAuthorizationException exception = new CloudFoundryAuthorizationException(Reason.INVALID_TOKEN,
"invalid-token");
willThrow(exception).given(this.tokenValidator).validate(any());
this.contextRunner.run(withWebTestClient((client) -> client.get()
.uri("/cfApplication")
.accept(MediaType.APPLICATION_JSON)
.header("Authorization", "bearer " + mockAccessToken())
.exchange()
.expectStatus()
.isUnauthorized()));
}
@Test
void linksToOtherEndpointsWithRestrictedAccess() {
given(this.tokenValidator.validate(any())).willReturn(Mono.empty());
given(this.securityService.getAccessLevel(any(), eq("app-id"))).willReturn(Mono.just(AccessLevel.RESTRICTED));
this.contextRunner.run(withWebTestClient((client) -> client.get()
.uri("/cfApplication")
.accept(MediaType.APPLICATION_JSON)
.header("Authorization", "bearer " + mockAccessToken())
.exchange()
.expectStatus()
.isOk()
.expectBody()
.jsonPath("_links.length()")
.isEqualTo(2)
.jsonPath("_links.self.href")
.isNotEmpty()
.jsonPath("_links.self.templated")
.isEqualTo(false)
.jsonPath("_links.info.href")
.isNotEmpty()
.jsonPath("_links.info.templated")
.isEqualTo(false)
.jsonPath("_links.env")
.doesNotExist()
.jsonPath("_links.test")
.doesNotExist()
.jsonPath("_links.test-part")
.doesNotExist()));
}
private ContextConsumer<AssertableReactiveWebApplicationContext> withWebTestClient(
Consumer<WebTestClient> clientConsumer) {
return (context) -> {
int port = ((AnnotationConfigReactiveWebServerApplicationContext) context.getSourceApplicationContext())
.getWebServer()
.getPort();
clientConsumer.accept(WebTestClient.bindToServer()
.baseUrl("http://localhost:" + port)
.responseTimeout(Duration.ofMinutes(5))
.build());
};
}
private String mockAccessToken() {
return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b3B0YWwu"
+ "Y29tIiwiZXhwIjoxNDI2NDIwODAwLCJhd2Vzb21lIjp0cnVlfQ."
+ Base64.getEncoder().encodeToString("signature".getBytes());
}
@Configuration(proxyBeanMethods = false)
static class CloudFoundryReactiveConfiguration {
@Bean
CloudFoundrySecurityInterceptor interceptor(ReactiveTokenValidator tokenValidator,
ReactiveCloudFoundrySecurityService securityService) {
return new CloudFoundrySecurityInterceptor(tokenValidator, securityService, "app-id");
}
@Bean
EndpointMediaTypes EndpointMediaTypes() {
return new EndpointMediaTypes(Collections.singletonList("application/json"),
Collections.singletonList("application/json"));
}
@Bean
CloudFoundryWebFluxEndpointHandlerMapping cloudFoundryWebEndpointServletHandlerMapping(
WebEndpointDiscoverer webEndpointDiscoverer, EndpointMediaTypes endpointMediaTypes,
CloudFoundrySecurityInterceptor interceptor) {
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.setAllowedOrigins(Arrays.asList("https://example.com"));
corsConfiguration.setAllowedMethods(Arrays.asList("GET", "POST"));
Collection<ExposableWebEndpoint> webEndpoints = webEndpointDiscoverer.getEndpoints();
List<ExposableEndpoint<?>> allEndpoints = new ArrayList<>(webEndpoints);
return new CloudFoundryWebFluxEndpointHandlerMapping(new EndpointMapping("/cfApplication"), webEndpoints,
endpointMediaTypes, corsConfiguration, interceptor, allEndpoints);
}
@Bean
WebEndpointDiscoverer webEndpointDiscoverer(ApplicationContext applicationContext,
EndpointMediaTypes endpointMediaTypes) {
ParameterValueMapper parameterMapper = new ConversionServiceParameterValueMapper(
DefaultConversionService.getSharedInstance());
return new WebEndpointDiscoverer(applicationContext, parameterMapper, endpointMediaTypes, null, null,
Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
}
@Bean
EndpointDelegate endpointDelegate() {
return mock(EndpointDelegate.class);
}
}
@Endpoint(id = "test")
static class TestEndpoint {
private final EndpointDelegate endpointDelegate;
TestEndpoint(EndpointDelegate endpointDelegate) {
this.endpointDelegate = endpointDelegate;
}
@ReadOperation
Map<String, Object> readAll() {
return Collections.singletonMap("All", true);
}
@ReadOperation
Map<String, Object> readPart(@Selector String part) {
return Collections.singletonMap("part", part);
}
@WriteOperation
void write(String foo, String bar) {
this.endpointDelegate.write(foo, bar);
}
}
@Endpoint(id = "env")
static class TestEnvEndpoint {
@ReadOperation
Map<String, Object> readAll() {
return Collections.singletonMap("All", true);
}
}
@Endpoint(id = "info")
static class TestInfoEndpoint {
@ReadOperation
Map<String, Object> readAll() {
return Collections.singletonMap("All", true);
}
}
@Configuration(proxyBeanMethods = false)
@Import(CloudFoundryReactiveConfiguration.class)
static class TestEndpointConfiguration {
@Bean
TestEndpoint testEndpoint(EndpointDelegate endpointDelegate) {
return new TestEndpoint(endpointDelegate);
}
@Bean
TestInfoEndpoint testInfoEnvEndpoint() {
return new TestInfoEndpoint();
}
@Bean
TestEnvEndpoint testEnvEndpoint() {
return new TestEnvEndpoint();
}
}
interface EndpointDelegate {
void write();
void write(String foo, String bar);
}
}

View File

@@ -0,0 +1,396 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.reactive;
import java.io.IOException;
import java.time.Duration;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import javax.net.ssl.SSLException;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import org.assertj.core.api.InstanceOfAssertFactories;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import reactor.netty.http.HttpResources;
import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.health.HealthContributorAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.info.InfoContributorAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.info.InfoEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration;
import org.springframework.boot.actuate.endpoint.ApiVersion;
import org.springframework.boot.actuate.endpoint.EndpointId;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
import org.springframework.boot.actuate.endpoint.web.WebOperation;
import org.springframework.boot.actuate.endpoint.web.WebOperationRequestPredicate;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
import org.springframework.boot.autoconfigure.info.ProjectInfoAutoConfiguration;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet.CloudFoundryInfoEndpointWebExtension;
import org.springframework.boot.http.converter.autoconfigure.HttpMessageConvertersAutoConfiguration;
import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration;
import org.springframework.boot.security.autoconfigure.reactive.ReactiveSecurityAutoConfiguration;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.ssl.jks.JksSslStoreBundle;
import org.springframework.boot.ssl.jks.JksSslStoreDetails;
import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner;
import org.springframework.boot.testsupport.classpath.resources.WithPackageResources;
import org.springframework.boot.testsupport.classpath.resources.WithResource;
import org.springframework.boot.webclient.WebClientCustomizer;
import org.springframework.boot.webclient.autoconfigure.WebClientAutoConfiguration;
import org.springframework.boot.webflux.autoconfigure.WebFluxAutoConfiguration;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.security.core.userdetails.MapReactiveUserDetailsService;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.WebFilterChainProxy;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.reactive.function.client.WebClient;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link ReactiveCloudFoundryActuatorAutoConfiguration}.
*
* @author Madhura Bhave
* @author Moritz Halbritter
*/
class ReactiveCloudFoundryActuatorAutoConfigurationTests {
private static final String V2_JSON = ApiVersion.V2.getProducedMimeType().toString();
private static final String V3_JSON = ApiVersion.V3.getProducedMimeType().toString();
private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner()
.withConfiguration(
AutoConfigurations.of(ReactiveSecurityAutoConfiguration.class, WebFluxAutoConfiguration.class,
JacksonAutoConfiguration.class, HttpMessageConvertersAutoConfiguration.class,
PropertyPlaceholderAutoConfiguration.class, WebClientCustomizerConfig.class,
WebClientAutoConfiguration.class, ManagementContextAutoConfiguration.class,
EndpointAutoConfiguration.class, WebEndpointAutoConfiguration.class,
HealthContributorAutoConfiguration.class, HealthEndpointAutoConfiguration.class,
InfoContributorAutoConfiguration.class, InfoEndpointAutoConfiguration.class,
ProjectInfoAutoConfiguration.class, ReactiveCloudFoundryActuatorAutoConfiguration.class))
.withUserConfiguration(UserDetailsServiceConfiguration.class);
private static final String BASE_PATH = "/cloudfoundryapplication";
@AfterEach
void close() {
HttpResources.reset();
}
@Test
void cloudFoundryPlatformActive() {
this.contextRunner
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
"vcap.application.cf_api:https://my-cloud-controller.com")
.run((context) -> {
CloudFoundryWebFluxEndpointHandlerMapping handlerMapping = getHandlerMapping(context);
assertThat(handlerMapping).extracting("endpointMapping.path").isEqualTo("/cloudfoundryapplication");
assertThat(handlerMapping)
.extracting("corsConfiguration", InstanceOfAssertFactories.type(CorsConfiguration.class))
.satisfies((corsConfiguration) -> {
assertThat(corsConfiguration.getAllowedOrigins()).contains("*");
assertThat(corsConfiguration.getAllowedMethods())
.containsAll(Arrays.asList(HttpMethod.GET.name(), HttpMethod.POST.name()));
assertThat(corsConfiguration.getAllowedHeaders())
.containsAll(Arrays.asList("Authorization", "X-Cf-App-Instance", "Content-Type"));
});
});
}
@Test
void cloudfoundryapplicationProducesActuatorMediaType() {
this.contextRunner
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
"vcap.application.cf_api:https://my-cloud-controller.com")
.run((context) -> {
WebTestClient webTestClient = WebTestClient.bindToApplicationContext(context).build();
webTestClient.get().uri("/cloudfoundryapplication").header("Content-Type", V2_JSON + ";charset=UTF-8");
});
}
@Test
void cloudFoundryPlatformActiveSetsApplicationId() {
this.contextRunner
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
"vcap.application.cf_api:https://my-cloud-controller.com")
.run((context) -> assertThat(getHandlerMapping(context)).extracting("securityInterceptor.applicationId")
.isEqualTo("my-app-id"));
}
@Test
void cloudFoundryPlatformActiveSetsCloudControllerUrl() {
this.contextRunner
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
"vcap.application.cf_api:https://my-cloud-controller.com")
.run((context) -> assertThat(getHandlerMapping(context))
.extracting("securityInterceptor.cloudFoundrySecurityService.cloudControllerUrl")
.isEqualTo("https://my-cloud-controller.com"));
}
@Test
void cloudFoundryPlatformActiveAndCloudControllerUrlNotPresent() {
this.contextRunner.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id")
.run((context) -> assertThat(context.getBean("cloudFoundryWebFluxEndpointHandlerMapping",
CloudFoundryWebFluxEndpointHandlerMapping.class))
.extracting("securityInterceptor.cloudFoundrySecurityService")
.isNull());
}
@Test
@SuppressWarnings("unchecked")
void cloudFoundryPathsIgnoredBySpringSecurity() {
this.contextRunner.withBean(TestEndpoint.class, TestEndpoint::new)
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
"vcap.application.cf_api:https://my-cloud-controller.com")
.run((context) -> {
assertThat(context.getBean(WebFilterChainProxy.class))
.extracting("filters", InstanceOfAssertFactories.list(SecurityWebFilterChain.class))
.satisfies((filters) -> {
Boolean cfBaseRequestMatches = getMatches(filters, BASE_PATH);
Boolean cfBaseWithTrailingSlashRequestMatches = getMatches(filters, BASE_PATH + "/");
Boolean cfRequestMatches = getMatches(filters, BASE_PATH + "/test");
Boolean cfRequestWithAdditionalPathMatches = getMatches(filters, BASE_PATH + "/test/a");
Boolean otherCfRequestMatches = getMatches(filters, BASE_PATH + "/other-path");
Boolean otherRequestMatches = getMatches(filters, "/some-other-path");
assertThat(cfBaseRequestMatches).isTrue();
assertThat(cfBaseWithTrailingSlashRequestMatches).isTrue();
assertThat(cfRequestMatches).isTrue();
assertThat(cfRequestWithAdditionalPathMatches).isTrue();
assertThat(otherCfRequestMatches).isFalse();
assertThat(otherRequestMatches).isFalse();
otherRequestMatches = filters.get(1)
.matches(MockServerWebExchange.from(MockServerHttpRequest.get("/some-other-path").build()))
.block(Duration.ofSeconds(30));
assertThat(otherRequestMatches).isTrue();
});
});
}
private static Boolean getMatches(List<? extends SecurityWebFilterChain> filters, String urlTemplate) {
return filters.get(0)
.matches(MockServerWebExchange.from(MockServerHttpRequest.get(urlTemplate).build()))
.block(Duration.ofSeconds(30));
}
@Test
void cloudFoundryPlatformInactive() {
this.contextRunner
.run((context) -> assertThat(context.containsBean("cloudFoundryWebFluxEndpointHandlerMapping")).isFalse());
}
@Test
void cloudFoundryManagementEndpointsDisabled() {
this.contextRunner.withPropertyValues("VCAP_APPLICATION=---", "management.cloudfoundry.enabled:false")
.run((context) -> assertThat(context.containsBean("cloudFoundryWebFluxEndpointHandlerMapping")).isFalse());
}
@Test
void allEndpointsAvailableUnderCloudFoundryWithoutEnablingWebIncludes() {
this.contextRunner.withBean(TestEndpoint.class, TestEndpoint::new)
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
"vcap.application.cf_api:https://my-cloud-controller.com")
.run((context) -> {
CloudFoundryWebFluxEndpointHandlerMapping handlerMapping = getHandlerMapping(context);
Collection<ExposableWebEndpoint> endpoints = handlerMapping.getEndpoints();
List<EndpointId> endpointIds = endpoints.stream().map(ExposableWebEndpoint::getEndpointId).toList();
assertThat(endpointIds).contains(EndpointId.of("test"));
});
}
@Test
void endpointPathCustomizationIsNotApplied() {
this.contextRunner.withBean(TestEndpoint.class, TestEndpoint::new)
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
"vcap.application.cf_api:https://my-cloud-controller.com")
.run((context) -> {
CloudFoundryWebFluxEndpointHandlerMapping handlerMapping = getHandlerMapping(context);
Collection<ExposableWebEndpoint> endpoints = handlerMapping.getEndpoints();
ExposableWebEndpoint endpoint = endpoints.stream()
.filter((candidate) -> EndpointId.of("test").equals(candidate.getEndpointId()))
.findFirst()
.get();
assertThat(endpoint.getOperations()).hasSize(1);
WebOperation operation = endpoint.getOperations().iterator().next();
assertThat(operation.getRequestPredicate().getPath()).isEqualTo("test");
});
}
@Test
void healthEndpointInvokerShouldBeCloudFoundryWebExtension() {
this.contextRunner.withConfiguration(AutoConfigurations.of(HealthEndpointAutoConfiguration.class))
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
"vcap.application.cf_api:https://my-cloud-controller.com")
.run((context) -> {
Collection<ExposableWebEndpoint> endpoints = getHandlerMapping(context).getEndpoints();
ExposableWebEndpoint endpoint = endpoints.iterator().next();
assertThat(endpoint.getOperations()).hasSize(2);
WebOperation webOperation = findOperationWithRequestPath(endpoint, "health");
assertThat(webOperation).extracting("invoker")
.extracting("target")
.isInstanceOf(CloudFoundryReactiveHealthEndpointWebExtension.class);
});
}
@Test
@WithResource(name = "git.properties", content = """
#Generated by Git-Commit-Id-Plugin
#Thu May 23 09:26:42 BST 2013
git.commit.id.abbrev=e02a4f3
git.commit.user.email=dsyer@vmware.com
git.commit.message.full=Update Spring
git.commit.id=e02a4f3b6f452cdbf6dd311f1362679eb4c31ced
git.commit.message.short=Update Spring
git.commit.user.name=Dave Syer
git.build.user.name=Dave Syer
git.build.user.email=dsyer@vmware.com
git.branch=develop
git.commit.time=2013-04-24T08\\:42\\:13+0100
git.build.time=2013-05-23T09\\:26\\:42+0100
""")
@SuppressWarnings("unchecked")
void gitFullDetailsAlwaysPresent() {
this.contextRunner.withPropertyValues("VCAP_APPLICATION:---").run((context) -> {
CloudFoundryInfoEndpointWebExtension extension = context
.getBean(CloudFoundryInfoEndpointWebExtension.class);
Map<String, Object> git = (Map<String, Object>) extension.info().get("git");
Map<String, Object> commit = (Map<String, Object>) git.get("commit");
assertThat(commit).hasSize(4);
});
}
@Test
@WithPackageResources("test.jks")
void skipSslValidation() throws IOException {
JksSslStoreDetails keyStoreDetails = new JksSslStoreDetails("JKS", null, "classpath:test.jks", "secret");
SslBundle sslBundle = SslBundle.of(new JksSslStoreBundle(keyStoreDetails, keyStoreDetails));
try (MockWebServer server = new MockWebServer()) {
server.useHttps(sslBundle.createSslContext().getSocketFactory(), false);
server.enqueue(new MockResponse().setResponseCode(204));
server.start();
this.contextRunner.withConfiguration(AutoConfigurations.of(HealthEndpointAutoConfiguration.class))
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
"vcap.application.cf_api:https://my-cloud-controller.com",
"management.cloudfoundry.skip-ssl-validation:true")
.run((context) -> assertThat(getHandlerMapping(context))
.extracting("securityInterceptor.cloudFoundrySecurityService.webClient",
InstanceOfAssertFactories.type(WebClient.class))
.satisfies((webClient) -> {
ResponseEntity<Void> response = webClient.get()
.uri(server.url("/").uri())
.retrieve()
.toBodilessEntity()
.block(Duration.ofSeconds(30));
assertThat(response.getStatusCode()).isEqualTo(HttpStatusCode.valueOf(204));
}));
}
}
@Test
@WithPackageResources("test.jks")
void sslValidationNotSkippedByDefault() throws IOException {
JksSslStoreDetails keyStoreDetails = new JksSslStoreDetails("JKS", null, "classpath:test.jks", "secret");
SslBundle sslBundle = SslBundle.of(new JksSslStoreBundle(keyStoreDetails, keyStoreDetails));
try (MockWebServer server = new MockWebServer()) {
server.useHttps(sslBundle.createSslContext().getSocketFactory(), false);
server.enqueue(new MockResponse().setResponseCode(204));
server.start();
this.contextRunner.withConfiguration(AutoConfigurations.of(HealthEndpointAutoConfiguration.class))
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
"vcap.application.cf_api:https://my-cloud-controller.com")
.run((context) -> assertThat(getHandlerMapping(context))
.extracting("securityInterceptor.cloudFoundrySecurityService.webClient",
InstanceOfAssertFactories.type(WebClient.class))
.satisfies((webClient) -> assertThatExceptionOfType(RuntimeException.class)
.isThrownBy(() -> webClient.get()
.uri(server.url("/").uri())
.retrieve()
.toBodilessEntity()
.block(Duration.ofSeconds(30)))
.withCauseInstanceOf(SSLException.class)));
}
}
private CloudFoundryWebFluxEndpointHandlerMapping getHandlerMapping(ApplicationContext context) {
return context.getBean("cloudFoundryWebFluxEndpointHandlerMapping",
CloudFoundryWebFluxEndpointHandlerMapping.class);
}
private WebOperation findOperationWithRequestPath(ExposableWebEndpoint endpoint, String requestPath) {
for (WebOperation operation : endpoint.getOperations()) {
WebOperationRequestPredicate predicate = operation.getRequestPredicate();
if (predicate.getPath().equals(requestPath) && predicate.getProduces().contains(V3_JSON)) {
return operation;
}
}
throw new IllegalStateException(
"No operation found with request path " + requestPath + " from " + endpoint.getOperations());
}
@Endpoint(id = "test")
static class TestEndpoint {
@ReadOperation
String hello() {
return "hello world";
}
}
@Configuration(proxyBeanMethods = false)
static class WebClientCustomizerConfig {
@Bean
WebClientCustomizer webClientCustomizer() {
return mock(WebClientCustomizer.class);
}
}
@Configuration(proxyBeanMethods = false)
static class UserDetailsServiceConfiguration {
@Bean
MapReactiveUserDetailsService userDetailsService() {
return new MapReactiveUserDetailsService(
User.withUsername("alice").password("secret").roles("admin").build());
}
}
}

View File

@@ -0,0 +1,169 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.reactive;
import java.time.Duration;
import java.util.Base64;
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.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.AccessLevel;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
/**
* Tests for {@link CloudFoundrySecurityInterceptor}.
*
* @author Madhura Bhave
*/
@ExtendWith(MockitoExtension.class)
class ReactiveCloudFoundrySecurityInterceptorTests {
@Mock
private ReactiveTokenValidator tokenValidator;
@Mock
private ReactiveCloudFoundrySecurityService securityService;
private CloudFoundrySecurityInterceptor interceptor;
@BeforeEach
void setup() {
this.interceptor = new CloudFoundrySecurityInterceptor(this.tokenValidator, this.securityService, "my-app-id");
}
@Test
void preHandleWhenRequestIsPreFlightShouldBeOk() {
MockServerWebExchange request = MockServerWebExchange.from(MockServerHttpRequest.options("/a")
.header(HttpHeaders.ORIGIN, "https://example.com")
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
.build());
StepVerifier.create(this.interceptor.preHandle(request, "/a"))
.consumeNextWith((response) -> assertThat(response.getStatus()).isEqualTo(HttpStatus.OK))
.expectComplete()
.verify(Duration.ofSeconds(30));
}
@Test
void preHandleWhenTokenIsMissingShouldReturnMissingAuthorization() {
MockServerWebExchange request = MockServerWebExchange.from(MockServerHttpRequest.get("/a").build());
StepVerifier.create(this.interceptor.preHandle(request, "/a"))
.consumeNextWith(
(response) -> assertThat(response.getStatus()).isEqualTo(Reason.MISSING_AUTHORIZATION.getStatus()))
.expectComplete()
.verify(Duration.ofSeconds(30));
}
@Test
void preHandleWhenTokenIsNotBearerShouldReturnMissingAuthorization() {
MockServerWebExchange request = MockServerWebExchange
.from(MockServerHttpRequest.get("/a").header(HttpHeaders.AUTHORIZATION, mockAccessToken()).build());
StepVerifier.create(this.interceptor.preHandle(request, "/a"))
.consumeNextWith(
(response) -> assertThat(response.getStatus()).isEqualTo(Reason.MISSING_AUTHORIZATION.getStatus()))
.expectComplete()
.verify(Duration.ofSeconds(30));
}
@Test
void preHandleWhenApplicationIdIsNullShouldReturnError() {
this.interceptor = new CloudFoundrySecurityInterceptor(this.tokenValidator, this.securityService, null);
MockServerWebExchange request = MockServerWebExchange.from(MockServerHttpRequest.get("/a")
.header(HttpHeaders.AUTHORIZATION, "bearer " + mockAccessToken())
.build());
StepVerifier.create(this.interceptor.preHandle(request, "/a"))
.consumeErrorWith((ex) -> assertThat(((CloudFoundryAuthorizationException) ex).getReason())
.isEqualTo(Reason.SERVICE_UNAVAILABLE))
.verify();
}
@Test
void preHandleWhenCloudFoundrySecurityServiceIsNullShouldReturnError() {
this.interceptor = new CloudFoundrySecurityInterceptor(this.tokenValidator, null, "my-app-id");
MockServerWebExchange request = MockServerWebExchange
.from(MockServerHttpRequest.get("/a").header(HttpHeaders.AUTHORIZATION, mockAccessToken()).build());
StepVerifier.create(this.interceptor.preHandle(request, "/a"))
.consumeErrorWith((ex) -> assertThat(((CloudFoundryAuthorizationException) ex).getReason())
.isEqualTo(Reason.SERVICE_UNAVAILABLE))
.verify();
}
@Test
void preHandleWhenAccessIsNotAllowedShouldReturnAccessDenied() {
given(this.securityService.getAccessLevel(mockAccessToken(), "my-app-id"))
.willReturn(Mono.just(AccessLevel.RESTRICTED));
given(this.tokenValidator.validate(any())).willReturn(Mono.empty());
MockServerWebExchange request = MockServerWebExchange.from(MockServerHttpRequest.get("/a")
.header(HttpHeaders.AUTHORIZATION, "bearer " + mockAccessToken())
.build());
StepVerifier.create(this.interceptor.preHandle(request, "/a"))
.consumeNextWith((response) -> assertThat(response.getStatus()).isEqualTo(Reason.ACCESS_DENIED.getStatus()))
.expectComplete()
.verify(Duration.ofSeconds(30));
}
@Test
void preHandleSuccessfulWithFullAccess() {
String accessToken = mockAccessToken();
given(this.securityService.getAccessLevel(accessToken, "my-app-id")).willReturn(Mono.just(AccessLevel.FULL));
given(this.tokenValidator.validate(any())).willReturn(Mono.empty());
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/a")
.header(HttpHeaders.AUTHORIZATION, "bearer " + mockAccessToken())
.build());
StepVerifier.create(this.interceptor.preHandle(exchange, "/a")).consumeNextWith((response) -> {
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK);
assertThat((AccessLevel) exchange.getAttribute("cloudFoundryAccessLevel")).isEqualTo(AccessLevel.FULL);
}).expectComplete().verify(Duration.ofSeconds(30));
}
@Test
void preHandleSuccessfulWithRestrictedAccess() {
String accessToken = mockAccessToken();
given(this.securityService.getAccessLevel(accessToken, "my-app-id"))
.willReturn(Mono.just(AccessLevel.RESTRICTED));
given(this.tokenValidator.validate(any())).willReturn(Mono.empty());
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/info")
.header(HttpHeaders.AUTHORIZATION, "bearer " + mockAccessToken())
.build());
StepVerifier.create(this.interceptor.preHandle(exchange, "info")).consumeNextWith((response) -> {
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK);
assertThat((AccessLevel) exchange.getAttribute("cloudFoundryAccessLevel"))
.isEqualTo(AccessLevel.RESTRICTED);
}).expectComplete().verify(Duration.ofSeconds(30));
}
private String mockAccessToken() {
return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b3B0YWwu"
+ "Y29tIiwiZXhwIjoxNDI2NDIwODAwLCJhd2Vzb21lIjp0cnVlfQ."
+ Base64.getEncoder().encodeToString("signature".getBytes());
}
}

View File

@@ -0,0 +1,245 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.reactive;
import java.util.function.Consumer;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import reactor.test.StepVerifier;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.AccessLevel;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
import org.springframework.http.HttpHeaders;
import org.springframework.web.reactive.function.client.WebClient;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link ReactiveCloudFoundrySecurityService}.
*
* @author Madhura Bhave
*/
class ReactiveCloudFoundrySecurityServiceTests {
private static final String CLOUD_CONTROLLER = "/my-cloud-controller.com";
private static final String CLOUD_CONTROLLER_PERMISSIONS = CLOUD_CONTROLLER + "/v2/apps/my-app-id/permissions";
private static final String UAA_URL = "https://my-cloud-controller.com/uaa";
private ReactiveCloudFoundrySecurityService securityService;
private MockWebServer server;
@BeforeEach
void setup() {
this.server = new MockWebServer();
WebClient.Builder builder = WebClient.builder().baseUrl(this.server.url("/").toString());
this.securityService = new ReactiveCloudFoundrySecurityService(builder, CLOUD_CONTROLLER, false);
}
@AfterEach
void shutdown() throws Exception {
this.server.shutdown();
}
@Test
void getAccessLevelWhenSpaceDeveloperShouldReturnFull() throws Exception {
String responseBody = "{\"read_sensitive_data\": true,\"read_basic_data\": true}";
prepareResponse((response) -> response.setBody(responseBody).setHeader("Content-Type", "application/json"));
StepVerifier.create(this.securityService.getAccessLevel("my-access-token", "my-app-id"))
.consumeNextWith((accessLevel) -> assertThat(accessLevel).isEqualTo(AccessLevel.FULL))
.expectComplete()
.verify();
expectRequest((request) -> {
assertThat(request.getHeader(HttpHeaders.AUTHORIZATION)).isEqualTo("bearer my-access-token");
assertThat(request.getPath()).isEqualTo(CLOUD_CONTROLLER_PERMISSIONS);
});
}
@Test
void getAccessLevelWhenNotSpaceDeveloperShouldReturnRestricted() throws Exception {
String responseBody = "{\"read_sensitive_data\": false,\"read_basic_data\": true}";
prepareResponse((response) -> response.setBody(responseBody).setHeader("Content-Type", "application/json"));
StepVerifier.create(this.securityService.getAccessLevel("my-access-token", "my-app-id"))
.consumeNextWith((accessLevel) -> assertThat(accessLevel).isEqualTo(AccessLevel.RESTRICTED))
.expectComplete()
.verify();
expectRequest((request) -> {
assertThat(request.getHeader(HttpHeaders.AUTHORIZATION)).isEqualTo("bearer my-access-token");
assertThat(request.getPath()).isEqualTo(CLOUD_CONTROLLER_PERMISSIONS);
});
}
@Test
void getAccessLevelWhenTokenIsNotValidShouldThrowException() throws Exception {
prepareResponse((response) -> response.setResponseCode(401));
StepVerifier.create(this.securityService.getAccessLevel("my-access-token", "my-app-id"))
.consumeErrorWith((throwable) -> {
assertThat(throwable).isInstanceOf(CloudFoundryAuthorizationException.class);
assertThat(((CloudFoundryAuthorizationException) throwable).getReason())
.isEqualTo(Reason.INVALID_TOKEN);
})
.verify();
expectRequest((request) -> {
assertThat(request.getHeader(HttpHeaders.AUTHORIZATION)).isEqualTo("bearer my-access-token");
assertThat(request.getPath()).isEqualTo(CLOUD_CONTROLLER_PERMISSIONS);
});
}
@Test
void getAccessLevelWhenForbiddenShouldThrowException() throws Exception {
prepareResponse((response) -> response.setResponseCode(403));
StepVerifier.create(this.securityService.getAccessLevel("my-access-token", "my-app-id"))
.consumeErrorWith((throwable) -> {
assertThat(throwable).isInstanceOf(CloudFoundryAuthorizationException.class);
assertThat(((CloudFoundryAuthorizationException) throwable).getReason())
.isEqualTo(Reason.ACCESS_DENIED);
})
.verify();
expectRequest((request) -> {
assertThat(request.getHeader(HttpHeaders.AUTHORIZATION)).isEqualTo("bearer my-access-token");
assertThat(request.getPath()).isEqualTo(CLOUD_CONTROLLER_PERMISSIONS);
});
}
@Test
void getAccessLevelWhenCloudControllerIsNotReachableThrowsException() throws Exception {
prepareResponse((response) -> response.setResponseCode(500));
StepVerifier.create(this.securityService.getAccessLevel("my-access-token", "my-app-id"))
.consumeErrorWith((throwable) -> {
assertThat(throwable).isInstanceOf(CloudFoundryAuthorizationException.class);
assertThat(((CloudFoundryAuthorizationException) throwable).getReason())
.isEqualTo(Reason.SERVICE_UNAVAILABLE);
})
.verify();
expectRequest((request) -> {
assertThat(request.getHeader(HttpHeaders.AUTHORIZATION)).isEqualTo("bearer my-access-token");
assertThat(request.getPath()).isEqualTo(CLOUD_CONTROLLER_PERMISSIONS);
});
}
@Test
void fetchTokenKeysWhenSuccessfulShouldReturnListOfKeysFromUAA() throws Exception {
String tokenKeyValue = """
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0m59l2u9iDnMbrXHfqkO
rn2dVQ3vfBJqcDuFUK03d+1PZGbVlNCqnkpIJ8syFppW8ljnWweP7+LiWpRoz0I7
fYb3d8TjhV86Y997Fl4DBrxgM6KTJOuE/uxnoDhZQ14LgOU2ckXjOzOdTsnGMKQB
LCl0vpcXBtFLMaSbpv1ozi8h7DJyVZ6EnFQZUWGdgTMhDrmqevfx95U/16c5WBDO
kqwIn7Glry9n9Suxygbf8g5AzpWcusZgDLIIZ7JTUldBb8qU2a0Dl4mvLZOn4wPo
jfj9Cw2QICsc5+Pwf21fP+hzf+1WSRHbnYv8uanRO0gZ8ekGaghM/2H6gqJbo2nI
JwIDAQAB
-----END PUBLIC KEY-----""";
prepareResponse((response) -> {
response.setBody("{\"token_endpoint\":\"/my-uaa.com\"}");
response.setHeader("Content-Type", "application/json");
});
String responseBody = "{\"keys\" : [ {\"kid\":\"test-key\",\"value\" : \"" + tokenKeyValue.replace("\n", "\\n")
+ "\"} ]}";
prepareResponse((response) -> {
response.setBody(responseBody);
response.setHeader("Content-Type", "application/json");
});
StepVerifier.create(this.securityService.fetchTokenKeys())
.consumeNextWith((tokenKeys) -> assertThat(tokenKeys.get("test-key")).isEqualTo(tokenKeyValue))
.expectComplete()
.verify();
expectRequest((request) -> assertThat(request.getPath()).isEqualTo("/my-cloud-controller.com/info"));
expectRequest((request) -> assertThat(request.getPath()).isEqualTo("/my-uaa.com/token_keys"));
}
@Test
void fetchTokenKeysWhenNoKeysReturnedFromUAA() throws Exception {
prepareResponse((response) -> {
response.setBody("{\"token_endpoint\":\"/my-uaa.com\"}");
response.setHeader("Content-Type", "application/json");
});
String responseBody = "{\"keys\": []}";
prepareResponse((response) -> {
response.setBody(responseBody);
response.setHeader("Content-Type", "application/json");
});
StepVerifier.create(this.securityService.fetchTokenKeys())
.consumeNextWith((tokenKeys) -> assertThat(tokenKeys).isEmpty())
.expectComplete()
.verify();
expectRequest((request) -> assertThat(request.getPath()).isEqualTo("/my-cloud-controller.com/info"));
expectRequest((request) -> assertThat(request.getPath()).isEqualTo("/my-uaa.com/token_keys"));
}
@Test
void fetchTokenKeysWhenUnsuccessfulShouldThrowException() throws Exception {
prepareResponse((response) -> {
response.setBody("{\"token_endpoint\":\"/my-uaa.com\"}");
response.setHeader("Content-Type", "application/json");
});
prepareResponse((response) -> response.setResponseCode(500));
StepVerifier.create(this.securityService.fetchTokenKeys())
.consumeErrorWith((throwable) -> assertThat(((CloudFoundryAuthorizationException) throwable).getReason())
.isEqualTo(Reason.SERVICE_UNAVAILABLE))
.verify();
expectRequest((request) -> assertThat(request.getPath()).isEqualTo("/my-cloud-controller.com/info"));
expectRequest((request) -> assertThat(request.getPath()).isEqualTo("/my-uaa.com/token_keys"));
}
@Test
void getUaaUrlShouldCallCloudControllerInfoOnlyOnce() throws Exception {
prepareResponse((response) -> {
response.setBody("{\"token_endpoint\":\"" + UAA_URL + "\"}");
response.setHeader("Content-Type", "application/json");
});
StepVerifier.create(this.securityService.getUaaUrl())
.consumeNextWith((uaaUrl) -> assertThat(uaaUrl).isEqualTo(UAA_URL))
.expectComplete()
.verify();
expectRequest((request) -> assertThat(request.getPath()).isEqualTo(CLOUD_CONTROLLER + "/info"));
expectRequestCount(1);
}
@Test
void getUaaUrlWhenCloudControllerUrlIsNotReachableShouldThrowException() throws Exception {
prepareResponse((response) -> response.setResponseCode(500));
StepVerifier.create(this.securityService.getUaaUrl()).consumeErrorWith((throwable) -> {
assertThat(throwable).isInstanceOf(CloudFoundryAuthorizationException.class);
assertThat(((CloudFoundryAuthorizationException) throwable).getReason())
.isEqualTo(Reason.SERVICE_UNAVAILABLE);
}).verify();
expectRequest((request) -> assertThat(request.getPath()).isEqualTo(CLOUD_CONTROLLER + "/info"));
}
private void prepareResponse(Consumer<MockResponse> consumer) {
MockResponse response = new MockResponse();
consumer.accept(response);
this.server.enqueue(response);
}
private void expectRequest(Consumer<RecordedRequest> consumer) throws InterruptedException {
consumer.accept(this.server.takeRequest());
}
private void expectRequestCount(int count) {
assertThat(count).isEqualTo(this.server.getRequestCount());
}
}

View File

@@ -0,0 +1,321 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.reactive;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Duration;
import java.util.Base64;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
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.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import reactor.test.publisher.PublisherProbe;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.Token;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.util.StreamUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
/**
* Tests for {@link ReactiveTokenValidator}.
*
* @author Madhura Bhave
*/
@ExtendWith(MockitoExtension.class)
class ReactiveTokenValidatorTests {
private static final byte[] DOT = ".".getBytes();
@Mock
private ReactiveCloudFoundrySecurityService securityService;
private ReactiveTokenValidator tokenValidator;
private static final String VALID_KEY = """
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0m59l2u9iDnMbrXHfqkO
rn2dVQ3vfBJqcDuFUK03d+1PZGbVlNCqnkpIJ8syFppW8ljnWweP7+LiWpRoz0I7
fYb3d8TjhV86Y997Fl4DBrxgM6KTJOuE/uxnoDhZQ14LgOU2ckXjOzOdTsnGMKQB
LCl0vpcXBtFLMaSbpv1ozi8h7DJyVZ6EnFQZUWGdgTMhDrmqevfx95U/16c5WBDO
kqwIn7Glry9n9Suxygbf8g5AzpWcusZgDLIIZ7JTUldBb8qU2a0Dl4mvLZOn4wPo
jfj9Cw2QICsc5+Pwf21fP+hzf+1WSRHbnYv8uanRO0gZ8ekGaghM/2H6gqJbo2nI
JwIDAQAB
-----END PUBLIC KEY-----""";
private static final String INVALID_KEY = """
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxzYuc22QSst/dS7geYYK
5l5kLxU0tayNdixkEQ17ix+CUcUbKIsnyftZxaCYT46rQtXgCaYRdJcbB3hmyrOa
vkhTpX79xJZnQmfuamMbZBqitvscxW9zRR9tBUL6vdi/0rpoUwPMEh8+Bw7CgYR0
FK0DhWYBNDfe9HKcyZEv3max8Cdq18htxjEsdYO0iwzhtKRXomBWTdhD5ykd/fAC
VTr4+KEY+IeLvubHVmLUhbE5NgWXxrRpGasDqzKhCTmsa2Ysf712rl57SlH0Wz/M
r3F7aM9YpErzeYLrl0GhQr9BVJxOvXcVd4kmY+XkiCcrkyS1cnghnllh+LCwQu1s
YwIDAQAB
-----END PUBLIC KEY-----""";
private static final Map<String, String> INVALID_KEYS = new ConcurrentHashMap<>();
private static final Map<String, String> VALID_KEYS = new ConcurrentHashMap<>();
@BeforeEach
void setup() {
VALID_KEYS.put("valid-key", VALID_KEY);
INVALID_KEYS.put("invalid-key", INVALID_KEY);
this.tokenValidator = new ReactiveTokenValidator(this.securityService);
}
@Test
void validateTokenWhenKidValidationFailsTwiceShouldThrowException() throws Exception {
PublisherProbe<Map<String, String>> fetchTokenKeys = PublisherProbe.of(Mono.just(VALID_KEYS));
ReflectionTestUtils.setField(this.tokenValidator, "cachedTokenKeys", VALID_KEYS);
given(this.securityService.fetchTokenKeys()).willReturn(fetchTokenKeys.mono());
given(this.securityService.getUaaUrl()).willReturn(Mono.just("http://localhost:8080/uaa"));
String header = "{\"alg\": \"RS256\", \"kid\": \"invalid-key\",\"typ\": \"JWT\"}";
String claims = "{\"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}";
StepVerifier
.create(this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
.consumeErrorWith((ex) -> {
assertThat(ex).isExactlyInstanceOf(CloudFoundryAuthorizationException.class);
assertThat(((CloudFoundryAuthorizationException) ex).getReason()).isEqualTo(Reason.INVALID_KEY_ID);
})
.verify();
assertThat(this.tokenValidator).hasFieldOrPropertyWithValue("cachedTokenKeys", VALID_KEYS);
fetchTokenKeys.assertWasSubscribed();
}
@Test
void validateTokenWhenKidValidationSucceedsInTheSecondAttempt() throws Exception {
PublisherProbe<Map<String, String>> fetchTokenKeys = PublisherProbe.of(Mono.just(VALID_KEYS));
ReflectionTestUtils.setField(this.tokenValidator, "cachedTokenKeys", INVALID_KEYS);
given(this.securityService.fetchTokenKeys()).willReturn(fetchTokenKeys.mono());
given(this.securityService.getUaaUrl()).willReturn(Mono.just("http://localhost:8080/uaa"));
String header = "{\"alg\": \"RS256\", \"kid\": \"valid-key\",\"typ\": \"JWT\"}";
String claims = "{\"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}";
StepVerifier
.create(this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
.expectComplete()
.verify(Duration.ofSeconds(30));
assertThat(this.tokenValidator).hasFieldOrPropertyWithValue("cachedTokenKeys", VALID_KEYS);
fetchTokenKeys.assertWasSubscribed();
}
@Test
void validateTokenWhenCacheIsEmptyShouldFetchTokenKeys() throws Exception {
PublisherProbe<Map<String, String>> fetchTokenKeys = PublisherProbe.of(Mono.just(VALID_KEYS));
given(this.securityService.fetchTokenKeys()).willReturn(fetchTokenKeys.mono());
given(this.securityService.getUaaUrl()).willReturn(Mono.just("http://localhost:8080/uaa"));
String header = "{\"alg\": \"RS256\", \"kid\": \"valid-key\",\"typ\": \"JWT\"}";
String claims = "{\"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}";
StepVerifier
.create(this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
.expectComplete()
.verify(Duration.ofSeconds(30));
assertThat(this.tokenValidator).hasFieldOrPropertyWithValue("cachedTokenKeys", VALID_KEYS);
fetchTokenKeys.assertWasSubscribed();
}
@Test
void validateTokenWhenCacheEmptyAndInvalidKeyShouldThrowException() throws Exception {
PublisherProbe<Map<String, String>> fetchTokenKeys = PublisherProbe.of(Mono.just(VALID_KEYS));
given(this.securityService.fetchTokenKeys()).willReturn(fetchTokenKeys.mono());
given(this.securityService.getUaaUrl()).willReturn(Mono.just("http://localhost:8080/uaa"));
String header = "{\"alg\": \"RS256\", \"kid\": \"invalid-key\",\"typ\": \"JWT\"}";
String claims = "{\"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}";
StepVerifier
.create(this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
.consumeErrorWith((ex) -> {
assertThat(ex).isExactlyInstanceOf(CloudFoundryAuthorizationException.class);
assertThat(((CloudFoundryAuthorizationException) ex).getReason()).isEqualTo(Reason.INVALID_KEY_ID);
})
.verify();
assertThat(this.tokenValidator).hasFieldOrPropertyWithValue("cachedTokenKeys", VALID_KEYS);
fetchTokenKeys.assertWasSubscribed();
}
@Test
void validateTokenWhenCacheValidShouldNotFetchTokenKeys() throws Exception {
PublisherProbe<Map<String, String>> fetchTokenKeys = PublisherProbe.empty();
ReflectionTestUtils.setField(this.tokenValidator, "cachedTokenKeys", VALID_KEYS);
given(this.securityService.getUaaUrl()).willReturn(Mono.just("http://localhost:8080/uaa"));
String header = "{\"alg\": \"RS256\", \"kid\": \"valid-key\",\"typ\": \"JWT\"}";
String claims = "{\"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}";
StepVerifier
.create(this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
.expectComplete()
.verify(Duration.ofSeconds(30));
fetchTokenKeys.assertWasNotSubscribed();
}
@Test
void validateTokenWhenSignatureInvalidShouldThrowException() throws Exception {
Map<String, String> KEYS = Collections.singletonMap("valid-key", INVALID_KEY);
given(this.securityService.fetchTokenKeys()).willReturn(Mono.just(KEYS));
given(this.securityService.getUaaUrl()).willReturn(Mono.just("http://localhost:8080/uaa"));
String header = "{ \"alg\": \"RS256\", \"kid\": \"valid-key\",\"typ\": \"JWT\"}";
String claims = "{ \"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}";
StepVerifier
.create(this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
.consumeErrorWith((ex) -> {
assertThat(ex).isExactlyInstanceOf(CloudFoundryAuthorizationException.class);
assertThat(((CloudFoundryAuthorizationException) ex).getReason()).isEqualTo(Reason.INVALID_SIGNATURE);
})
.verify();
}
@Test
void validateTokenWhenTokenAlgorithmIsNotRS256ShouldThrowException() throws Exception {
given(this.securityService.fetchTokenKeys()).willReturn(Mono.just(VALID_KEYS));
given(this.securityService.getUaaUrl()).willReturn(Mono.just("http://localhost:8080/uaa"));
String header = "{ \"alg\": \"HS256\", \"kid\": \"valid-key\", \"typ\": \"JWT\"}";
String claims = "{ \"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}";
StepVerifier
.create(this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
.consumeErrorWith((ex) -> {
assertThat(ex).isExactlyInstanceOf(CloudFoundryAuthorizationException.class);
assertThat(((CloudFoundryAuthorizationException) ex).getReason())
.isEqualTo(Reason.UNSUPPORTED_TOKEN_SIGNING_ALGORITHM);
})
.verify();
}
@Test
void validateTokenWhenExpiredShouldThrowException() throws Exception {
given(this.securityService.fetchTokenKeys()).willReturn(Mono.just(VALID_KEYS));
given(this.securityService.getUaaUrl()).willReturn(Mono.just("http://localhost:8080/uaa"));
String header = "{ \"alg\": \"RS256\", \"kid\": \"valid-key\", \"typ\": \"JWT\"}";
String claims = "{ \"jti\": \"0236399c350c47f3ae77e67a75e75e7d\", \"exp\": 1477509977, \"scope\": [\"actuator.read\"]}";
StepVerifier
.create(this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
.consumeErrorWith((ex) -> {
assertThat(ex).isExactlyInstanceOf(CloudFoundryAuthorizationException.class);
assertThat(((CloudFoundryAuthorizationException) ex).getReason()).isEqualTo(Reason.TOKEN_EXPIRED);
})
.verify();
}
@Test
void validateTokenWhenIssuerIsNotValidShouldThrowException() throws Exception {
given(this.securityService.fetchTokenKeys()).willReturn(Mono.just(VALID_KEYS));
given(this.securityService.getUaaUrl()).willReturn(Mono.just("https://other-uaa.com"));
String header = "{ \"alg\": \"RS256\", \"kid\": \"valid-key\", \"typ\": \"JWT\", \"scope\": [\"actuator.read\"]}";
String claims = "{ \"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"foo.bar\"]}";
StepVerifier
.create(this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
.consumeErrorWith((ex) -> {
assertThat(ex).isExactlyInstanceOf(CloudFoundryAuthorizationException.class);
assertThat(((CloudFoundryAuthorizationException) ex).getReason()).isEqualTo(Reason.INVALID_ISSUER);
})
.verify();
}
@Test
void validateTokenWhenAudienceIsNotValidShouldThrowException() throws Exception {
given(this.securityService.fetchTokenKeys()).willReturn(Mono.just(VALID_KEYS));
given(this.securityService.getUaaUrl()).willReturn(Mono.just("http://localhost:8080/uaa"));
String header = "{ \"alg\": \"RS256\", \"kid\": \"valid-key\", \"typ\": \"JWT\"}";
String claims = "{ \"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"foo.bar\"]}";
StepVerifier
.create(this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
.consumeErrorWith((ex) -> {
assertThat(ex).isExactlyInstanceOf(CloudFoundryAuthorizationException.class);
assertThat(((CloudFoundryAuthorizationException) ex).getReason()).isEqualTo(Reason.INVALID_AUDIENCE);
})
.verify();
}
private String getSignedToken(byte[] header, byte[] claims) throws Exception {
PrivateKey privateKey = getPrivateKey();
Signature signature = Signature.getInstance("SHA256WithRSA");
signature.initSign(privateKey);
byte[] content = dotConcat(Base64.getUrlEncoder().encode(header), Base64.getEncoder().encode(claims));
signature.update(content);
byte[] crypto = signature.sign();
byte[] token = dotConcat(Base64.getUrlEncoder().encode(header), Base64.getUrlEncoder().encode(claims),
Base64.getUrlEncoder().encode(crypto));
return new String(token, StandardCharsets.UTF_8);
}
private PrivateKey getPrivateKey() throws InvalidKeySpecException, NoSuchAlgorithmException {
String signingKey = """
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDSbn2Xa72IOcxu
tcd+qQ6ufZ1VDe98EmpwO4VQrTd37U9kZtWU0KqeSkgnyzIWmlbyWOdbB4/v4uJa
lGjPQjt9hvd3xOOFXzpj33sWXgMGvGAzopMk64T+7GegOFlDXguA5TZyReM7M51O
ycYwpAEsKXS+lxcG0UsxpJum/WjOLyHsMnJVnoScVBlRYZ2BMyEOuap69/H3lT/X
pzlYEM6SrAifsaWvL2f1K7HKBt/yDkDOlZy6xmAMsghnslNSV0FvypTZrQOXia8t
k6fjA+iN+P0LDZAgKxzn4/B/bV8/6HN/7VZJEdudi/y5qdE7SBnx6QZqCEz/YfqC
olujacgnAgMBAAECggEAc9X2tJ/OWWrXqinOg160gkELloJxTi8lAFsDbAGuAwpT
JcWl1KF5CmGBjsY/8ElNi2J9GJL1HOwcBhikCVNARD1DhF6RkB13mvquWwWtTMvt
eP8JWM19DIc+E+hw2rCuTGngqs7l4vTqpzBTNPtS2eiIJ1IsjsgvSEiAlk/wnW48
11cf6SQMQcT3HNTWrS+yLycEuWKb6Khh8RpD9D+i8w2+IspWz5lTP7BrKCUNsLOx
6+5T52HcaZ9z3wMnDqfqIKWl3h8M+q+HFQ4EN5BPWYV4fF7EOx7+Qf2fKDFPoTjC
VTWzDRNAA1xPqwdF7IdPVOXCdaUJDOhHeXZGaTNSwQKBgQDxb9UiR/Jh1R3muL7I
neIt1gXa0O+SK7NWYl4DkArYo7V81ztxI8r+xKEeu5zRZZkpaJHxOnd3VfADascw
UfALvxGxN2z42lE6zdhrmxZ3ma+akQFsv7NyXcBT00sdW+xmOiCaAj0cgxNOXiV3
sYOwUy3SqUIPO2obpb+KC5ALHwKBgQDfH+NSQ/jn89oVZ3lzUORa+Z+aL1TGsgzs
p7IG0MTEYiR9/AExYUwJab0M4PDXhumeoACMfkCFALNVhpch2nXZv7X5445yRgfD
ONY4WknecuA0rfCLTruNWnQ3RR+BXmd9jD/5igd9hEIawz3V+jCHvAtzI8/CZIBt
AArBs5kp+QKBgQCdxwN1n6baIDemK10iJWtFoPO6h4fH8h8EeMwPb/ZmlLVpnA4Q
Zd+mlkDkoJ5eiRKKaPfWuOqRZeuvj/wTq7g/NOIO+bWQ+rrSvuqLh5IrHpgPXmub
8bsHJhUlspMH4KagN6ROgOAG3fGj6Qp7KdpxRCpR3KJ66czxvGNrhxre6QKBgB+s
MCGiYnfSprd5G8VhyziazKwfYeJerfT+DQhopDXYVKPJnQW8cQW5C8wDNkzx6sHI
pqtK1K/MnKhcVaHJmAcT7qoNQlA4Xqu4qrgPIQNBvU/dDRNJVthG6c5aspEzrG8m
9IHgtRV9K8EOy/1O6YqrB9kNUVWf3JccdWpvqyNJAoGAORzJiQCOk4egbdcozDTo
4Tg4qk/03qpTy5k64DxkX1nJHu8V/hsKwq9Af7Fj/iHy2Av54BLPlBaGPwMi2bzB
gYjmUomvx/fqOTQks9Rc4PIMB43p6Rdj0sh+52SKPDR2eHbwsmpuQUXnAs20BPPI
J/OOn5zOs8yf26os0q3+JUM=
-----END PRIVATE KEY-----""";
String privateKey = signingKey.replace("-----BEGIN PRIVATE KEY-----\n", "");
privateKey = privateKey.replace("-----END PRIVATE KEY-----", "");
privateKey = privateKey.replace("\n", "");
byte[] pkcs8EncodedBytes = Base64.getDecoder().decode(privateKey);
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(pkcs8EncodedBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
return keyFactory.generatePrivate(keySpec);
}
private byte[] dotConcat(byte[]... bytes) throws IOException {
ByteArrayOutputStream result = new ByteArrayOutputStream();
for (int i = 0; i < bytes.length; i++) {
if (i > 0) {
StreamUtils.copy(DOT, result);
}
StreamUtils.copy(bytes[i], result);
}
return result.toByteArray();
}
}

View File

@@ -0,0 +1,337 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import jakarta.servlet.Filter;
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.health.HealthContributorAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration;
import org.springframework.boot.actuate.endpoint.ApiVersion;
import org.springframework.boot.actuate.endpoint.EndpointId;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.endpoint.annotation.WriteOperation;
import org.springframework.boot.actuate.endpoint.web.EndpointMapping;
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
import org.springframework.boot.actuate.endpoint.web.WebOperation;
import org.springframework.boot.actuate.endpoint.web.WebOperationRequestPredicate;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
import org.springframework.boot.http.converter.autoconfigure.HttpMessageConvertersAutoConfiguration;
import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration;
import org.springframework.boot.restclient.autoconfigure.RestTemplateAutoConfiguration;
import org.springframework.boot.security.autoconfigure.servlet.SecurityAutoConfiguration;
import org.springframework.boot.servlet.actuate.autoconfigure.ServletManagementContextAutoConfiguration;
import org.springframework.boot.test.context.assertj.AssertableWebApplicationContext;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.boot.webmvc.autoconfigure.DispatcherServletAutoConfiguration;
import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration;
import org.springframework.context.ApplicationContext;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.config.BeanIds;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.filter.CompositeFilter;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Tests for {@link CloudFoundryActuatorAutoConfiguration}.
*
* @author Madhura Bhave
*/
class CloudFoundryActuatorAutoConfigurationTests {
private static final String V3_JSON = ApiVersion.V3.getProducedMimeType().toString();
private static final String BASE_PATH = "/cloudfoundryapplication";
private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(SecurityAutoConfiguration.class, WebMvcAutoConfiguration.class,
JacksonAutoConfiguration.class, DispatcherServletAutoConfiguration.class,
HttpMessageConvertersAutoConfiguration.class, PropertyPlaceholderAutoConfiguration.class,
RestTemplateAutoConfiguration.class, ManagementContextAutoConfiguration.class,
ServletManagementContextAutoConfiguration.class, EndpointAutoConfiguration.class,
WebEndpointAutoConfiguration.class, CloudFoundryActuatorAutoConfiguration.class));
@Test
void cloudFoundryPlatformActive() {
this.contextRunner
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
"vcap.application.cf_api:https://my-cloud-controller.com")
.run((context) -> {
CloudFoundryWebEndpointServletHandlerMapping handlerMapping = getHandlerMapping(context);
EndpointMapping endpointMapping = (EndpointMapping) ReflectionTestUtils.getField(handlerMapping,
"endpointMapping");
assertThat(endpointMapping.getPath()).isEqualTo("/cloudfoundryapplication");
CorsConfiguration corsConfiguration = (CorsConfiguration) ReflectionTestUtils.getField(handlerMapping,
"corsConfiguration");
assertThat(corsConfiguration.getAllowedOrigins()).contains("*");
assertThat(corsConfiguration.getAllowedMethods())
.containsAll(Arrays.asList(HttpMethod.GET.name(), HttpMethod.POST.name()));
assertThat(corsConfiguration.getAllowedHeaders())
.containsAll(Arrays.asList("Authorization", "X-Cf-App-Instance", "Content-Type"));
});
}
@Test
void cloudfoundryapplicationProducesActuatorMediaType() {
this.contextRunner
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
"vcap.application.cf_api:https://my-cloud-controller.com")
.run((context) -> {
MockMvcTester mvc = MockMvcTester.from(context);
assertThat(mvc.get().uri("/cloudfoundryapplication")).hasHeader("Content-Type", V3_JSON);
});
}
@Test
void cloudFoundryPlatformActiveSetsApplicationId() {
this.contextRunner
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
"vcap.application.cf_api:https://my-cloud-controller.com")
.run((context) -> {
CloudFoundryWebEndpointServletHandlerMapping handlerMapping = getHandlerMapping(context);
Object interceptor = ReflectionTestUtils.getField(handlerMapping, "securityInterceptor");
String applicationId = (String) ReflectionTestUtils.getField(interceptor, "applicationId");
assertThat(applicationId).isEqualTo("my-app-id");
});
}
@Test
void cloudFoundryPlatformActiveSetsCloudControllerUrl() {
this.contextRunner
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
"vcap.application.cf_api:https://my-cloud-controller.com")
.run((context) -> {
CloudFoundryWebEndpointServletHandlerMapping handlerMapping = getHandlerMapping(context);
Object interceptor = ReflectionTestUtils.getField(handlerMapping, "securityInterceptor");
Object interceptorSecurityService = ReflectionTestUtils.getField(interceptor,
"cloudFoundrySecurityService");
String cloudControllerUrl = (String) ReflectionTestUtils.getField(interceptorSecurityService,
"cloudControllerUrl");
assertThat(cloudControllerUrl).isEqualTo("https://my-cloud-controller.com");
});
}
@Test
void skipSslValidation() {
this.contextRunner
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
"vcap.application.cf_api:https://my-cloud-controller.com",
"management.cloudfoundry.skip-ssl-validation:true")
.run((context) -> {
CloudFoundryWebEndpointServletHandlerMapping handlerMapping = getHandlerMapping(context);
Object interceptor = ReflectionTestUtils.getField(handlerMapping, "securityInterceptor");
Object interceptorSecurityService = ReflectionTestUtils.getField(interceptor,
"cloudFoundrySecurityService");
RestTemplate restTemplate = (RestTemplate) ReflectionTestUtils.getField(interceptorSecurityService,
"restTemplate");
assertThat(restTemplate.getRequestFactory()).isInstanceOf(SkipSslVerificationHttpRequestFactory.class);
});
}
@Test
void cloudFoundryPlatformActiveAndCloudControllerUrlNotPresent() {
this.contextRunner.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id")
.run((context) -> {
CloudFoundryWebEndpointServletHandlerMapping handlerMapping = getHandlerMapping(context);
Object securityInterceptor = ReflectionTestUtils.getField(handlerMapping, "securityInterceptor");
Object interceptorSecurityService = ReflectionTestUtils.getField(securityInterceptor,
"cloudFoundrySecurityService");
assertThat(interceptorSecurityService).isNull();
});
}
@Test
void cloudFoundryPathsPermittedBySpringSecurity() {
this.contextRunner.withBean(TestEndpoint.class, TestEndpoint::new)
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id")
.run((context) -> {
SecurityFilterChain chain = getSecurityFilterChain(context);
MockHttpServletRequest request = new MockHttpServletRequest();
testCloudFoundrySecurity(request, BASE_PATH, chain);
testCloudFoundrySecurity(request, BASE_PATH + "/", chain);
testCloudFoundrySecurity(request, BASE_PATH + "/test", chain);
testCloudFoundrySecurity(request, BASE_PATH + "/test/a", chain);
request.setServletPath(BASE_PATH + "/other-path");
request.setRequestURI(BASE_PATH + "/other-path");
assertThat(chain.matches(request)).isFalse();
request.setServletPath("/some-other-path");
request.setRequestURI("/some-other-path");
assertThat(chain.matches(request)).isFalse();
});
}
@Test
void cloudFoundryPathsPermittedWithCsrfBySpringSecurity() {
this.contextRunner.withBean(TestEndpoint.class, TestEndpoint::new)
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id")
.run((context) -> {
MockMvc mvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build();
mvc.perform(post(BASE_PATH + "/test?name=test").contentType(MediaType.APPLICATION_JSON)
.with(csrf().useInvalidToken())).andExpect(status().isServiceUnavailable());
// If CSRF fails we'll get a 403, if it works we get service unavailable
// because of "Cloud controller URL is not available"
});
}
private SecurityFilterChain getSecurityFilterChain(AssertableWebApplicationContext context) {
Filter springSecurityFilterChain = context.getBean(BeanIds.SPRING_SECURITY_FILTER_CHAIN, Filter.class);
FilterChainProxy filterChainProxy = getFilterChainProxy(springSecurityFilterChain);
SecurityFilterChain securityFilterChain = filterChainProxy.getFilterChains().get(0);
return securityFilterChain;
}
private FilterChainProxy getFilterChainProxy(Filter filter) {
if (filter instanceof FilterChainProxy filterChainProxy) {
return filterChainProxy;
}
if (filter instanceof CompositeFilter) {
List<?> filters = (List<?>) ReflectionTestUtils.getField(filter, "filters");
return (FilterChainProxy) filters.stream()
.filter(FilterChainProxy.class::isInstance)
.findFirst()
.orElseThrow();
}
throw new IllegalStateException("No FilterChainProxy found");
}
private static void testCloudFoundrySecurity(MockHttpServletRequest request, String requestUri,
SecurityFilterChain chain) {
request.setRequestURI(requestUri);
assertThat(chain.matches(request)).isTrue();
}
@Test
void cloudFoundryPlatformInactive() {
this.contextRunner.withPropertyValues()
.run((context) -> assertThat(context.containsBean("cloudFoundryWebEndpointServletHandlerMapping"))
.isFalse());
}
@Test
void cloudFoundryManagementEndpointsDisabled() {
this.contextRunner.withPropertyValues("VCAP_APPLICATION=---", "management.cloudfoundry.enabled:false")
.run((context) -> assertThat(context.containsBean("cloudFoundryEndpointHandlerMapping")).isFalse());
}
@Test
void allEndpointsAvailableUnderCloudFoundryWithoutExposeAllOnWeb() {
this.contextRunner.withBean(TestEndpoint.class, TestEndpoint::new)
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
"vcap.application.cf_api:https://my-cloud-controller.com")
.run((context) -> {
CloudFoundryWebEndpointServletHandlerMapping handlerMapping = getHandlerMapping(context);
Collection<ExposableWebEndpoint> endpoints = handlerMapping.getEndpoints();
assertThat(endpoints.stream()
.filter((candidate) -> EndpointId.of("test").equals(candidate.getEndpointId()))
.findFirst()).isNotEmpty();
});
}
@Test
void endpointPathCustomizationIsNotApplied() {
this.contextRunner
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
"vcap.application.cf_api:https://my-cloud-controller.com",
"management.endpoints.web.path-mapping.test=custom")
.withBean(TestEndpoint.class, TestEndpoint::new)
.run((context) -> {
CloudFoundryWebEndpointServletHandlerMapping handlerMapping = getHandlerMapping(context);
Collection<ExposableWebEndpoint> endpoints = handlerMapping.getEndpoints();
ExposableWebEndpoint endpoint = endpoints.stream()
.filter((candidate) -> EndpointId.of("test").equals(candidate.getEndpointId()))
.findFirst()
.get();
Collection<WebOperation> operations = endpoint.getOperations();
assertThat(operations).hasSize(2);
assertThat(operations.iterator().next().getRequestPredicate().getPath()).isEqualTo("test");
});
}
@Test
void healthEndpointInvokerShouldBeCloudFoundryWebExtension() {
this.contextRunner
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
"vcap.application.cf_api:https://my-cloud-controller.com")
.withConfiguration(AutoConfigurations.of(HealthContributorAutoConfiguration.class,
HealthEndpointAutoConfiguration.class))
.run((context) -> {
Collection<ExposableWebEndpoint> endpoints = context
.getBean("cloudFoundryWebEndpointServletHandlerMapping",
CloudFoundryWebEndpointServletHandlerMapping.class)
.getEndpoints();
ExposableWebEndpoint endpoint = endpoints.iterator().next();
assertThat(endpoint.getOperations()).hasSize(2);
WebOperation webOperation = findOperationWithRequestPath(endpoint, "health");
assertThat(webOperation).extracting("invoker.target")
.isInstanceOf(CloudFoundryHealthEndpointWebExtension.class);
});
}
private CloudFoundryWebEndpointServletHandlerMapping getHandlerMapping(ApplicationContext context) {
return context.getBean("cloudFoundryWebEndpointServletHandlerMapping",
CloudFoundryWebEndpointServletHandlerMapping.class);
}
private WebOperation findOperationWithRequestPath(ExposableWebEndpoint endpoint, String requestPath) {
for (WebOperation operation : endpoint.getOperations()) {
WebOperationRequestPredicate predicate = operation.getRequestPredicate();
if (predicate.getPath().equals(requestPath) && predicate.getProduces().contains(V3_JSON)) {
return operation;
}
}
throw new IllegalStateException(
"No operation found with request path " + requestPath + " from " + endpoint.getOperations());
}
@Endpoint(id = "test")
static class TestEndpoint {
@ReadOperation
String hello() {
return "hello world";
}
@WriteOperation
void update(String name) {
}
}
}

View File

@@ -0,0 +1,82 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet;
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.health.HealthContributorAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration;
import org.springframework.boot.actuate.endpoint.ApiVersion;
import org.springframework.boot.actuate.health.CompositeHealth;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthComponent;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
import org.springframework.boot.http.converter.autoconfigure.HttpMessageConvertersAutoConfiguration;
import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration;
import org.springframework.boot.restclient.autoconfigure.RestTemplateAutoConfiguration;
import org.springframework.boot.security.autoconfigure.servlet.SecurityAutoConfiguration;
import org.springframework.boot.servlet.actuate.autoconfigure.ServletManagementContextAutoConfiguration;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.boot.webmvc.autoconfigure.DispatcherServletAutoConfiguration;
import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link CloudFoundryHealthEndpointWebExtension}.
*
* @author Madhura Bhave
*/
class CloudFoundryHealthEndpointWebExtensionTests {
private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner()
.withPropertyValues("VCAP_APPLICATION={}")
.withConfiguration(AutoConfigurations.of(SecurityAutoConfiguration.class, WebMvcAutoConfiguration.class,
JacksonAutoConfiguration.class, DispatcherServletAutoConfiguration.class,
HttpMessageConvertersAutoConfiguration.class, PropertyPlaceholderAutoConfiguration.class,
RestTemplateAutoConfiguration.class, ManagementContextAutoConfiguration.class,
ServletManagementContextAutoConfiguration.class, EndpointAutoConfiguration.class,
WebEndpointAutoConfiguration.class, HealthContributorAutoConfiguration.class,
HealthEndpointAutoConfiguration.class, CloudFoundryActuatorAutoConfiguration.class))
.withUserConfiguration(TestHealthIndicator.class);
@Test
void healthComponentsAlwaysPresent() {
this.contextRunner.run((context) -> {
CloudFoundryHealthEndpointWebExtension extension = context
.getBean(CloudFoundryHealthEndpointWebExtension.class);
HealthComponent body = extension.health(ApiVersion.V3).getBody();
HealthComponent health = ((CompositeHealth) body).getComponents().entrySet().iterator().next().getValue();
assertThat(((Health) health).getDetails()).containsEntry("spring", "boot");
});
}
private static final class TestHealthIndicator implements HealthIndicator {
@Override
public Health health() {
return Health.up().withDetail("spring", "boot").build();
}
}
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.info.InfoContributorAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.info.InfoEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
import org.springframework.boot.autoconfigure.info.ProjectInfoAutoConfiguration;
import org.springframework.boot.http.converter.autoconfigure.HttpMessageConvertersAutoConfiguration;
import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration;
import org.springframework.boot.restclient.autoconfigure.RestTemplateAutoConfiguration;
import org.springframework.boot.security.autoconfigure.servlet.SecurityAutoConfiguration;
import org.springframework.boot.servlet.actuate.autoconfigure.ServletManagementContextAutoConfiguration;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.boot.testsupport.classpath.resources.WithResource;
import org.springframework.boot.webmvc.autoconfigure.DispatcherServletAutoConfiguration;
import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link CloudFoundryInfoEndpointWebExtension}.
*
* @author Madhura Bhave
*/
class CloudFoundryInfoEndpointWebExtensionTests {
private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner()
.withPropertyValues("VCAP_APPLICATION={}")
.withConfiguration(AutoConfigurations.of(SecurityAutoConfiguration.class, WebMvcAutoConfiguration.class,
JacksonAutoConfiguration.class, DispatcherServletAutoConfiguration.class,
HttpMessageConvertersAutoConfiguration.class, PropertyPlaceholderAutoConfiguration.class,
RestTemplateAutoConfiguration.class, ManagementContextAutoConfiguration.class,
ServletManagementContextAutoConfiguration.class, EndpointAutoConfiguration.class,
WebEndpointAutoConfiguration.class, ProjectInfoAutoConfiguration.class,
InfoContributorAutoConfiguration.class, InfoEndpointAutoConfiguration.class,
HealthEndpointAutoConfiguration.class, CloudFoundryActuatorAutoConfiguration.class));
@Test
@WithResource(name = "git.properties", content = """
#Generated by Git-Commit-Id-Plugin
#Thu May 23 09:26:42 BST 2013
git.commit.id.abbrev=e02a4f3
git.commit.user.email=dsyer@vmware.com
git.commit.message.full=Update Spring
git.commit.id=e02a4f3b6f452cdbf6dd311f1362679eb4c31ced
git.commit.message.short=Update Spring
git.commit.user.name=Dave Syer
git.build.user.name=Dave Syer
git.build.user.email=dsyer@vmware.com
git.branch=develop
git.commit.time=2013-04-24T08\\:42\\:13+0100
git.build.time=2013-05-23T09\\:26\\:42+0100
""")
@SuppressWarnings("unchecked")
void gitFullDetailsAlwaysPresent() {
this.contextRunner.run((context) -> {
CloudFoundryInfoEndpointWebExtension extension = context
.getBean(CloudFoundryInfoEndpointWebExtension.class);
Map<String, Object> git = (Map<String, Object>) extension.info().get("git");
Map<String, Object> commit = (Map<String, Object>) git.get("commit");
assertThat(commit).hasSize(4);
});
}
}

View File

@@ -0,0 +1,355 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.endpoint.ExposableEndpoint;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.endpoint.annotation.Selector;
import org.springframework.boot.actuate.endpoint.annotation.WriteOperation;
import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper;
import org.springframework.boot.actuate.endpoint.invoke.convert.ConversionServiceParameterValueMapper;
import org.springframework.boot.actuate.endpoint.web.EndpointMapping;
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
import org.springframework.boot.actuate.endpoint.web.annotation.WebEndpointDiscoverer;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.AccessLevel;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory;
import org.springframework.boot.web.server.servlet.context.AnnotationConfigServletWebServerApplicationContext;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.willThrow;
import static org.mockito.Mockito.mock;
/**
* Integration tests for web endpoints exposed using Spring MVC on CloudFoundry.
*
* @author Madhura Bhave
*/
class CloudFoundryMvcWebEndpointIntegrationTests {
private final TokenValidator tokenValidator = mock(TokenValidator.class);
private final CloudFoundrySecurityService securityService = mock(CloudFoundrySecurityService.class);
@Test
void operationWithSecurityInterceptorForbidden() {
given(this.securityService.getAccessLevel(any(), eq("app-id"))).willReturn(AccessLevel.RESTRICTED);
load(TestEndpointConfiguration.class,
(client) -> client.get()
.uri("/cfApplication/test")
.accept(MediaType.APPLICATION_JSON)
.header("Authorization", "bearer " + mockAccessToken())
.exchange()
.expectStatus()
.isEqualTo(HttpStatus.FORBIDDEN));
}
@Test
void operationWithSecurityInterceptorSuccess() {
given(this.securityService.getAccessLevel(any(), eq("app-id"))).willReturn(AccessLevel.FULL);
load(TestEndpointConfiguration.class,
(client) -> client.get()
.uri("/cfApplication/test")
.accept(MediaType.APPLICATION_JSON)
.header("Authorization", "bearer " + mockAccessToken())
.exchange()
.expectStatus()
.isEqualTo(HttpStatus.OK));
}
@Test
void responseToOptionsRequestIncludesCorsHeaders() {
load(TestEndpointConfiguration.class,
(client) -> client.options()
.uri("/cfApplication/test")
.accept(MediaType.APPLICATION_JSON)
.header("Access-Control-Request-Method", "POST")
.header("Origin", "https://example.com")
.exchange()
.expectStatus()
.isOk()
.expectHeader()
.valueEquals("Access-Control-Allow-Origin", "https://example.com")
.expectHeader()
.valueEquals("Access-Control-Allow-Methods", "GET,POST"));
}
@Test
void linksToOtherEndpointsWithFullAccess() {
given(this.securityService.getAccessLevel(any(), eq("app-id"))).willReturn(AccessLevel.FULL);
load(TestEndpointConfiguration.class,
(client) -> client.get()
.uri("/cfApplication")
.accept(MediaType.APPLICATION_JSON)
.header("Authorization", "bearer " + mockAccessToken())
.exchange()
.expectStatus()
.isOk()
.expectBody()
.jsonPath("_links.length()")
.isEqualTo(5)
.jsonPath("_links.self.href")
.isNotEmpty()
.jsonPath("_links.self.templated")
.isEqualTo(false)
.jsonPath("_links.info.href")
.isNotEmpty()
.jsonPath("_links.info.templated")
.isEqualTo(false)
.jsonPath("_links.env.href")
.isNotEmpty()
.jsonPath("_links.env.templated")
.isEqualTo(false)
.jsonPath("_links.test.href")
.isNotEmpty()
.jsonPath("_links.test.templated")
.isEqualTo(false)
.jsonPath("_links.test-part.href")
.isNotEmpty()
.jsonPath("_links.test-part.templated")
.isEqualTo(true));
}
@Test
void linksToOtherEndpointsForbidden() {
CloudFoundryAuthorizationException exception = new CloudFoundryAuthorizationException(Reason.INVALID_TOKEN,
"invalid-token");
willThrow(exception).given(this.tokenValidator).validate(any());
load(TestEndpointConfiguration.class,
(client) -> client.get()
.uri("/cfApplication")
.accept(MediaType.APPLICATION_JSON)
.header("Authorization", "bearer " + mockAccessToken())
.exchange()
.expectStatus()
.isUnauthorized());
}
@Test
void linksToOtherEndpointsWithRestrictedAccess() {
given(this.securityService.getAccessLevel(any(), eq("app-id"))).willReturn(AccessLevel.RESTRICTED);
load(TestEndpointConfiguration.class,
(client) -> client.get()
.uri("/cfApplication")
.accept(MediaType.APPLICATION_JSON)
.header("Authorization", "bearer " + mockAccessToken())
.exchange()
.expectStatus()
.isOk()
.expectBody()
.jsonPath("_links.length()")
.isEqualTo(2)
.jsonPath("_links.self.href")
.isNotEmpty()
.jsonPath("_links.self.templated")
.isEqualTo(false)
.jsonPath("_links.info.href")
.isNotEmpty()
.jsonPath("_links.info.templated")
.isEqualTo(false)
.jsonPath("_links.env")
.doesNotExist()
.jsonPath("_links.test")
.doesNotExist()
.jsonPath("_links.test-part")
.doesNotExist());
}
private void load(Class<?> configuration, Consumer<WebTestClient> clientConsumer) {
BiConsumer<ApplicationContext, WebTestClient> consumer = (context, client) -> clientConsumer.accept(client);
new WebApplicationContextRunner(AnnotationConfigServletWebServerApplicationContext::new)
.withUserConfiguration(configuration, CloudFoundryMvcConfiguration.class)
.withBean(TokenValidator.class, () -> this.tokenValidator)
.withBean(CloudFoundrySecurityService.class, () -> this.securityService)
.run((context) -> consumer.accept(context, WebTestClient.bindToServer()
.baseUrl("http://localhost:" + getPort(
(AnnotationConfigServletWebServerApplicationContext) context.getSourceApplicationContext()))
.responseTimeout(Duration.ofMinutes(5))
.build()));
}
private int getPort(AnnotationConfigServletWebServerApplicationContext context) {
return context.getWebServer().getPort();
}
private String mockAccessToken() {
return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b3B0YWwu"
+ "Y29tIiwiZXhwIjoxNDI2NDIwODAwLCJhd2Vzb21lIjp0cnVlfQ."
+ Base64.getEncoder().encodeToString("signature".getBytes());
}
@Configuration(proxyBeanMethods = false)
@EnableWebMvc
static class CloudFoundryMvcConfiguration {
@Bean
CloudFoundrySecurityInterceptor interceptor(TokenValidator tokenValidator,
CloudFoundrySecurityService securityService) {
return new CloudFoundrySecurityInterceptor(tokenValidator, securityService, "app-id");
}
@Bean
EndpointMediaTypes EndpointMediaTypes() {
return new EndpointMediaTypes(Collections.singletonList("application/json"),
Collections.singletonList("application/json"));
}
@Bean
CloudFoundryWebEndpointServletHandlerMapping cloudFoundryWebEndpointServletHandlerMapping(
WebEndpointDiscoverer webEndpointDiscoverer, EndpointMediaTypes endpointMediaTypes,
CloudFoundrySecurityInterceptor interceptor) {
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.setAllowedOrigins(Arrays.asList("https://example.com"));
corsConfiguration.setAllowedMethods(Arrays.asList("GET", "POST"));
Collection<ExposableWebEndpoint> webEndpoints = webEndpointDiscoverer.getEndpoints();
List<ExposableEndpoint<?>> allEndpoints = new ArrayList<>(webEndpoints);
return new CloudFoundryWebEndpointServletHandlerMapping(new EndpointMapping("/cfApplication"), webEndpoints,
endpointMediaTypes, corsConfiguration, interceptor, allEndpoints);
}
@Bean
WebEndpointDiscoverer webEndpointDiscoverer(ApplicationContext applicationContext,
EndpointMediaTypes endpointMediaTypes) {
ParameterValueMapper parameterMapper = new ConversionServiceParameterValueMapper(
DefaultConversionService.getSharedInstance());
return new WebEndpointDiscoverer(applicationContext, parameterMapper, endpointMediaTypes, null, null,
Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
}
@Bean
EndpointDelegate endpointDelegate() {
return mock(EndpointDelegate.class);
}
@Bean
TomcatServletWebServerFactory tomcat() {
return new TomcatServletWebServerFactory(0);
}
@Bean
DispatcherServlet dispatcherServlet() {
return new DispatcherServlet();
}
}
@Endpoint(id = "test")
static class TestEndpoint {
private final EndpointDelegate endpointDelegate;
TestEndpoint(EndpointDelegate endpointDelegate) {
this.endpointDelegate = endpointDelegate;
}
@ReadOperation
Map<String, Object> readAll() {
return Collections.singletonMap("All", true);
}
@ReadOperation
Map<String, Object> readPart(@Selector String part) {
return Collections.singletonMap("part", part);
}
@WriteOperation
void write(String foo, String bar) {
this.endpointDelegate.write(foo, bar);
}
}
@Endpoint(id = "env")
static class TestEnvEndpoint {
@ReadOperation
Map<String, Object> readAll() {
return Collections.singletonMap("All", true);
}
}
@Endpoint(id = "info")
static class TestInfoEndpoint {
@ReadOperation
Map<String, Object> readAll() {
return Collections.singletonMap("All", true);
}
}
@Configuration(proxyBeanMethods = false)
@Import(CloudFoundryMvcConfiguration.class)
static class TestEndpointConfiguration {
@Bean
TestEndpoint testEndpoint(EndpointDelegate endpointDelegate) {
return new TestEndpoint(endpointDelegate);
}
@Bean
TestInfoEndpoint testInfoEnvEndpoint() {
return new TestInfoEndpoint();
}
@Bean
TestEnvEndpoint testEnvEndpoint() {
return new TestEnvEndpoint();
}
}
interface EndpointDelegate {
void write();
void write(String foo, String bar);
}
}

View File

@@ -0,0 +1,139 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet;
import java.util.Base64;
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.junit.jupiter.MockitoExtension;
import org.springframework.boot.actuate.endpoint.EndpointId;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.AccessLevel;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.SecurityResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockHttpServletRequest;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.assertArg;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
/**
* Tests for {@link CloudFoundrySecurityInterceptor}.
*
* @author Madhura Bhave
*/
@ExtendWith(MockitoExtension.class)
class CloudFoundrySecurityInterceptorTests {
@Mock
private TokenValidator tokenValidator;
@Mock
private CloudFoundrySecurityService securityService;
private CloudFoundrySecurityInterceptor interceptor;
private MockHttpServletRequest request;
@BeforeEach
void setup() {
this.interceptor = new CloudFoundrySecurityInterceptor(this.tokenValidator, this.securityService, "my-app-id");
this.request = new MockHttpServletRequest();
}
@Test
void preHandleWhenRequestIsPreFlightShouldReturnTrue() {
this.request.setMethod("OPTIONS");
this.request.addHeader(HttpHeaders.ORIGIN, "https://example.com");
this.request.addHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET");
SecurityResponse response = this.interceptor.preHandle(this.request, EndpointId.of("test"));
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK);
}
@Test
void preHandleWhenTokenIsMissingShouldReturnFalse() {
SecurityResponse response = this.interceptor.preHandle(this.request, EndpointId.of("test"));
assertThat(response.getStatus()).isEqualTo(Reason.MISSING_AUTHORIZATION.getStatus());
}
@Test
void preHandleWhenTokenIsNotBearerShouldReturnFalse() {
this.request.addHeader("Authorization", mockAccessToken());
SecurityResponse response = this.interceptor.preHandle(this.request, EndpointId.of("test"));
assertThat(response.getStatus()).isEqualTo(Reason.MISSING_AUTHORIZATION.getStatus());
}
@Test
void preHandleWhenApplicationIdIsNullShouldReturnFalse() {
this.interceptor = new CloudFoundrySecurityInterceptor(this.tokenValidator, this.securityService, null);
this.request.addHeader("Authorization", "bearer " + mockAccessToken());
SecurityResponse response = this.interceptor.preHandle(this.request, EndpointId.of("test"));
assertThat(response.getStatus()).isEqualTo(Reason.SERVICE_UNAVAILABLE.getStatus());
}
@Test
void preHandleWhenCloudFoundrySecurityServiceIsNullShouldReturnFalse() {
this.interceptor = new CloudFoundrySecurityInterceptor(this.tokenValidator, null, "my-app-id");
this.request.addHeader("Authorization", "bearer " + mockAccessToken());
SecurityResponse response = this.interceptor.preHandle(this.request, EndpointId.of("test"));
assertThat(response.getStatus()).isEqualTo(Reason.SERVICE_UNAVAILABLE.getStatus());
}
@Test
void preHandleWhenAccessIsNotAllowedShouldReturnFalse() {
String accessToken = mockAccessToken();
this.request.addHeader("Authorization", "bearer " + accessToken);
given(this.securityService.getAccessLevel(accessToken, "my-app-id")).willReturn(AccessLevel.RESTRICTED);
SecurityResponse response = this.interceptor.preHandle(this.request, EndpointId.of("test"));
assertThat(response.getStatus()).isEqualTo(Reason.ACCESS_DENIED.getStatus());
}
@Test
void preHandleSuccessfulWithFullAccess() {
String accessToken = mockAccessToken();
this.request.addHeader("Authorization", "Bearer " + accessToken);
given(this.securityService.getAccessLevel(accessToken, "my-app-id")).willReturn(AccessLevel.FULL);
SecurityResponse response = this.interceptor.preHandle(this.request, EndpointId.of("test"));
then(this.tokenValidator).should().validate(assertArg((token) -> assertThat(token).hasToString(accessToken)));
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK);
assertThat(this.request.getAttribute("cloudFoundryAccessLevel")).isEqualTo(AccessLevel.FULL);
}
@Test
void preHandleSuccessfulWithRestrictedAccess() {
String accessToken = mockAccessToken();
this.request.addHeader("Authorization", "Bearer " + accessToken);
given(this.securityService.getAccessLevel(accessToken, "my-app-id")).willReturn(AccessLevel.RESTRICTED);
SecurityResponse response = this.interceptor.preHandle(this.request, EndpointId.of("info"));
then(this.tokenValidator).should().validate(assertArg((token) -> assertThat(token).hasToString(accessToken)));
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK);
assertThat(this.request.getAttribute("cloudFoundryAccessLevel")).isEqualTo(AccessLevel.RESTRICTED);
}
private String mockAccessToken() {
return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b3B0YWwu"
+ "Y29tIiwiZXhwIjoxNDI2NDIwODAwLCJhd2Vzb21lIjp0cnVlfQ."
+ Base64.getEncoder().encodeToString("signature".getBytes());
}
}

View File

@@ -0,0 +1,207 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet;
import java.util.Map;
import java.util.function.Consumer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.AccessLevel;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
import org.springframework.boot.restclient.RestTemplateBuilder;
import org.springframework.boot.restclient.test.MockServerRestTemplateCustomizer;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestTemplate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withUnauthorizedRequest;
/**
* Tests for {@link CloudFoundrySecurityService}.
*
* @author Madhura Bhave
*/
class CloudFoundrySecurityServiceTests {
private static final String CLOUD_CONTROLLER = "https://my-cloud-controller.com";
private static final String CLOUD_CONTROLLER_PERMISSIONS = CLOUD_CONTROLLER + "/v2/apps/my-app-id/permissions";
private static final String UAA_URL = "https://my-uaa.com";
private CloudFoundrySecurityService securityService;
private MockRestServiceServer server;
@BeforeEach
void setup() {
MockServerRestTemplateCustomizer mockServerCustomizer = new MockServerRestTemplateCustomizer();
RestTemplateBuilder builder = new RestTemplateBuilder(mockServerCustomizer);
this.securityService = new CloudFoundrySecurityService(builder, CLOUD_CONTROLLER, false);
this.server = mockServerCustomizer.getServer();
}
@Test
void skipSslValidationWhenTrue() {
RestTemplateBuilder builder = new RestTemplateBuilder();
this.securityService = new CloudFoundrySecurityService(builder, CLOUD_CONTROLLER, true);
RestTemplate restTemplate = (RestTemplate) ReflectionTestUtils.getField(this.securityService, "restTemplate");
assertThat(restTemplate.getRequestFactory()).isInstanceOf(SkipSslVerificationHttpRequestFactory.class);
}
@Test
void doNotSkipSslValidationWhenFalse() {
RestTemplateBuilder builder = new RestTemplateBuilder();
this.securityService = new CloudFoundrySecurityService(builder, CLOUD_CONTROLLER, false);
RestTemplate restTemplate = (RestTemplate) ReflectionTestUtils.getField(this.securityService, "restTemplate");
assertThat(restTemplate.getRequestFactory()).isNotInstanceOf(SkipSslVerificationHttpRequestFactory.class);
}
@Test
void getAccessLevelWhenSpaceDeveloperShouldReturnFull() {
String responseBody = "{\"read_sensitive_data\": true,\"read_basic_data\": true}";
this.server.expect(requestTo(CLOUD_CONTROLLER_PERMISSIONS))
.andExpect(header("Authorization", "bearer my-access-token"))
.andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON));
AccessLevel accessLevel = this.securityService.getAccessLevel("my-access-token", "my-app-id");
this.server.verify();
assertThat(accessLevel).isEqualTo(AccessLevel.FULL);
}
@Test
void getAccessLevelWhenNotSpaceDeveloperShouldReturnRestricted() {
String responseBody = "{\"read_sensitive_data\": false,\"read_basic_data\": true}";
this.server.expect(requestTo(CLOUD_CONTROLLER_PERMISSIONS))
.andExpect(header("Authorization", "bearer my-access-token"))
.andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON));
AccessLevel accessLevel = this.securityService.getAccessLevel("my-access-token", "my-app-id");
this.server.verify();
assertThat(accessLevel).isEqualTo(AccessLevel.RESTRICTED);
}
@Test
void getAccessLevelWhenTokenIsNotValidShouldThrowException() {
this.server.expect(requestTo(CLOUD_CONTROLLER_PERMISSIONS))
.andExpect(header("Authorization", "bearer my-access-token"))
.andRespond(withUnauthorizedRequest());
assertThatExceptionOfType(CloudFoundryAuthorizationException.class)
.isThrownBy(() -> this.securityService.getAccessLevel("my-access-token", "my-app-id"))
.satisfies(reasonRequirement(Reason.INVALID_TOKEN));
}
@Test
void getAccessLevelWhenForbiddenShouldThrowException() {
this.server.expect(requestTo(CLOUD_CONTROLLER_PERMISSIONS))
.andExpect(header("Authorization", "bearer my-access-token"))
.andRespond(withStatus(HttpStatus.FORBIDDEN));
assertThatExceptionOfType(CloudFoundryAuthorizationException.class)
.isThrownBy(() -> this.securityService.getAccessLevel("my-access-token", "my-app-id"))
.satisfies(reasonRequirement(Reason.ACCESS_DENIED));
}
@Test
void getAccessLevelWhenCloudControllerIsNotReachableThrowsException() {
this.server.expect(requestTo(CLOUD_CONTROLLER_PERMISSIONS))
.andExpect(header("Authorization", "bearer my-access-token"))
.andRespond(withServerError());
assertThatExceptionOfType(CloudFoundryAuthorizationException.class)
.isThrownBy(() -> this.securityService.getAccessLevel("my-access-token", "my-app-id"))
.satisfies(reasonRequirement(Reason.SERVICE_UNAVAILABLE));
}
@Test
void fetchTokenKeysWhenSuccessfulShouldReturnListOfKeysFromUAA() {
this.server.expect(requestTo(CLOUD_CONTROLLER + "/info"))
.andRespond(withSuccess("{\"token_endpoint\":\"https://my-uaa.com\"}", MediaType.APPLICATION_JSON));
String tokenKeyValue = """
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0m59l2u9iDnMbrXHfqkO
rn2dVQ3vfBJqcDuFUK03d+1PZGbVlNCqnkpIJ8syFppW8ljnWweP7+LiWpRoz0I7
fYb3d8TjhV86Y997Fl4DBrxgM6KTJOuE/uxnoDhZQ14LgOU2ckXjOzOdTsnGMKQB
LCl0vpcXBtFLMaSbpv1ozi8h7DJyVZ6EnFQZUWGdgTMhDrmqevfx95U/16c5WBDO
kqwIn7Glry9n9Suxygbf8g5AzpWcusZgDLIIZ7JTUldBb8qU2a0Dl4mvLZOn4wPo
jfj9Cw2QICsc5+Pwf21fP+hzf+1WSRHbnYv8uanRO0gZ8ekGaghM/2H6gqJbo2nI
JwIDAQAB
-----END PUBLIC KEY-----""";
String responseBody = "{\"keys\" : [ {\"kid\":\"test-key\",\"value\" : \"" + tokenKeyValue.replace("\n", "\\n")
+ "\"} ]}";
this.server.expect(requestTo(UAA_URL + "/token_keys"))
.andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON));
Map<String, String> tokenKeys = this.securityService.fetchTokenKeys();
this.server.verify();
assertThat(tokenKeys).containsEntry("test-key", tokenKeyValue);
}
@Test
void fetchTokenKeysWhenNoKeysReturnedFromUAA() {
this.server.expect(requestTo(CLOUD_CONTROLLER + "/info"))
.andRespond(withSuccess("{\"token_endpoint\":\"" + UAA_URL + "\"}", MediaType.APPLICATION_JSON));
String responseBody = "{\"keys\": []}";
this.server.expect(requestTo(UAA_URL + "/token_keys"))
.andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON));
Map<String, String> tokenKeys = this.securityService.fetchTokenKeys();
this.server.verify();
assertThat(tokenKeys).isEmpty();
}
@Test
void fetchTokenKeysWhenUnsuccessfulShouldThrowException() {
this.server.expect(requestTo(CLOUD_CONTROLLER + "/info"))
.andRespond(withSuccess("{\"token_endpoint\":\"" + UAA_URL + "\"}", MediaType.APPLICATION_JSON));
this.server.expect(requestTo(UAA_URL + "/token_keys")).andRespond(withServerError());
assertThatExceptionOfType(CloudFoundryAuthorizationException.class)
.isThrownBy(() -> this.securityService.fetchTokenKeys())
.satisfies(reasonRequirement(Reason.SERVICE_UNAVAILABLE));
}
@Test
void getUaaUrlShouldCallCloudControllerInfoOnlyOnce() {
this.server.expect(requestTo(CLOUD_CONTROLLER + "/info"))
.andRespond(withSuccess("{\"token_endpoint\":\"" + UAA_URL + "\"}", MediaType.APPLICATION_JSON));
String uaaUrl = this.securityService.getUaaUrl();
this.server.verify();
assertThat(uaaUrl).isEqualTo(UAA_URL);
// Second call should not need to hit server
uaaUrl = this.securityService.getUaaUrl();
assertThat(uaaUrl).isEqualTo(UAA_URL);
}
@Test
void getUaaUrlWhenCloudControllerUrlIsNotReachableShouldThrowException() {
this.server.expect(requestTo(CLOUD_CONTROLLER + "/info")).andRespond(withServerError());
assertThatExceptionOfType(CloudFoundryAuthorizationException.class)
.isThrownBy(() -> this.securityService.getUaaUrl())
.satisfies(reasonRequirement(Reason.SERVICE_UNAVAILABLE));
}
private Consumer<CloudFoundryAuthorizationException> reasonRequirement(Reason reason) {
return (ex) -> assertThat(ex.getReason()).isEqualTo(reason);
}
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet;
import org.junit.jupiter.api.Test;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
import org.springframework.boot.actuate.endpoint.web.Link;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet.CloudFoundryWebEndpointServletHandlerMapping.CloudFoundryLinksHandler;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet.CloudFoundryWebEndpointServletHandlerMapping.CloudFoundryWebEndpointServletHandlerMappingRuntimeHints;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link CloudFoundryWebEndpointServletHandlerMapping}.
*
* @author Moritz Halbritter
*/
class CloudFoundryWebEndpointServletHandlerMappingTests {
@Test
void shouldRegisterHints() {
RuntimeHints runtimeHints = new RuntimeHints();
new CloudFoundryWebEndpointServletHandlerMappingRuntimeHints().registerHints(runtimeHints,
getClass().getClassLoader());
assertThat(RuntimeHintsPredicates.reflection().onMethodInvocation(CloudFoundryLinksHandler.class, "links"))
.accepts(runtimeHints);
assertThat(RuntimeHintsPredicates.reflection().onType(Link.class)).accepts(runtimeHints);
}
}

View File

@@ -0,0 +1,83 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet;
import javax.net.ssl.SSLHandshakeException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.testsupport.classpath.resources.WithPackageResources;
import org.springframework.boot.testsupport.web.servlet.ExampleServlet;
import org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory;
import org.springframework.boot.web.server.Ssl;
import org.springframework.boot.web.server.WebServer;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestTemplate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
/**
* Test for {@link SkipSslVerificationHttpRequestFactory}.
*/
class SkipSslVerificationHttpRequestFactoryTests {
private WebServer webServer;
@AfterEach
void shutdownContainer() {
if (this.webServer != null) {
this.webServer.stop();
}
}
@Test
@WithPackageResources("test.jks")
void restCallToSelfSignedServerShouldNotThrowSslException() {
String httpsUrl = getHttpsUrl();
SkipSslVerificationHttpRequestFactory requestFactory = new SkipSslVerificationHttpRequestFactory();
RestTemplate restTemplate = new RestTemplate(requestFactory);
RestTemplate otherRestTemplate = new RestTemplate();
ResponseEntity<String> responseEntity = restTemplate.getForEntity(httpsUrl, String.class);
assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThatExceptionOfType(ResourceAccessException.class)
.isThrownBy(() -> otherRestTemplate.getForEntity(httpsUrl, String.class))
.withCauseInstanceOf(SSLHandshakeException.class);
}
private String getHttpsUrl() {
TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory(0);
factory.setSsl(getSsl("password", "classpath:test.jks"));
this.webServer = factory.getWebServer(new ServletRegistrationBean<>(new ExampleServlet(), "/hello"));
this.webServer.start();
return "https://localhost:" + this.webServer.getPort() + "/hello";
}
private Ssl getSsl(String keyPassword, String keyStore) {
Ssl ssl = new Ssl();
ssl.setEnabled(true);
ssl.setKeyPassword(keyPassword);
ssl.setKeyStore(keyStore);
ssl.setKeyStorePassword("secret");
return ssl;
}
}

View File

@@ -0,0 +1,264 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* 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
*
* https://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.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import java.util.Collections;
import java.util.Map;
import java.util.function.Consumer;
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.junit.jupiter.MockitoExtension;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.Token;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.util.StreamUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.never;
/**
* Tests for {@link TokenValidator}.
*
* @author Madhura Bhave
*/
@ExtendWith(MockitoExtension.class)
class TokenValidatorTests {
private static final byte[] DOT = ".".getBytes();
@Mock
private CloudFoundrySecurityService securityService;
private TokenValidator tokenValidator;
private static final String VALID_KEY = """
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0m59l2u9iDnMbrXHfqkO
rn2dVQ3vfBJqcDuFUK03d+1PZGbVlNCqnkpIJ8syFppW8ljnWweP7+LiWpRoz0I7
fYb3d8TjhV86Y997Fl4DBrxgM6KTJOuE/uxnoDhZQ14LgOU2ckXjOzOdTsnGMKQB
LCl0vpcXBtFLMaSbpv1ozi8h7DJyVZ6EnFQZUWGdgTMhDrmqevfx95U/16c5WBDO
kqwIn7Glry9n9Suxygbf8g5AzpWcusZgDLIIZ7JTUldBb8qU2a0Dl4mvLZOn4wPo
jfj9Cw2QICsc5+Pwf21fP+hzf+1WSRHbnYv8uanRO0gZ8ekGaghM/2H6gqJbo2nI
JwIDAQAB
-----END PUBLIC KEY-----""";
private static final String INVALID_KEY = """
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxzYuc22QSst/dS7geYYK
5l5kLxU0tayNdixkEQ17ix+CUcUbKIsnyftZxaCYT46rQtXgCaYRdJcbB3hmyrOa
vkhTpX79xJZnQmfuamMbZBqitvscxW9zRR9tBUL6vdi/0rpoUwPMEh8+Bw7CgYR0
FK0DhWYBNDfe9HKcyZEv3max8Cdq18htxjEsdYO0iwzhtKRXomBWTdhD5ykd/fAC
VTr4+KEY+IeLvubHVmLUhbE5NgWXxrRpGasDqzKhCTmsa2Ysf712rl57SlH0Wz/M
r3F7aM9YpErzeYLrl0GhQr9BVJxOvXcVd4kmY+XkiCcrkyS1cnghnllh+LCwQu1s
YwIDAQAB
-----END PUBLIC KEY-----""";
private static final Map<String, String> INVALID_KEYS = Collections.singletonMap("invalid-key", INVALID_KEY);
private static final Map<String, String> VALID_KEYS = Collections.singletonMap("valid-key", VALID_KEY);
@BeforeEach
void setup() {
this.tokenValidator = new TokenValidator(this.securityService);
}
@Test
void validateTokenWhenKidValidationFailsTwiceShouldThrowException() {
ReflectionTestUtils.setField(this.tokenValidator, "tokenKeys", INVALID_KEYS);
given(this.securityService.fetchTokenKeys()).willReturn(INVALID_KEYS);
String header = "{\"alg\": \"RS256\", \"kid\": \"valid-key\",\"typ\": \"JWT\"}";
String claims = "{\"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}";
assertThatExceptionOfType(CloudFoundryAuthorizationException.class)
.isThrownBy(
() -> this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
.satisfies(reasonRequirement(Reason.INVALID_KEY_ID));
}
@Test
void validateTokenWhenKidValidationSucceedsInTheSecondAttempt() throws Exception {
ReflectionTestUtils.setField(this.tokenValidator, "tokenKeys", INVALID_KEYS);
given(this.securityService.fetchTokenKeys()).willReturn(VALID_KEYS);
given(this.securityService.getUaaUrl()).willReturn("http://localhost:8080/uaa");
String header = "{ \"alg\": \"RS256\", \"kid\": \"valid-key\",\"typ\": \"JWT\"}";
String claims = "{ \"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}";
this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes())));
then(this.securityService).should().fetchTokenKeys();
}
@Test
void validateTokenShouldFetchTokenKeysIfNull() throws Exception {
given(this.securityService.fetchTokenKeys()).willReturn(VALID_KEYS);
given(this.securityService.getUaaUrl()).willReturn("http://localhost:8080/uaa");
String header = "{ \"alg\": \"RS256\", \"kid\": \"valid-key\",\"typ\": \"JWT\"}";
String claims = "{ \"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}";
this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes())));
then(this.securityService).should().fetchTokenKeys();
}
@Test
void validateTokenWhenValidShouldNotFetchTokenKeys() throws Exception {
ReflectionTestUtils.setField(this.tokenValidator, "tokenKeys", VALID_KEYS);
given(this.securityService.getUaaUrl()).willReturn("http://localhost:8080/uaa");
String header = "{ \"alg\": \"RS256\", \"kid\": \"valid-key\",\"typ\": \"JWT\"}";
String claims = "{ \"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}";
this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes())));
then(this.securityService).should(never()).fetchTokenKeys();
}
@Test
void validateTokenWhenSignatureInvalidShouldThrowException() {
ReflectionTestUtils.setField(this.tokenValidator, "tokenKeys",
Collections.singletonMap("valid-key", INVALID_KEY));
String header = "{ \"alg\": \"RS256\", \"kid\": \"valid-key\",\"typ\": \"JWT\"}";
String claims = "{ \"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}";
assertThatExceptionOfType(CloudFoundryAuthorizationException.class)
.isThrownBy(
() -> this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
.satisfies(reasonRequirement(Reason.INVALID_SIGNATURE));
}
@Test
void validateTokenWhenTokenAlgorithmIsNotRS256ShouldThrowException() {
String header = "{ \"alg\": \"HS256\", \"typ\": \"JWT\"}";
String claims = "{ \"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}";
assertThatExceptionOfType(CloudFoundryAuthorizationException.class)
.isThrownBy(
() -> this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
.satisfies(reasonRequirement(Reason.UNSUPPORTED_TOKEN_SIGNING_ALGORITHM));
}
@Test
void validateTokenWhenExpiredShouldThrowException() {
given(this.securityService.fetchTokenKeys()).willReturn(VALID_KEYS);
given(this.securityService.fetchTokenKeys()).willReturn(VALID_KEYS);
String header = "{ \"alg\": \"RS256\", \"kid\": \"valid-key\", \"typ\": \"JWT\"}";
String claims = "{ \"jti\": \"0236399c350c47f3ae77e67a75e75e7d\", \"exp\": 1477509977, \"scope\": [\"actuator.read\"]}";
assertThatExceptionOfType(CloudFoundryAuthorizationException.class)
.isThrownBy(
() -> this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
.satisfies(reasonRequirement(Reason.TOKEN_EXPIRED));
}
@Test
void validateTokenWhenIssuerIsNotValidShouldThrowException() {
given(this.securityService.fetchTokenKeys()).willReturn(VALID_KEYS);
given(this.securityService.getUaaUrl()).willReturn("https://other-uaa.com");
String header = "{ \"alg\": \"RS256\", \"kid\": \"valid-key\", \"typ\": \"JWT\", \"scope\": [\"actuator.read\"]}";
String claims = "{ \"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\"}";
assertThatExceptionOfType(CloudFoundryAuthorizationException.class)
.isThrownBy(
() -> this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
.satisfies(reasonRequirement(Reason.INVALID_ISSUER));
}
@Test
void validateTokenWhenAudienceIsNotValidShouldThrowException() {
given(this.securityService.fetchTokenKeys()).willReturn(VALID_KEYS);
given(this.securityService.getUaaUrl()).willReturn("http://localhost:8080/uaa");
String header = "{ \"alg\": \"RS256\", \"kid\": \"valid-key\", \"typ\": \"JWT\"}";
String claims = "{ \"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"foo.bar\"]}";
assertThatExceptionOfType(CloudFoundryAuthorizationException.class)
.isThrownBy(
() -> this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
.satisfies(reasonRequirement(Reason.INVALID_AUDIENCE));
}
private String getSignedToken(byte[] header, byte[] claims) throws Exception {
PrivateKey privateKey = getPrivateKey();
Signature signature = Signature.getInstance("SHA256WithRSA");
signature.initSign(privateKey);
byte[] content = dotConcat(Base64.getUrlEncoder().encode(header), Base64.getEncoder().encode(claims));
signature.update(content);
byte[] crypto = signature.sign();
byte[] token = dotConcat(Base64.getUrlEncoder().encode(header), Base64.getUrlEncoder().encode(claims),
Base64.getUrlEncoder().encode(crypto));
return new String(token, StandardCharsets.UTF_8);
}
private PrivateKey getPrivateKey() throws InvalidKeySpecException, NoSuchAlgorithmException {
String signingKey = """
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDSbn2Xa72IOcxu
tcd+qQ6ufZ1VDe98EmpwO4VQrTd37U9kZtWU0KqeSkgnyzIWmlbyWOdbB4/v4uJa
lGjPQjt9hvd3xOOFXzpj33sWXgMGvGAzopMk64T+7GegOFlDXguA5TZyReM7M51O
ycYwpAEsKXS+lxcG0UsxpJum/WjOLyHsMnJVnoScVBlRYZ2BMyEOuap69/H3lT/X
pzlYEM6SrAifsaWvL2f1K7HKBt/yDkDOlZy6xmAMsghnslNSV0FvypTZrQOXia8t
k6fjA+iN+P0LDZAgKxzn4/B/bV8/6HN/7VZJEdudi/y5qdE7SBnx6QZqCEz/YfqC
olujacgnAgMBAAECggEAc9X2tJ/OWWrXqinOg160gkELloJxTi8lAFsDbAGuAwpT
JcWl1KF5CmGBjsY/8ElNi2J9GJL1HOwcBhikCVNARD1DhF6RkB13mvquWwWtTMvt
eP8JWM19DIc+E+hw2rCuTGngqs7l4vTqpzBTNPtS2eiIJ1IsjsgvSEiAlk/wnW48
11cf6SQMQcT3HNTWrS+yLycEuWKb6Khh8RpD9D+i8w2+IspWz5lTP7BrKCUNsLOx
6+5T52HcaZ9z3wMnDqfqIKWl3h8M+q+HFQ4EN5BPWYV4fF7EOx7+Qf2fKDFPoTjC
VTWzDRNAA1xPqwdF7IdPVOXCdaUJDOhHeXZGaTNSwQKBgQDxb9UiR/Jh1R3muL7I
neIt1gXa0O+SK7NWYl4DkArYo7V81ztxI8r+xKEeu5zRZZkpaJHxOnd3VfADascw
UfALvxGxN2z42lE6zdhrmxZ3ma+akQFsv7NyXcBT00sdW+xmOiCaAj0cgxNOXiV3
sYOwUy3SqUIPO2obpb+KC5ALHwKBgQDfH+NSQ/jn89oVZ3lzUORa+Z+aL1TGsgzs
p7IG0MTEYiR9/AExYUwJab0M4PDXhumeoACMfkCFALNVhpch2nXZv7X5445yRgfD
ONY4WknecuA0rfCLTruNWnQ3RR+BXmd9jD/5igd9hEIawz3V+jCHvAtzI8/CZIBt
AArBs5kp+QKBgQCdxwN1n6baIDemK10iJWtFoPO6h4fH8h8EeMwPb/ZmlLVpnA4Q
Zd+mlkDkoJ5eiRKKaPfWuOqRZeuvj/wTq7g/NOIO+bWQ+rrSvuqLh5IrHpgPXmub
8bsHJhUlspMH4KagN6ROgOAG3fGj6Qp7KdpxRCpR3KJ66czxvGNrhxre6QKBgB+s
MCGiYnfSprd5G8VhyziazKwfYeJerfT+DQhopDXYVKPJnQW8cQW5C8wDNkzx6sHI
pqtK1K/MnKhcVaHJmAcT7qoNQlA4Xqu4qrgPIQNBvU/dDRNJVthG6c5aspEzrG8m
9IHgtRV9K8EOy/1O6YqrB9kNUVWf3JccdWpvqyNJAoGAORzJiQCOk4egbdcozDTo
4Tg4qk/03qpTy5k64DxkX1nJHu8V/hsKwq9Af7Fj/iHy2Av54BLPlBaGPwMi2bzB
gYjmUomvx/fqOTQks9Rc4PIMB43p6Rdj0sh+52SKPDR2eHbwsmpuQUXnAs20BPPI
J/OOn5zOs8yf26os0q3+JUM=
-----END PRIVATE KEY-----""";
String privateKey = signingKey.replace("-----BEGIN PRIVATE KEY-----\n", "");
privateKey = privateKey.replace("-----END PRIVATE KEY-----", "");
privateKey = privateKey.replace("\n", "");
byte[] pkcs8EncodedBytes = Base64.getDecoder().decode(privateKey);
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(pkcs8EncodedBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
return keyFactory.generatePrivate(keySpec);
}
private byte[] dotConcat(byte[]... bytes) throws IOException {
ByteArrayOutputStream result = new ByteArrayOutputStream();
for (int i = 0; i < bytes.length; i++) {
if (i > 0) {
StreamUtils.copy(DOT, result);
}
StreamUtils.copy(bytes[i], result);
}
return result.toByteArray();
}
private Consumer<CloudFoundryAuthorizationException> reasonRequirement(Reason reason) {
return (ex) -> assertThat(ex.getReason()).isEqualTo(reason);
}
}