diff --git a/assembly/assembly-wsmaster-war/src/main/java/org/eclipse/che/api/deploy/WsMasterModule.java b/assembly/assembly-wsmaster-war/src/main/java/org/eclipse/che/api/deploy/WsMasterModule.java index c6e011f35d..3347f5b5e4 100644 --- a/assembly/assembly-wsmaster-war/src/main/java/org/eclipse/che/api/deploy/WsMasterModule.java +++ b/assembly/assembly-wsmaster-war/src/main/java/org/eclipse/che/api/deploy/WsMasterModule.java @@ -180,6 +180,7 @@ protected void configure() { scmFileResolverResolverMultibinder.addBinding().to(GitSshScmFileResolver.class); install(new org.eclipse.che.api.factory.server.scm.KubernetesScmModule()); + install(new org.eclipse.che.security.oauth.KubernetesOAuthModule()); install(new org.eclipse.che.api.factory.server.bitbucket.BitbucketServerModule()); install(new org.eclipse.che.api.factory.server.gitlab.GitlabModule()); install(new org.eclipse.che.api.factory.server.github.GithubModule()); diff --git a/infrastructures/infrastructure-factory/pom.xml b/infrastructures/infrastructure-factory/pom.xml index 2aa8824e8a..ed3bf6cc89 100644 --- a/infrastructures/infrastructure-factory/pom.xml +++ b/infrastructures/infrastructure-factory/pom.xml @@ -51,6 +51,10 @@ jakarta.ws.rs jakarta.ws.rs-api + + org.eclipse.che.core + che-core-api-auth + org.eclipse.che.core che-core-api-core diff --git a/infrastructures/infrastructure-factory/src/main/java/org/eclipse/che/security/oauth/KubernetesOAuthModule.java b/infrastructures/infrastructure-factory/src/main/java/org/eclipse/che/security/oauth/KubernetesOAuthModule.java new file mode 100644 index 0000000000..776eaab85f --- /dev/null +++ b/infrastructures/infrastructure-factory/src/main/java/org/eclipse/che/security/oauth/KubernetesOAuthModule.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2012-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package org.eclipse.che.security.oauth; + +import com.google.inject.AbstractModule; +import org.eclipse.che.security.oauth.kubernetes.KubernetesUserWorkspaceUrlProvider; + +/** Binds the Kubernetes backed implementations of the OAuth SPI. */ +public class KubernetesOAuthModule extends AbstractModule { + @Override + protected void configure() { + bind(UserWorkspaceUrlProvider.class).to(KubernetesUserWorkspaceUrlProvider.class); + } +} diff --git a/infrastructures/infrastructure-factory/src/main/java/org/eclipse/che/security/oauth/kubernetes/KubernetesUserWorkspaceUrlProvider.java b/infrastructures/infrastructure-factory/src/main/java/org/eclipse/che/security/oauth/kubernetes/KubernetesUserWorkspaceUrlProvider.java new file mode 100644 index 0000000000..e67260203a --- /dev/null +++ b/infrastructures/infrastructure-factory/src/main/java/org/eclipse/che/security/oauth/kubernetes/KubernetesUserWorkspaceUrlProvider.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2012-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package org.eclipse.che.security.oauth.kubernetes; + +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; +import io.fabric8.kubernetes.client.KubernetesClientException; +import io.fabric8.kubernetes.client.dsl.base.ResourceDefinitionContext; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import javax.inject.Inject; +import javax.inject.Singleton; +import org.eclipse.che.api.core.ServerException; +import org.eclipse.che.api.workspace.server.spi.InfrastructureException; +import org.eclipse.che.api.workspace.server.spi.NamespaceResolutionContext; +import org.eclipse.che.commons.env.EnvironmentContext; +import org.eclipse.che.security.oauth.UserWorkspaceUrlProvider; +import org.eclipse.che.workspace.infrastructure.kubernetes.CheServerKubernetesClientFactory; +import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.KubernetesNamespaceFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Reads the main URLs of the current user's workspaces from the {@code DevWorkspace} custom + * resources living in the namespace of that user. + * + *

{@code status.mainUrl} is published by the DevWorkspace Operator and holds the URL of the + * endpoint marked with the {@code type: main} attribute, which for a browser IDE is the URL the + * workbench itself is served from. Comparing against it, rather than reconstructing the URL layout + * that the Che operator generates, keeps this check correct for both the {@code + * ////} and the legacy {@code ////} + * path strategies. + * + *

Both of those are gateway routed, which is what every editor definition shipped with the Che + * operator asks for by declaring {@code urlRewriteSupported: true} on its {@code type: main} + * endpoint. The Che operator publishes gateway routed endpoints as {@code https} and under a path + * that names either the user or the workspace, which is what makes the main URL usable as an + * authorization boundary in the first place. + * + *

An editor definition that turns {@code urlRewriteSupported} off is exposed through a dedicated + * Route or Ingress instead. Its main URL then names a host of its own, carries only whatever the + * endpoint declares as its {@code path}, and is {@code https} only if the endpoint asks to be + * secure. The redirect is refused for such a workspace: {@code + * OAuthIdeRedirectManager#isLocatedUnder} rejects an empty path, because matching on the host alone + * would accept the workspace of any other user on the same host, and the callback URL is required + * to be {@code https}. + */ +@Singleton +public class KubernetesUserWorkspaceUrlProvider implements UserWorkspaceUrlProvider { + private static final Logger LOG = + LoggerFactory.getLogger(KubernetesUserWorkspaceUrlProvider.class); + + private static final ResourceDefinitionContext DEV_WORKSPACE_CONTEXT = + new ResourceDefinitionContext.Builder() + .withGroup("workspace.devfile.io") + .withVersion("v1alpha2") + .withKind("DevWorkspace") + .withPlural("devworkspaces") + .withNamespaced(true) + .build(); + + private final KubernetesNamespaceFactory namespaceFactory; + private final CheServerKubernetesClientFactory cheServerKubernetesClientFactory; + + @Inject + public KubernetesUserWorkspaceUrlProvider( + KubernetesNamespaceFactory namespaceFactory, + CheServerKubernetesClientFactory cheServerKubernetesClientFactory) { + this.namespaceFactory = namespaceFactory; + this.cheServerKubernetesClientFactory = cheServerKubernetesClientFactory; + } + + @Override + public Set getWorkspaceUrls() throws ServerException { + Set urls = new LinkedHashSet<>(); + try { + String namespace = + namespaceFactory.evaluateNamespaceName( + new NamespaceResolutionContext(EnvironmentContext.getCurrent().getSubject())); + List devWorkspaces = + cheServerKubernetesClientFactory + .create() + .genericKubernetesResources(DEV_WORKSPACE_CONTEXT) + .inNamespace(namespace) + .list() + .getItems(); + for (GenericKubernetesResource devWorkspace : devWorkspaces) { + Object mainUrl = devWorkspace.get("status", "mainUrl"); + if (mainUrl instanceof String && !((String) mainUrl).isBlank()) { + urls.add((String) mainUrl); + } + } + } catch (InfrastructureException | KubernetesClientException e) { + // The message of a Kubernetes API failure names the service account and the namespaces it + // was denied, and the message of a ServerException is returned to the caller. Keep it here. + LOG.warn("Failed to read the workspaces of the current user: {}", e.getMessage(), e); + throw new ServerException("Failed to read the workspaces of the current user"); + } + LOG.debug("Resolved {} workspace URL(s) for the current user", urls.size()); + return urls; + } +} diff --git a/infrastructures/infrastructure-factory/src/test/java/org/eclipse/che/security/oauth/kubernetes/KubernetesUserWorkspaceUrlProviderTest.java b/infrastructures/infrastructure-factory/src/test/java/org/eclipse/che/security/oauth/kubernetes/KubernetesUserWorkspaceUrlProviderTest.java new file mode 100644 index 0000000000..b4b9f84674 --- /dev/null +++ b/infrastructures/infrastructure-factory/src/test/java/org/eclipse/che/security/oauth/kubernetes/KubernetesUserWorkspaceUrlProviderTest.java @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2012-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package org.eclipse.che.security.oauth.kubernetes; + +import static java.util.Collections.emptyList; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; +import static org.testng.Assert.fail; + +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; +import io.fabric8.kubernetes.api.model.GenericKubernetesResourceList; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientException; +import io.fabric8.kubernetes.client.dsl.MixedOperation; +import io.fabric8.kubernetes.client.dsl.NonNamespaceOperation; +import io.fabric8.kubernetes.client.dsl.Resource; +import io.fabric8.kubernetes.client.dsl.base.ResourceDefinitionContext; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.eclipse.che.api.core.ServerException; +import org.eclipse.che.api.workspace.server.spi.InfrastructureException; +import org.eclipse.che.api.workspace.server.spi.NamespaceResolutionContext; +import org.eclipse.che.commons.env.EnvironmentContext; +import org.eclipse.che.commons.subject.SubjectImpl; +import org.eclipse.che.workspace.infrastructure.kubernetes.CheServerKubernetesClientFactory; +import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.KubernetesNamespaceFactory; +import org.mockito.Mock; +import org.mockito.testng.MockitoTestNGListener; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Listeners; +import org.testng.annotations.Test; + +@Listeners(MockitoTestNGListener.class) +public class KubernetesUserWorkspaceUrlProviderTest { + + private static final String NAMESPACE = "alice-che"; + + @Mock private KubernetesNamespaceFactory namespaceFactory; + @Mock private CheServerKubernetesClientFactory clientFactory; + @Mock private KubernetesClient kubeClient; + + @Mock + private MixedOperation< + GenericKubernetesResource, + GenericKubernetesResourceList, + Resource> + devWorkspacesOperation; + + private KubernetesUserWorkspaceUrlProvider provider; + + @BeforeMethod + public void setUp() throws Exception { + provider = new KubernetesUserWorkspaceUrlProvider(namespaceFactory, clientFactory); + when(clientFactory.create()).thenReturn(kubeClient); + when(kubeClient.genericKubernetesResources(any(ResourceDefinitionContext.class))) + .thenReturn(devWorkspacesOperation); + when(namespaceFactory.evaluateNamespaceName(any(NamespaceResolutionContext.class))) + .thenReturn(NAMESPACE); + + EnvironmentContext context = new EnvironmentContext(); + context.setSubject(new SubjectImpl("alice", emptyList(), "alice-id", "token", false)); + EnvironmentContext.setCurrent(context); + } + + @AfterMethod + public void tearDown() { + EnvironmentContext.reset(); + } + + @Test + public void shouldReturnMainUrlsOfTheDevWorkspacesOfTheUser() throws Exception { + mockDevWorkspaces( + NAMESPACE, + devWorkspace("https://che.example.com/alice/first/3100/"), + devWorkspace("https://che.example.com/alice/second/3100/")); + + Set urls = provider.getWorkspaceUrls(); + + assertEquals( + urls, + Set.of( + "https://che.example.com/alice/first/3100/", + "https://che.example.com/alice/second/3100/")); + } + + /** Only the namespace Che resolves for the current user may be read, and no other. */ + @Test + public void shouldReadOnlyTheNamespaceResolvedForTheCurrentUser() throws Exception { + mockDevWorkspaces(NAMESPACE, devWorkspace("https://che.example.com/alice/first/3100/")); + + provider.getWorkspaceUrls(); + + verify(devWorkspacesOperation).inNamespace(NAMESPACE); + verifyNoMoreInteractions(devWorkspacesOperation); + } + + @Test + public void shouldSkipDevWorkspacesWithoutMainUrl() throws Exception { + mockDevWorkspaces( + NAMESPACE, + devWorkspaceWithoutStatus(), + devWorkspace(null), + devWorkspace(""), + devWorkspace(" "), + devWorkspace("https://che.example.com/alice/first/3100/")); + + Set urls = provider.getWorkspaceUrls(); + + assertEquals(urls, Set.of("https://che.example.com/alice/first/3100/")); + } + + /** The CRD does not constrain us to a string here, so a non string value must not blow up. */ + @Test + public void shouldSkipDevWorkspacesWithANonStringMainUrl() throws Exception { + GenericKubernetesResource devWorkspace = devWorkspace(null); + ((Map) devWorkspace.getAdditionalProperties().get("status")) + .put("mainUrl", List.of("https://che.example.com/alice/first/3100/")); + mockDevWorkspaces(NAMESPACE, devWorkspace); + + assertTrue(provider.getWorkspaceUrls().isEmpty()); + } + + @Test + public void shouldReturnEmptySetWhenTheUserHasNoDevWorkspaces() throws Exception { + mockDevWorkspaces(NAMESPACE); + + assertTrue(provider.getWorkspaceUrls().isEmpty()); + } + + @Test(expectedExceptions = ServerException.class) + public void shouldFailWhenTheNamespaceCannotBeResolved() throws Exception { + when(namespaceFactory.evaluateNamespaceName(any(NamespaceResolutionContext.class))) + .thenThrow(new InfrastructureException("no namespace")); + + provider.getWorkspaceUrls(); + } + + @Test(expectedExceptions = ServerException.class) + public void shouldFailWhenTheDevWorkspacesCannotBeRead() throws Exception { + mockUnreadableDevWorkspaces(); + + provider.getWorkspaceUrls(); + } + + /** + * The message of a {@link ServerException} is serialized into the response body, and a Kubernetes + * API failure names the service account and the namespaces it was denied. + */ + @Test + public void shouldNotLeakTheKubernetesFailureIntoTheExceptionMessage() throws Exception { + mockUnreadableDevWorkspaces(); + + try { + provider.getWorkspaceUrls(); + fail("Expected a ServerException"); + } catch (ServerException e) { + assertFalse(e.getMessage().contains("system:serviceaccount:eclipse-che:che"), e.getMessage()); + assertFalse(e.getMessage().contains(NAMESPACE), e.getMessage()); + } + } + + private void mockUnreadableDevWorkspaces() { + NonNamespaceOperation< + GenericKubernetesResource, + GenericKubernetesResourceList, + Resource> + inNamespace = mock(NonNamespaceOperation.class); + when(devWorkspacesOperation.inNamespace(NAMESPACE)).thenReturn(inNamespace); + when(inNamespace.list()) + .thenThrow( + new KubernetesClientException( + "devworkspaces.workspace.devfile.io is forbidden: User" + + " \"system:serviceaccount:eclipse-che:che\" cannot list resource in namespace" + + " \"" + + NAMESPACE + + "\"")); + } + + private void mockDevWorkspaces(String namespace, GenericKubernetesResource... devWorkspaces) { + NonNamespaceOperation< + GenericKubernetesResource, + GenericKubernetesResourceList, + Resource> + inNamespace = mock(NonNamespaceOperation.class); + GenericKubernetesResourceList list = new GenericKubernetesResourceList(); + list.setItems(List.of(devWorkspaces)); + when(devWorkspacesOperation.inNamespace(namespace)).thenReturn(inNamespace); + when(inNamespace.list()).thenReturn(list); + } + + private static GenericKubernetesResource devWorkspace(String mainUrl) { + GenericKubernetesResource devWorkspace = new GenericKubernetesResource(); + devWorkspace.setApiVersion("workspace.devfile.io/v1alpha2"); + devWorkspace.setKind("DevWorkspace"); + Map status = new HashMap<>(); + if (mainUrl != null) { + status.put("mainUrl", mainUrl); + } + devWorkspace.setAdditionalProperty("status", status); + return devWorkspace; + } + + /** A DevWorkspace that has not been reconciled yet has no {@code status} at all. */ + private static GenericKubernetesResource devWorkspaceWithoutStatus() { + GenericKubernetesResource devWorkspace = new GenericKubernetesResource(); + devWorkspace.setApiVersion("workspace.devfile.io/v1alpha2"); + devWorkspace.setKind("DevWorkspace"); + return devWorkspace; + } +} diff --git a/wsmaster/che-core-api-auth/pom.xml b/wsmaster/che-core-api-auth/pom.xml index 3b277fe456..36c626788a 100644 --- a/wsmaster/che-core-api-auth/pom.xml +++ b/wsmaster/che-core-api-auth/pom.xml @@ -27,6 +27,10 @@ false + + com.google.code.gson + gson + com.google.guava guava diff --git a/wsmaster/che-core-api-auth/src/main/java/org/eclipse/che/security/oauth/OAuthAuthenticationService.java b/wsmaster/che-core-api-auth/src/main/java/org/eclipse/che/security/oauth/OAuthAuthenticationService.java index 11aae456f9..7a4b5a9794 100644 --- a/wsmaster/che-core-api-auth/src/main/java/org/eclipse/che/security/oauth/OAuthAuthenticationService.java +++ b/wsmaster/che-core-api-auth/src/main/java/org/eclipse/che/security/oauth/OAuthAuthenticationService.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2012-2025 Red Hat, Inc. + * Copyright (c) 2012-2026 Red Hat, Inc. * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ @@ -40,6 +40,7 @@ public class OAuthAuthenticationService extends Service { @Inject private OAuthAPI oAuthAPI; @Inject private AuthorisationRequestManager authorisationRequestManager; + @Inject private OAuthIdeRedirectManager ideRedirectManager; /** * Redirect request to OAuth provider site for authentication|authorization. Client must provide @@ -74,6 +75,17 @@ public Response callback(@QueryParam("errorValues") List errorValues) return oAuthAPI.callback(uriInfo, errorValues); } + /** + * Processes an OAuth callback issued to the IDE redirect proxy and redirects to the workspace IDE + * with the authorization code. Used by browser-based IDE extensions that cannot register a + * protocol based {@code redirect_uri}. + */ + @GET + @Path("ide-redirect") + public Response ideRedirect() throws BadRequestException, ForbiddenException, ServerException { + return ideRedirectManager.ideRedirect(uriInfo); + } + /** * Gets list of installed OAuth authenticators. * diff --git a/wsmaster/che-core-api-auth/src/main/java/org/eclipse/che/security/oauth/OAuthIdeRedirectManager.java b/wsmaster/che-core-api-auth/src/main/java/org/eclipse/che/security/oauth/OAuthIdeRedirectManager.java new file mode 100644 index 0000000000..e930fdebee --- /dev/null +++ b/wsmaster/che-core-api-auth/src/main/java/org/eclipse/che/security/oauth/OAuthIdeRedirectManager.java @@ -0,0 +1,284 @@ +/* + * Copyright (c) 2012-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package org.eclipse.che.security.oauth; + +import com.google.common.annotations.VisibleForTesting; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriBuilder; +import jakarta.ws.rs.core.UriBuilderException; +import jakarta.ws.rs.core.UriInfo; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Set; +import javax.inject.Inject; +import javax.inject.Singleton; +import org.eclipse.che.api.core.BadRequestException; +import org.eclipse.che.api.core.ForbiddenException; +import org.eclipse.che.api.core.ServerException; +import org.eclipse.che.commons.env.EnvironmentContext; +import org.eclipse.che.commons.subject.Subject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Stateless OAuth redirect proxy for browser-based IDE extensions. + * + *

IDE extensions (e.g. GitLab Workflow) running in a browser workspace cannot use protocol-based + * redirect URIs ({@code vscode://...}). {@link OAuthAuthenticationService#ideRedirect()} acts as a + * stable, pre-registrable OAuth {@code redirect_uri} that forwards the authorization code to the + * dynamic workspace callback URL. + * + *

The workspace callback URL is encoded in the OAuth {@code state} parameter as a base64url JSON + * object: {@code {"s":"","c":""}}. + * + *

The {@code state} parameter is chosen by whoever builds the authorization URL, so the callback + * URL it carries is untrusted input. Before the authorization code is forwarded, the callback URL + * is checked to be located under the main URL of a workspace that belongs to the authenticated user + * of the current request (see {@link UserWorkspaceUrlProvider}). Without that check an attacker + * could hand a victim an authorization URL pointing at the attacker's own workspace and collect the + * victim's authorization code. + */ +@Singleton +public class OAuthIdeRedirectManager { + private static final Logger LOG = LoggerFactory.getLogger(OAuthIdeRedirectManager.class); + + /** Name of the {@code state} field holding the CSRF token expected by the IDE extension. */ + private static final String STATE_CSRF_FIELD = "s"; + + /** Name of the {@code state} field holding the workspace callback URL. */ + private static final String STATE_CALLBACK_URL_FIELD = "c"; + + private final UserWorkspaceUrlProvider userWorkspaceUrlProvider; + + @Inject + public OAuthIdeRedirectManager(UserWorkspaceUrlProvider userWorkspaceUrlProvider) { + this.userWorkspaceUrlProvider = userWorkspaceUrlProvider; + } + + /** + * Receives an OAuth callback from the identity provider and redirects to the workspace IDE with + * the authorization code and CSRF state. + */ + public Response ideRedirect(UriInfo uriInfo) + throws BadRequestException, ForbiddenException, ServerException { + Subject subject = EnvironmentContext.getCurrent().getSubject(); + if (subject == null || subject.isAnonymous()) { + throw new ForbiddenException("IDE OAuth redirect requires an authenticated user"); + } + + MultivaluedMap params = uriInfo.getQueryParameters(); + String code = params.getFirst("code"); + String error = params.getFirst("error"); + if (isNullOrBlank(code) && isNullOrBlank(error)) { + throw new BadRequestException("Missing both 'code' and 'error' query parameters"); + } + + JsonObject state = decodeState(params.getFirst("state")); + String csrfState = getStringField(state, STATE_CSRF_FIELD); + URI callbackUri = parseCallbackUrl(getStringField(state, STATE_CALLBACK_URL_FIELD)); + + // Read once: the check below is repeated on the URL that is actually redirected to. + Set workspaceUrls = userWorkspaceUrlProvider.getWorkspaceUrls(); + authorizeCallbackUrl(subject, callbackUri, workspaceUrls); + + UriBuilder target = UriBuilder.fromUri(callbackUri).queryParam("state", csrfState); + if (!isNullOrBlank(code)) { + target.queryParam("code", code); + } + if (!isNullOrBlank(error)) { + target.queryParam("error", error); + String errorDescription = params.getFirst("error_description"); + if (!isNullOrBlank(errorDescription)) { + target.queryParam("error_description", errorDescription); + } + } + + URI redirectTarget; + try { + redirectTarget = target.build(); + } catch (IllegalArgumentException | UriBuilderException e) { + throw new BadRequestException("Unable to build the redirect URL: " + e.getMessage()); + } + + // The check above was made against the URL the target is derived from. Repeat it on the exact + // value that is handed to the redirect, so that the guarantee holds at the point of use. + authorizeCallbackUrl(subject, redirectTarget, workspaceUrls); + + return Response.temporaryRedirect(redirectTarget) + .header("Cache-Control", "no-store") + .header("Referrer-Policy", "no-referrer") + .build(); + } + + /** + * Fails unless the callback URL is located under the main URL of one of the workspaces of the + * given user. + * + * @param workspaceUrls main URLs of the workspaces of {@code subject}, see {@link + * UserWorkspaceUrlProvider} + */ + private void authorizeCallbackUrl(Subject subject, URI callbackUri, Set workspaceUrls) + throws ForbiddenException { + for (String workspaceUrl : workspaceUrls) { + if (isLocatedUnder(callbackUri, workspaceUrl)) { + return; + } + } + // The callback URL is attacker controlled, so it is logged at debug level only. + LOG.warn( + "IDE OAuth redirect blocked: the callback URL does not belong to any of the {} workspace(s)" + + " of user '{}'", + workspaceUrls.size(), + subject.getUserName()); + LOG.debug( + "IDE OAuth redirect blocked: callback URL '{}' is not under any of {}", + callbackUri, + workspaceUrls); + throw new ForbiddenException( + "The callback URL does not belong to a workspace of the authenticated user"); + } + + /** + * Returns {@code true} if {@code callbackUri} addresses the same origin as {@code workspaceUrl} + * and its path is {@code workspaceUrl}'s path or a path segment underneath it. + * + *

Comparison is segment aware, so {@code /user/wksp/3100} does not match {@code + * /user/wksp/31000}. Only the origin and the path of {@code workspaceUrl} are taken into account: + * the main URL published by the DevWorkspace Operator may carry a query string of its own. + */ + @VisibleForTesting + static boolean isLocatedUnder(URI callbackUri, String workspaceUrl) { + if (isNullOrBlank(workspaceUrl)) { + return false; + } + URI workspaceUri; + try { + workspaceUri = new URI(workspaceUrl).normalize(); + } catch (URISyntaxException e) { + LOG.warn("Ignoring unparseable workspace URL '{}': {}", workspaceUrl, e.getMessage()); + return false; + } + if (workspaceUri.getScheme() == null || workspaceUri.getHost() == null) { + return false; + } + if (!workspaceUri.getScheme().equalsIgnoreCase(callbackUri.getScheme()) + || !workspaceUri.getHost().equalsIgnoreCase(callbackUri.getHost()) + || effectivePort(workspaceUri) != effectivePort(callbackUri)) { + return false; + } + + String workspacePath = trimTrailingSlashes(workspaceUri.getPath()); + // An empty workspace path would turn every path into a match. + if (workspacePath.isEmpty()) { + return false; + } + String callbackPath = trimTrailingSlashes(callbackUri.getPath()); + return callbackPath.equals(workspacePath) || callbackPath.startsWith(workspacePath + "/"); + } + + /** Decodes the base64url encoded JSON object carried by the OAuth {@code state} parameter. */ + private static JsonObject decodeState(String state) throws BadRequestException { + if (isNullOrBlank(state)) { + throw new BadRequestException("Missing 'state' query parameter"); + } + try { + byte[] jsonBytes = Base64.getUrlDecoder().decode(state); + return JsonParser.parseString(new String(jsonBytes, StandardCharsets.UTF_8)) + .getAsJsonObject(); + } catch (IllegalArgumentException | IllegalStateException | JsonParseException e) { + throw new BadRequestException("Invalid 'state' parameter: not a valid base64url JSON object"); + } + } + + private static String getStringField(JsonObject state, String field) throws BadRequestException { + try { + if (state.has(field) && state.get(field).getAsJsonPrimitive().isString()) { + String value = state.get(field).getAsString(); + if (!value.isBlank()) { + return value; + } + } + } catch (IllegalStateException | ClassCastException e) { + // fall through to the exception below + } + throw new BadRequestException( + "Invalid 'state' parameter: missing or malformed field '" + field + "'"); + } + + /** Parses and sanity checks the workspace callback URL taken from the {@code state} parameter. */ + private static URI parseCallbackUrl(String callbackUrl) throws BadRequestException { + URI uri; + try { + uri = new URI(callbackUrl); + } catch (URISyntaxException e) { + throw new BadRequestException("Invalid callback URL in 'state' parameter: " + e.getMessage()); + } + if (!uri.isAbsolute() || uri.isOpaque() || uri.getHost() == null) { + throw new BadRequestException("Callback URL must be an absolute URL with a host"); + } + // The redirect carries the authorization code in its query string, so it must not travel in + // cleartext. This costs nothing in practice: the Che operator publishes the main URL of a + // gateway routed workspace as https whatever the ingress actually serves, and a workspace that + // really is served over http fails the scheme comparison in isLocatedUnder anyway. + if (!uri.getScheme().equalsIgnoreCase("https")) { + throw new BadRequestException("Callback URL must use the https scheme"); + } + if (uri.getUserInfo() != null) { + throw new BadRequestException("Callback URL must not contain user information"); + } + if (uri.getFragment() != null) { + throw new BadRequestException("Callback URL must not contain a fragment"); + } + // The authorization check below compares decoded paths, so any percent encoding in the path is + // a chance for the two forms to disagree. A workspace path is built from normalized segments + // and never needs encoding, so reject it outright rather than reasoning about which escapes are + // harmless. Percent encoding in the query, which the IDE does use, is unaffected. + String rawPath = uri.getRawPath(); + if (rawPath == null || rawPath.indexOf('%') >= 0) { + throw new BadRequestException("Callback URL must not contain a percent encoded path"); + } + URI normalized = uri.normalize(); + if (normalized.getPath().contains("..")) { + throw new BadRequestException("Callback URL must not contain relative path segments"); + } + return normalized; + } + + /** Returns the port of the URI, substituting the default port of its scheme when unset. */ + private static int effectivePort(URI uri) { + if (uri.getPort() != -1) { + return uri.getPort(); + } + return "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80; + } + + private static String trimTrailingSlashes(String path) { + if (path == null) { + return ""; + } + int end = path.length(); + while (end > 0 && path.charAt(end - 1) == '/') { + end--; + } + return path.substring(0, end); + } + + private static boolean isNullOrBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/wsmaster/che-core-api-auth/src/main/java/org/eclipse/che/security/oauth/UserWorkspaceUrlProvider.java b/wsmaster/che-core-api-auth/src/main/java/org/eclipse/che/security/oauth/UserWorkspaceUrlProvider.java new file mode 100644 index 0000000000..6c5a07f59b --- /dev/null +++ b/wsmaster/che-core-api-auth/src/main/java/org/eclipse/che/security/oauth/UserWorkspaceUrlProvider.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2012-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package org.eclipse.che.security.oauth; + +import java.util.Set; +import org.eclipse.che.api.core.ServerException; + +/** + * Supplies the main URLs of the workspaces that belong to the user of the current request. + * + *

This is the authorization boundary of the OAuth IDE redirect proxy: {@link + * OAuthIdeRedirectManager} only forwards an authorization code to a URL that is located under one + * of the returned URLs. The implementation is infrastructure specific and resolves the user from + * {@link org.eclipse.che.commons.env.EnvironmentContext}. + */ +public interface UserWorkspaceUrlProvider { + + /** + * Returns the main URLs of the workspaces owned by the user of the current request. Never {@code + * null}; an empty set means that no workspace URL may be used as a redirect target. + * + * @throws ServerException if the workspaces of the current user cannot be resolved + */ + Set getWorkspaceUrls() throws ServerException; +} diff --git a/wsmaster/che-core-api-auth/src/test/java/org/eclipse/che/security/oauth/OAuthIdeRedirectManagerTest.java b/wsmaster/che-core-api-auth/src/test/java/org/eclipse/che/security/oauth/OAuthIdeRedirectManagerTest.java new file mode 100644 index 0000000000..e3f9bd2090 --- /dev/null +++ b/wsmaster/che-core-api-auth/src/test/java/org/eclipse/che/security/oauth/OAuthIdeRedirectManagerTest.java @@ -0,0 +1,380 @@ +/* + * Copyright (c) 2012-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package org.eclipse.che.security.oauth; + +import static java.util.Collections.emptyList; +import static java.util.Collections.emptySet; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +import com.google.common.collect.ImmutableSet; +import com.google.gson.JsonObject; +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import org.eclipse.che.api.core.BadRequestException; +import org.eclipse.che.api.core.ForbiddenException; +import org.eclipse.che.commons.env.EnvironmentContext; +import org.eclipse.che.commons.subject.Subject; +import org.eclipse.che.commons.subject.SubjectImpl; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class OAuthIdeRedirectManagerTest { + + private static final String CHE_HOST = "https://che.apps.cluster.example.com"; + + /** Main URL of the workspace of the authenticated user, as published by the DevWorkspace. */ + private static final String OWN_WORKSPACE_URL = CHE_HOST + "/alice/nodejs-angular/3100/"; + + /** Main URL of a workspace of a different user on the same Che host. */ + private static final String FOREIGN_WORKSPACE_URL = CHE_HOST + "/mallory/collector/3100/"; + + /** Callback the browser IDE of the authenticated user actually listens on. */ + private static final String OWN_CALLBACK_URL = + CHE_HOST + + "/alice/nodejs-angular/3100/callback?vscode-reqid=1&vscode-scheme=code-oss" + + "&vscode-authority=gitlab.gitlab-workflow&vscode-path=%2Fauthentication"; + + private UserWorkspaceUrlProvider workspaceUrlProvider; + private OAuthIdeRedirectManager manager; + + @BeforeMethod + public void setUp() throws Exception { + workspaceUrlProvider = mock(UserWorkspaceUrlProvider.class); + when(workspaceUrlProvider.getWorkspaceUrls()).thenReturn(ImmutableSet.of(OWN_WORKSPACE_URL)); + manager = new OAuthIdeRedirectManager(workspaceUrlProvider); + setSubject(new SubjectImpl("alice", emptyList(), "alice-id", "token", false)); + } + + @AfterMethod + public void tearDown() { + EnvironmentContext.reset(); + } + + @Test + public void shouldRedirectToOwnWorkspaceWithCodeAndState() throws Exception { + UriInfo uriInfo = + mockUriInfo("code", "auth-code-abc", "state", compositeState("csrf-123", OWN_CALLBACK_URL)); + + Response response = manager.ideRedirect(uriInfo); + + assertEquals(response.getStatus(), 307); + String location = location(response); + assertTrue(location.startsWith(CHE_HOST + "/alice/nodejs-angular/3100/callback?"), location); + assertTrue(location.contains("code=auth-code-abc"), location); + assertTrue(location.contains("state=csrf-123"), location); + // the query the IDE put into the callback URL must survive + assertTrue(location.contains("vscode-reqid=1"), location); + assertTrue(location.contains("vscode-path=%2Fauthentication"), location); + assertEquals(response.getMetadata().getFirst("Cache-Control"), "no-store"); + assertEquals(response.getMetadata().getFirst("Referrer-Policy"), "no-referrer"); + } + + @Test + public void shouldForwardErrorParams() throws Exception { + UriInfo uriInfo = + mockUriInfo( + "state", compositeState("csrf", OWN_CALLBACK_URL), + "error", "access_denied", + "error_description", "User denied access"); + + String location = location(manager.ideRedirect(uriInfo)); + + assertTrue(location.contains("error=access_denied"), location); + assertTrue(location.contains("error_description=User"), location); + assertFalse(location.contains("code="), location); + } + + /** + * The callback URL is authorized twice: once early and once on the value handed to the redirect. + * Resolving the workspaces hits the Kubernetes API, so it must happen only once per request. + */ + @Test + public void shouldResolveTheWorkspacesOfTheUserOnlyOnce() throws Exception { + manager.ideRedirect( + mockUriInfo("code", "c", "state", compositeState("csrf", OWN_CALLBACK_URL))); + + verify(workspaceUrlProvider, times(1)).getWorkspaceUrls(); + } + + @Test + public void shouldAcceptAnyOfTheWorkspacesOfTheUser() throws Exception { + when(workspaceUrlProvider.getWorkspaceUrls()) + .thenReturn(ImmutableSet.of(CHE_HOST + "/alice/other/3100/", OWN_WORKSPACE_URL)); + + Response response = + manager.ideRedirect( + mockUriInfo("code", "c", "state", compositeState("csrf", OWN_CALLBACK_URL))); + + assertEquals(response.getStatus(), 307); + } + + // --- the callback URL must belong to the authenticated user ------------------------------- + + @Test(expectedExceptions = ForbiddenException.class) + public void shouldRejectCallbackToAnotherUsersWorkspace() throws Exception { + String foreignCallback = CHE_HOST + "/mallory/collector/3100/callback?vscode-reqid=1"; + + manager.ideRedirect(mockUriInfo("code", "c", "state", compositeState("csrf", foreignCallback))); + } + + @Test(expectedExceptions = ForbiddenException.class) + public void shouldRejectCallbackWhenUserHasNoWorkspaces() throws Exception { + when(workspaceUrlProvider.getWorkspaceUrls()).thenReturn(emptySet()); + + manager.ideRedirect( + mockUriInfo("code", "c", "state", compositeState("csrf", OWN_CALLBACK_URL))); + } + + @Test(expectedExceptions = ForbiddenException.class) + public void shouldRejectCallbackOutsideOfTheWorkspacePath() throws Exception { + manager.ideRedirect( + mockUriInfo("code", "c", "state", compositeState("csrf", CHE_HOST + "/callback"))); + } + + @Test(expectedExceptions = ForbiddenException.class) + public void shouldRejectPathTraversalOutOfTheWorkspacePath() throws Exception { + String traversal = CHE_HOST + "/alice/nodejs-angular/3100/../../../mallory/collector/3100/cb"; + + manager.ideRedirect(mockUriInfo("code", "c", "state", compositeState("csrf", traversal))); + } + + @Test(expectedExceptions = ForbiddenException.class) + public void shouldRejectAnonymousUser() throws Exception { + setSubject(Subject.ANONYMOUS); + + manager.ideRedirect( + mockUriInfo("code", "c", "state", compositeState("csrf", OWN_CALLBACK_URL))); + } + + // --- callback URL syntax ------------------------------------------------------------------ + + @Test(expectedExceptions = BadRequestException.class) + public void shouldRejectCallbackWithUserInfo() throws Exception { + String withUserInfo = "https://alice@che.apps.cluster.example.com/alice/nodejs-angular/3100/cb"; + + manager.ideRedirect(mockUriInfo("code", "c", "state", compositeState("csrf", withUserInfo))); + } + + @Test(expectedExceptions = BadRequestException.class) + public void shouldRejectCallbackWithFragment() throws Exception { + String withFragment = OWN_WORKSPACE_URL + "callback#fragment"; + + manager.ideRedirect(mockUriInfo("code", "c", "state", compositeState("csrf", withFragment))); + } + + @Test(expectedExceptions = BadRequestException.class) + public void shouldRejectCallbackWithEncodedPathSeparator() throws Exception { + String encodedSlash = CHE_HOST + "/alice%2Fnodejs-angular/3100/callback"; + + manager.ideRedirect(mockUriInfo("code", "c", "state", compositeState("csrf", encodedSlash))); + } + + /** + * The authorization check compares decoded paths, so percent encoding in the path is rejected + * whether or not it decodes to something dangerous. + */ + @Test(expectedExceptions = BadRequestException.class) + public void shouldRejectCallbackWithAnyPercentEncodedPath() throws Exception { + String encodedDash = CHE_HOST + "/alice/nodejs%2Dangular/3100/callback"; + + manager.ideRedirect(mockUriInfo("code", "c", "state", compositeState("csrf", encodedDash))); + } + + /** Only the path is restricted: the IDE legitimately percent encodes its callback query. */ + @Test + public void shouldAcceptAPercentEncodedQuery() throws Exception { + Response response = + manager.ideRedirect( + mockUriInfo("code", "c", "state", compositeState("csrf", OWN_CALLBACK_URL))); + + assertEquals(response.getStatus(), 307); + assertTrue(location(response).contains("vscode-path=%2Fauthentication")); + } + + @Test(expectedExceptions = BadRequestException.class) + public void shouldRejectCallbackWithNonHttpScheme() throws Exception { + manager.ideRedirect( + mockUriInfo("code", "c", "state", compositeState("csrf", "ftp://che.example.com/cb"))); + } + + /** The redirect carries the authorization code in its query string. */ + @Test(expectedExceptions = BadRequestException.class) + public void shouldRejectCleartextCallback() throws Exception { + String cleartext = "http://che.apps.cluster.example.com/alice/nodejs-angular/3100/callback"; + + manager.ideRedirect(mockUriInfo("code", "c", "state", compositeState("csrf", cleartext))); + } + + /** Even when the workspace itself publishes a cleartext main URL. */ + @Test(expectedExceptions = BadRequestException.class) + public void shouldRejectCleartextCallbackToACleartextWorkspace() throws Exception { + String cleartextWorkspace = "http://che.apps.cluster.example.com/alice/nodejs-angular/3100/"; + when(workspaceUrlProvider.getWorkspaceUrls()).thenReturn(ImmutableSet.of(cleartextWorkspace)); + + manager.ideRedirect( + mockUriInfo("code", "c", "state", compositeState("csrf", cleartextWorkspace + "callback"))); + } + + @Test(expectedExceptions = BadRequestException.class) + public void shouldRejectRelativeCallbackUrl() throws Exception { + manager.ideRedirect( + mockUriInfo("code", "c", "state", compositeState("csrf", "/alice/ws/3100/cb"))); + } + + // --- state and code parameters -------------------------------------------------------------- + + @Test(expectedExceptions = BadRequestException.class) + public void shouldRejectMissingState() throws Exception { + manager.ideRedirect(mockUriInfo("code", "auth-code")); + } + + @Test(expectedExceptions = BadRequestException.class) + public void shouldRejectNonBase64State() throws Exception { + manager.ideRedirect(mockUriInfo("code", "auth-code", "state", "not!base64!json")); + } + + @Test(expectedExceptions = BadRequestException.class) + public void shouldRejectStateWithoutCallbackUrl() throws Exception { + JsonObject json = new JsonObject(); + json.addProperty("s", "csrf"); + + manager.ideRedirect(mockUriInfo("code", "auth-code", "state", encode(json))); + } + + @Test(expectedExceptions = BadRequestException.class) + public void shouldRejectStateWithoutCsrfToken() throws Exception { + JsonObject json = new JsonObject(); + json.addProperty("c", OWN_CALLBACK_URL); + + manager.ideRedirect(mockUriInfo("code", "auth-code", "state", encode(json))); + } + + @Test(expectedExceptions = BadRequestException.class) + public void shouldRejectStateWithNonStringCallbackUrl() throws Exception { + JsonObject json = new JsonObject(); + json.addProperty("s", "csrf"); + json.add("c", new JsonObject()); + + manager.ideRedirect(mockUriInfo("code", "auth-code", "state", encode(json))); + } + + @Test(expectedExceptions = BadRequestException.class) + public void shouldRejectMissingCodeAndError() throws Exception { + manager.ideRedirect(mockUriInfo("state", compositeState("csrf", OWN_CALLBACK_URL))); + } + + // --- isLocatedUnder ------------------------------------------------------------------------- + + @Test + public void isLocatedUnderMatchesTheWorkspaceRootItself() { + assertTrue(isLocatedUnder(CHE_HOST + "/alice/ws/3100", CHE_HOST + "/alice/ws/3100/")); + assertTrue(isLocatedUnder(CHE_HOST + "/alice/ws/3100/", CHE_HOST + "/alice/ws/3100")); + } + + @Test + public void isLocatedUnderComparesWholePathSegments() { + assertFalse(isLocatedUnder(CHE_HOST + "/alice/ws/31000/cb", CHE_HOST + "/alice/ws/3100/")); + assertFalse(isLocatedUnder(CHE_HOST + "/alice/wsp/3100/cb", CHE_HOST + "/alice/ws/3100/")); + } + + @Test + public void isLocatedUnderIgnoresTheQueryOfTheWorkspaceUrl() { + assertTrue( + isLocatedUnder( + CHE_HOST + "/alice/ws/3100/cb", CHE_HOST + "/alice/ws/3100/?tkn=eclipse-che")); + } + + @Test + public void isLocatedUnderRequiresTheSameOrigin() { + assertFalse( + isLocatedUnder("https://evil.example.com/alice/nodejs-angular/3100/cb", OWN_WORKSPACE_URL)); + assertFalse( + isLocatedUnder( + "http://che.apps.cluster.example.com/alice/nodejs-angular/3100/cb", OWN_WORKSPACE_URL)); + assertFalse(isLocatedUnder(CHE_HOST + ":8443/alice/nodejs-angular/3100/cb", OWN_WORKSPACE_URL)); + } + + @Test + public void isLocatedUnderTreatsTheDefaultPortAsEqualToTheImplicitOne() { + assertTrue(isLocatedUnder(CHE_HOST + ":443/alice/nodejs-angular/3100/cb", OWN_WORKSPACE_URL)); + } + + @Test + public void isLocatedUnderNeverMatchesABlankOrRootWorkspaceUrl() { + assertFalse(isLocatedUnder(CHE_HOST + "/anything", null)); + assertFalse(isLocatedUnder(CHE_HOST + "/anything", "")); + assertFalse(isLocatedUnder(CHE_HOST + "/anything", CHE_HOST)); + assertFalse(isLocatedUnder(CHE_HOST + "/anything", CHE_HOST + "/")); + } + + @Test + public void isLocatedUnderIgnoresUnparseableWorkspaceUrls() { + assertFalse(isLocatedUnder(CHE_HOST + "/alice/ws/3100/cb", "not a url")); + assertFalse(isLocatedUnder(CHE_HOST + "/alice/ws/3100/cb", "/alice/ws/3100/")); + } + + @Test + public void isLocatedUnderDoesNotMatchAForeignWorkspaceOnTheSameHost() { + assertFalse(isLocatedUnder(CHE_HOST + "/alice/ws/3100/cb", FOREIGN_WORKSPACE_URL)); + } + + // --- helpers --------------------------------------------------------------------------------- + + private static boolean isLocatedUnder(String callbackUrl, String workspaceUrl) { + return OAuthIdeRedirectManager.isLocatedUnder(URI.create(callbackUrl), workspaceUrl); + } + + private static void setSubject(Subject subject) { + EnvironmentContext context = new EnvironmentContext(); + context.setSubject(subject); + EnvironmentContext.setCurrent(context); + } + + private static String location(Response response) { + return ((URI) response.getMetadata().getFirst("Location")).toString(); + } + + private static String compositeState(String csrfState, String callbackUrl) { + JsonObject json = new JsonObject(); + json.addProperty("s", csrfState); + json.addProperty("c", callbackUrl); + return encode(json); + } + + private static String encode(JsonObject json) { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(json.toString().getBytes(StandardCharsets.UTF_8)); + } + + private static UriInfo mockUriInfo(String... keyValues) { + MultivaluedMap params = new MultivaluedHashMap<>(); + for (int i = 0; i < keyValues.length; i += 2) { + params.putSingle(keyValues[i], keyValues[i + 1]); + } + UriInfo uriInfo = mock(UriInfo.class); + when(uriInfo.getQueryParameters()).thenReturn(params); + return uriInfo; + } +}