diff --git a/infrastructures/infrastructure-factory/src/main/java/org/eclipse/che/api/factory/server/scm/kubernetes/KubernetesPersonalAccessTokenManager.java b/infrastructures/infrastructure-factory/src/main/java/org/eclipse/che/api/factory/server/scm/kubernetes/KubernetesPersonalAccessTokenManager.java index e5567b34b3a..18ebaefcb71 100644 --- a/infrastructures/infrastructure-factory/src/main/java/org/eclipse/che/api/factory/server/scm/kubernetes/KubernetesPersonalAccessTokenManager.java +++ b/infrastructures/infrastructure-factory/src/main/java/org/eclipse/che/api/factory/server/scm/kubernetes/KubernetesPersonalAccessTokenManager.java @@ -12,6 +12,7 @@ package org.eclipse.che.api.factory.server.scm.kubernetes; import static com.google.common.base.Strings.isNullOrEmpty; +import static java.lang.Long.parseLong; import static org.eclipse.che.api.factory.server.scm.PersonalAccessTokenFetcher.OAUTH_2_PREFIX; import static org.eclipse.che.commons.lang.StringUtils.trimEnd; @@ -24,6 +25,8 @@ import io.fabric8.kubernetes.api.model.SecretBuilder; import io.fabric8.kubernetes.client.KubernetesClientException; import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.Base64; import java.util.Collections; @@ -75,8 +78,22 @@ public class KubernetesPersonalAccessTokenManager implements PersonalAccessToken public static final String ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_NAME = "che.eclipse.org/scm-personal-access-token-name"; public static final String ANNOTATION_SCM_URL = "che.eclipse.org/scm-url"; + + /** Kubernetes secret annotation key for the token expiration time in seconds. */ + public static final String ANNOTATION_SCM_TOKEN_EXPIRES_IN = + "che.eclipse.org/scm-token-expires-in"; + public static final String TOKEN_DATA_FIELD = "token"; + /** Kubernetes secret data field key for the OAuth refresh token. */ + public static final String REFRESH_TOKEN_DATA_FIELD = "refresh-token"; + + /** + * Number of seconds before the actual expiration time at which an OAuth token is already + * considered expired and gets refreshed. + */ + private static final long TOKEN_EXPIRATION_LEEWAY_SECONDS = 60; + private final KubernetesNamespaceFactory namespaceFactory; private final CheServerKubernetesClientFactory cheServerKubernetesClientFactory; private final ScmPersonalAccessTokenFetcher scmPersonalAccessTokenFetcher; @@ -102,34 +119,41 @@ public void store(PersonalAccessToken personalAccessToken) throws UnsatisfiedScmPreconditionException, ScmConfigurationPersistenceException { try { String namespace = getFirstNamespace(); + ImmutableMap.Builder annotations = + new ImmutableMap.Builder() + .put(ANNOTATION_CHE_USERID, personalAccessToken.getCheUserId()) + .put(ANNOTATION_SCM_URL, personalAccessToken.getScmProviderUrl()) + .put(ANNOTATION_SCM_PROVIDER_NAME, personalAccessToken.getScmProviderName()) + .put(ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_ID, personalAccessToken.getScmTokenId()) + .put( + ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_NAME, personalAccessToken.getScmTokenName()); + // Only OAuth tokens with a known lifetime get the annotation. Storing `0` would be + // indistinguishable from a token that expires immediately, so it is omitted instead. + if (personalAccessToken.getExpiresIn() > 0) { + annotations.put( + ANNOTATION_SCM_TOKEN_EXPIRES_IN, String.valueOf(personalAccessToken.getExpiresIn())); + } ObjectMeta meta = new ObjectMetaBuilder() .withName(NameGenerator.generate(NAME_PATTERN, 5)) - .withAnnotations( - new ImmutableMap.Builder() - .put(ANNOTATION_CHE_USERID, personalAccessToken.getCheUserId()) - .put(ANNOTATION_SCM_URL, personalAccessToken.getScmProviderUrl()) - .put(ANNOTATION_SCM_PROVIDER_NAME, personalAccessToken.getScmProviderName()) - .put( - ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_ID, - personalAccessToken.getScmTokenId()) - .put( - ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_NAME, - personalAccessToken.getScmTokenName()) - .build()) + .withAnnotations(annotations.build()) .withLabels(SECRET_LABELS) .build(); - Secret secret = - new SecretBuilder() - .withMetadata(meta) - .withData( - Map.of( - TOKEN_DATA_FIELD, - Base64.getEncoder() - .encodeToString( - personalAccessToken.getToken().getBytes(StandardCharsets.UTF_8)))) - .build(); + // Kubernetes secrets store data as Base64-encoded values + String tokenEncoded = + Base64.getEncoder() + .encodeToString(personalAccessToken.getToken().getBytes(StandardCharsets.UTF_8)); + ImmutableMap.Builder data = + new ImmutableMap.Builder().put(TOKEN_DATA_FIELD, tokenEncoded); + // Refresh token is absent for PATs and for OAuth providers that don't issue one + String refreshToken = personalAccessToken.getRefreshToken(); + if (!isNullOrEmpty(refreshToken)) { + data.put( + REFRESH_TOKEN_DATA_FIELD, + Base64.getEncoder().encodeToString(refreshToken.getBytes(StandardCharsets.UTF_8))); + } + Secret secret = new SecretBuilder().withMetadata(meta).withData(data.build()).build(); cheServerKubernetesClientFactory .create() @@ -217,12 +241,40 @@ public Optional get( .findFirst(); } + @Override + public Optional getStored( + Subject cheUser, + @Nullable String oAuthProviderName, + @Nullable String scmServerUrl, + @Nullable String namespaceName) + throws ScmConfigurationPersistenceException, ScmCommunicationException { + return doGetPersonalAccessTokens(cheUser, oAuthProviderName, scmServerUrl, namespaceName, false) + .stream() + .findFirst(); + } + private List doGetPersonalAccessTokens( Subject cheUser, @Nullable String oAuthProviderName, @Nullable String scmServerUrl, @Nullable String namespaceName) throws ScmConfigurationPersistenceException, ScmCommunicationException { + return doGetPersonalAccessTokens(cheUser, oAuthProviderName, scmServerUrl, namespaceName, true); + } + + /** + * @param refreshAndValidate whether the tokens have to be checked against the SCM provider: an + * expired OAuth token gets refreshed, and a token that the provider does not accept gets + * removed. When {@code false}, the tokens are returned exactly as they are stored, see {@link + * #getStored(Subject, String, String, String)}. + */ + private List doGetPersonalAccessTokens( + Subject cheUser, + @Nullable String oAuthProviderName, + @Nullable String scmServerUrl, + @Nullable String namespaceName, + boolean refreshAndValidate) + throws ScmConfigurationPersistenceException, ScmCommunicationException { List result = new ArrayList<>(); try { LOG.debug( @@ -244,6 +296,29 @@ private List doGetPersonalAccessTokens( LOG.debug("Iterating over secret {}", secret.getMetadata().getName()); PersonalAccessTokenParams personalAccessTokenParams = this.secret2PersonalAccessTokenParams(secret); + + if (!refreshAndValidate) { + LOG.debug("Returning the token from secret {} as is", secret.getMetadata().getName()); + result.add(secret2PersonalAccessToken(secret, personalAccessTokenParams, null)); + continue; + } + + // OAuth tokens are short-living, e.g. GitLab issues them for 2 hours. An expired one is + // refreshed in place, so that the user does not have to go through the OAuth flow + // again. If the refresh fails, the regular validation below takes over. + if (isOAuthTokenSecret(secret) && isTokenExpired(secret, personalAccessTokenParams)) { + Optional refreshedToken = + refreshExpiredOAuthToken( + cheUser, + secret, + personalAccessTokenParams.getScmProviderUrl(), + namespaceMeta.getName()); + if (refreshedToken.isPresent()) { + result.add(refreshedToken.get()); + continue; + } + } + Optional scmUsername = scmPersonalAccessTokenFetcher.getScmUsername(personalAccessTokenParams); @@ -252,19 +327,8 @@ private List doGetPersonalAccessTokens( "Creating personal access token for user {} and OAuth provider {}", cheUser.getUserId(), oAuthProviderName); - Map secretAnnotations = secret.getMetadata().getAnnotations(); - - PersonalAccessToken personalAccessToken = - new PersonalAccessToken( - personalAccessTokenParams.getScmProviderUrl(), - getScmProviderName(personalAccessTokenParams), - secretAnnotations.get(ANNOTATION_CHE_USERID), - personalAccessTokenParams.getOrganization(), - scmUsername.get(), - personalAccessTokenParams.getScmTokenName(), - personalAccessTokenParams.getScmTokenId(), - personalAccessTokenParams.getToken()); - result.add(personalAccessToken); + result.add( + secret2PersonalAccessToken(secret, personalAccessTokenParams, scmUsername.get())); continue; } @@ -292,6 +356,29 @@ private List doGetPersonalAccessTokens( return result; } + /** + * Builds a personal access token out of the secret it is stored in. + * + * @param secret the secret the token is stored in + * @param params the token parameters read from the secret + * @param scmUsername the SCM username the token belongs to, or {@code null} if the token was not + * validated against the SCM provider + */ + private PersonalAccessToken secret2PersonalAccessToken( + Secret secret, PersonalAccessTokenParams params, @Nullable String scmUsername) { + return new PersonalAccessToken( + params.getScmProviderUrl(), + getScmProviderName(params), + secret.getMetadata().getAnnotations().get(ANNOTATION_CHE_USERID), + params.getOrganization(), + scmUsername, + params.getScmTokenName(), + params.getScmTokenId(), + params.getToken(), + params.getRefreshToken(), + params.getExpiresIn()); + } + /** * Checks whether the token was obtained with the OAuth flow. Such tokens are stored with a * generated {@code oauth2-} name, while the manually configured personal access tokens @@ -317,6 +404,80 @@ private static boolean isOAuthTokenSecret(Secret secret) { return tokenName != null && tokenName.startsWith(OAUTH_2_PREFIX); } + /** + * Checks whether the token kept in the given secret has expired. The lifetime is counted from the + * secret creation time, as the {@code che.eclipse.org/scm-token-expires-in} annotation holds the + * number of seconds the token was valid for when it was stored. + * + * @param secret the secret the token is stored in + * @param params the token parameters read from the secret + * @return {@code true} if the token is known to expire and its lifetime is over + */ + private static boolean isTokenExpired(Secret secret, PersonalAccessTokenParams params) { + // Tokens without a known lifetime, e.g. personal access tokens, never expire from Che's + // point of view. + if (params.getExpiresIn() <= 0) { + return false; + } + String creationTimestamp = secret.getMetadata().getCreationTimestamp(); + if (isNullOrEmpty(creationTimestamp)) { + return false; + } + try { + Instant expiresAt = Instant.parse(creationTimestamp).plusSeconds(params.getExpiresIn()); + // A token that is about to expire is treated as expired, so that it does not run out in the + // middle of the operation it is handed out for. + return !Instant.now().isBefore(expiresAt.minusSeconds(TOKEN_EXPIRATION_LEEWAY_SECONDS)); + } catch (DateTimeParseException e) { + LOG.warn( + "Invalid creation timestamp '{}' in secret '{}'. Treating token as non-expiring.", + creationTimestamp, + secret.getMetadata().getName()); + return false; + } + } + + /** + * Refreshes the expired OAuth token kept in the given secret. The refreshed token is stored in a + * new secret, and the outdated one is removed. + * + * @param cheUser the user the token belongs to + * @param secret the secret keeping the expired token + * @param scmServerUrl the SCM server URL to refresh the token for + * @param namespaceName the namespace the outdated secret lives in + * @return the refreshed token, or {@link Optional#empty()} if the token could not be refreshed + */ + private Optional refreshExpiredOAuthToken( + Subject cheUser, Secret secret, String scmServerUrl, String namespaceName) { + String secretName = secret.getMetadata().getName(); + PersonalAccessToken refreshedToken; + try { + LOG.debug("Refreshing the expired OAuth token from secret {}", secretName); + refreshedToken = + scmPersonalAccessTokenFetcher.refreshPersonalAccessToken(cheUser, scmServerUrl); + store(refreshedToken); + gitCredentialManager.createOrReplace(refreshedToken); + } catch (ScmUnauthorizedException + | ScmCommunicationException + | UnknownScmProviderException + | UnsatisfiedScmPreconditionException + | ScmConfigurationPersistenceException e) { + // The caller falls back to the regular validation flow, which either finds another valid + // token or reports that none exists, so that a new one is requested from the user. + LOG.debug("Failed to refresh the expired OAuth token from secret {}", secretName, e); + return Optional.empty(); + } + + // The outdated secret is superseded by the newly stored one. Failing to remove it is not + // fatal, it is just left behind to be cleaned up on the next refresh. + try { + cheServerKubernetesClientFactory.create().secrets().inNamespace(namespaceName).delete(secret); + } catch (InfrastructureException | KubernetesClientException e) { + LOG.warn("Failed to remove the outdated OAuth token secret {}", secretName, e); + } + return Optional.of(refreshedToken); + } + /** * Returns the list of namespaces to search for the personal access token secrets. * @@ -398,10 +559,32 @@ private boolean deleteSecretIfMisconfigured(Secret secret) throws Infrastructure return false; } + /** Extracts token parameters from a Kubernetes secret, decoding Base64-encoded data fields. */ private PersonalAccessTokenParams secret2PersonalAccessTokenParams(Secret secret) { Map secretAnnotations = secret.getMetadata().getAnnotations(); - - String token = new String(Base64.getDecoder().decode(secret.getData().get("token"))).trim(); + String refreshTokenData = secret.getData().get(REFRESH_TOKEN_DATA_FIELD); + String expiresInAnnotation = secretAnnotations.get(ANNOTATION_SCM_TOKEN_EXPIRES_IN); + + String token = + new String(Base64.getDecoder().decode(secret.getData().get(TOKEN_DATA_FIELD))).trim(); + // Refresh token and expiresIn may be absent in PAT secrets, or secrets created before OAuth + // refresh support + String refreshToken = + isNullOrEmpty(refreshTokenData) + ? null + : new String(Base64.getDecoder().decode(refreshTokenData)).trim(); + long expiresIn = 0; + if (!isNullOrEmpty(expiresInAnnotation)) { + try { + expiresIn = parseLong(expiresInAnnotation.trim()); + } catch (NumberFormatException e) { + LOG.warn( + "Invalid '{}' annotation value '{}' in secret '{}'. Treating token as non-expiring.", + ANNOTATION_SCM_TOKEN_EXPIRES_IN, + expiresInAnnotation, + secret.getMetadata().getName()); + } + } String configuredOAuthTokenName = secretAnnotations.get(ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_NAME); String configuredTokenId = secretAnnotations.get(ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_ID); @@ -415,7 +598,9 @@ private PersonalAccessTokenParams secret2PersonalAccessTokenParams(Secret secret configuredOAuthTokenName, configuredTokenId, token, - configuredScmOrganization); + configuredScmOrganization, + refreshToken, + expiresIn); } private boolean isSecretMatchesSearchCriteria( @@ -426,8 +611,7 @@ private boolean isSecretMatchesSearchCriteria( Map secretAnnotations = secret.getMetadata().getAnnotations(); String configuredScmServerUrl = secretAnnotations.get(ANNOTATION_SCM_URL); String configuredCheUserId = secretAnnotations.get(ANNOTATION_CHE_USERID); - String configuredOAuthProviderName = - secretAnnotations.get(ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_NAME); + String configuredOAuthProviderName = secretAnnotations.get(ANNOTATION_SCM_PROVIDER_NAME); return (configuredCheUserId.equals(cheUser.getUserId())) && (oAuthProviderName == null || oAuthProviderName.equals(configuredOAuthProviderName)) @@ -490,8 +674,7 @@ && isOAuthTokenSecret(secret)) { public void storeGitCredentials(String scmServerUrl) throws UnsatisfiedScmPreconditionException, ScmConfigurationPersistenceException, - ScmCommunicationException, - ScmUnauthorizedException { + ScmCommunicationException { Subject subject = EnvironmentContext.getCurrent().getSubject(); Optional tokenOptional = doGetPersonalAccessTokens(subject, null, scmServerUrl, null).stream().findFirst(); diff --git a/infrastructures/infrastructure-factory/src/test/java/org/eclipse/che/api/factory/server/scm/kubernetes/KubernetesGitCredentialManagerTest.java b/infrastructures/infrastructure-factory/src/test/java/org/eclipse/che/api/factory/server/scm/kubernetes/KubernetesGitCredentialManagerTest.java index 3358519587d..38ffe2449e5 100644 --- a/infrastructures/infrastructure-factory/src/test/java/org/eclipse/che/api/factory/server/scm/kubernetes/KubernetesGitCredentialManagerTest.java +++ b/infrastructures/infrastructure-factory/src/test/java/org/eclipse/che/api/factory/server/scm/kubernetes/KubernetesGitCredentialManagerTest.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/ @@ -95,10 +95,13 @@ public void testCreateAndSaveNewPATGitCredential() throws Exception { "https://bitbucket.com", "provider", "cheUser", + null, "username", "token-name", "tid-23434", - "token123"); + "token123", + null, + 0); // when kubernetesGitCredentialManager.createOrReplace(token); @@ -120,12 +123,15 @@ public void shouldUseHardcodedUsernameIfScmOrganizationIsDefined() throws Except PersonalAccessToken token = new PersonalAccessToken( "https://bitbucket-server.com:5648", + "provider", "cheUser", "cheOrganization", "username", "token-name", "tid-23434", - "token123"); + "token123", + null, + 0); Map annotations = new HashMap<>(DEFAULT_SECRET_ANNOTATIONS); @@ -182,10 +188,13 @@ public void testCreateAndSaveNewOAuthGitCredential() throws Exception { "https://bitbucket.com", "provider", "cheUser", + null, "username", "oauth2-token-name", "tid-23434", - "token123"); + "token123", + null, + 0); // when kubernetesGitCredentialManager.createOrReplace(token); @@ -218,10 +227,13 @@ public void testCreateAndSaveNewBitbucketOAuthGitCredential() throws Exception { "https://bitbucket.com", "bitbucket", "cheUser", + null, "username", "oauth2-token-name", "tid-23434", - "token123"); + "token123", + null, + 0); // when kubernetesGitCredentialManager.createOrReplace(token); @@ -244,10 +256,13 @@ public void testUpdateTokenInExistingCredential() throws Exception { "https://bitbucket.com:5648", "provider", "cheUser", + null, "username", "token-name", "tid-23434", - "token123"); + "token123", + null, + 0); Map annotations = new HashMap<>(DEFAULT_SECRET_ANNOTATIONS); diff --git a/infrastructures/infrastructure-factory/src/test/java/org/eclipse/che/api/factory/server/scm/kubernetes/KubernetesPersonalAccessTokenManagerTest.java b/infrastructures/infrastructure-factory/src/test/java/org/eclipse/che/api/factory/server/scm/kubernetes/KubernetesPersonalAccessTokenManagerTest.java index 2465a22a4fc..0ae3061852b 100644 --- a/infrastructures/infrastructure-factory/src/test/java/org/eclipse/che/api/factory/server/scm/kubernetes/KubernetesPersonalAccessTokenManagerTest.java +++ b/infrastructures/infrastructure-factory/src/test/java/org/eclipse/che/api/factory/server/scm/kubernetes/KubernetesPersonalAccessTokenManagerTest.java @@ -16,7 +16,9 @@ import static org.eclipse.che.api.factory.server.scm.kubernetes.KubernetesPersonalAccessTokenManager.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; @@ -35,19 +37,25 @@ import io.fabric8.kubernetes.api.model.SecretBuilder; import io.fabric8.kubernetes.api.model.SecretList; 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 java.time.Instant; import java.util.Arrays; import java.util.Base64; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import org.eclipse.che.api.factory.server.scm.GitCredentialManager; import org.eclipse.che.api.factory.server.scm.PersonalAccessToken; import org.eclipse.che.api.factory.server.scm.PersonalAccessTokenParams; import org.eclipse.che.api.factory.server.scm.ScmPersonalAccessTokenFetcher; +import org.eclipse.che.api.factory.server.scm.exception.ScmCommunicationException; +import org.eclipse.che.api.factory.server.scm.exception.ScmUnauthorizedException; import org.eclipse.che.commons.env.EnvironmentContext; import org.eclipse.che.commons.subject.Subject; import org.eclipse.che.commons.subject.SubjectImpl; @@ -154,10 +162,13 @@ public void testSavingOfPersonalAccessToken() throws Exception { "https://bitbucket.com", "provider", "cheUser", + null, "username", "token-name", "tid-24", - "token123"); + "token123", + "refresh123", + 3600); // when personalAccessTokenManager.store(token); @@ -619,7 +630,16 @@ public void shouldReturnFirstValidTokenAndDeleteTheOlderOne() throws Exception { when(kubernetesnamespace.secrets()).thenReturn(secrets); PersonalAccessToken token = new PersonalAccessToken( - "http://host1", "provider", "cheUser", "username", "token-name", "tid-24", "token123"); + "http://host1", + "provider", + "cheUser", + null, + "username", + "token-name", + "tid-24", + "token123", + "refresh123", + 3600); when(scmPersonalAccessTokenFetcher.refreshPersonalAccessToken( any(org.eclipse.che.commons.subject.Subject.class), eq("http://host1"))) .thenReturn(token); @@ -746,10 +766,13 @@ public void shouldKeepPersonalAccessTokenSecretOnForceRefresh() throws Exception "http://host1", "gitlab", "user1", + null, "user", "oauth2-fghij", "new-oauth-id", - "new-oauth-token"); + "new-oauth-token", + "refresh-token", + 3600); when(scmPersonalAccessTokenFetcher.refreshPersonalAccessToken( any(Subject.class), eq("http://host1"))) .thenReturn(token); @@ -762,65 +785,902 @@ public void shouldKeepPersonalAccessTokenSecretOnForceRefresh() throws Exception } @Test - public void shouldRemoveToken() throws Exception { + public void shouldRefreshExpiredOAuthToken() throws Exception { // given - Subject subject = mock(Subject.class); - when(subject.getUserId()).thenReturn("user"); - EnvironmentContext context = spy(EnvironmentContext.getCurrent()); - EnvironmentContext.setCurrent(context); - doReturn(subject).when(context).getSubject(); KubernetesNamespaceMeta meta = new KubernetesNamespaceMetaImpl("test"); when(namespaceFactory.list()).thenReturn(singletonList(meta)); KubernetesNamespace kubernetesnamespace = Mockito.mock(KubernetesNamespace.class); KubernetesSecrets secrets = Mockito.mock(KubernetesSecrets.class); + when(namespaceFactory.access(eq(null), eq(meta.getName()))).thenReturn(kubernetesnamespace); when(kubernetesnamespace.secrets()).thenReturn(secrets); when(cheServerKubernetesClientFactory.create()).thenReturn(kubeClient); when(kubeClient.secrets()).thenReturn(secretsMixedOperation); - Map data1 = - Map.of("token", Base64.getEncoder().encodeToString("token1".getBytes(UTF_8))); - Map data2 = - Map.of("token", Base64.getEncoder().encodeToString("token2".getBytes(UTF_8))); - ObjectMeta meta1 = + when(secretsMixedOperation.inNamespace(eq(meta.getName()))).thenReturn(nonNamespaceOperation); + // the token was issued for an hour back in 2021, so it is long expired by now + ObjectMeta oauthMeta = new ObjectMetaBuilder() + .withName("personal-access-token-old") .withCreationTimestamp("2021-07-01T12:00:00Z") .withAnnotations( Map.of( ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_NAME, - "github", + "oauth2-abcde", ANNOTATION_CHE_USERID, - "user", + "user1", ANNOTATION_SCM_URL, "http://host1", + ANNOTATION_SCM_PROVIDER_NAME, + "gitlab", ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_ID, - "id1")) + "oauth-id", + ANNOTATION_SCM_TOKEN_EXPIRES_IN, + "3600")) .build(); - ObjectMeta meta2 = + Secret oauthSecret = + new SecretBuilder() + .withMetadata(oauthMeta) + .withData( + Map.of( + "token", Base64.getEncoder().encodeToString("expired-token".getBytes(UTF_8)))) + .build(); + when(secrets.get(any(LabelSelector.class))).thenReturn(List.of(oauthSecret)); + PersonalAccessToken refreshedToken = + new PersonalAccessToken( + "http://host1", + "gitlab", + "user1", + null, + "user", + "oauth2-fghij", + "new-oauth-id", + "new-oauth-token", + "new-refresh-token", + 3600); + when(scmPersonalAccessTokenFetcher.refreshPersonalAccessToken( + any(Subject.class), eq("http://host1"))) + .thenReturn(refreshedToken); + + // when + Optional token = + personalAccessTokenManager.get( + new SubjectImpl("user", Collections.emptyList(), "user1", "t1", false), + null, + "http://host1", + null); + + // then + assertTrue(token.isPresent()); + assertEquals(token.get().getToken(), "new-oauth-token"); + // the refreshed token is stored and the outdated secret is removed + verify(nonNamespaceOperation, times(1)).createOrReplace(any(Secret.class)); + verify(nonNamespaceOperation, times(1)).delete(eq(oauthSecret)); + // there is no point in validating the token that is known to be expired + verify(scmPersonalAccessTokenFetcher, never()) + .getScmUsername(any(PersonalAccessTokenParams.class)); + } + + @Test + public void shouldNotRefreshOAuthTokenThatIsStillValid() throws Exception { + // given + KubernetesNamespaceMeta meta = new KubernetesNamespaceMetaImpl("test"); + when(namespaceFactory.list()).thenReturn(singletonList(meta)); + KubernetesNamespace kubernetesnamespace = Mockito.mock(KubernetesNamespace.class); + KubernetesSecrets secrets = Mockito.mock(KubernetesSecrets.class); + when(namespaceFactory.access(eq(null), eq(meta.getName()))).thenReturn(kubernetesnamespace); + when(kubernetesnamespace.secrets()).thenReturn(secrets); + when(scmPersonalAccessTokenFetcher.getScmUsername(any(PersonalAccessTokenParams.class))) + .thenReturn(Optional.of("user")); + // the token has just been issued for an hour + ObjectMeta oauthMeta = new ObjectMetaBuilder() - .withCreationTimestamp("2021-07-02T12:00:00Z") + .withName("personal-access-token-fresh") + .withCreationTimestamp(Instant.now().toString()) .withAnnotations( Map.of( ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_NAME, - "github", + "oauth2-abcde", ANNOTATION_CHE_USERID, - "user", + "user1", ANNOTATION_SCM_URL, - "http://host2", + "http://host1", + ANNOTATION_SCM_PROVIDER_NAME, + "gitlab", ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_ID, - "id2")) + "oauth-id", + ANNOTATION_SCM_TOKEN_EXPIRES_IN, + "3600")) .build(); - Secret secret1 = new SecretBuilder().withMetadata(meta1).withData(data1).build(); - Secret secret2 = new SecretBuilder().withMetadata(meta2).withData(data2).build(); - when(secrets.get(any(LabelSelector.class))).thenReturn(Arrays.asList(secret1, secret2)); + Secret oauthSecret = + new SecretBuilder() + .withMetadata(oauthMeta) + .withData( + Map.of("token", Base64.getEncoder().encodeToString("oauth-token".getBytes(UTF_8)))) + .build(); + when(secrets.get(any(LabelSelector.class))).thenReturn(List.of(oauthSecret)); + + // when + Optional token = + personalAccessTokenManager.get( + new SubjectImpl("user", Collections.emptyList(), "user1", "t1", false), + null, + "http://host1", + null); + + // then + assertTrue(token.isPresent()); + assertEquals(token.get().getToken(), "oauth-token"); + verify(scmPersonalAccessTokenFetcher, never()) + .refreshPersonalAccessToken(any(Subject.class), eq("http://host1")); + } + + @Test + public void shouldRefreshOAuthTokenThatIsAboutToExpire() throws Exception { + // given + KubernetesNamespaceMeta meta = new KubernetesNamespaceMetaImpl("test"); + when(namespaceFactory.list()).thenReturn(singletonList(meta)); + KubernetesNamespace kubernetesnamespace = Mockito.mock(KubernetesNamespace.class); + KubernetesSecrets secrets = Mockito.mock(KubernetesSecrets.class); when(namespaceFactory.access(eq(null), eq(meta.getName()))).thenReturn(kubernetesnamespace); + when(kubernetesnamespace.secrets()).thenReturn(secrets); when(cheServerKubernetesClientFactory.create()).thenReturn(kubeClient); when(kubeClient.secrets()).thenReturn(secretsMixedOperation); when(secretsMixedOperation.inNamespace(eq(meta.getName()))).thenReturn(nonNamespaceOperation); + // the token is valid for a couple of seconds more, which is within the expiration leeway + Secret oauthSecret = + tokenSecret( + "personal-access-token-almost-expired", + "oauth2-abcde", + Instant.now().minusSeconds(3595).toString(), + "3600", + "almost-expired-token"); + when(secrets.get(any(LabelSelector.class))).thenReturn(List.of(oauthSecret)); + PersonalAccessToken refreshedToken = refreshedToken(); + when(scmPersonalAccessTokenFetcher.refreshPersonalAccessToken( + any(Subject.class), eq("http://host1"))) + .thenReturn(refreshedToken); + + // when + Optional token = + personalAccessTokenManager.get( + new SubjectImpl("user", Collections.emptyList(), "user1", "t1", false), + null, + "http://host1", + null); + + // then + assertTrue(token.isPresent()); + assertEquals(token.get().getToken(), "new-oauth-token"); + // the git credentials have to be updated, otherwise the workspace keeps the expired token + verify(gitCredentialManager, times(1)).createOrReplace(eq(refreshedToken)); + } + + @Test + public void shouldFallBackToStoredOAuthTokenIfRefreshFails() throws Exception { + // given + KubernetesNamespaceMeta meta = new KubernetesNamespaceMetaImpl("test"); + when(namespaceFactory.list()).thenReturn(singletonList(meta)); + KubernetesNamespace kubernetesnamespace = Mockito.mock(KubernetesNamespace.class); + KubernetesSecrets secrets = Mockito.mock(KubernetesSecrets.class); + when(namespaceFactory.access(eq(null), eq(meta.getName()))).thenReturn(kubernetesnamespace); when(kubernetesnamespace.secrets()).thenReturn(secrets); + Secret oauthSecret = + tokenSecret( + "personal-access-token-expired", + "oauth2-abcde", + "2021-07-01T12:00:00Z", + "3600", + "expired-token"); + when(secrets.get(any(LabelSelector.class))).thenReturn(List.of(oauthSecret)); + when(scmPersonalAccessTokenFetcher.refreshPersonalAccessToken( + any(Subject.class), eq("http://host1"))) + .thenThrow(new ScmCommunicationException("the SCM provider is not reachable")); + // the SCM provider still accepts the stored token, e.g. Che and the provider disagree on the + // expiration time + when(scmPersonalAccessTokenFetcher.getScmUsername(any(PersonalAccessTokenParams.class))) + .thenReturn(Optional.of("user")); // when - personalAccessTokenManager.remove("http://host1"); + Optional token = + personalAccessTokenManager.get( + new SubjectImpl("user", Collections.emptyList(), "user1", "t1", false), + null, + "http://host1", + null); // then - verify(nonNamespaceOperation, times(1)).delete(eq(secret1)); + assertTrue(token.isPresent()); + assertEquals(token.get().getToken(), "expired-token"); + verify(gitCredentialManager, never()).createOrReplace(any(PersonalAccessToken.class)); + } + + @Test + public void shouldRemoveExpiredOAuthTokenSecretIfRefreshFailsAndTokenIsInvalid() + throws Exception { + // given + KubernetesNamespaceMeta meta = new KubernetesNamespaceMetaImpl("test"); + when(namespaceFactory.list()).thenReturn(singletonList(meta)); + KubernetesNamespace kubernetesnamespace = Mockito.mock(KubernetesNamespace.class); + KubernetesSecrets secrets = Mockito.mock(KubernetesSecrets.class); + when(namespaceFactory.access(eq(null), eq(meta.getName()))).thenReturn(kubernetesnamespace); + when(kubernetesnamespace.secrets()).thenReturn(secrets); + when(cheServerKubernetesClientFactory.create()).thenReturn(kubeClient); + when(kubeClient.secrets()).thenReturn(secretsMixedOperation); + when(secretsMixedOperation.inNamespace(eq(meta.getName()))).thenReturn(nonNamespaceOperation); + Secret oauthSecret = + tokenSecret( + "personal-access-token-expired", + "oauth2-abcde", + "2021-07-01T12:00:00Z", + "3600", + "expired-token"); + when(secrets.get(any(LabelSelector.class))).thenReturn(List.of(oauthSecret)); + when(scmPersonalAccessTokenFetcher.refreshPersonalAccessToken( + any(Subject.class), eq("http://host1"))) + .thenThrow( + new ScmUnauthorizedException( + "the refresh token is revoked", "gitlab", "2.0", "http://host1/oauth")); + when(scmPersonalAccessTokenFetcher.getScmUsername(any(PersonalAccessTokenParams.class))) + .thenReturn(Optional.empty()); + + // when + Optional token = + personalAccessTokenManager.get( + new SubjectImpl("user", Collections.emptyList(), "user1", "t1", false), + null, + "http://host1", + null); + + // then + assertFalse(token.isPresent()); + verify(nonNamespaceOperation, times(1)).delete(eq(oauthSecret)); + } + + @Test + public void shouldReturnRefreshedOAuthTokenWhenOutdatedSecretRemovalFails() throws Exception { + // given + KubernetesNamespaceMeta meta = new KubernetesNamespaceMetaImpl("test"); + when(namespaceFactory.list()).thenReturn(singletonList(meta)); + KubernetesNamespace kubernetesnamespace = Mockito.mock(KubernetesNamespace.class); + KubernetesSecrets secrets = Mockito.mock(KubernetesSecrets.class); + when(namespaceFactory.access(eq(null), eq(meta.getName()))).thenReturn(kubernetesnamespace); + when(kubernetesnamespace.secrets()).thenReturn(secrets); + when(cheServerKubernetesClientFactory.create()).thenReturn(kubeClient); + when(kubeClient.secrets()).thenReturn(secretsMixedOperation); + when(secretsMixedOperation.inNamespace(eq(meta.getName()))).thenReturn(nonNamespaceOperation); + Secret oauthSecret = + tokenSecret( + "personal-access-token-expired", + "oauth2-abcde", + "2021-07-01T12:00:00Z", + "3600", + "expired-token"); + when(secrets.get(any(LabelSelector.class))).thenReturn(List.of(oauthSecret)); + when(scmPersonalAccessTokenFetcher.refreshPersonalAccessToken( + any(Subject.class), eq("http://host1"))) + .thenReturn(refreshedToken()); + // the outdated secret is left behind, which must not fail the whole operation + doThrow(new KubernetesClientException("failed to delete the secret")) + .when(nonNamespaceOperation) + .delete(any(Secret.class)); + + // when + Optional token = + personalAccessTokenManager.get( + new SubjectImpl("user", Collections.emptyList(), "user1", "t1", false), + null, + "http://host1", + null); + + // then + assertTrue(token.isPresent()); + assertEquals(token.get().getToken(), "new-oauth-token"); + } + + @Test + public void shouldNotRefreshExpiredPersonalAccessToken() throws Exception { + // given + KubernetesNamespaceMeta meta = new KubernetesNamespaceMetaImpl("test"); + when(namespaceFactory.list()).thenReturn(singletonList(meta)); + KubernetesNamespace kubernetesnamespace = Mockito.mock(KubernetesNamespace.class); + KubernetesSecrets secrets = Mockito.mock(KubernetesSecrets.class); + when(namespaceFactory.access(eq(null), eq(meta.getName()))).thenReturn(kubernetesnamespace); + when(kubernetesnamespace.secrets()).thenReturn(secrets); + when(scmPersonalAccessTokenFetcher.getScmUsername(any(PersonalAccessTokenParams.class))) + .thenReturn(Optional.of("user")); + // a manually configured token cannot be refreshed, even if it somehow got the expiration + // annotation + Secret patSecret = + tokenSecret( + "personal-access-token-pat", "gitlab", "2021-07-01T12:00:00Z", "3600", "pat-token"); + when(secrets.get(any(LabelSelector.class))).thenReturn(List.of(patSecret)); + + // when + Optional token = + personalAccessTokenManager.get( + new SubjectImpl("user", Collections.emptyList(), "user1", "t1", false), + null, + "http://host1", + null); + + // then + assertTrue(token.isPresent()); + assertEquals(token.get().getToken(), "pat-token"); + verify(scmPersonalAccessTokenFetcher, never()) + .refreshPersonalAccessToken(any(Subject.class), eq("http://host1")); + } + + @Test + public void shouldNotRefreshOAuthTokenWithoutExpirationAnnotation() throws Exception { + // given + KubernetesNamespaceMeta meta = new KubernetesNamespaceMetaImpl("test"); + when(namespaceFactory.list()).thenReturn(singletonList(meta)); + KubernetesNamespace kubernetesnamespace = Mockito.mock(KubernetesNamespace.class); + KubernetesSecrets secrets = Mockito.mock(KubernetesSecrets.class); + when(namespaceFactory.access(eq(null), eq(meta.getName()))).thenReturn(kubernetesnamespace); + when(kubernetesnamespace.secrets()).thenReturn(secrets); + when(scmPersonalAccessTokenFetcher.getScmUsername(any(PersonalAccessTokenParams.class))) + .thenReturn(Optional.of("user")); + // secrets created before the OAuth refresh support have no known lifetime + Secret oauthSecret = + tokenSecret( + "personal-access-token-legacy", + "oauth2-abcde", + "2021-07-01T12:00:00Z", + null, + "oauth-token"); + when(secrets.get(any(LabelSelector.class))).thenReturn(List.of(oauthSecret)); + + // when + Optional token = + personalAccessTokenManager.get( + new SubjectImpl("user", Collections.emptyList(), "user1", "t1", false), + null, + "http://host1", + null); + + // then + assertTrue(token.isPresent()); + assertEquals(token.get().getToken(), "oauth-token"); + verify(scmPersonalAccessTokenFetcher, never()) + .refreshPersonalAccessToken(any(Subject.class), eq("http://host1")); + } + + @Test + public void shouldNotRefreshOAuthTokenWithUnparsableCreationTimestamp() throws Exception { + // given + KubernetesNamespaceMeta meta = new KubernetesNamespaceMetaImpl("test"); + when(namespaceFactory.list()).thenReturn(singletonList(meta)); + KubernetesNamespace kubernetesnamespace = Mockito.mock(KubernetesNamespace.class); + KubernetesSecrets secrets = Mockito.mock(KubernetesSecrets.class); + when(namespaceFactory.access(eq(null), eq(meta.getName()))).thenReturn(kubernetesnamespace); + when(kubernetesnamespace.secrets()).thenReturn(secrets); + when(scmPersonalAccessTokenFetcher.getScmUsername(any(PersonalAccessTokenParams.class))) + .thenReturn(Optional.of("user")); + // the expiration time cannot be calculated, so the token is validated the regular way + Secret oauthSecret = + tokenSecret( + "personal-access-token-broken", + "oauth2-abcde", + "not-a-timestamp", + "3600", + "oauth-token"); + when(secrets.get(any(LabelSelector.class))).thenReturn(List.of(oauthSecret)); + + // when + Optional token = + personalAccessTokenManager.get( + new SubjectImpl("user", Collections.emptyList(), "user1", "t1", false), + null, + "http://host1", + null); + + // then + assertTrue(token.isPresent()); + assertEquals(token.get().getToken(), "oauth-token"); + verify(scmPersonalAccessTokenFetcher, never()) + .refreshPersonalAccessToken(any(Subject.class), eq("http://host1")); + } + + @Test + public void shouldRefreshExpiredOAuthTokenOnStoreGitCredentials() throws Exception { + // given + Subject subject = mock(Subject.class); + when(subject.getUserId()).thenReturn("user1"); + EnvironmentContext context = spy(EnvironmentContext.getCurrent()); + EnvironmentContext.setCurrent(context); + doReturn(subject).when(context).getSubject(); + KubernetesNamespaceMeta meta = new KubernetesNamespaceMetaImpl("test"); + when(namespaceFactory.list()).thenReturn(singletonList(meta)); + KubernetesNamespace kubernetesnamespace = Mockito.mock(KubernetesNamespace.class); + KubernetesSecrets secrets = Mockito.mock(KubernetesSecrets.class); + when(namespaceFactory.access(eq(null), eq(meta.getName()))).thenReturn(kubernetesnamespace); + when(kubernetesnamespace.secrets()).thenReturn(secrets); + when(cheServerKubernetesClientFactory.create()).thenReturn(kubeClient); + when(kubeClient.secrets()).thenReturn(secretsMixedOperation); + when(secretsMixedOperation.inNamespace(eq(meta.getName()))).thenReturn(nonNamespaceOperation); + Secret oauthSecret = + tokenSecret( + "personal-access-token-expired", + "oauth2-abcde", + "2021-07-01T12:00:00Z", + "3600", + "expired-token"); + when(secrets.get(any(LabelSelector.class))).thenReturn(List.of(oauthSecret)); + PersonalAccessToken refreshedToken = refreshedToken(); + when(scmPersonalAccessTokenFetcher.refreshPersonalAccessToken( + any(Subject.class), eq("http://host1"))) + .thenReturn(refreshedToken); + + // when + personalAccessTokenManager.storeGitCredentials("http://host1"); + + // then + verify(gitCredentialManager, atLeastOnce()).createOrReplace(eq(refreshedToken)); + verify(nonNamespaceOperation, times(1)).delete(eq(oauthSecret)); + } + + @Test + public void shouldNotRefreshOrValidateTheTokenReadWithGetStored() throws Exception { + // given + KubernetesNamespaceMeta meta = new KubernetesNamespaceMetaImpl("test"); + when(namespaceFactory.list()).thenReturn(singletonList(meta)); + KubernetesNamespace kubernetesnamespace = Mockito.mock(KubernetesNamespace.class); + KubernetesSecrets secrets = Mockito.mock(KubernetesSecrets.class); + when(namespaceFactory.access(eq(null), eq(meta.getName()))).thenReturn(kubernetesnamespace); + when(kubernetesnamespace.secrets()).thenReturn(secrets); + Map annotations = new HashMap<>(); + annotations.put(ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_NAME, "oauth2-abcde"); + annotations.put(ANNOTATION_CHE_USERID, "user1"); + annotations.put(ANNOTATION_SCM_URL, "http://host1"); + annotations.put(ANNOTATION_SCM_PROVIDER_NAME, "gitlab"); + annotations.put(ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_ID, "token-id"); + annotations.put(ANNOTATION_SCM_TOKEN_EXPIRES_IN, "3600"); + Secret oauthSecret = + new SecretBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName("personal-access-token-expired") + .withCreationTimestamp("2021-07-01T12:00:00Z") + .withAnnotations(annotations) + .build()) + .withData( + Map.of( + TOKEN_DATA_FIELD, + Base64.getEncoder().encodeToString("expired-token".getBytes(UTF_8)), + REFRESH_TOKEN_DATA_FIELD, + Base64.getEncoder().encodeToString("stored-refresh-token".getBytes(UTF_8)))) + .build(); + when(secrets.get(any(LabelSelector.class))).thenReturn(List.of(oauthSecret)); + + // when + // this is the read the OAuth API performs to restore the in-memory credential it lost, e.g. on + // a server restart, before refreshing the token itself + Optional token = + personalAccessTokenManager.getStored( + new SubjectImpl("user", Collections.emptyList(), "user1", "t1", false), + "gitlab", + null, + null); + + // then + assertTrue(token.isPresent()); + assertEquals(token.get().getToken(), "expired-token"); + // the refresh token is what the OAuth API needs to perform the refresh + assertEquals(token.get().getRefreshToken(), "stored-refresh-token"); + // refreshing the token from within its own refresh would never terminate + verify(scmPersonalAccessTokenFetcher, never()) + .refreshPersonalAccessToken(any(Subject.class), eq("http://host1")); + // validating the expired token would delete the secret that keeps the refresh token + verify(scmPersonalAccessTokenFetcher, never()) + .getScmUsername(any(PersonalAccessTokenParams.class)); + // nothing is written to the cluster, the token is only read + verify(cheServerKubernetesClientFactory, never()).create(); + } + + /** The token the SCM provider returns when the expired one gets refreshed. */ + private static PersonalAccessToken refreshedToken() { + return new PersonalAccessToken( + "http://host1", + "gitlab", + "user1", + null, + "user", + "oauth2-fghij", + "new-oauth-id", + "new-oauth-token", + "new-refresh-token", + 3600); + } + + /** + * Builds a token secret of the 'user1' user for the 'http://host1' SCM server, so that the + * expiration related tests do not have to repeat the whole secret structure. + * + * @param secretName name of the secret + * @param tokenName token name annotation value, prefixed with 'oauth2-' for the OAuth tokens + * @param creationTimestamp creation timestamp of the secret, the token lifetime is counted from + * @param expiresIn token lifetime annotation value in seconds, or {@code null} if it is not set + * @param token the token value kept in the secret + */ + private static Secret tokenSecret( + String secretName, + String tokenName, + String creationTimestamp, + String expiresIn, + String token) { + Map annotations = new HashMap<>(); + annotations.put(ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_NAME, tokenName); + annotations.put(ANNOTATION_CHE_USERID, "user1"); + annotations.put(ANNOTATION_SCM_URL, "http://host1"); + annotations.put(ANNOTATION_SCM_PROVIDER_NAME, "gitlab"); + annotations.put(ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_ID, "token-id"); + if (expiresIn != null) { + annotations.put(ANNOTATION_SCM_TOKEN_EXPIRES_IN, expiresIn); + } + return new SecretBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(secretName) + .withCreationTimestamp(creationTimestamp) + .withAnnotations(annotations) + .build()) + .withData( + Map.of(TOKEN_DATA_FIELD, Base64.getEncoder().encodeToString(token.getBytes(UTF_8)))) + .build(); + } + + @Test + public void shouldRemoveToken() throws Exception { + // given + Subject subject = mock(Subject.class); + when(subject.getUserId()).thenReturn("user"); + EnvironmentContext context = spy(EnvironmentContext.getCurrent()); + EnvironmentContext.setCurrent(context); + doReturn(subject).when(context).getSubject(); + KubernetesNamespaceMeta meta = new KubernetesNamespaceMetaImpl("test"); + when(namespaceFactory.list()).thenReturn(singletonList(meta)); + KubernetesNamespace kubernetesnamespace = Mockito.mock(KubernetesNamespace.class); + KubernetesSecrets secrets = Mockito.mock(KubernetesSecrets.class); + when(kubernetesnamespace.secrets()).thenReturn(secrets); + when(cheServerKubernetesClientFactory.create()).thenReturn(kubeClient); + when(kubeClient.secrets()).thenReturn(secretsMixedOperation); + Map data1 = + Map.of("token", Base64.getEncoder().encodeToString("token1".getBytes(UTF_8))); + Map data2 = + Map.of("token", Base64.getEncoder().encodeToString("token2".getBytes(UTF_8))); + ObjectMeta meta1 = + new ObjectMetaBuilder() + .withCreationTimestamp("2021-07-01T12:00:00Z") + .withAnnotations( + Map.of( + ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_NAME, + "github", + ANNOTATION_CHE_USERID, + "user", + ANNOTATION_SCM_URL, + "http://host1", + ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_ID, + "id1")) + .build(); + ObjectMeta meta2 = + new ObjectMetaBuilder() + .withCreationTimestamp("2021-07-02T12:00:00Z") + .withAnnotations( + Map.of( + ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_NAME, + "github", + ANNOTATION_CHE_USERID, + "user", + ANNOTATION_SCM_URL, + "http://host2", + ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_ID, + "id2")) + .build(); + Secret secret1 = new SecretBuilder().withMetadata(meta1).withData(data1).build(); + Secret secret2 = new SecretBuilder().withMetadata(meta2).withData(data2).build(); + when(secrets.get(any(LabelSelector.class))).thenReturn(Arrays.asList(secret1, secret2)); + when(namespaceFactory.access(eq(null), eq(meta.getName()))).thenReturn(kubernetesnamespace); + when(cheServerKubernetesClientFactory.create()).thenReturn(kubeClient); + when(kubeClient.secrets()).thenReturn(secretsMixedOperation); + when(secretsMixedOperation.inNamespace(eq(meta.getName()))).thenReturn(nonNamespaceOperation); + when(kubernetesnamespace.secrets()).thenReturn(secrets); + + // when + personalAccessTokenManager.remove("http://host1"); + + // then + verify(nonNamespaceOperation, times(1)).delete(eq(secret1)); + } + + @Test + public void shouldStoreRefreshTokenAndExpiryInSecret() throws Exception { + // given + KubernetesNamespaceMeta meta = new KubernetesNamespaceMetaImpl("test"); + when(namespaceFactory.list()).thenReturn(singletonList(meta)); + when(cheServerKubernetesClientFactory.create()).thenReturn(kubeClient); + when(kubeClient.secrets()).thenReturn(secretsMixedOperation); + when(secretsMixedOperation.inNamespace(eq(meta.getName()))).thenReturn(nonNamespaceOperation); + ArgumentCaptor captor = ArgumentCaptor.forClass(Secret.class); + + PersonalAccessToken token = + new PersonalAccessToken( + "https://github.com", + "github", + "cheUser", + null, + "username", + "token-name", + "tid-24", + "access-token", + "refresh-token-value", + 3600); + + // when + personalAccessTokenManager.store(token); + + // then + verify(nonNamespaceOperation).createOrReplace(captor.capture()); + Secret createdSecret = captor.getValue(); + assertEquals( + new String(Base64.getDecoder().decode(createdSecret.getData().get("token")), UTF_8), + "access-token"); + assertEquals( + new String(Base64.getDecoder().decode(createdSecret.getData().get("refresh-token")), UTF_8), + "refresh-token-value"); + assertEquals( + createdSecret.getMetadata().getAnnotations().get(ANNOTATION_SCM_TOKEN_EXPIRES_IN), "3600"); + } + + @Test + public void shouldStoreSecretWithoutRefreshTokenFieldWhenRefreshTokenIsNull() throws Exception { + // given + KubernetesNamespaceMeta meta = new KubernetesNamespaceMetaImpl("test"); + when(namespaceFactory.list()).thenReturn(singletonList(meta)); + when(cheServerKubernetesClientFactory.create()).thenReturn(kubeClient); + when(kubeClient.secrets()).thenReturn(secretsMixedOperation); + when(secretsMixedOperation.inNamespace(eq(meta.getName()))).thenReturn(nonNamespaceOperation); + ArgumentCaptor captor = ArgumentCaptor.forClass(Secret.class); + + PersonalAccessToken token = + new PersonalAccessToken( + "https://github.com", + "github", + "cheUser", + null, + "username", + "token-name", + "tid-24", + "access-token", + null, + 0); + + // when + personalAccessTokenManager.store(token); + + // then + verify(nonNamespaceOperation).createOrReplace(captor.capture()); + Secret createdSecret = captor.getValue(); + assertEquals( + new String(Base64.getDecoder().decode(createdSecret.getData().get("token")), UTF_8), + "access-token"); + assertFalse(createdSecret.getData().containsKey("refresh-token")); + assertFalse( + createdSecret.getMetadata().getAnnotations().containsKey(ANNOTATION_SCM_TOKEN_EXPIRES_IN)); + } + + @Test + public void shouldStoreSecretWithoutRefreshTokenFieldWhenRefreshTokenIsEmpty() throws Exception { + // given + KubernetesNamespaceMeta meta = new KubernetesNamespaceMetaImpl("test"); + when(namespaceFactory.list()).thenReturn(singletonList(meta)); + when(cheServerKubernetesClientFactory.create()).thenReturn(kubeClient); + when(kubeClient.secrets()).thenReturn(secretsMixedOperation); + when(secretsMixedOperation.inNamespace(eq(meta.getName()))).thenReturn(nonNamespaceOperation); + ArgumentCaptor captor = ArgumentCaptor.forClass(Secret.class); + + PersonalAccessToken token = + new PersonalAccessToken( + "https://github.com", + "github", + "cheUser", + null, + "username", + "token-name", + "tid-24", + "access-token", + "", + 0); + + // when + personalAccessTokenManager.store(token); + + // then + verify(nonNamespaceOperation).createOrReplace(captor.capture()); + Secret createdSecret = captor.getValue(); + assertEquals(createdSecret.getData().keySet(), Set.of("token")); + } + + @Test + public void shouldDecodeRefreshTokenAndExpiryFromSecret() throws Exception { + // given + KubernetesNamespaceMeta meta = new KubernetesNamespaceMetaImpl("test"); + when(namespaceFactory.list()).thenReturn(singletonList(meta)); + KubernetesNamespace kubernetesnamespace = Mockito.mock(KubernetesNamespace.class); + KubernetesSecrets secrets = Mockito.mock(KubernetesSecrets.class); + when(namespaceFactory.access(eq(null), eq(meta.getName()))).thenReturn(kubernetesnamespace); + when(kubernetesnamespace.secrets()).thenReturn(secrets); + when(scmPersonalAccessTokenFetcher.getScmUsername(any(PersonalAccessTokenParams.class))) + .thenReturn(Optional.of("user")); + + Map data = + Map.of( + "token", Base64.getEncoder().encodeToString("access-token".getBytes(UTF_8)), + "refresh-token", + Base64.getEncoder().encodeToString("refresh-token-value".getBytes(UTF_8))); + + ObjectMeta metaData = + new ObjectMetaBuilder() + .withAnnotations( + Map.of( + ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_NAME, + "oauth2-token", + ANNOTATION_CHE_USERID, + "user1", + ANNOTATION_SCM_URL, + "http://github.com", + ANNOTATION_SCM_TOKEN_EXPIRES_IN, + "7200")) + .build(); + + Secret secret = new SecretBuilder().withMetadata(metaData).withData(data).build(); + when(secrets.get(any(LabelSelector.class))).thenReturn(singletonList(secret)); + + // when + PersonalAccessToken result = + personalAccessTokenManager + .get( + new SubjectImpl("user", Collections.emptyList(), "user1", "t1", false), + null, + "http://github.com", + null) + .get(); + + // then + assertEquals(result.getToken(), "access-token"); + assertEquals(result.getRefreshToken(), "refresh-token-value"); + assertEquals(result.getExpiresIn(), 7200L); + } + + @Test + public void shouldHandleMissingRefreshTokenAndExpiryInSecret() throws Exception { + // given + KubernetesNamespaceMeta meta = new KubernetesNamespaceMetaImpl("test"); + when(namespaceFactory.list()).thenReturn(singletonList(meta)); + KubernetesNamespace kubernetesnamespace = Mockito.mock(KubernetesNamespace.class); + KubernetesSecrets secrets = Mockito.mock(KubernetesSecrets.class); + when(namespaceFactory.access(eq(null), eq(meta.getName()))).thenReturn(kubernetesnamespace); + when(kubernetesnamespace.secrets()).thenReturn(secrets); + when(scmPersonalAccessTokenFetcher.getScmUsername(any(PersonalAccessTokenParams.class))) + .thenReturn(Optional.of("user")); + + Map data = + Map.of("token", Base64.getEncoder().encodeToString("token-value".getBytes(UTF_8))); + + ObjectMeta metaData = + new ObjectMetaBuilder() + .withAnnotations( + Map.of( + ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_NAME, + "pat-name", + ANNOTATION_CHE_USERID, + "user1", + ANNOTATION_SCM_URL, + "http://host1")) + .build(); + + Secret secret = new SecretBuilder().withMetadata(metaData).withData(data).build(); + when(secrets.get(any(LabelSelector.class))).thenReturn(singletonList(secret)); + + // when + PersonalAccessToken result = + personalAccessTokenManager + .get( + new SubjectImpl("user", Collections.emptyList(), "user1", "t1", false), + null, + "http://host1", + null) + .get(); + + // then + assertEquals(result.getToken(), "token-value"); + assertEquals(result.getRefreshToken(), null); + assertEquals(result.getExpiresIn(), 0L); + } + + @Test + public void shouldMatchSecretByOAuthProviderName() throws Exception { + // given + KubernetesNamespaceMeta meta = new KubernetesNamespaceMetaImpl("test"); + when(namespaceFactory.list()).thenReturn(singletonList(meta)); + KubernetesNamespace kubernetesnamespace = Mockito.mock(KubernetesNamespace.class); + KubernetesSecrets secrets = Mockito.mock(KubernetesSecrets.class); + when(namespaceFactory.access(eq(null), eq(meta.getName()))).thenReturn(kubernetesnamespace); + when(kubernetesnamespace.secrets()).thenReturn(secrets); + when(scmPersonalAccessTokenFetcher.getScmUsername(any(PersonalAccessTokenParams.class))) + .thenReturn(Optional.of("user")); + + Map data = + Map.of("token", Base64.getEncoder().encodeToString("token1".getBytes(UTF_8))); + + ObjectMeta metaData = + new ObjectMetaBuilder() + .withAnnotations( + Map.of( + ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_NAME, + "oauth2-token-name", + ANNOTATION_SCM_PROVIDER_NAME, + "github", + ANNOTATION_CHE_USERID, + "user1", + ANNOTATION_SCM_URL, + "http://github.com")) + .build(); + + Secret secret = new SecretBuilder().withMetadata(metaData).withData(data).build(); + when(secrets.get(any(LabelSelector.class))).thenReturn(singletonList(secret)); + + // when + Optional result = + personalAccessTokenManager.get( + new SubjectImpl("user", Collections.emptyList(), "user1", "t1", false), + "github", + null, + null); + + // then + assertTrue(result.isPresent()); + assertEquals(result.get().getToken(), "token1"); + } + + @Test + public void shouldNotMatchSecretWithWrongProviderName() throws Exception { + // given + KubernetesNamespaceMeta meta = new KubernetesNamespaceMetaImpl("test"); + when(namespaceFactory.list()).thenReturn(singletonList(meta)); + KubernetesNamespace kubernetesnamespace = Mockito.mock(KubernetesNamespace.class); + KubernetesSecrets secrets = Mockito.mock(KubernetesSecrets.class); + when(namespaceFactory.access(eq(null), eq(meta.getName()))).thenReturn(kubernetesnamespace); + when(kubernetesnamespace.secrets()).thenReturn(secrets); + + Map data = + Map.of("token", Base64.getEncoder().encodeToString("token1".getBytes(UTF_8))); + + ObjectMeta metaData = + new ObjectMetaBuilder() + .withAnnotations( + Map.of( + ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_NAME, + "oauth2-token-name", + ANNOTATION_SCM_PROVIDER_NAME, + "gitlab", + ANNOTATION_CHE_USERID, + "user1", + ANNOTATION_SCM_URL, + "http://gitlab.com")) + .build(); + + Secret secret = new SecretBuilder().withMetadata(metaData).withData(data).build(); + when(secrets.get(any(LabelSelector.class))).thenReturn(singletonList(secret)); + + // when + Optional result = + personalAccessTokenManager.get( + new SubjectImpl("user", Collections.emptyList(), "user1", "t1", false), + "github", + null, + null); + + // then + assertFalse(result.isPresent()); } } diff --git a/wsmaster/che-core-api-auth-gitlab/src/test/java/org/eclipse/che/security/oauth/GitLabAuthenticatorTest.java b/wsmaster/che-core-api-auth-gitlab/src/test/java/org/eclipse/che/security/oauth/GitLabAuthenticatorTest.java index 2fcb9bfe5aa..41b0a4d802f 100644 --- a/wsmaster/che-core-api-auth-gitlab/src/test/java/org/eclipse/che/security/oauth/GitLabAuthenticatorTest.java +++ b/wsmaster/che-core-api-auth-gitlab/src/test/java/org/eclipse/che/security/oauth/GitLabAuthenticatorTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2012-2024 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/ @@ -17,6 +17,7 @@ import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static java.lang.Long.MAX_VALUE; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertNull; @@ -58,7 +59,12 @@ public void shouldGetToken() throws Exception { flowField.get(gitLabOAuthAuthenticator), new MemoryDataStoreFactory() .getDataStore("test") - .set("userId", new StoredCredential().setAccessToken("token"))); + .set( + "userId", + new StoredCredential() + .setAccessToken("token") + .setRefreshToken("refreshToken") + .setExpirationTimeMilliseconds(MAX_VALUE))); stubFor( get(urlEqualTo("/api/v4/user")) .withHeader(HttpHeaders.AUTHORIZATION, equalTo("Bearer token")) @@ -83,7 +89,12 @@ public void shouldGetEmptyToken() throws Exception { flowField.get(gitLabOAuthAuthenticator), new MemoryDataStoreFactory() .getDataStore("test") - .set("userId", new StoredCredential().setAccessToken("token"))); + .set( + "userId", + new StoredCredential() + .setAccessToken("token") + .setRefreshToken("refreshToken") + .setExpirationTimeMilliseconds(MAX_VALUE))); stubFor( get(urlEqualTo("/api/v4/user")) .withHeader(HttpHeaders.AUTHORIZATION, equalTo("Bearer token")) diff --git a/wsmaster/che-core-api-auth-shared/src/main/java/org/eclipse/che/api/auth/shared/dto/OAuthToken.java b/wsmaster/che-core-api-auth-shared/src/main/java/org/eclipse/che/api/auth/shared/dto/OAuthToken.java index 4b8461cc1f1..ba0ed7803d6 100644 --- a/wsmaster/che-core-api-auth-shared/src/main/java/org/eclipse/che/api/auth/shared/dto/OAuthToken.java +++ b/wsmaster/che-core-api-auth-shared/src/main/java/org/eclipse/che/api/auth/shared/dto/OAuthToken.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2012-2018 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/ @@ -35,4 +35,20 @@ public interface OAuthToken { void setScope(String scope); OAuthToken withScope(String scope); + + /** Get OAuth refresh token used to obtain new access tokens without re-authorization. */ + String getRefreshToken(); + + /** Set OAuth refresh token. */ + void setRefreshToken(String refreshToken); + + OAuthToken withRefreshToken(String refreshToken); + + /** Get token expiration time in seconds. */ + long getExpiresIn(); + + /** Set token expiration time in seconds. */ + void setExpiresIn(long expiresIn); + + OAuthToken withExpiresIn(long expiresIn); } diff --git a/wsmaster/che-core-api-auth/src/main/java/org/eclipse/che/security/oauth/EmbeddedOAuthAPI.java b/wsmaster/che-core-api-auth/src/main/java/org/eclipse/che/security/oauth/EmbeddedOAuthAPI.java index 76effaba139..ec7a9223b18 100644 --- a/wsmaster/che-core-api-auth/src/main/java/org/eclipse/che/security/oauth/EmbeddedOAuthAPI.java +++ b/wsmaster/che-core-api-auth/src/main/java/org/eclipse/che/security/oauth/EmbeddedOAuthAPI.java @@ -21,6 +21,7 @@ import static org.eclipse.che.security.oauth.OAuthAuthenticator.SSL_ERROR_CODE; import static org.eclipse.che.security.oauth1.OAuthAuthenticationService.ERROR_QUERY_NAME; +import com.google.api.client.auth.oauth2.TokenResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.ws.rs.HttpMethod; import jakarta.ws.rs.core.Response; @@ -106,7 +107,12 @@ public Response callback(UriInfo uriInfo, @Nullable List errorValues) OAuthAuthenticator oauth = getAuthenticator(providerName); final List scopes = params.get("scope"); try { - String token = oauth.callback(requestUrl, scopes == null ? emptyList() : scopes); + TokenResponse tokenResponse = + oauth.callback(requestUrl, scopes == null ? emptyList() : scopes); + // Store the full token response (including refresh token and expiry) so that + // tokens can be refreshed later without requiring re-authorization. + // Providers that issue non-expiring tokens omit `expires_in`, so fall back to 0. + Long expiresInSeconds = tokenResponse.getExpiresInSeconds(); personalAccessTokenManager.store( new PersonalAccessToken( oauth.getEndpointUrl(), @@ -116,7 +122,9 @@ public Response callback(UriInfo uriInfo, @Nullable List errorValues) null, NameGenerator.generate(OAUTH_2_PREFIX, 5), NameGenerator.generate("id-", 5), - token)); + tokenResponse.getAccessToken(), + tokenResponse.getRefreshToken(), + expiresInSeconds == null ? 0 : expiresInSeconds)); } catch (OAuthAuthenticationException e) { return Response.temporaryRedirect( URI.create( @@ -236,10 +244,13 @@ public OAuthToken getOrRefreshToken(String oauthProvider) } else { Optional tokenOptional; try { - tokenOptional = personalAccessTokenManager.get(subject, oauthProvider, null, null); + // The token is read as stored: refreshing it is this class' own job, so a read that + // refreshes would come back here through the SCM token fetcher and never terminate. + tokenOptional = personalAccessTokenManager.getStored(subject, oauthProvider, null, null); if (tokenOptional.isEmpty()) { tokenOptional = - personalAccessTokenManager.get(subject, null, provider.getEndpointUrl(), null); + personalAccessTokenManager.getStored( + subject, null, provider.getEndpointUrl(), null); } if (tokenOptional.isPresent()) { return newDto(OAuthToken.class).withToken(tokenOptional.get().getToken()); @@ -260,23 +271,53 @@ public OAuthToken refreshToken(String oauthProvider) throws NotFoundException, UnauthorizedException, ServerException { OAuthAuthenticator provider = getAuthenticator(oauthProvider); Subject subject = EnvironmentContext.getCurrent().getSubject(); + String userId = subject.getUserId(); + String userName = subject.getUserName(); try { - OAuthToken token = provider.refreshToken(subject.getUserId()); - if (token == null) { - token = provider.refreshToken(subject.getUserName()); + OAuthToken storedToken = provider.refreshToken(userId); + if (storedToken == null) { + storedToken = provider.refreshToken(userName); } - if (token != null) { - return token; + if (storedToken != null) { + return storedToken; } else { - throw new UnauthorizedException( - "OAuth token for user " + subject.getUserId() + " was not found"); + // Credential was not found in the in-memory store (e.g. after server restart). + // Restore it from the persisted Kubernetes secret so the OAuth flow can refresh it. The + // token is read as stored: it is this call that refreshes it, so letting the manager + // refresh it on read would call back here endlessly. + Optional tokenOptional = + personalAccessTokenManager.getStored(subject, oauthProvider, null, null); + if (tokenOptional.isPresent()) { + PersonalAccessToken token = tokenOptional.get(); + if (isNullOrEmpty(token.getRefreshToken())) { + throw getUnauthorizedException(userId); + } + // Re-populate the in-memory credential store from the persisted token + TokenResponse tokenResponse = + new TokenResponse() + .setAccessToken(token.getToken()) + .setRefreshToken(token.getRefreshToken()); + // leave `expires_in` unset when the persisted token carries no expiry, + // so that the credential is not treated as already expired + if (token.getExpiresIn() > 0) { + tokenResponse.setExpiresInSeconds(token.getExpiresIn()); + } + provider.flow.createAndStoreCredential(tokenResponse, userId); + return provider.refreshToken(userId); + } else { + throw getUnauthorizedException(userId); + } } - } catch (IOException e) { + } catch (IOException | ScmConfigurationPersistenceException | ScmCommunicationException e) { throw new ServerException(e.getLocalizedMessage(), e); } } + private UnauthorizedException getUnauthorizedException(String userId) { + return new UnauthorizedException("OAuth token for user " + userId + " was not found"); + } + @Override public void invalidateToken(String oauthProvider) throws NotFoundException, UnauthorizedException, ServerException { @@ -292,6 +333,11 @@ public void invalidateToken(String oauthProvider) } } + @Override + public String getProviderUrl(String oauthProvider) throws NotFoundException { + return getAuthenticator(oauthProvider).getEndpointUrl(); + } + protected OAuthAuthenticator getAuthenticator(String oauthProviderName) throws NotFoundException { OAuthAuthenticator oauth = oauth2Providers.getAuthenticator(oauthProviderName); if (oauth == null) { diff --git a/wsmaster/che-core-api-auth/src/main/java/org/eclipse/che/security/oauth/OAuthAPI.java b/wsmaster/che-core-api-auth/src/main/java/org/eclipse/che/security/oauth/OAuthAPI.java index 229ecb6db32..b3083f06f6c 100644 --- a/wsmaster/che-core-api-auth/src/main/java/org/eclipse/che/security/oauth/OAuthAPI.java +++ b/wsmaster/che-core-api-auth/src/main/java/org/eclipse/che/security/oauth/OAuthAPI.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/ @@ -63,7 +63,7 @@ OAuthToken getOrRefreshToken(String oauthProvider) * Refreshes the token for the given OAuth provider. * * @param oauthProvider - the OAuth provider name - * @return the refreshed token + * @return the refreshed OAuth token */ OAuthToken refreshToken(String oauthProvider) throws NotFoundException, UnauthorizedException, ServerException, ForbiddenException; @@ -71,4 +71,12 @@ OAuthToken refreshToken(String oauthProvider) /** Implementation of method {@link OAuthAuthenticationService#invalidate(String)}} */ void invalidateToken(String oauthProvider) throws NotFoundException, UnauthorizedException, ServerException, ForbiddenException; + + /** + * Returns the endpoint URL of the given OAuth provider. + * + * @param oauthProvider - the OAuth provider name + * @return the provider endpoint URL + */ + String getProviderUrl(String oauthProvider) throws NotFoundException; } 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 11aae456f9e..3772724e7de 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/ @@ -14,6 +14,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; @@ -30,6 +31,12 @@ import org.eclipse.che.api.core.rest.Service; import org.eclipse.che.api.core.rest.annotations.Required; import org.eclipse.che.api.factory.server.scm.AuthorisationRequestManager; +import org.eclipse.che.api.factory.server.scm.PersonalAccessTokenManager; +import org.eclipse.che.api.factory.server.scm.exception.ScmCommunicationException; +import org.eclipse.che.api.factory.server.scm.exception.ScmConfigurationPersistenceException; +import org.eclipse.che.api.factory.server.scm.exception.ScmUnauthorizedException; +import org.eclipse.che.api.factory.server.scm.exception.UnknownScmProviderException; +import org.eclipse.che.api.factory.server.scm.exception.UnsatisfiedScmPreconditionException; import org.eclipse.che.security.oauth.shared.dto.OAuthAuthenticatorDescriptor; /** RESTful wrapper for OAuthAuthenticator. */ @@ -40,6 +47,7 @@ public class OAuthAuthenticationService extends Service { @Inject private OAuthAPI oAuthAPI; @Inject private AuthorisationRequestManager authorisationRequestManager; + @Inject private PersonalAccessTokenManager personalAccessTokenManager; /** * Redirect request to OAuth provider site for authentication|authorization. Client must provide @@ -105,6 +113,23 @@ public OAuthToken token(@Required @QueryParam("oauth_provider") String oauthProv return oAuthAPI.getOrRefreshToken(oauthProvider); } + /** + * Refreshes the OAuth token for the given provider and persists the updated token as a Kubernetes + * secret and git credential, so that subsequent SCM operations use the new access token. + * + * @param providerUrl URL of the OAuth provider instance the token belongs to. + */ + @POST + @Path("refresh") + public void refresh(@Required @QueryParam("provider_url") String providerUrl) + throws UnsatisfiedScmPreconditionException, + ScmConfigurationPersistenceException, + ScmCommunicationException, + UnknownScmProviderException, + ScmUnauthorizedException { + personalAccessTokenManager.forceRefreshPersonalAccessToken(providerUrl); + } + /** * Invalidate OAuth token for user. * diff --git a/wsmaster/che-core-api-auth/src/main/java/org/eclipse/che/security/oauth/OAuthAuthenticator.java b/wsmaster/che-core-api-auth/src/main/java/org/eclipse/che/security/oauth/OAuthAuthenticator.java index 3d8c209d104..c11b81ebda9 100644 --- a/wsmaster/che-core-api-auth/src/main/java/org/eclipse/che/security/oauth/OAuthAuthenticator.java +++ b/wsmaster/che-core-api-auth/src/main/java/org/eclipse/che/security/oauth/OAuthAuthenticator.java @@ -179,12 +179,12 @@ protected String findRedirectUrl(URL requestUrl) { * server * @param scopes specify exactly what type of access needed. This list must be exactly the same as * list passed to the method {@link #getAuthenticateUrl(URL, java.util.List)} - * @return access token + * @return TokenResponse object with the token data * @throws OAuthAuthenticationException if authentication failed or requestUrl does * not contain required parameters, e.g. 'code' * @throws ScmCommunicationException if communication with SCM failed */ - public String callback(URL requestUrl, List scopes) + public TokenResponse callback(URL requestUrl, List scopes) throws OAuthAuthenticationException, ScmCommunicationException { if (!isConfigured()) { throw new OAuthAuthenticationException(AUTHENTICATOR_IS_NOT_CONFIGURED); @@ -209,7 +209,7 @@ public String callback(URL requestUrl, List scopes) userId = EnvironmentContext.getCurrent().getSubject().getUserId(); } flow.createAndStoreCredential(tokenResponse, userId); - return tokenResponse.getAccessToken(); + return tokenResponse; } catch (IOException ioe) { if (ioe instanceof SSLHandshakeException) { throw new ScmCommunicationException( @@ -328,7 +328,7 @@ public OAuthToken getOrRefreshToken(String userId) throws IOException { return null; } } - return newDto(OAuthToken.class).withToken(credential.getAccessToken()); + return newOAuthToken(credential); } /** @@ -366,7 +366,21 @@ public OAuthToken refreshToken(String userId) throws IOException { } return null; } - return newDto(OAuthToken.class).withToken(credential.getAccessToken()); + return newOAuthToken(credential); + } + + /** + * Create an {@link OAuthToken} DTO from the given credential. Expiration time is set only when + * the credential provides it, since {@link OAuthToken#withExpiresIn(long)} accepts a primitive + * and {@link Credential#getExpiresInSeconds()} may return {@code null}. + */ + private OAuthToken newOAuthToken(Credential credential) { + OAuthToken oAuthToken = + newDto(OAuthToken.class) + .withToken(credential.getAccessToken()) + .withRefreshToken(credential.getRefreshToken()); + Long expiresIn = credential.getExpiresInSeconds(); + return expiresIn == null ? oAuthToken : oAuthToken.withExpiresIn(expiresIn); } /** diff --git a/wsmaster/che-core-api-auth/src/test/java/org/eclipse/che/security/oauth/EmbeddedOAuthAPITest.java b/wsmaster/che-core-api-auth/src/test/java/org/eclipse/che/security/oauth/EmbeddedOAuthAPITest.java index 095a1fc2a0e..e9b6838644d 100644 --- a/wsmaster/che-core-api-auth/src/test/java/org/eclipse/che/security/oauth/EmbeddedOAuthAPITest.java +++ b/wsmaster/che-core-api-auth/src/test/java/org/eclipse/che/security/oauth/EmbeddedOAuthAPITest.java @@ -23,24 +23,31 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertNull; import static org.testng.Assert.assertTrue; +import com.google.api.client.auth.oauth2.AuthorizationCodeFlow; +import com.google.api.client.auth.oauth2.TokenResponse; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.UriBuilder; import jakarta.ws.rs.core.UriInfo; import java.lang.reflect.Field; import java.net.URI; import java.net.URL; +import java.util.Optional; import java.util.Set; import org.eclipse.che.api.auth.shared.dto.OAuthToken; import org.eclipse.che.api.core.NotFoundException; +import org.eclipse.che.api.core.ServerException; +import org.eclipse.che.api.core.UnauthorizedException; import org.eclipse.che.api.factory.server.scm.PersonalAccessToken; import org.eclipse.che.api.factory.server.scm.PersonalAccessTokenManager; import org.eclipse.che.api.factory.server.scm.exception.ScmCommunicationException; +import org.eclipse.che.commons.subject.Subject; import org.eclipse.che.security.oauth.shared.dto.OAuthAuthenticatorDescriptor; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; @@ -150,8 +157,10 @@ public void shouldStoreTokenOnCallback() throws Exception { // given UriInfo uriInfo = mock(UriInfo.class); OAuthAuthenticator authenticator = mock(OAuthAuthenticator.class); + TokenResponse tokenResponse = mock(TokenResponse.class); when(authenticator.getEndpointUrl()).thenReturn("http://eclipse.che"); - when(authenticator.callback(any(URL.class), anyList())).thenReturn("token"); + when(tokenResponse.getAccessToken()).thenReturn("token"); + when(authenticator.callback(any(URL.class), anyList())).thenReturn(tokenResponse); when(uriInfo.getRequestUri()) .thenReturn( new URI( @@ -178,6 +187,7 @@ public void shouldEncodeRedirectUrl() throws Exception { // given UriInfo uriInfo = mock(UriInfo.class); OAuthAuthenticator authenticator = mock(OAuthAuthenticator.class); + when(authenticator.callback(any(URL.class), anyList())).thenReturn(mock(TokenResponse.class)); when(uriInfo.getRequestUri()) .thenReturn( new URI( @@ -200,6 +210,7 @@ public void shouldNotEncodeRedirectUrl() throws Exception { // given UriInfo uriInfo = mock(UriInfo.class); OAuthAuthenticator authenticator = mock(OAuthAuthenticator.class); + when(authenticator.callback(any(URL.class), anyList())).thenReturn(mock(TokenResponse.class)); when(uriInfo.getRequestUri()) .thenReturn( new URI( @@ -260,4 +271,222 @@ public void shouldHaveNullClientIdForOAuth1Providers() throws Exception { assertEquals(descriptor.getName(), "bitbucket"); assertNull(descriptor.getClientId()); } + + @Test + public void shouldStoreRefreshTokenAndExpiryOnCallback() throws Exception { + // given + UriInfo uriInfo = mock(UriInfo.class); + OAuthAuthenticator authenticator = mock(OAuthAuthenticator.class); + TokenResponse tokenResponse = mock(TokenResponse.class); + when(authenticator.getEndpointUrl()).thenReturn("http://eclipse.che"); + when(tokenResponse.getAccessToken()).thenReturn("access-token"); + when(tokenResponse.getRefreshToken()).thenReturn("refresh-token"); + when(tokenResponse.getExpiresInSeconds()).thenReturn(3600L); + when(authenticator.callback(any(URL.class), anyList())).thenReturn(tokenResponse); + when(uriInfo.getRequestUri()) + .thenReturn( + new URI( + "http://eclipse.che?state=oauth_provider%3Dgithub%26redirect_after_login%3DredirectUrl")); + when(oauth2Providers.getAuthenticator("github")).thenReturn(authenticator); + ArgumentCaptor tokenCapture = + ArgumentCaptor.forClass(PersonalAccessToken.class); + + // when + embeddedOAuthAPI.callback(uriInfo, emptyList()); + + // then + verify(personalAccessTokenManager).store(tokenCapture.capture()); + PersonalAccessToken token = tokenCapture.getValue(); + assertEquals(token.getToken(), "access-token"); + assertEquals(token.getRefreshToken(), "refresh-token"); + assertEquals(token.getExpiresIn(), 3600L); + } + + @Test + public void shouldStoreZeroExpiryOnCallbackWhenTokenResponseHasNoExpiresIn() throws Exception { + // given + UriInfo uriInfo = mock(UriInfo.class); + OAuthAuthenticator authenticator = mock(OAuthAuthenticator.class); + TokenResponse tokenResponse = mock(TokenResponse.class); + when(authenticator.getEndpointUrl()).thenReturn("http://eclipse.che"); + when(tokenResponse.getAccessToken()).thenReturn("access-token"); + when(tokenResponse.getRefreshToken()).thenReturn("refresh-token"); + // providers that issue non-expiring tokens omit `expires_in` + when(tokenResponse.getExpiresInSeconds()).thenReturn(null); + when(authenticator.callback(any(URL.class), anyList())).thenReturn(tokenResponse); + when(uriInfo.getRequestUri()) + .thenReturn( + new URI( + "http://eclipse.che?state=oauth_provider%3Dgithub%26redirect_after_login%3DredirectUrl")); + when(oauth2Providers.getAuthenticator("github")).thenReturn(authenticator); + ArgumentCaptor tokenCapture = + ArgumentCaptor.forClass(PersonalAccessToken.class); + + // when + embeddedOAuthAPI.callback(uriInfo, emptyList()); + + // then + verify(personalAccessTokenManager).store(tokenCapture.capture()); + PersonalAccessToken token = tokenCapture.getValue(); + assertEquals(token.getToken(), "access-token"); + assertEquals(token.getRefreshToken(), "refresh-token"); + assertEquals(token.getExpiresIn(), 0L); + } + + @Test + public void shouldRestoreCredentialFromPersistedTokenOnRefresh() throws Exception { + // given + String provider = "github"; + OAuthAuthenticator authenticator = mock(OAuthAuthenticator.class); + when(oauth2Providers.getAuthenticator(provider)).thenReturn(authenticator); + + OAuthToken refreshedToken = + newDto(OAuthToken.class).withToken("new-access-token").withRefreshToken("new-refresh"); + when(authenticator.refreshToken("0000-00-0000")).thenReturn(null).thenReturn(refreshedToken); + when(authenticator.refreshToken("Anonymous")).thenReturn(null); + + AuthorizationCodeFlow flow = mock(AuthorizationCodeFlow.class); + Field flowField = OAuthAuthenticator.class.getDeclaredField("flow"); + flowField.setAccessible(true); + flowField.set(authenticator, flow); + + PersonalAccessToken persistedToken = + new PersonalAccessToken( + "https://github.com", + provider, + "0000-00-0000", + null, + null, + "oauth2-token", + "id-token", + "old-access-token", + "refresh-token-123", + 3600); + when(personalAccessTokenManager.getStored(any(Subject.class), eq(provider), eq(null), eq(null))) + .thenReturn(Optional.of(persistedToken)); + ArgumentCaptor tokenResponseCaptor = + ArgumentCaptor.forClass(TokenResponse.class); + + // when + OAuthToken result = embeddedOAuthAPI.refreshToken(provider); + + // then + assertEquals(result.getToken(), "new-access-token"); + verify(flow).createAndStoreCredential(tokenResponseCaptor.capture(), eq("0000-00-0000")); + TokenResponse tokenResponse = tokenResponseCaptor.getValue(); + assertEquals(tokenResponse.getAccessToken(), "old-access-token"); + assertEquals(tokenResponse.getRefreshToken(), "refresh-token-123"); + assertEquals(tokenResponse.getExpiresInSeconds(), Long.valueOf(3600)); + // reading the token the regular way makes the manager refresh it, which comes back here + // through the SCM token fetcher and never terminates + verify(personalAccessTokenManager, never()).get(any(Subject.class), any(), any(), any()); + } + + @Test + public void shouldNotSetExpiresInSecondsOnRefreshWhenPersistedTokenHasNoExpiry() + throws Exception { + // given + String provider = "github"; + OAuthAuthenticator authenticator = mock(OAuthAuthenticator.class); + when(oauth2Providers.getAuthenticator(provider)).thenReturn(authenticator); + + OAuthToken refreshedToken = + newDto(OAuthToken.class).withToken("new-access-token").withRefreshToken("new-refresh"); + when(authenticator.refreshToken("0000-00-0000")).thenReturn(null).thenReturn(refreshedToken); + when(authenticator.refreshToken("Anonymous")).thenReturn(null); + + AuthorizationCodeFlow flow = mock(AuthorizationCodeFlow.class); + Field flowField = OAuthAuthenticator.class.getDeclaredField("flow"); + flowField.setAccessible(true); + flowField.set(authenticator, flow); + + // tokens persisted without `expires_in` are stored with the `0` default + PersonalAccessToken persistedToken = + new PersonalAccessToken( + "https://github.com", + provider, + "0000-00-0000", + null, + null, + "oauth2-token", + "id-token", + "old-access-token", + "refresh-token-123", + 0); + when(personalAccessTokenManager.getStored(any(Subject.class), eq(provider), eq(null), eq(null))) + .thenReturn(Optional.of(persistedToken)); + ArgumentCaptor tokenResponseCaptor = + ArgumentCaptor.forClass(TokenResponse.class); + + // when + OAuthToken result = embeddedOAuthAPI.refreshToken(provider); + + // then + assertEquals(result.getToken(), "new-access-token"); + verify(flow).createAndStoreCredential(tokenResponseCaptor.capture(), eq("0000-00-0000")); + TokenResponse tokenResponse = tokenResponseCaptor.getValue(); + assertEquals(tokenResponse.getAccessToken(), "old-access-token"); + assertEquals(tokenResponse.getRefreshToken(), "refresh-token-123"); + assertNull(tokenResponse.getExpiresInSeconds()); + } + + @Test( + expectedExceptions = UnauthorizedException.class, + expectedExceptionsMessageRegExp = "OAuth token for user 0000-00-0000 was not found") + public void shouldThrowUnauthorizedOnRefreshWhenPersistedTokenHasNoRefreshToken() + throws Exception { + // given + String provider = "github"; + OAuthAuthenticator authenticator = mock(OAuthAuthenticator.class); + when(oauth2Providers.getAuthenticator(provider)).thenReturn(authenticator); + when(authenticator.refreshToken(anyString())).thenReturn(null); + + PersonalAccessToken persistedToken = + new PersonalAccessToken( + "https://github.com", + provider, + "0000-00-0000", + null, + null, + "oauth2-token", + "id-token", + "old-access-token", + null, + 0); + when(personalAccessTokenManager.getStored(any(Subject.class), eq(provider), eq(null), eq(null))) + .thenReturn(Optional.of(persistedToken)); + + // when + embeddedOAuthAPI.refreshToken(provider); + } + + @Test( + expectedExceptions = UnauthorizedException.class, + expectedExceptionsMessageRegExp = "OAuth token for user 0000-00-0000 was not found") + public void shouldThrowUnauthorizedOnRefreshWhenNoPersistedTokenExists() throws Exception { + // given + String provider = "github"; + OAuthAuthenticator authenticator = mock(OAuthAuthenticator.class); + when(oauth2Providers.getAuthenticator(provider)).thenReturn(authenticator); + when(authenticator.refreshToken(anyString())).thenReturn(null); + when(personalAccessTokenManager.getStored(any(Subject.class), eq(provider), eq(null), eq(null))) + .thenReturn(Optional.empty()); + + // when + embeddedOAuthAPI.refreshToken(provider); + } + + @Test(expectedExceptions = ServerException.class) + public void shouldWrapScmCommunicationExceptionInServerExceptionOnRefresh() throws Exception { + // given + String provider = "github"; + OAuthAuthenticator authenticator = mock(OAuthAuthenticator.class); + when(oauth2Providers.getAuthenticator(provider)).thenReturn(authenticator); + when(authenticator.refreshToken(anyString())).thenReturn(null); + when(personalAccessTokenManager.getStored(any(Subject.class), eq(provider), eq(null), eq(null))) + .thenThrow(new ScmCommunicationException("SCM error")); + + // when + embeddedOAuthAPI.refreshToken(provider); + } } diff --git a/wsmaster/che-core-api-auth/src/test/java/org/eclipse/che/security/oauth/OAuthAuthenticatorTest.java b/wsmaster/che-core-api-auth/src/test/java/org/eclipse/che/security/oauth/OAuthAuthenticatorTest.java new file mode 100644 index 00000000000..59218ef144a --- /dev/null +++ b/wsmaster/che-core-api-auth/src/test/java/org/eclipse/che/security/oauth/OAuthAuthenticatorTest.java @@ -0,0 +1,192 @@ +/* + * 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 com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.google.api.client.auth.oauth2.TokenResponse; +import com.google.api.client.util.store.MemoryDataStoreFactory; +import java.io.IOException; +import org.eclipse.che.api.auth.shared.dto.OAuthToken; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +/** Tests for {@link OAuthAuthenticator}. */ +public class OAuthAuthenticatorTest { + + private static final String USER_ID = "user1"; + private static final String TOKEN_PATH = "/oauth/token"; + + private WireMockServer wireMockServer; + private TestOAuthAuthenticator authenticator; + + @BeforeClass + void start() { + wireMockServer = new WireMockServer(wireMockConfig().dynamicPort()); + wireMockServer.start(); + WireMock.configureFor("localhost", wireMockServer.port()); + } + + @AfterClass + void stop() { + if (wireMockServer != null) { + wireMockServer.stop(); + } + } + + @BeforeMethod + void setUp() throws IOException { + wireMockServer.resetAll(); + authenticator = new TestOAuthAuthenticator(wireMockServer.baseUrl() + TOKEN_PATH); + } + + @Test + public void shouldReturnNullWhenNoCredentialStored() throws Exception { + assertNull(authenticator.getOrRefreshToken(USER_ID)); + } + + @Test + public void shouldReturnTokenWithExpirationWhenCredentialHasExpiry() throws Exception { + // given + storeCredential("access-token", "refresh-token", 3600L); + + // when + OAuthToken token = authenticator.getOrRefreshToken(USER_ID); + + // then + assertEquals(token.getToken(), "access-token"); + assertEquals(token.getRefreshToken(), "refresh-token"); + assertTrue( + token.getExpiresIn() > 0 && token.getExpiresIn() <= 3600, + "Unexpected expiration time: " + token.getExpiresIn()); + } + + /** Providers that issue non-expiring tokens omit {@code expires_in} from the token response. */ + @Test + public void shouldReturnTokenWithoutExpirationWhenCredentialHasNoExpiry() throws Exception { + // given + storeCredential("access-token", "refresh-token", null); + + // when + OAuthToken token = authenticator.getOrRefreshToken(USER_ID); + + // then + assertEquals(token.getToken(), "access-token"); + assertEquals(token.getRefreshToken(), "refresh-token"); + assertEquals(token.getExpiresIn(), 0L); + } + + @Test + public void shouldReturnNullOnRefreshWhenNoCredentialStored() throws Exception { + assertNull(authenticator.refreshToken(USER_ID)); + } + + @Test + public void shouldReturnRefreshedTokenWithExpiration() throws Exception { + // given + storeCredential("access-token", "refresh-token", 3600L); + stubTokenEndpoint( + "{\"access_token\":\"new-access-token\",\"refresh_token\":\"new-refresh-token\"," + + "\"expires_in\":7200,\"token_type\":\"Bearer\"}"); + + // when + OAuthToken token = authenticator.refreshToken(USER_ID); + + // then + assertEquals(token.getToken(), "new-access-token"); + assertEquals(token.getRefreshToken(), "new-refresh-token"); + assertTrue( + token.getExpiresIn() > 0 && token.getExpiresIn() <= 7200, + "Unexpected expiration time: " + token.getExpiresIn()); + } + + @Test + public void shouldReturnRefreshedTokenWhenResponseHasNoExpiresIn() throws Exception { + // given + storeCredential("access-token", "refresh-token", 3600L); + stubTokenEndpoint( + "{\"access_token\":\"new-access-token\",\"refresh_token\":\"new-refresh-token\"," + + "\"token_type\":\"Bearer\"}"); + + // when + OAuthToken token = authenticator.refreshToken(USER_ID); + + // then + assertEquals(token.getToken(), "new-access-token"); + assertEquals(token.getRefreshToken(), "new-refresh-token"); + assertEquals(token.getExpiresIn(), 0L); + } + + @Test + public void shouldInvalidateCredentialWhenRefreshFails() throws Exception { + // given + storeCredential("access-token", "refresh-token", 3600L); + stubFor(post(urlPathEqualTo(TOKEN_PATH)).willReturn(aResponse().withStatus(400))); + + // when + OAuthToken token = authenticator.refreshToken(USER_ID); + + // then + assertNull(token); + assertNull(authenticator.flow.loadCredential(USER_ID)); + } + + private void storeCredential(String accessToken, String refreshToken, Long expiresInSeconds) + throws IOException { + authenticator.flow.createAndStoreCredential( + new TokenResponse() + .setAccessToken(accessToken) + .setRefreshToken(refreshToken) + .setExpiresInSeconds(expiresInSeconds), + USER_ID); + } + + private void stubTokenEndpoint(String body) { + stubFor( + post(urlPathEqualTo(TOKEN_PATH)) + .willReturn(aResponse().withHeader("Content-Type", "application/json").withBody(body))); + } + + private static class TestOAuthAuthenticator extends OAuthAuthenticator { + + TestOAuthAuthenticator(String tokenUri) throws IOException { + configure( + "clientId", + "clientSecret", + new String[] {"http://localhost/callback"}, + "http://localhost/auth", + tokenUri, + new MemoryDataStoreFactory()); + } + + @Override + public String getOAuthProvider() { + return "test"; + } + + @Override + public String getEndpointUrl() { + return "http://localhost"; + } + } +} diff --git a/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsPersonalAccessTokenFetcher.java b/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsPersonalAccessTokenFetcher.java index e7cf44a9bed..c7e6633c813 100644 --- a/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsPersonalAccessTokenFetcher.java +++ b/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsPersonalAccessTokenFetcher.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/ @@ -120,10 +120,13 @@ private PersonalAccessToken fetchOrRefreshPersonalAccessToken( scmServerUrl, OAUTH_PROVIDER_NAME, cheSubject.getUserId(), + null, valid.get().second, tokenName, tokenId, - oAuthToken.getToken()); + oAuthToken.getToken(), + oAuthToken.getRefreshToken(), + oAuthToken.getExpiresIn()); } catch (UnauthorizedException e) { throw buildScmUnauthorizedException(cheSubject); } catch (NotFoundException nfe) { diff --git a/wsmaster/che-core-api-factory-bitbucket-server/src/main/java/org/eclipse/che/api/factory/server/bitbucket/BitbucketServerPersonalAccessTokenFetcher.java b/wsmaster/che-core-api-factory-bitbucket-server/src/main/java/org/eclipse/che/api/factory/server/bitbucket/BitbucketServerPersonalAccessTokenFetcher.java index 3ff7c6e3da2..2880944e520 100644 --- a/wsmaster/che-core-api-factory-bitbucket-server/src/main/java/org/eclipse/che/api/factory/server/bitbucket/BitbucketServerPersonalAccessTokenFetcher.java +++ b/wsmaster/che-core-api-factory-bitbucket-server/src/main/java/org/eclipse/che/api/factory/server/bitbucket/BitbucketServerPersonalAccessTokenFetcher.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/ @@ -116,7 +116,9 @@ private PersonalAccessToken fetchOrRefreshPersonalAccessToken( user.getSlug(), token.getName(), valueOf(token.getId()), - token.getToken()); + token.getToken(), + null, + 0); } catch (ScmBadRequestException | ScmItemNotFoundException e) { throw new ScmCommunicationException(e.getMessage(), e); } diff --git a/wsmaster/che-core-api-factory-bitbucket/src/main/java/org/eclipse/che/api/factory/server/bitbucket/BitbucketPersonalAccessTokenFetcher.java b/wsmaster/che-core-api-factory-bitbucket/src/main/java/org/eclipse/che/api/factory/server/bitbucket/BitbucketPersonalAccessTokenFetcher.java index 1c25cfa881c..98c79b3f769 100644 --- a/wsmaster/che-core-api-factory-bitbucket/src/main/java/org/eclipse/che/api/factory/server/bitbucket/BitbucketPersonalAccessTokenFetcher.java +++ b/wsmaster/che-core-api-factory-bitbucket/src/main/java/org/eclipse/che/api/factory/server/bitbucket/BitbucketPersonalAccessTokenFetcher.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/ @@ -131,10 +131,13 @@ private PersonalAccessToken fetchOrRefreshPersonalAccessToken( scmServerUrl, OAUTH_PROVIDER_NAME, cheSubject.getUserId(), + null, valid.get().second, tokenName, tokenId, - oAuthToken.getToken()); + oAuthToken.getToken(), + oAuthToken.getRefreshToken(), + oAuthToken.getExpiresIn()); } catch (UnauthorizedException e) { throw buildScmUnauthorizedException(cheSubject); } catch (NotFoundException nfe) { diff --git a/wsmaster/che-core-api-factory-github-common/src/main/java/org/eclipse/che/api/factory/server/github/AbstractGithubPersonalAccessTokenFetcher.java b/wsmaster/che-core-api-factory-github-common/src/main/java/org/eclipse/che/api/factory/server/github/AbstractGithubPersonalAccessTokenFetcher.java index c1f35e29cf8..4592491ca49 100644 --- a/wsmaster/che-core-api-factory-github-common/src/main/java/org/eclipse/che/api/factory/server/github/AbstractGithubPersonalAccessTokenFetcher.java +++ b/wsmaster/che-core-api-factory-github-common/src/main/java/org/eclipse/che/api/factory/server/github/AbstractGithubPersonalAccessTokenFetcher.java @@ -168,10 +168,13 @@ public PersonalAccessToken fetchPersonalAccessToken(Subject cheSubject, String s scmServerUrl, OAUTH_PROVIDER_NAME, cheSubject.getUserId(), + null, valid.get().second, tokenName, tokenId, - oAuthToken.getToken()); + oAuthToken.getToken(), + oAuthToken.getRefreshToken(), + oAuthToken.getExpiresIn()); } catch (UnauthorizedException e) { throw buildScmUnauthorizedException(cheSubject); } catch (NotFoundException nfe) { diff --git a/wsmaster/che-core-api-factory-gitlab-common/src/main/java/org/eclipse/che/api/factory/server/gitlab/AbstractGitlabOAuthTokenFetcher.java b/wsmaster/che-core-api-factory-gitlab-common/src/main/java/org/eclipse/che/api/factory/server/gitlab/AbstractGitlabOAuthTokenFetcher.java index 05949fb1729..4c1b6fba2fd 100644 --- a/wsmaster/che-core-api-factory-gitlab-common/src/main/java/org/eclipse/che/api/factory/server/gitlab/AbstractGitlabOAuthTokenFetcher.java +++ b/wsmaster/che-core-api-factory-gitlab-common/src/main/java/org/eclipse/che/api/factory/server/gitlab/AbstractGitlabOAuthTokenFetcher.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2012-2024 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/ @@ -111,10 +111,13 @@ private PersonalAccessToken fetchOrRefreshPersonalAccessToken( scmServerUrl, providerName, cheSubject.getUserId(), + null, valid.get().second, tokenName, tokenId, - oAuthToken.getToken()); + oAuthToken.getToken(), + oAuthToken.getRefreshToken(), + oAuthToken.getExpiresIn()); } catch (UnauthorizedException e) { throw buildScmUnauthorizedException(cheSubject); } catch (NotFoundException nfe) { diff --git a/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/scm/PersonalAccessToken.java b/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/scm/PersonalAccessToken.java index a32d77902c2..1be216c215c 100644 --- a/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/scm/PersonalAccessToken.java +++ b/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/scm/PersonalAccessToken.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/ @@ -28,6 +28,12 @@ public class PersonalAccessToken { /** Organization that user belongs to. Can be null if user is not a member of any organization. */ @Nullable private final String scmOrganization; + /** OAuth refresh token for obtaining new access tokens. Null for non-OAuth (PAT) tokens. */ + @Nullable private final String refreshToken; + + /** Token expiration time in seconds. 0 if the token does not expire. */ + @Nullable private final long expiresIn; + private final String scmTokenName; private final String scmTokenId; private final String token; @@ -41,7 +47,9 @@ public PersonalAccessToken( String scmUserName, String scmTokenName, String scmTokenId, - String token) { + String token, + String refreshToken, + long expiresIn) { this.scmProviderUrl = scmProviderUrl; this.scmOrganization = scmOrganization; this.scmProviderName = scmProviderName; @@ -49,26 +57,9 @@ public PersonalAccessToken( this.scmTokenName = scmTokenName; this.scmTokenId = scmTokenId; this.token = token; + this.refreshToken = refreshToken; this.cheUserId = cheUserId; - } - - public PersonalAccessToken( - String scmProviderUrl, - String scmProviderName, - String cheUserId, - String scmUserName, - String scmTokenName, - String scmTokenId, - String token) { - this( - scmProviderUrl, - scmProviderName, - cheUserId, - null, - scmUserName, - scmTokenName, - scmTokenId, - token); + this.expiresIn = expiresIn; } public PersonalAccessToken( @@ -81,7 +72,9 @@ public PersonalAccessToken( scmUserName, null, null, - token); + token, + null, + 0); } public String getScmProviderUrl() { @@ -104,6 +97,11 @@ public String getToken() { return token; } + @Nullable + public String getRefreshToken() { + return refreshToken; + } + public String getCheUserId() { return cheUserId; } @@ -113,6 +111,11 @@ public String getScmOrganization() { return scmOrganization; } + @Nullable + public long getExpiresIn() { + return expiresIn; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -125,13 +128,23 @@ public boolean equals(Object o) { && Objects.equal(scmTokenName, that.scmTokenName) && Objects.equal(scmTokenId, that.scmTokenId) && Objects.equal(token, that.token) - && Objects.equal(cheUserId, that.cheUserId); + && Objects.equal(refreshToken, that.refreshToken) + && Objects.equal(cheUserId, that.cheUserId) + && Objects.equal(expiresIn, that.expiresIn); } @Override public int hashCode() { return Objects.hashCode( - scmProviderUrl, scmUserName, scmOrganization, scmTokenName, scmTokenId, token, cheUserId); + scmProviderUrl, + scmUserName, + scmOrganization, + scmTokenName, + scmTokenId, + token, + refreshToken, + cheUserId, + expiresIn); } @Override @@ -156,10 +169,16 @@ public String toString() { + scmTokenId + '\'' + ", token='" - + token + + (token == null ? "" : "") + + '\'' + + ", refreshToken='" + + (refreshToken == null ? "" : "") + '\'' + ", cheUserId='" + cheUserId + + '\'' + + ", expiresIn=" + + expiresIn + '}'; } diff --git a/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/scm/PersonalAccessTokenManager.java b/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/scm/PersonalAccessTokenManager.java index 4089da75ecf..99997251d65 100644 --- a/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/scm/PersonalAccessTokenManager.java +++ b/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/scm/PersonalAccessTokenManager.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/ @@ -82,6 +82,31 @@ Optional get( @Nullable String namespaceName) throws ScmConfigurationPersistenceException, ScmCommunicationException; + /** + * The same as {@link #get(Subject, String, String, String)}, but the token is returned exactly as + * it is stored: it is neither refreshed nor validated against the SCM provider. + * + *

This is what the OAuth token refresh flow has to use, as it needs the persisted refresh + * token to perform the refresh itself. Reading the token the regular way would make it refresh + * the very token that is already being refreshed, looping endlessly, and validating an expired + * token would drop the secret that keeps the refresh token the ongoing refresh needs. + * + * @param cheUser Che user object + * @param oAuthProviderName OAuth provider name to get token for + * @param scmServerUrl Git provider endpoint + * @param namespaceName The user's namespace name. + * @return the stored personal access token + * @throws ScmConfigurationPersistenceException - problem occurred during communication with + * permanent storage. + * @throws ScmCommunicationException - problem occurred during communication with SCM server. + */ + Optional getStored( + Subject cheUser, + @Nullable String oAuthProviderName, + @Nullable String scmServerUrl, + @Nullable String namespaceName) + throws ScmConfigurationPersistenceException, ScmCommunicationException; + /** * Gets {@link PersonalAccessToken} from permanent storage. If the token is not found try to fetch * it from scm provider and save it in a permanent storage and set (update) git-credentials. diff --git a/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/scm/PersonalAccessTokenParams.java b/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/scm/PersonalAccessTokenParams.java index 3b06b65b6f2..bc5f8954893 100644 --- a/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/scm/PersonalAccessTokenParams.java +++ b/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/scm/PersonalAccessTokenParams.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2012-2024 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/ @@ -20,19 +20,39 @@ public class PersonalAccessTokenParams { private final String token; private final String organization; + /** OAuth refresh token for obtaining new access tokens. Null for non-OAuth (PAT) tokens. */ + private final String refreshToken; + + /** Token expiration time in seconds. 0 if the token does not expire. */ + private final long expiresIn; + public PersonalAccessTokenParams( String scmProviderUrl, String scmProviderName, String scmTokenName, String scmTokenId, String token, - String organization) { + String organization, + String refreshToken, + long expiresIn) { this.scmProviderUrl = scmProviderUrl; this.scmProviderName = scmProviderName; this.scmTokenName = scmTokenName; this.scmTokenId = scmTokenId; this.token = token; this.organization = organization; + this.refreshToken = refreshToken; + this.expiresIn = expiresIn; + } + + public PersonalAccessTokenParams( + String scmProviderUrl, + String scmProviderName, + String scmTokenName, + String scmTokenId, + String token, + String organization) { + this(scmProviderUrl, scmProviderName, scmTokenName, scmTokenId, token, organization, null, 0); } public String getScmProviderUrl() { @@ -66,4 +86,14 @@ public String getOrganization() { public String getScmProviderName() { return scmProviderName; } + + /** Returns the OAuth refresh token, or {@code null} for non-OAuth tokens. */ + public String getRefreshToken() { + return refreshToken; + } + + /** Returns the token expiration time in seconds, or 0 for non-OAuth tokens. */ + public long getExpiresIn() { + return expiresIn; + } }