From 6edf714df8e5a182cf85dcacd51220fb1dd5856a Mon Sep 17 00:00:00 2001 From: Ihor Vinokur Date: Mon, 31 Aug 2026 16:11:14 +0300 Subject: [PATCH 1/7] feat(oauth): add refresh token and expiry support to OAuth token flow Persist OAuth refresh tokens and expiration times in Kubernetes secrets alongside access tokens, enabling token refresh without re-authorization after server restarts. Adds a POST /oauth/refresh endpoint, updates the OAuthToken DTO, PersonalAccessToken, and PersonalAccessTokenParams with refreshToken/expiresIn fields, and restores in-memory credentials from persisted secrets when the credential store is empty. Co-Authored-By: Claude Opus 4.6 --- .../KubernetesPersonalAccessTokenManager.java | 54 +++- .../KubernetesGitCredentialManagerTest.java | 27 +- ...ernetesPersonalAccessTokenManagerTest.java | 239 +++++++++++++++++- .../oauth/GitLabAuthenticatorTest.java | 17 +- .../che/api/auth/shared/dto/OAuthToken.java | 18 +- .../che/security/oauth/EmbeddedOAuthAPI.java | 55 +++- .../eclipse/che/security/oauth/OAuthAPI.java | 12 +- .../oauth/OAuthAuthenticationService.java | 46 +++- .../security/oauth/OAuthAuthenticator.java | 16 +- .../security/oauth/EmbeddedOAuthAPITest.java | 142 ++++++++++- ...AzureDevOpsPersonalAccessTokenFetcher.java | 7 +- ...ucketServerPersonalAccessTokenFetcher.java | 6 +- .../BitbucketPersonalAccessTokenFetcher.java | 7 +- ...tractGithubPersonalAccessTokenFetcher.java | 5 +- .../AbstractGitlabOAuthTokenFetcher.java | 7 +- .../server/scm/PersonalAccessToken.java | 67 +++-- .../server/scm/PersonalAccessTokenParams.java | 34 ++- 17 files changed, 685 insertions(+), 74 deletions(-) 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 739ca2b597b..5b703191c88 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 @@ -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/ @@ -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.commons.lang.StringUtils.trimEnd; import com.google.common.collect.ImmutableMap; @@ -76,6 +77,12 @@ public class KubernetesPersonalAccessTokenManager implements PersonalAccessToken public static final String ANNOTATION_SCM_URL = "che.eclipse.org/scm-url"; 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"; + + /** Kubernetes secret data field key for the token expiration time in seconds. */ + public static final String EXPIRES_IN_DATA_FIELD = "expires-in"; + private final KubernetesNamespaceFactory namespaceFactory; private final CheServerKubernetesClientFactory cheServerKubernetesClientFactory; private final ScmPersonalAccessTokenFetcher scmPersonalAccessTokenFetcher; @@ -119,15 +126,30 @@ public void store(PersonalAccessToken personalAccessToken) .withLabels(SECRET_LABELS) .build(); + // Kubernetes secrets store data as Base64-encoded values + String tokenEncoded = + Base64.getEncoder() + .encodeToString(personalAccessToken.getToken().getBytes(StandardCharsets.UTF_8)); + String refreshTokenEncoded = + Base64.getEncoder() + .encodeToString( + personalAccessToken.getRefreshToken().getBytes(StandardCharsets.UTF_8)); + String expiresInEncoded = + Base64.getEncoder() + .encodeToString( + String.valueOf(personalAccessToken.getExpiresIn()) + .getBytes(StandardCharsets.UTF_8)); Secret secret = new SecretBuilder() .withMetadata(meta) .withData( Map.of( TOKEN_DATA_FIELD, - Base64.getEncoder() - .encodeToString( - personalAccessToken.getToken().getBytes(StandardCharsets.UTF_8)))) + tokenEncoded, + REFRESH_TOKEN_DATA_FIELD, + refreshTokenEncoded, + EXPIRES_IN_DATA_FIELD, + expiresInEncoded)) .build(); cheServerKubernetesClientFactory @@ -262,7 +284,9 @@ private List doGetPersonalAccessTokens( scmUsername.get(), personalAccessTokenParams.getScmTokenName(), personalAccessTokenParams.getScmTokenId(), - personalAccessTokenParams.getToken()); + personalAccessTokenParams.getToken(), + personalAccessTokenParams.getRefreshToken(), + personalAccessTokenParams.getExpiresIn()); result.add(personalAccessToken); continue; } @@ -368,10 +392,23 @@ 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 refreshTokenData = secret.getData().get("refresh-token"); + String expiresInData = secret.getData().get("expires-in"); String token = new String(Base64.getDecoder().decode(secret.getData().get("token"))).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 = + isNullOrEmpty(expiresInData) + ? 0 + : parseLong(new String(Base64.getDecoder().decode(expiresInData))); String configuredOAuthTokenName = secretAnnotations.get(ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_NAME); String configuredTokenId = secretAnnotations.get(ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_ID); @@ -385,7 +422,9 @@ private PersonalAccessTokenParams secret2PersonalAccessTokenParams(Secret secret configuredOAuthTokenName, configuredTokenId, token, - configuredScmOrganization); + configuredScmOrganization, + refreshToken, + expiresIn); } private boolean isSecretMatchesSearchCriteria( @@ -396,8 +435,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)) 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 357c351d73f..b85b92a8e08 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 @@ -153,10 +153,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); @@ -618,7 +621,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); @@ -692,4 +704,227 @@ public void shouldRemoveToken() throws Exception { // 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( + new String(Base64.getDecoder().decode(createdSecret.getData().get("expires-in")), UTF_8), + "3600"); + } + + @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)), + "expires-in", Base64.getEncoder().encodeToString("7200".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")) + .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..dcc15ab579b 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,10 @@ 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. personalAccessTokenManager.store( new PersonalAccessToken( oauth.getEndpointUrl(), @@ -116,7 +120,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(), + tokenResponse.getExpiresInSeconds())); } catch (OAuthAuthenticationException e) { return Response.temporaryRedirect( URI.create( @@ -260,23 +266,47 @@ 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. + Optional tokenOptional = + personalAccessTokenManager.get(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()) + .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 +322,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..01e22000bc1 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,14 @@ 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.GitCredentialManager; +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.ScmConfigurationPersistenceException; +import org.eclipse.che.api.factory.server.scm.exception.UnsatisfiedScmPreconditionException; +import org.eclipse.che.commons.env.EnvironmentContext; +import org.eclipse.che.commons.lang.NameGenerator; +import org.eclipse.che.commons.subject.Subject; import org.eclipse.che.security.oauth.shared.dto.OAuthAuthenticatorDescriptor; /** RESTful wrapper for OAuthAuthenticator. */ @@ -40,6 +49,8 @@ public class OAuthAuthenticationService extends Service { @Inject private OAuthAPI oAuthAPI; @Inject private AuthorisationRequestManager authorisationRequestManager; + @Inject private PersonalAccessTokenManager personalAccessTokenManager; + @Inject private GitCredentialManager gitCredentialManager; /** * Redirect request to OAuth provider site for authentication|authorization. Client must provide @@ -105,6 +116,39 @@ 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 oauthProvider OAuth provider name + */ + @POST + @Path("refresh") + public void refresh(@Required @QueryParam("oauth_provider") String oauthProvider) + throws ServerException, + UnauthorizedException, + NotFoundException, + ForbiddenException, + UnsatisfiedScmPreconditionException, + ScmConfigurationPersistenceException { + OAuthToken token = oAuthAPI.refreshToken(oauthProvider); + Subject subject = EnvironmentContext.getCurrent().getSubject(); + PersonalAccessToken personalAccessToken = + new PersonalAccessToken( + oAuthAPI.getProviderUrl(oauthProvider), + oauthProvider, + subject.getUserId(), + null, + subject.getUserName(), + NameGenerator.generate("oauth2-", 5), + NameGenerator.generate("id-", 5), + token.getToken(), + token.getRefreshToken(), + token.getExpiresIn()); + personalAccessTokenManager.store(personalAccessToken); + gitCredentialManager.createOrReplace(personalAccessToken); + } + /** * 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..e2bcac47f06 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,10 @@ public OAuthToken getOrRefreshToken(String userId) throws IOException { return null; } } - return newDto(OAuthToken.class).withToken(credential.getAccessToken()); + return newDto(OAuthToken.class) + .withToken(credential.getAccessToken()) + .withRefreshToken(credential.getRefreshToken()) + .withExpiresIn(credential.getExpiresInSeconds()); } /** @@ -366,7 +369,10 @@ public OAuthToken refreshToken(String userId) throws IOException { } return null; } - return newDto(OAuthToken.class).withToken(credential.getAccessToken()); + return newDto(OAuthToken.class) + .withToken(credential.getAccessToken()) + .withRefreshToken(credential.getRefreshToken()) + .withExpiresIn(credential.getExpiresInSeconds()); } /** 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..3ecfc948db8 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 @@ -29,18 +29,24 @@ 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 +156,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 +186,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 +209,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 +270,134 @@ 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 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.get(any(Subject.class), eq(provider), eq(null), eq(null))) + .thenReturn(Optional.of(persistedToken)); + + // when + OAuthToken result = embeddedOAuthAPI.refreshToken(provider); + + // then + assertEquals(result.getToken(), "new-access-token"); + verify(flow).createAndStoreCredential(any(TokenResponse.class), eq("0000-00-0000")); + } + + @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.get(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.get(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.get(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-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..c44130cc4ba 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 @@ -158,8 +171,14 @@ public String toString() { + ", token='" + token + '\'' + + ", refreshToken='" + + refreshToken + + '\'' + ", cheUserId='" + cheUserId + + '\'' + + ", expiresIn=" + + expiresIn + '}'; } 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; + } } From 5817bd09b60f51f6a6255dae06ad9ab52afb7c61 Mon Sep 17 00:00:00 2001 From: Ihor Vinokur Date: Thu, 10 Sep 2026 15:52:03 +0300 Subject: [PATCH 2/7] fixup! feat(oauth): add refresh token and expiry support to OAuth token flow --- .../KubernetesPersonalAccessTokenManager.java | 36 +++++++---------- ...ernetesPersonalAccessTokenManagerTest.java | 10 ++--- .../oauth/OAuthAuthenticationService.java | 40 ++++++------------- 3 files changed, 32 insertions(+), 54 deletions(-) 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 5b703191c88..5f49efdc794 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 @@ -75,14 +75,16 @@ 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"; - /** Kubernetes secret data field key for the token expiration time in seconds. */ - public static final String EXPIRES_IN_DATA_FIELD = "expires-in"; - private final KubernetesNamespaceFactory namespaceFactory; private final CheServerKubernetesClientFactory cheServerKubernetesClientFactory; private final ScmPersonalAccessTokenFetcher scmPersonalAccessTokenFetcher; @@ -122,6 +124,9 @@ public void store(PersonalAccessToken personalAccessToken) .put( ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_NAME, personalAccessToken.getScmTokenName()) + .put( + ANNOTATION_SCM_TOKEN_EXPIRES_IN, + String.valueOf(personalAccessToken.getExpiresIn())) .build()) .withLabels(SECRET_LABELS) .build(); @@ -134,22 +139,13 @@ public void store(PersonalAccessToken personalAccessToken) Base64.getEncoder() .encodeToString( personalAccessToken.getRefreshToken().getBytes(StandardCharsets.UTF_8)); - String expiresInEncoded = - Base64.getEncoder() - .encodeToString( - String.valueOf(personalAccessToken.getExpiresIn()) - .getBytes(StandardCharsets.UTF_8)); Secret secret = new SecretBuilder() .withMetadata(meta) .withData( Map.of( - TOKEN_DATA_FIELD, - tokenEncoded, - REFRESH_TOKEN_DATA_FIELD, - refreshTokenEncoded, - EXPIRES_IN_DATA_FIELD, - expiresInEncoded)) + TOKEN_DATA_FIELD, tokenEncoded, + REFRESH_TOKEN_DATA_FIELD, refreshTokenEncoded)) .build(); cheServerKubernetesClientFactory @@ -395,20 +391,18 @@ private boolean deleteSecretIfMisconfigured(Secret secret) throws Infrastructure /** Extracts token parameters from a Kubernetes secret, decoding Base64-encoded data fields. */ private PersonalAccessTokenParams secret2PersonalAccessTokenParams(Secret secret) { Map secretAnnotations = secret.getMetadata().getAnnotations(); - String refreshTokenData = secret.getData().get("refresh-token"); - String expiresInData = secret.getData().get("expires-in"); + 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"))).trim(); + 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 = - isNullOrEmpty(expiresInData) - ? 0 - : parseLong(new String(Base64.getDecoder().decode(expiresInData))); + long expiresIn = isNullOrEmpty(expiresInAnnotation) ? 0 : parseLong(expiresInAnnotation.trim()); String configuredOAuthTokenName = secretAnnotations.get(ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_NAME); String configuredTokenId = secretAnnotations.get(ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_ID); 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 b85b92a8e08..93e42886e61 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 @@ -741,8 +741,7 @@ public void shouldStoreRefreshTokenAndExpiryInSecret() throws Exception { new String(Base64.getDecoder().decode(createdSecret.getData().get("refresh-token")), UTF_8), "refresh-token-value"); assertEquals( - new String(Base64.getDecoder().decode(createdSecret.getData().get("expires-in")), UTF_8), - "3600"); + createdSecret.getMetadata().getAnnotations().get(ANNOTATION_SCM_TOKEN_EXPIRES_IN), "3600"); } @Test @@ -761,8 +760,7 @@ public void shouldDecodeRefreshTokenAndExpiryFromSecret() throws Exception { Map.of( "token", Base64.getEncoder().encodeToString("access-token".getBytes(UTF_8)), "refresh-token", - Base64.getEncoder().encodeToString("refresh-token-value".getBytes(UTF_8)), - "expires-in", Base64.getEncoder().encodeToString("7200".getBytes(UTF_8))); + Base64.getEncoder().encodeToString("refresh-token-value".getBytes(UTF_8))); ObjectMeta metaData = new ObjectMetaBuilder() @@ -773,7 +771,9 @@ public void shouldDecodeRefreshTokenAndExpiryFromSecret() throws Exception { ANNOTATION_CHE_USERID, "user1", ANNOTATION_SCM_URL, - "http://github.com")) + "http://github.com", + ANNOTATION_SCM_TOKEN_EXPIRES_IN, + "7200")) .build(); Secret secret = new SecretBuilder().withMetadata(metaData).withData(data).build(); 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 01e22000bc1..915568ce427 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 @@ -32,13 +32,12 @@ 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.GitCredentialManager; -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.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.commons.env.EnvironmentContext; -import org.eclipse.che.commons.lang.NameGenerator; -import org.eclipse.che.commons.subject.Subject; import org.eclipse.che.security.oauth.shared.dto.OAuthAuthenticatorDescriptor; /** RESTful wrapper for OAuthAuthenticator. */ @@ -120,33 +119,18 @@ public OAuthToken token(@Required @QueryParam("oauth_provider") String oauthProv * 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 oauthProvider OAuth provider name + * @param providerUrl URL of the OAuth provider instance the token belongs to. Optional, if not + * set, the URL configured for the given provider is used. */ @POST @Path("refresh") - public void refresh(@Required @QueryParam("oauth_provider") String oauthProvider) - throws ServerException, - UnauthorizedException, - NotFoundException, - ForbiddenException, - UnsatisfiedScmPreconditionException, - ScmConfigurationPersistenceException { - OAuthToken token = oAuthAPI.refreshToken(oauthProvider); - Subject subject = EnvironmentContext.getCurrent().getSubject(); - PersonalAccessToken personalAccessToken = - new PersonalAccessToken( - oAuthAPI.getProviderUrl(oauthProvider), - oauthProvider, - subject.getUserId(), - null, - subject.getUserName(), - NameGenerator.generate("oauth2-", 5), - NameGenerator.generate("id-", 5), - token.getToken(), - token.getRefreshToken(), - token.getExpiresIn()); - personalAccessTokenManager.store(personalAccessToken); - gitCredentialManager.createOrReplace(personalAccessToken); + public void refresh(@Required @QueryParam("provider_url") String providerUrl) + throws UnsatisfiedScmPreconditionException, + ScmConfigurationPersistenceException, + ScmCommunicationException, + UnknownScmProviderException, + ScmUnauthorizedException { + personalAccessTokenManager.forceRefreshPersonalAccessToken(providerUrl); } /** From 4de79423b8e53930e730ad6d7a17e28718a1d9c9 Mon Sep 17 00:00:00 2001 From: Ihor Vinokur Date: Fri, 11 Sep 2026 15:50:16 +0300 Subject: [PATCH 3/7] fixup! feat(oauth): add refresh token and expiry support to OAuth token flow Handle providers that omit `expires_in` and issue no refresh token: * `OAuthAuthenticator`: extract `newOAuthToken(Credential)` and set the expiration only when the credential provides one, since `OAuthToken#withExpiresIn` takes a primitive. * `EmbeddedOAuthAPI`: fall back to 0 when the token response has no `expires_in`. * `KubernetesPersonalAccessTokenManager`: only write the `refresh-token` secret field when a refresh token is present. Add tests for each case, including a new `OAuthAuthenticatorTest` for the OAuth2 authenticator. Co-Authored-By: Claude Opus 5 --- .../KubernetesPersonalAccessTokenManager.java | 22 +- ...ernetesPersonalAccessTokenManagerTest.java | 70 +++++++ .../che/security/oauth/EmbeddedOAuthAPI.java | 4 +- .../oauth/OAuthAuthenticationService.java | 2 - .../security/oauth/OAuthAuthenticator.java | 24 ++- .../security/oauth/EmbeddedOAuthAPITest.java | 31 +++ .../oauth/OAuthAuthenticatorTest.java | 192 ++++++++++++++++++ 7 files changed, 322 insertions(+), 23 deletions(-) create mode 100644 wsmaster/che-core-api-auth/src/test/java/org/eclipse/che/security/oauth/OAuthAuthenticatorTest.java 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 5f49efdc794..eb614a60613 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 @@ -135,18 +135,16 @@ public void store(PersonalAccessToken personalAccessToken) String tokenEncoded = Base64.getEncoder() .encodeToString(personalAccessToken.getToken().getBytes(StandardCharsets.UTF_8)); - String refreshTokenEncoded = - Base64.getEncoder() - .encodeToString( - personalAccessToken.getRefreshToken().getBytes(StandardCharsets.UTF_8)); - Secret secret = - new SecretBuilder() - .withMetadata(meta) - .withData( - Map.of( - TOKEN_DATA_FIELD, tokenEncoded, - REFRESH_TOKEN_DATA_FIELD, refreshTokenEncoded)) - .build(); + 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() 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 93e42886e61..a9f8a458ef3 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 @@ -43,6 +43,7 @@ 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; @@ -744,6 +745,75 @@ public void shouldStoreRefreshTokenAndExpiryInSecret() throws Exception { 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")); + assertEquals( + createdSecret.getMetadata().getAnnotations().get(ANNOTATION_SCM_TOKEN_EXPIRES_IN), "0"); + } + + @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 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 dcc15ab579b..b29182d179d 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 @@ -111,6 +111,8 @@ public Response callback(UriInfo uriInfo, @Nullable List errorValues) 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(), @@ -122,7 +124,7 @@ public Response callback(UriInfo uriInfo, @Nullable List errorValues) NameGenerator.generate("id-", 5), tokenResponse.getAccessToken(), tokenResponse.getRefreshToken(), - tokenResponse.getExpiresInSeconds())); + expiresInSeconds == null ? 0 : expiresInSeconds)); } catch (OAuthAuthenticationException e) { return Response.temporaryRedirect( URI.create( 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 915568ce427..2f544eaa498 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 @@ -31,7 +31,6 @@ 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.GitCredentialManager; 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; @@ -49,7 +48,6 @@ public class OAuthAuthenticationService extends Service { @Inject private OAuthAPI oAuthAPI; @Inject private AuthorisationRequestManager authorisationRequestManager; @Inject private PersonalAccessTokenManager personalAccessTokenManager; - @Inject private GitCredentialManager gitCredentialManager; /** * Redirect request to OAuth provider site for authentication|authorization. Client must provide 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 e2bcac47f06..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 @@ -328,10 +328,7 @@ public OAuthToken getOrRefreshToken(String userId) throws IOException { return null; } } - return newDto(OAuthToken.class) - .withToken(credential.getAccessToken()) - .withRefreshToken(credential.getRefreshToken()) - .withExpiresIn(credential.getExpiresInSeconds()); + return newOAuthToken(credential); } /** @@ -369,10 +366,21 @@ public OAuthToken refreshToken(String userId) throws IOException { } return null; } - return newDto(OAuthToken.class) - .withToken(credential.getAccessToken()) - .withRefreshToken(credential.getRefreshToken()) - .withExpiresIn(credential.getExpiresInSeconds()); + 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 3ecfc948db8..3d6408ea93f 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 @@ -301,6 +301,37 @@ public void shouldStoreRefreshTokenAndExpiryOnCallback() throws Exception { 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 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"; + } + } +} From 98d75258e6eb0fb12873a03594ec02c95c429766 Mon Sep 17 00:00:00 2001 From: Ihor Vinokur Date: Tue, 15 Sep 2026 10:44:06 +0300 Subject: [PATCH 4/7] fixup! feat(oauth): add refresh token and expiry support to OAuth token flow --- .../KubernetesPersonalAccessTokenManager.java | 30 +++++++++---------- ...ernetesPersonalAccessTokenManagerTest.java | 4 +-- .../oauth/OAuthAuthenticationService.java | 3 +- 3 files changed, 18 insertions(+), 19 deletions(-) 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 eb614a60613..b904ae10d0b 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 @@ -110,24 +110,24 @@ 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()) - .put( - ANNOTATION_SCM_TOKEN_EXPIRES_IN, - String.valueOf(personalAccessToken.getExpiresIn())) - .build()) + .withAnnotations(annotations.build()) .withLabels(SECRET_LABELS) .build(); 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 a9f8a458ef3..199c892c719 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 @@ -778,8 +778,8 @@ public void shouldStoreSecretWithoutRefreshTokenFieldWhenRefreshTokenIsNull() th new String(Base64.getDecoder().decode(createdSecret.getData().get("token")), UTF_8), "access-token"); assertFalse(createdSecret.getData().containsKey("refresh-token")); - assertEquals( - createdSecret.getMetadata().getAnnotations().get(ANNOTATION_SCM_TOKEN_EXPIRES_IN), "0"); + assertFalse( + createdSecret.getMetadata().getAnnotations().containsKey(ANNOTATION_SCM_TOKEN_EXPIRES_IN)); } @Test 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 2f544eaa498..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 @@ -117,8 +117,7 @@ public OAuthToken token(@Required @QueryParam("oauth_provider") String oauthProv * 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. Optional, if not - * set, the URL configured for the given provider is used. + * @param providerUrl URL of the OAuth provider instance the token belongs to. */ @POST @Path("refresh") From 213c3ef40f2fa6d1a0388665657aa3e4e9240667 Mon Sep 17 00:00:00 2001 From: Ihor Vinokur Date: Tue, 15 Sep 2026 12:51:20 +0300 Subject: [PATCH 5/7] fixup! feat(oauth): add refresh token and expiry support to OAuth token flow Co-Authored-By: Claude Opus 5 --- .../che/security/oauth/EmbeddedOAuthAPI.java | 8 ++- .../security/oauth/EmbeddedOAuthAPITest.java | 56 ++++++++++++++++++- 2 files changed, 61 insertions(+), 3 deletions(-) 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 b29182d179d..4ac743f1225 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 @@ -292,8 +292,12 @@ public OAuthToken refreshToken(String oauthProvider) TokenResponse tokenResponse = new TokenResponse() .setAccessToken(token.getToken()) - .setRefreshToken(token.getRefreshToken()) - .setExpiresInSeconds(token.getExpiresIn()); + .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 { 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 3d6408ea93f..8e02ed99629 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 @@ -363,13 +363,67 @@ public void shouldRestoreCredentialFromPersistedTokenOnRefresh() throws Exceptio 3600); when(personalAccessTokenManager.get(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(any(TokenResponse.class), eq("0000-00-0000")); + 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)); + } + + @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.get(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( From 36a4c3a527bfead25ff581f675905da6be796ee3 Mon Sep 17 00:00:00 2001 From: Ihor Vinokur Date: Fri, 18 Sep 2026 17:59:39 +0300 Subject: [PATCH 6/7] fixup! feat(oauth): add refresh token and expiry support to OAuth token flow --- .../KubernetesPersonalAccessTokenManager.java | 13 ++++++++++++- .../api/factory/server/scm/PersonalAccessToken.java | 4 ++-- 2 files changed, 14 insertions(+), 3 deletions(-) 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 b904ae10d0b..8576f8054d4 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 @@ -400,7 +400,18 @@ private PersonalAccessTokenParams secret2PersonalAccessTokenParams(Secret secret isNullOrEmpty(refreshTokenData) ? null : new String(Base64.getDecoder().decode(refreshTokenData)).trim(); - long expiresIn = isNullOrEmpty(expiresInAnnotation) ? 0 : parseLong(expiresInAnnotation.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); 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 c44130cc4ba..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 @@ -169,10 +169,10 @@ public String toString() { + scmTokenId + '\'' + ", token='" - + token + + (token == null ? "" : "") + '\'' + ", refreshToken='" - + refreshToken + + (refreshToken == null ? "" : "") + '\'' + ", cheUserId='" + cheUserId From 14aabbf2d1583cd6bcfd59b6b95759fb19619513 Mon Sep 17 00:00:00 2001 From: Ihor Vinokur Date: Wed, 23 Sep 2026 16:56:35 +0300 Subject: [PATCH 7/7] fixup! feat(oauth): add refresh token and expiry support to OAuth token flow Refresh expired OAuth tokens in place when reading them from the secrets, instead of forcing the user through the OAuth flow again. A token is considered expired once the lifetime stored in the che.eclipse.org/scm-token-expires-in annotation has elapsed since the secret was created, with a 60 seconds leeway. The refreshed token is stored in a new secret and the outdated one is removed; if the refresh fails, the regular validation flow takes over. Co-Authored-By: Claude Opus 5 --- .../KubernetesPersonalAccessTokenManager.java | 102 +++- ...ernetesPersonalAccessTokenManagerTest.java | 494 ++++++++++++++++++ 2 files changed, 594 insertions(+), 2 deletions(-) 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 3fa2cd1db17..853232accdc 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 @@ -25,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; @@ -86,6 +88,12 @@ public class KubernetesPersonalAccessTokenManager implements PersonalAccessToken /** 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; @@ -260,6 +268,23 @@ private List doGetPersonalAccessTokens( LOG.debug("Iterating over secret {}", secret.getMetadata().getName()); PersonalAccessTokenParams personalAccessTokenParams = this.secret2PersonalAccessTokenParams(secret); + + // 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); @@ -335,6 +360,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. * @@ -531,8 +630,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/KubernetesPersonalAccessTokenManagerTest.java b/infrastructures/infrastructure-factory/src/test/java/org/eclipse/che/api/factory/server/scm/kubernetes/KubernetesPersonalAccessTokenManagerTest.java index 8a519a9895d..bec5ccf91d2 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,12 +37,15 @@ 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; @@ -49,6 +54,8 @@ 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; @@ -777,6 +784,493 @@ public void shouldKeepPersonalAccessTokenSecretOnForceRefresh() throws Exception verify(nonNamespaceOperation, never()).delete(eq(patSecret)); } + @Test + public void shouldRefreshExpiredOAuthToken() 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 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, + "oauth2-abcde", + ANNOTATION_CHE_USERID, + "user1", + ANNOTATION_SCM_URL, + "http://host1", + ANNOTATION_SCM_PROVIDER_NAME, + "gitlab", + ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_ID, + "oauth-id", + ANNOTATION_SCM_TOKEN_EXPIRES_IN, + "3600")) + .build(); + 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() + .withName("personal-access-token-fresh") + .withCreationTimestamp(Instant.now().toString()) + .withAnnotations( + Map.of( + ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_NAME, + "oauth2-abcde", + ANNOTATION_CHE_USERID, + "user1", + ANNOTATION_SCM_URL, + "http://host1", + ANNOTATION_SCM_PROVIDER_NAME, + "gitlab", + ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_ID, + "oauth-id", + ANNOTATION_SCM_TOKEN_EXPIRES_IN, + "3600")) + .build(); + 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 + Optional token = + personalAccessTokenManager.get( + new SubjectImpl("user", Collections.emptyList(), "user1", "t1", false), + null, + "http://host1", + null); + + // then + 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)); + } + + /** 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