diff --git a/multiapps-controller-api/src/main/java/org/cloudfoundry/multiapps/controller/api/model/UserCredentials.java b/multiapps-controller-api/src/main/java/org/cloudfoundry/multiapps/controller/api/model/UserCredentials.java index 5c3c6a8d9e..f8a0c5d172 100644 --- a/multiapps-controller-api/src/main/java/org/cloudfoundry/multiapps/controller/api/model/UserCredentials.java +++ b/multiapps-controller-api/src/main/java/org/cloudfoundry/multiapps/controller/api/model/UserCredentials.java @@ -22,4 +22,9 @@ public interface UserCredentials { @JsonProperty("password") String getPassword(); //this could potentially contain a TLS certificate as well, if the remote endpoint is a custom registry/repository + + @Nullable + @Value.Parameter + @JsonProperty("token") + String getToken(); } diff --git a/multiapps-controller-api/src/main/resources/mtarest.yaml b/multiapps-controller-api/src/main/resources/mtarest.yaml index 851d4d6640..7a204d1f89 100644 --- a/multiapps-controller-api/src/main/resources/mtarest.yaml +++ b/multiapps-controller-api/src/main/resources/mtarest.yaml @@ -804,3 +804,6 @@ definitions: password: type: "string" readOnly: true + token: + type: "string" + readOnly: true diff --git a/multiapps-controller-web/src/main/java/org/cloudfoundry/multiapps/controller/web/api/impl/FilesApiServiceImpl.java b/multiapps-controller-web/src/main/java/org/cloudfoundry/multiapps/controller/web/api/impl/FilesApiServiceImpl.java index 8a52541155..940de7314a 100644 --- a/multiapps-controller-web/src/main/java/org/cloudfoundry/multiapps/controller/web/api/impl/FilesApiServiceImpl.java +++ b/multiapps-controller-web/src/main/java/org/cloudfoundry/multiapps/controller/web/api/impl/FilesApiServiceImpl.java @@ -128,7 +128,7 @@ public ResponseEntity startUploadFromUrl(String spaceGuid, String namespac .decode(fileUrl.getFileUrl())); String urlWithoutUserInfo = UriUtil.stripUserInfo(decodedUrl); LOGGER.trace(Messages.RECEIVED_UPLOAD_FROM_URL_REQUEST, urlWithoutUserInfo); - filesApiServiceAuditLog.logStartUploadFromUrl(SecurityContextUtil.getUsername(), spaceGuid, decodedUrl); + filesApiServiceAuditLog.logStartUploadFromUrl(SecurityContextUtil.getUsername(), spaceGuid, urlWithoutUserInfo); var existingJob = getExistingJob(spaceGuid, namespace, urlWithoutUserInfo); if (existingJob == null) { diff --git a/multiapps-controller-web/src/main/java/org/cloudfoundry/multiapps/controller/web/upload/client/DeployFromUrlRemoteClient.java b/multiapps-controller-web/src/main/java/org/cloudfoundry/multiapps/controller/web/upload/client/DeployFromUrlRemoteClient.java index 521ac2a8bc..99d63dbbf4 100644 --- a/multiapps-controller-web/src/main/java/org/cloudfoundry/multiapps/controller/web/upload/client/DeployFromUrlRemoteClient.java +++ b/multiapps-controller-web/src/main/java/org/cloudfoundry/multiapps/controller/web/upload/client/DeployFromUrlRemoteClient.java @@ -11,8 +11,6 @@ import java.text.MessageFormat; import java.time.Duration; import java.util.Arrays; -import java.util.Base64; - import jakarta.inject.Inject; import jakarta.inject.Named; import org.apache.commons.io.IOUtils; @@ -27,6 +25,7 @@ import org.cloudfoundry.multiapps.controller.web.Constants; import org.cloudfoundry.multiapps.controller.web.Messages; import org.cloudfoundry.multiapps.controller.web.upload.UploadFromUrlContext; +import org.cloudfoundry.multiapps.controller.web.upload.client.auth.AuthenticatorResolver; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; @@ -36,7 +35,6 @@ public class DeployFromUrlRemoteClient { private static final Duration HTTP_CONNECT_TIMEOUT = Duration.ofMinutes(10); - private static final String USERNAME_PASSWORD_URL_FORMAT = "{0}:{1}"; private static final int ERROR_RESPONSE_BODY_MAX_LENGTH = 4 * 1024; // Ten is value that is used by most HTTP clients (JDK HttpClient, curl, browsers) private static final int MAX_REDIRECTS = 10; @@ -49,11 +47,13 @@ public class DeployFromUrlRemoteClient { private final ApplicationConfiguration applicationConfiguration; private final AddressValidator addressValidator; + private final AuthenticatorResolver authenticatorResolver; @Inject - public DeployFromUrlRemoteClient(ApplicationConfiguration applicationConfiguration, AddressValidator addressValidator) { + public DeployFromUrlRemoteClient(ApplicationConfiguration applicationConfiguration, AddressValidator addressValidator, AuthenticatorResolver authenticatorResolver) { this.applicationConfiguration = applicationConfiguration; this.addressValidator = addressValidator; + this.authenticatorResolver = authenticatorResolver; } public FileFromUrlData downloadFileFromUrl(UploadFromUrlContext uploadFromUrlContext) throws Exception { @@ -175,22 +175,8 @@ private HttpRequest buildFetchFileRequest(String decodedUrl, UserCredentials use .GET() .timeout(Duration.ofMinutes(15)); var uri = URI.create(decodedUrl); - var userInfo = uri.getUserInfo(); - if (userCredentials != null) { - builder.uri(uri); - String userCredentialsUrlFormat = MessageFormat.format(USERNAME_PASSWORD_URL_FORMAT, userCredentials.getUsername(), - userCredentials.getPassword()); - String encodedAuth = Base64.getEncoder() - .encodeToString(userCredentialsUrlFormat.getBytes()); - builder.header(HttpHeaders.AUTHORIZATION, "Basic " + encodedAuth); - } else if (userInfo != null) { - builder.uri(URI.create(decodedUrl.replace(uri.getRawUserInfo() + "@", ""))); - String encodedAuth = Base64.getEncoder() - .encodeToString(userInfo.getBytes()); - builder.header(HttpHeaders.AUTHORIZATION, "Basic " + encodedAuth); - } else { - builder.uri(uri); - } + authenticatorResolver.resolve(userCredentials, decodedUrl) + .applyTo(builder, uri); return builder.build(); } diff --git a/multiapps-controller-web/src/main/java/org/cloudfoundry/multiapps/controller/web/upload/client/auth/AuthenticatorResolver.java b/multiapps-controller-web/src/main/java/org/cloudfoundry/multiapps/controller/web/upload/client/auth/AuthenticatorResolver.java new file mode 100644 index 0000000000..c0fee0f037 --- /dev/null +++ b/multiapps-controller-web/src/main/java/org/cloudfoundry/multiapps/controller/web/upload/client/auth/AuthenticatorResolver.java @@ -0,0 +1,36 @@ +package org.cloudfoundry.multiapps.controller.web.upload.client.auth; + +import java.net.URI; + +import jakarta.inject.Named; + +import org.cloudfoundry.multiapps.controller.api.model.UserCredentials; + +/** + * Resolves the single {@link RequestAuthenticator} to apply to a deploy-from-URL fetch, from the + * request's credentials and the target URI. The precedence is stated here, in one place: + *
    + *
  1. {@code token} on the credentials object → {@link BearerTokenAuthenticator}
  2. + *
  3. username/password on the credentials object → {@link BasicAuthenticator}
  4. + *
  5. user info embedded in the URL ({@code user:pass@host}) → {@link UrlUserInfoAuthenticator}
  6. + *
  7. otherwise → {@link NoAuthenticator}
  8. + *
+ * Adding a new scheme means adding one branch here plus its authenticator class — the downloader and + * the job/orchestration code stay untouched. + */ +@Named +public class AuthenticatorResolver { + + public RequestAuthenticator resolve(UserCredentials userCredentials, String decodedUrl) { + if (userCredentials != null && userCredentials.getToken() != null) { + return new BearerTokenAuthenticator(userCredentials.getToken()); + } + if (userCredentials != null) { + return new BasicAuthenticator(userCredentials.getUsername(), userCredentials.getPassword()); + } + if (URI.create(decodedUrl).getUserInfo() != null) { + return new UrlUserInfoAuthenticator(decodedUrl); + } + return new NoAuthenticator(); + } +} diff --git a/multiapps-controller-web/src/main/java/org/cloudfoundry/multiapps/controller/web/upload/client/auth/BasicAuthenticator.java b/multiapps-controller-web/src/main/java/org/cloudfoundry/multiapps/controller/web/upload/client/auth/BasicAuthenticator.java new file mode 100644 index 0000000000..b99180624c --- /dev/null +++ b/multiapps-controller-web/src/main/java/org/cloudfoundry/multiapps/controller/web/upload/client/auth/BasicAuthenticator.java @@ -0,0 +1,32 @@ +package org.cloudfoundry.multiapps.controller.web.upload.client.auth; + +import java.net.URI; +import java.net.http.HttpRequest; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.springframework.http.HttpHeaders; + +/** + * HTTP Basic authenticator built from an explicit username and password. Encodes {@code username:password} + * as Base64 and sets the {@code Authorization: Basic ...} header. The URI is used as-is. + */ +public class BasicAuthenticator implements RequestAuthenticator { + + private final String username; + private final String password; + + public BasicAuthenticator(String username, String password) { + this.username = username; + this.password = password; + } + + @Override + public void applyTo(HttpRequest.Builder builder, URI uri) { + builder.uri(uri); + String credentials = username + ":" + password; + String encodedAuth = Base64.getEncoder() + .encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + builder.header(HttpHeaders.AUTHORIZATION, "Basic " + encodedAuth); + } +} diff --git a/multiapps-controller-web/src/main/java/org/cloudfoundry/multiapps/controller/web/upload/client/auth/BearerTokenAuthenticator.java b/multiapps-controller-web/src/main/java/org/cloudfoundry/multiapps/controller/web/upload/client/auth/BearerTokenAuthenticator.java new file mode 100644 index 0000000000..0e58740df8 --- /dev/null +++ b/multiapps-controller-web/src/main/java/org/cloudfoundry/multiapps/controller/web/upload/client/auth/BearerTokenAuthenticator.java @@ -0,0 +1,26 @@ +package org.cloudfoundry.multiapps.controller.web.upload.client.auth; + +import java.net.URI; +import java.net.http.HttpRequest; + +import org.springframework.http.HttpHeaders; + +/** + * Bearer-token authenticator. Sets {@code Authorization: Bearer } with the caller-supplied token + * verbatim and targets the URI as-is. This is the scheme required by GitHub (fine-grained/classic PATs, + * GitHub App installation tokens, {@code GITHUB_TOKEN}), which all present identically on the wire. + */ +public class BearerTokenAuthenticator implements RequestAuthenticator { + + private final String token; + + public BearerTokenAuthenticator(String token) { + this.token = token; + } + + @Override + public void applyTo(HttpRequest.Builder builder, URI uri) { + builder.uri(uri); + builder.header(HttpHeaders.AUTHORIZATION, "Bearer " + token); + } +} diff --git a/multiapps-controller-web/src/main/java/org/cloudfoundry/multiapps/controller/web/upload/client/auth/NoAuthenticator.java b/multiapps-controller-web/src/main/java/org/cloudfoundry/multiapps/controller/web/upload/client/auth/NoAuthenticator.java new file mode 100644 index 0000000000..acee9a3c7d --- /dev/null +++ b/multiapps-controller-web/src/main/java/org/cloudfoundry/multiapps/controller/web/upload/client/auth/NoAuthenticator.java @@ -0,0 +1,16 @@ +package org.cloudfoundry.multiapps.controller.web.upload.client.auth; + +import java.net.URI; +import java.net.http.HttpRequest; + +/** + * No-op authenticator: targets the URI as-is and attaches no {@code Authorization} header. Used when + * the request carries no credentials. + */ +public class NoAuthenticator implements RequestAuthenticator { + + @Override + public void applyTo(HttpRequest.Builder builder, URI uri) { + builder.uri(uri); + } +} diff --git a/multiapps-controller-web/src/main/java/org/cloudfoundry/multiapps/controller/web/upload/client/auth/RequestAuthenticator.java b/multiapps-controller-web/src/main/java/org/cloudfoundry/multiapps/controller/web/upload/client/auth/RequestAuthenticator.java new file mode 100644 index 0000000000..a5c0287c94 --- /dev/null +++ b/multiapps-controller-web/src/main/java/org/cloudfoundry/multiapps/controller/web/upload/client/auth/RequestAuthenticator.java @@ -0,0 +1,25 @@ +package org.cloudfoundry.multiapps.controller.web.upload.client.auth; + +import java.net.URI; +import java.net.http.HttpRequest; + +/** + * Strategy for attaching authentication to an outbound deploy-from-URL fetch request. + *

+ * Each implementation owns a single authentication scheme: how it encodes its credential and which + * header (if any) it sets. The downloader stays agnostic of Base64 encoding and header names. + *

+ * The resolver ({@link AuthenticatorResolver}) picks exactly one authenticator per request based on + * the supplied credentials and the URL. Implementations must not mutate anything other than the + * request builder (and the request URI, when they strip embedded user info from it). + */ +public interface RequestAuthenticator { + + /** + * Applies this scheme to the request being built. + * + * @param builder the request builder to mutate (set the URI and, when applicable, the {@code Authorization} header) + * @param uri the target URI as parsed from the decoded URL + */ + void applyTo(HttpRequest.Builder builder, URI uri); +} diff --git a/multiapps-controller-web/src/main/java/org/cloudfoundry/multiapps/controller/web/upload/client/auth/UrlUserInfoAuthenticator.java b/multiapps-controller-web/src/main/java/org/cloudfoundry/multiapps/controller/web/upload/client/auth/UrlUserInfoAuthenticator.java new file mode 100644 index 0000000000..2f58444750 --- /dev/null +++ b/multiapps-controller-web/src/main/java/org/cloudfoundry/multiapps/controller/web/upload/client/auth/UrlUserInfoAuthenticator.java @@ -0,0 +1,34 @@ +package org.cloudfoundry.multiapps.controller.web.upload.client.auth; + +import java.net.URI; +import java.net.http.HttpRequest; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.springframework.http.HttpHeaders; + +/** + * HTTP Basic authenticator built from user info embedded in the URL itself ({@code https://user:pass@host/...}). + * Strips the raw user-info component from the URI (so it is not sent in the request line) and sets + * {@code Authorization: Basic ...} from the decoded user-info string. + *

+ * This preserves the legacy behavior for callers that embed credentials in the URL; it is deliberately + * not extended for new schemes — new credentials should be passed as explicit fields instead. + */ +public class UrlUserInfoAuthenticator implements RequestAuthenticator { + + private final String decodedUrl; + + public UrlUserInfoAuthenticator(String decodedUrl) { + this.decodedUrl = decodedUrl; + } + + @Override + public void applyTo(HttpRequest.Builder builder, URI uri) { + builder.uri(URI.create(decodedUrl.replace(uri.getRawUserInfo() + "@", ""))); + String encodedAuth = Base64.getEncoder() + .encodeToString(uri.getUserInfo() + .getBytes(StandardCharsets.UTF_8)); + builder.header(HttpHeaders.AUTHORIZATION, "Basic " + encodedAuth); + } +} diff --git a/multiapps-controller-web/src/test/java/org/cloudfoundry/multiapps/controller/web/api/impl/FilesApiServiceImplTest.java b/multiapps-controller-web/src/test/java/org/cloudfoundry/multiapps/controller/web/api/impl/FilesApiServiceImplTest.java index f89d48eac2..ee0bbe15a2 100644 --- a/multiapps-controller-web/src/test/java/org/cloudfoundry/multiapps/controller/web/api/impl/FilesApiServiceImplTest.java +++ b/multiapps-controller-web/src/test/java/org/cloudfoundry/multiapps/controller/web/api/impl/FilesApiServiceImplTest.java @@ -234,6 +234,35 @@ void testStartDeployFromUrl() { .executeUploadFromUrl(eq(SPACE_GUID), eq(NAMESPACE), any(String.class), any(String.class), eq(null)); } + @Test + void testStartDeployFromUrlAuditLogsStrippedUrl() { + AsyncUploadJobsQuery asyncUploadJobsQuery = mock(AsyncUploadJobsQuery.class, RETURNS_SELF); + when(uploadJobService.createQuery()).thenReturn(asyncUploadJobsQuery); + when(asyncUploadJobsQuery.list()).thenReturn(Collections.emptyList()); + + when(asyncUploadJobOrchestrator.executeUploadFromUrl(any(), any(), any(), any(), any())).thenReturn( + ImmutableAsyncUploadJobEntry.builder() + .id(UUID.randomUUID() + .toString()) + .url("https://google.com") + .startedAt(JOB_TIMESTAMP) + .state(State.INITIAL) + .user("user1") + .spaceGuid(SPACE_GUID) + .instanceIndex(0) + .build()); + + String fileUrlWithUserInfo = Base64.getUrlEncoder() + .encodeToString(DECODED_URL_WITH_CREDENTIALS_IN_THE_URL.getBytes(StandardCharsets.UTF_8)); + testedClass.startUploadFromUrl(SPACE_GUID, NAMESPACE, ImmutableFileUrl.builder() + .fileUrl(fileUrlWithUserInfo) + .build()); + + // The audit log must receive the userinfo-stripped URL, never the raw user:pass@ form. + verify(filesApiServiceAuditLog).logStartUploadFromUrl(eq("user1"), eq(SPACE_GUID), eq("https://google.com")); + verify(filesApiServiceAuditLog, never()).logStartUploadFromUrl(any(), any(), eq(DECODED_URL_WITH_CREDENTIALS_IN_THE_URL)); + } + @Test void testStartDeployFromUrlWhenExecutorRejectsJob() { AsyncUploadJobsQuery asyncUploadJobsQuery = mock(AsyncUploadJobsQuery.class, RETURNS_SELF); diff --git a/multiapps-controller-web/src/test/java/org/cloudfoundry/multiapps/controller/web/upload/client/DeployFromUrlRemoteClientTest.java b/multiapps-controller-web/src/test/java/org/cloudfoundry/multiapps/controller/web/upload/client/DeployFromUrlRemoteClientTest.java index da41bec479..0f5f5f9384 100644 --- a/multiapps-controller-web/src/test/java/org/cloudfoundry/multiapps/controller/web/upload/client/DeployFromUrlRemoteClientTest.java +++ b/multiapps-controller-web/src/test/java/org/cloudfoundry/multiapps/controller/web/upload/client/DeployFromUrlRemoteClientTest.java @@ -22,6 +22,7 @@ import org.cloudfoundry.multiapps.controller.web.Constants; import org.cloudfoundry.multiapps.controller.web.Messages; import org.cloudfoundry.multiapps.controller.web.upload.UploadFromUrlContext; +import org.cloudfoundry.multiapps.controller.web.upload.client.auth.AuthenticatorResolver; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; @@ -80,7 +81,7 @@ private class TestableDeployFromUrlRemoteClient extends DeployFromUrlRemoteClien public TestableDeployFromUrlRemoteClient(ApplicationConfiguration applicationConfiguration, AddressValidator addressValidator) { - super(applicationConfiguration, addressValidator); + super(applicationConfiguration, addressValidator, new AuthenticatorResolver()); } @Override @@ -115,13 +116,7 @@ void setUp() throws Exception { @Test void downloadFileFromUrlSuccess() throws Exception { - InputStream inputStream = new ByteArrayInputStream("test content".getBytes()); - when(httpResponse.body()).thenReturn(inputStream); - when(httpResponse.headers()).thenReturn(httpHeaders); - when(httpResponse.uri()).thenReturn(URI.create(SECURE_URL)); - when(httpResponse.statusCode()).thenReturn(200); - when(httpHeaders.firstValueAsLong(Constants.CONTENT_LENGTH)).thenReturn(OptionalLong.of(FILE_SIZE)); - when(httpClient.send(any(HttpRequest.class), eq(HttpResponse.BodyHandlers.ofInputStream()))).thenReturn(httpResponse); + InputStream inputStream = stubSuccessfulHttpResponse(SECURE_URL); FileFromUrlData result = client.downloadFileFromUrl(uploadContext); @@ -180,13 +175,7 @@ void downloadFileFromUrlFileSizeExceedsLimit() throws Exception { @Test void callRemoteEndpointWithRetrySuccess() throws Exception { - InputStream inputStream = new ByteArrayInputStream("test content".getBytes()); - when(httpResponse.statusCode()).thenReturn(200); - when(httpResponse.body()).thenReturn(inputStream); - when(httpClient.send(any(HttpRequest.class), eq(HttpResponse.BodyHandlers.ofInputStream()))).thenReturn(httpResponse); - - when(httpResponse.headers()).thenReturn(httpHeaders); - when(httpHeaders.firstValueAsLong(Constants.CONTENT_LENGTH)).thenReturn(OptionalLong.of(FILE_SIZE)); + stubSuccessfulHttpResponse(SECURE_URL); FileFromUrlData result = client.downloadFileFromUrl(uploadContext); verify(httpClient).send(any(HttpRequest.class), eq(HttpResponse.BodyHandlers.ofInputStream())); @@ -278,13 +267,7 @@ void tooManyRedirectsThrowsException() throws Exception { @Test void downloadFileFromUrlWithNullUserCredentials() throws Exception { when(uploadContext.getUserCredentials()).thenReturn(null); - InputStream inputStream = new ByteArrayInputStream("test content".getBytes()); - when(httpResponse.body()).thenReturn(inputStream); - when(httpResponse.headers()).thenReturn(httpHeaders); - when(httpResponse.uri()).thenReturn(URI.create(SECURE_URL)); - when(httpResponse.statusCode()).thenReturn(200); - when(httpHeaders.firstValueAsLong(Constants.CONTENT_LENGTH)).thenReturn(OptionalLong.of(FILE_SIZE)); - when(httpClient.send(any(HttpRequest.class), eq(HttpResponse.BodyHandlers.ofInputStream()))).thenReturn(httpResponse); + InputStream inputStream = stubSuccessfulHttpResponse(SECURE_URL); FileFromUrlData result = client.downloadFileFromUrl(uploadContext); @@ -307,17 +290,10 @@ void resilientOperationExecutorExceptionHandling() throws Exception { void downloadFileFromUrlWithCredentialsInUrl() throws Exception { when(uploadContext.getFileUrl()).thenReturn(URL_WITH_CREDENTIALS); when(uploadContext.getUserCredentials()).thenReturn(null); - InputStream inputStream = new ByteArrayInputStream("test content".getBytes()); - when(httpResponse.body()).thenReturn(inputStream); - when(httpResponse.headers()).thenReturn(httpHeaders); - when(httpResponse.uri()).thenReturn(URI.create("https://example.com/file.zip")); - when(httpResponse.statusCode()).thenReturn(200); - when(httpHeaders.firstValueAsLong(Constants.CONTENT_LENGTH)).thenReturn(OptionalLong.of(FILE_SIZE)); - when(httpClient.send(any(HttpRequest.class), eq(HttpResponse.BodyHandlers.ofInputStream()))).thenReturn(httpResponse); + stubSuccessfulHttpResponse("https://example.com/file.zip"); FileFromUrlData result = client.downloadFileFromUrl(uploadContext); - assertEquals(inputStream, result.fileInputStream()); assertEquals(URI.create("https://example.com/file.zip"), result.uri()); assertEquals(FILE_SIZE, result.fileSize()); @@ -337,13 +313,7 @@ void downloadFileFromUrlWithCredentialsInUrl() throws Exception { @Test void downloadFileFromUrlWithEncodedCharacters() throws Exception { when(uploadContext.getFileUrl()).thenReturn(URL_WITH_ENCODED_CHARS); - InputStream inputStream = new ByteArrayInputStream("test content".getBytes()); - when(httpResponse.body()).thenReturn(inputStream); - when(httpResponse.headers()).thenReturn(httpHeaders); - when(httpResponse.uri()).thenReturn(URI.create("https://example.com/file%20with%20spaces.zip")); - when(httpResponse.statusCode()).thenReturn(200); - when(httpHeaders.firstValueAsLong(Constants.CONTENT_LENGTH)).thenReturn(OptionalLong.of(FILE_SIZE)); - when(httpClient.send(any(HttpRequest.class), eq(HttpResponse.BodyHandlers.ofInputStream()))).thenReturn(httpResponse); + InputStream inputStream = stubSuccessfulHttpResponse("https://example.com/file%20with%20spaces.zip"); FileFromUrlData result = client.downloadFileFromUrl(uploadContext); @@ -352,6 +322,17 @@ void downloadFileFromUrlWithEncodedCharacters() throws Exception { assertEquals(FILE_SIZE, result.fileSize()); } + private InputStream stubSuccessfulHttpResponse(String responseUri) throws Exception { + InputStream inputStream = new ByteArrayInputStream("test content".getBytes()); + when(httpResponse.body()).thenReturn(inputStream); + when(httpResponse.headers()).thenReturn(httpHeaders); + when(httpResponse.uri()).thenReturn(URI.create(responseUri)); + when(httpResponse.statusCode()).thenReturn(200); + when(httpHeaders.firstValueAsLong(Constants.CONTENT_LENGTH)).thenReturn(OptionalLong.of(FILE_SIZE)); + when(httpClient.send(any(HttpRequest.class), eq(HttpResponse.BodyHandlers.ofInputStream()))).thenReturn(httpResponse); + return inputStream; + } + private HttpResponse mockRedirectResponse(int status, String location) { HttpResponse response = Mockito.mock(HttpResponse.class); HttpHeaders headers = HttpHeaders.of(Map.of(LOCATION, List.of(location)), (_, _) -> true); @@ -360,4 +341,33 @@ private HttpResponse mockRedirectResponse(int status, String locati when(response.body()).thenReturn(new ByteArrayInputStream(new byte[0])); return response; } + @Test + void downloadFileFromUrlWithTokenSendsBearer() throws Exception { + when(userCredentials.getToken()).thenReturn("ghp_secret_token"); + stubSuccessfulHttpResponse(SECURE_URL); + + client.downloadFileFromUrl(uploadContext); + + verify(httpClient).send(argThat(request -> request.headers() + .firstValue("Authorization") + .filter(value -> value.equals("Bearer ghp_secret_token")) + .isPresent()), + eq(HttpResponse.BodyHandlers.ofInputStream())); + } + + @Test + void downloadFileFromUrlTokenTakesPrecedenceOverBasic() throws Exception { + when(userCredentials.getToken()).thenReturn("ghp_secret_token"); + // username/password are also set in setUp(); the token must win. + stubSuccessfulHttpResponse(SECURE_URL); + + client.downloadFileFromUrl(uploadContext); + + verify(httpClient).send(argThat(request -> request.headers() + .firstValue("Authorization") + .filter(value -> value.equals("Bearer ghp_secret_token")) + .isPresent()), + eq(HttpResponse.BodyHandlers.ofInputStream())); + } + } diff --git a/multiapps-controller-web/src/test/java/org/cloudfoundry/multiapps/controller/web/upload/client/auth/AuthenticatorResolverTest.java b/multiapps-controller-web/src/test/java/org/cloudfoundry/multiapps/controller/web/upload/client/auth/AuthenticatorResolverTest.java new file mode 100644 index 0000000000..9ff12678d5 --- /dev/null +++ b/multiapps-controller-web/src/test/java/org/cloudfoundry/multiapps/controller/web/upload/client/auth/AuthenticatorResolverTest.java @@ -0,0 +1,119 @@ +package org.cloudfoundry.multiapps.controller.web.upload.client.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.net.http.HttpRequest; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.cloudfoundry.multiapps.controller.api.model.ImmutableUserCredentials; +import org.cloudfoundry.multiapps.controller.api.model.UserCredentials; +import org.junit.jupiter.api.Test; + +class AuthenticatorResolverTest { + + private static final String PLAIN_URL = "https://example.com/file.mtar"; + private static final String URL_WITH_USERINFO = "https://user:pass@example.com/file.mtar"; + private static final String TOKEN = "ghp_token"; + private static final String USERNAME = "user"; + private static final String PASSWORD = "pass"; + + private final AuthenticatorResolver resolver = new AuthenticatorResolver(); + + @Test + void tokenResolvesToBearer() { + UserCredentials credentials = ImmutableUserCredentials.builder() + .token(TOKEN) + .build(); + RequestAuthenticator authenticator = resolver.resolve(credentials, PLAIN_URL); + assertInstanceOf(BearerTokenAuthenticator.class, authenticator); + } + + @Test + void tokenTakesPrecedenceOverUsernamePassword() { + UserCredentials credentials = ImmutableUserCredentials.builder() + .username(USERNAME) + .password(PASSWORD) + .token(TOKEN) + .build(); + RequestAuthenticator authenticator = resolver.resolve(credentials, PLAIN_URL); + assertInstanceOf(BearerTokenAuthenticator.class, authenticator); + } + + @Test + void usernamePasswordResolvesToBasic() { + UserCredentials credentials = ImmutableUserCredentials.builder() + .username(USERNAME) + .password(PASSWORD) + .build(); + RequestAuthenticator authenticator = resolver.resolve(credentials, PLAIN_URL); + assertInstanceOf(BasicAuthenticator.class, authenticator); + } + + @Test + void urlUserInfoResolvesToUrlUserInfoAuthenticator() { + RequestAuthenticator authenticator = resolver.resolve(null, URL_WITH_USERINFO); + assertInstanceOf(UrlUserInfoAuthenticator.class, authenticator); + } + + @Test + void noCredentialsResolvesToNoAuthenticator() { + RequestAuthenticator authenticator = resolver.resolve(null, PLAIN_URL); + assertInstanceOf(NoAuthenticator.class, authenticator); + } + + @Test + void bearerAuthenticatorSetsBearerHeader() { + HttpRequest request = applyToRequest(new BearerTokenAuthenticator(TOKEN), URI.create(PLAIN_URL)); + assertEquals("Bearer " + TOKEN, authorizationHeader(request)); + assertEquals(PLAIN_URL, request.uri() + .toString()); + } + + @Test + void basicAuthenticatorSetsBasicHeader() { + HttpRequest request = applyToRequest(new BasicAuthenticator(USERNAME, PASSWORD), URI.create(PLAIN_URL)); + String expected = "Basic " + Base64.getEncoder() + .encodeToString("user:pass".getBytes(StandardCharsets.UTF_8)); + assertEquals(expected, authorizationHeader(request)); + } + + @Test + void urlUserInfoAuthenticatorStripsUserInfoAndSetsBasicHeader() { + URI uri = URI.create(URL_WITH_USERINFO); + HttpRequest request = applyToRequest(new UrlUserInfoAuthenticator(URL_WITH_USERINFO), uri); + assertFalse(request.uri() + .toString() + .contains("user:pass@")); + String expected = "Basic " + Base64.getEncoder() + .encodeToString("user:pass".getBytes(StandardCharsets.UTF_8)); + assertEquals(expected, authorizationHeader(request)); + } + + @Test + void noAuthenticatorSetsNoHeader() { + HttpRequest request = applyToRequest(new NoAuthenticator(), URI.create(PLAIN_URL)); + assertTrue(request.headers() + .firstValue("Authorization") + .isEmpty()); + assertEquals(PLAIN_URL, request.uri() + .toString()); + } + + private HttpRequest applyToRequest(RequestAuthenticator authenticator, URI uri) { + HttpRequest.Builder builder = HttpRequest.newBuilder() + .GET(); + authenticator.applyTo(builder, uri); + return builder.build(); + } + + private String authorizationHeader(HttpRequest request) { + return request.headers() + .firstValue("Authorization") + .orElseThrow(); + } +}