Repository navigation
Enable deploy from url with token
#1928
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
e0c63ee
e91a83f
9e29871
d34867a
ae87857
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -804,3 +804,6 @@ definitions: | |
| password: | ||
| type: "string" | ||
| readOnly: true | ||
| token: | ||
| type: "string" | ||
| readOnly: true | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,6 +23,9 @@ public final class Messages { | |
| public static final String NO_VALID_OBJECT_STORE_CONFIGURATION_FOUND = "No valid Object Store configuration found!"; | ||
| public static final String MISSING_PROPERTIES_FOR_CREATING_THE_SPECIFIC_PROVIDER = "Missing properties for creating the specific provider!"; | ||
| public static final String DEPLOY_FROM_URL_WRONG_CREDENTIALS_FOR_JOB_WITH_ID = "Credentials to {0} are wrong. Make sure that they are correct. Job id: {1}"; | ||
| public static final String DEPLOY_FROM_URL_FORBIDDEN = "Access to {0} is forbidden. Make sure the provided token has the required permissions."; | ||
| public static final String DEPLOY_FROM_URL_NOT_FOUND_WITH_TOKEN = "The resource at {0} was not found. If it is private, verify the provided token is valid and has read access."; | ||
|
Comment on lines
+26
to
+27
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The other deploy-from-URL errors append the job id for traceability, but these two drop it. Could we keep the pattern so the failures can be correlated in the logs? |
||
| public static final String DEPLOY_FROM_URL_CONNECTION_FAILED = "Failed to connect to the remote endpoint. Check the URL is reachable and the server is available."; | ||
| public static final String DEPLOY_FROM_URL_TARGET_ADDRESS_0_DENIED_FOR_JOB_WITH_ID_1 = "Download target host \"{0}\" resolves to a denied address. Job id: {1}"; | ||
| public static final String JOB_NOT_UPDATED_FOR_0_SECONDS = "Job not updated for {0} seconds"; | ||
| public static final String TOO_MANY_ACTIVE_OPERATIONS_IN_SPACE = "Too many active operations in space"; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,11 +11,10 @@ | |
| 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; | ||
| import org.apache.commons.lang3.StringUtils; | ||
| import org.cloudfoundry.multiapps.common.SLException; | ||
| import org.cloudfoundry.multiapps.controller.api.model.UserCredentials; | ||
| import org.cloudfoundry.multiapps.controller.client.util.CheckedSupplier; | ||
|
|
@@ -27,6 +26,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 +36,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 +48,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 { | ||
|
|
@@ -106,7 +107,7 @@ private HttpResponse<InputStream> followRedirectsWithValidation(String url, Stri | |
| return followRedirect(response, url, jobId, userCredentials, redirectCount); | ||
| } | ||
| if (status < 200 || status >= 300) { | ||
| throwErrorResponseException(response, url, status, jobId); | ||
| throwErrorResponseException(response, url, status, jobId, userCredentials); | ||
| } | ||
| return response; | ||
| } | ||
|
|
@@ -130,17 +131,29 @@ private HttpResponse<InputStream> followRedirect(HttpResponse<InputStream> respo | |
| } | ||
|
|
||
| private void throwErrorResponseException(HttpResponse<InputStream> response, String url, int status, | ||
| String jobId) throws IOException { | ||
| String jobId, UserCredentials userCredentials) throws IOException { | ||
| String error = LogSanitizer.sanitize(readErrorBodyFromResponse(response)); | ||
| LOGGER.error(error); | ||
| if (status == HttpStatus.UNAUTHORIZED.value()) { | ||
| throw new SLException(MessageFormat.format(Messages.DEPLOY_FROM_URL_WRONG_CREDENTIALS_FOR_JOB_WITH_ID, | ||
| UriUtil.stripUserInfo(url), jobId)); | ||
| } | ||
| if (status == HttpStatus.FORBIDDEN.value()) { | ||
| throw new SLException(MessageFormat.format(Messages.DEPLOY_FROM_URL_FORBIDDEN, | ||
| UriUtil.stripUserInfo(url))); | ||
| } | ||
| if (status == HttpStatus.NOT_FOUND.value() && hasToken(userCredentials)) { | ||
| throw new SLException(MessageFormat.format(Messages.DEPLOY_FROM_URL_NOT_FOUND_WITH_TOKEN, | ||
| UriUtil.stripUserInfo(url))); | ||
| } | ||
|
Comment on lines
+141
to
+148
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The 404 branch checks |
||
| throw new SLException(MessageFormat.format(Messages.ERROR_FROM_REMOTE_MTAR_ENDPOINT_FOR_JOB_WITH_ID, | ||
| getMaskedUri(urlDecodeUrl(url)), status, error, jobId)); | ||
| } | ||
|
|
||
| private boolean hasToken(UserCredentials userCredentials) { | ||
| return userCredentials != null && StringUtils.isNotBlank(userCredentials.getToken()); | ||
| } | ||
|
|
||
| private boolean isRedirect(int statusCode) { | ||
| return statusCode == HttpStatus.MOVED_PERMANENTLY.value() || statusCode == HttpStatus.FOUND.value() | ||
| || statusCode == HttpStatus.SEE_OTHER.value() || statusCode == HttpStatus.TEMPORARY_REDIRECT.value() | ||
|
|
@@ -175,22 +188,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(); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| package org.cloudfoundry.multiapps.controller.web.upload.client.auth; | ||
|
|
||
| import java.net.URI; | ||
|
|
||
| import jakarta.inject.Named; | ||
|
|
||
| import org.apache.commons.lang3.StringUtils; | ||
| 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: | ||
| * <ol> | ||
| * <li>{@code token} on the credentials object → {@link BearerTokenAuthenticator}</li> | ||
| * <li>username/password on the credentials object → {@link BasicAuthenticator}</li> | ||
| * <li>user info embedded in the URL ({@code user:pass@host}) → {@link UrlUserInfoAuthenticator}</li> | ||
| * <li>otherwise → {@link NoAuthenticator}</li> | ||
| * </ol> | ||
| * 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) { | ||
| if (StringUtils.isNotBlank(userCredentials.getToken())) { | ||
| return new BearerTokenAuthenticator(userCredentials.getToken()); | ||
| } | ||
| return new BasicAuthenticator(userCredentials.getUsername(), userCredentials.getPassword()); | ||
| } | ||
|
Comment on lines
+27
to
+31
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The token branch guards with |
||
| if (URI.create(decodedUrl).getUserInfo() != null) { | ||
| return new UrlUserInfoAuthenticator(decodedUrl); | ||
| } | ||
| return new NoAuthenticator(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <token>} 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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
| * <p> | ||
| * 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. | ||
| * <p> | ||
| * 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); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
| * <p> | ||
| * 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From what I understood readOnly true means the field is only present in responses but not in the request, which in our case is the opposite. I think it shoule be false here