/}
+ * 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;
+ }
+}