-
Notifications
You must be signed in to change notification settings - Fork 81
feat(oauth): add OAuth IDE redirect proxy for browser-based extensions #1070
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
Open
RomanNikitenko
wants to merge
3
commits into
main
Choose a base branch
from
ide-redirect-proxy
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
7a0ff60
feat(oauth): add OAuth IDE redirect proxy for browser-based extensions
RomanNikitenko 759b334
fix(oauth): authorize the IDE redirect URL at the point of use
RomanNikitenko 55eb039
fix(oauth): resolve the user namespace instead of listing all of them
RomanNikitenko File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
23 changes: 23 additions & 0 deletions
23
...structure-factory/src/main/java/org/eclipse/che/security/oauth/KubernetesOAuthModule.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
111 changes: 111 additions & 0 deletions
111
...in/java/org/eclipse/che/security/oauth/kubernetes/KubernetesUserWorkspaceUrlProvider.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
| * | ||
| * <p>{@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 | ||
| * /<username>/<workspace-name>/<port>/} and the legacy {@code /<workspace-id>/<component>/<port>/} | ||
| * path strategies. | ||
| * | ||
| * <p>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. | ||
| * | ||
| * <p>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<String> getWorkspaceUrls() throws ServerException { | ||
| Set<String> urls = new LinkedHashSet<>(); | ||
| try { | ||
| String namespace = | ||
| namespaceFactory.evaluateNamespaceName( | ||
| new NamespaceResolutionContext(EnvironmentContext.getCurrent().getSubject())); | ||
| List<GenericKubernetesResource> 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); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| } | ||
| } 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; | ||
| } | ||
| } | ||
228 changes: 228 additions & 0 deletions
228
...ava/org/eclipse/che/security/oauth/kubernetes/KubernetesUserWorkspaceUrlProviderTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<GenericKubernetesResource>> | ||
| 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<String> 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<String> 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<String, Object>) 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<GenericKubernetesResource>> | ||
| 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<GenericKubernetesResource>> | ||
| 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<String, Object> 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; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.