From 6edf714df8e5a182cf85dcacd51220fb1dd5856a Mon Sep 17 00:00:00 2001 From: Ihor Vinokur Date: Mon, 31 Aug 2026 16:11:14 +0300 Subject: [PATCH 01/12] 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 02/12] 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 03/12] 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 04/12] 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 05/12] 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 06/12] 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 07/12] 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 From 08451ecc7ae47523f47959516569e16de10a741c Mon Sep 17 00:00:00 2001 From: Ihor Vinokur Date: Thu, 24 Sep 2026 17:17:23 +0300 Subject: [PATCH 08/12] fixup! feat(oauth): add refresh token and expiry support to OAuth token flow Add PersonalAccessTokenManager#getStored, which returns the token exactly as persisted, without refreshing or validating it against the SCM provider, and use it from EmbeddedOAuthAPI. The OAuth refresh flow is the one that performs the refresh, so a regular read would make the token manager refresh the very token being refreshed and loop back through the SCM token fetcher endlessly. Validating an expired token would also delete the secret holding the refresh token that the ongoing refresh needs. Co-Authored-By: Claude Opus 5 --- .../KubernetesPersonalAccessTokenManager.java | 74 +++++++++++++++---- ...ernetesPersonalAccessTokenManagerTest.java | 58 +++++++++++++++ .../che/security/oauth/EmbeddedOAuthAPI.java | 13 +++- .../security/oauth/EmbeddedOAuthAPITest.java | 14 ++-- .../scm/PersonalAccessTokenManager.java | 27 ++++++- 5 files changed, 161 insertions(+), 25 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 853232accdc..18ebaefcb71 100644 --- a/infrastructures/infrastructure-factory/src/main/java/org/eclipse/che/api/factory/server/scm/kubernetes/KubernetesPersonalAccessTokenManager.java +++ b/infrastructures/infrastructure-factory/src/main/java/org/eclipse/che/api/factory/server/scm/kubernetes/KubernetesPersonalAccessTokenManager.java @@ -241,12 +241,40 @@ public Optional get( .findFirst(); } + @Override + public Optional getStored( + Subject cheUser, + @Nullable String oAuthProviderName, + @Nullable String scmServerUrl, + @Nullable String namespaceName) + throws ScmConfigurationPersistenceException, ScmCommunicationException { + return doGetPersonalAccessTokens(cheUser, oAuthProviderName, scmServerUrl, namespaceName, false) + .stream() + .findFirst(); + } + private List doGetPersonalAccessTokens( Subject cheUser, @Nullable String oAuthProviderName, @Nullable String scmServerUrl, @Nullable String namespaceName) throws ScmConfigurationPersistenceException, ScmCommunicationException { + return doGetPersonalAccessTokens(cheUser, oAuthProviderName, scmServerUrl, namespaceName, true); + } + + /** + * @param refreshAndValidate whether the tokens have to be checked against the SCM provider: an + * expired OAuth token gets refreshed, and a token that the provider does not accept gets + * removed. When {@code false}, the tokens are returned exactly as they are stored, see {@link + * #getStored(Subject, String, String, String)}. + */ + private List doGetPersonalAccessTokens( + Subject cheUser, + @Nullable String oAuthProviderName, + @Nullable String scmServerUrl, + @Nullable String namespaceName, + boolean refreshAndValidate) + throws ScmConfigurationPersistenceException, ScmCommunicationException { List result = new ArrayList<>(); try { LOG.debug( @@ -269,6 +297,12 @@ private List doGetPersonalAccessTokens( PersonalAccessTokenParams personalAccessTokenParams = this.secret2PersonalAccessTokenParams(secret); + if (!refreshAndValidate) { + LOG.debug("Returning the token from secret {} as is", secret.getMetadata().getName()); + result.add(secret2PersonalAccessToken(secret, personalAccessTokenParams, null)); + continue; + } + // OAuth tokens are short-living, e.g. GitLab issues them for 2 hours. An expired one is // refreshed in place, so that the user does not have to go through the OAuth flow // again. If the refresh fails, the regular validation below takes over. @@ -293,21 +327,8 @@ private List doGetPersonalAccessTokens( "Creating personal access token for user {} and OAuth provider {}", cheUser.getUserId(), oAuthProviderName); - Map secretAnnotations = secret.getMetadata().getAnnotations(); - - PersonalAccessToken personalAccessToken = - new PersonalAccessToken( - personalAccessTokenParams.getScmProviderUrl(), - getScmProviderName(personalAccessTokenParams), - secretAnnotations.get(ANNOTATION_CHE_USERID), - personalAccessTokenParams.getOrganization(), - scmUsername.get(), - personalAccessTokenParams.getScmTokenName(), - personalAccessTokenParams.getScmTokenId(), - personalAccessTokenParams.getToken(), - personalAccessTokenParams.getRefreshToken(), - personalAccessTokenParams.getExpiresIn()); - result.add(personalAccessToken); + result.add( + secret2PersonalAccessToken(secret, personalAccessTokenParams, scmUsername.get())); continue; } @@ -335,6 +356,29 @@ private List doGetPersonalAccessTokens( return result; } + /** + * Builds a personal access token out of the secret it is stored in. + * + * @param secret the secret the token is stored in + * @param params the token parameters read from the secret + * @param scmUsername the SCM username the token belongs to, or {@code null} if the token was not + * validated against the SCM provider + */ + private PersonalAccessToken secret2PersonalAccessToken( + Secret secret, PersonalAccessTokenParams params, @Nullable String scmUsername) { + return new PersonalAccessToken( + params.getScmProviderUrl(), + getScmProviderName(params), + secret.getMetadata().getAnnotations().get(ANNOTATION_CHE_USERID), + params.getOrganization(), + scmUsername, + params.getScmTokenName(), + params.getScmTokenId(), + params.getToken(), + params.getRefreshToken(), + params.getExpiresIn()); + } + /** * Checks whether the token was obtained with the OAuth flow. Such tokens are stored with a * generated {@code oauth2-} name, while the manually configured personal access tokens 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 bec5ccf91d2..0ae3061852b 100644 --- a/infrastructures/infrastructure-factory/src/test/java/org/eclipse/che/api/factory/server/scm/kubernetes/KubernetesPersonalAccessTokenManagerTest.java +++ b/infrastructures/infrastructure-factory/src/test/java/org/eclipse/che/api/factory/server/scm/kubernetes/KubernetesPersonalAccessTokenManagerTest.java @@ -1219,6 +1219,64 @@ public void shouldRefreshExpiredOAuthTokenOnStoreGitCredentials() throws Excepti verify(nonNamespaceOperation, times(1)).delete(eq(oauthSecret)); } + @Test + public void shouldNotRefreshOrValidateTheTokenReadWithGetStored() throws Exception { + // given + KubernetesNamespaceMeta meta = new KubernetesNamespaceMetaImpl("test"); + when(namespaceFactory.list()).thenReturn(singletonList(meta)); + KubernetesNamespace kubernetesnamespace = Mockito.mock(KubernetesNamespace.class); + KubernetesSecrets secrets = Mockito.mock(KubernetesSecrets.class); + when(namespaceFactory.access(eq(null), eq(meta.getName()))).thenReturn(kubernetesnamespace); + when(kubernetesnamespace.secrets()).thenReturn(secrets); + Map annotations = new HashMap<>(); + annotations.put(ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_NAME, "oauth2-abcde"); + annotations.put(ANNOTATION_CHE_USERID, "user1"); + annotations.put(ANNOTATION_SCM_URL, "http://host1"); + annotations.put(ANNOTATION_SCM_PROVIDER_NAME, "gitlab"); + annotations.put(ANNOTATION_SCM_PERSONAL_ACCESS_TOKEN_ID, "token-id"); + annotations.put(ANNOTATION_SCM_TOKEN_EXPIRES_IN, "3600"); + Secret oauthSecret = + new SecretBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName("personal-access-token-expired") + .withCreationTimestamp("2021-07-01T12:00:00Z") + .withAnnotations(annotations) + .build()) + .withData( + Map.of( + TOKEN_DATA_FIELD, + Base64.getEncoder().encodeToString("expired-token".getBytes(UTF_8)), + REFRESH_TOKEN_DATA_FIELD, + Base64.getEncoder().encodeToString("stored-refresh-token".getBytes(UTF_8)))) + .build(); + when(secrets.get(any(LabelSelector.class))).thenReturn(List.of(oauthSecret)); + + // when + // this is the read the OAuth API performs to restore the in-memory credential it lost, e.g. on + // a server restart, before refreshing the token itself + Optional token = + personalAccessTokenManager.getStored( + new SubjectImpl("user", Collections.emptyList(), "user1", "t1", false), + "gitlab", + null, + null); + + // then + assertTrue(token.isPresent()); + assertEquals(token.get().getToken(), "expired-token"); + // the refresh token is what the OAuth API needs to perform the refresh + assertEquals(token.get().getRefreshToken(), "stored-refresh-token"); + // refreshing the token from within its own refresh would never terminate + verify(scmPersonalAccessTokenFetcher, never()) + .refreshPersonalAccessToken(any(Subject.class), eq("http://host1")); + // validating the expired token would delete the secret that keeps the refresh token + verify(scmPersonalAccessTokenFetcher, never()) + .getScmUsername(any(PersonalAccessTokenParams.class)); + // nothing is written to the cluster, the token is only read + verify(cheServerKubernetesClientFactory, never()).create(); + } + /** The token the SCM provider returns when the expired one gets refreshed. */ private static PersonalAccessToken refreshedToken() { return new PersonalAccessToken( 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 4ac743f1225..ec7a9223b18 100644 --- a/wsmaster/che-core-api-auth/src/main/java/org/eclipse/che/security/oauth/EmbeddedOAuthAPI.java +++ b/wsmaster/che-core-api-auth/src/main/java/org/eclipse/che/security/oauth/EmbeddedOAuthAPI.java @@ -244,10 +244,13 @@ public OAuthToken getOrRefreshToken(String oauthProvider) } else { Optional tokenOptional; try { - tokenOptional = personalAccessTokenManager.get(subject, oauthProvider, null, null); + // The token is read as stored: refreshing it is this class' own job, so a read that + // refreshes would come back here through the SCM token fetcher and never terminate. + tokenOptional = personalAccessTokenManager.getStored(subject, oauthProvider, null, null); if (tokenOptional.isEmpty()) { tokenOptional = - personalAccessTokenManager.get(subject, null, provider.getEndpointUrl(), null); + personalAccessTokenManager.getStored( + subject, null, provider.getEndpointUrl(), null); } if (tokenOptional.isPresent()) { return newDto(OAuthToken.class).withToken(tokenOptional.get().getToken()); @@ -280,9 +283,11 @@ public OAuthToken refreshToken(String oauthProvider) return storedToken; } else { // 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. + // Restore it from the persisted Kubernetes secret so the OAuth flow can refresh it. The + // token is read as stored: it is this call that refreshes it, so letting the manager + // refresh it on read would call back here endlessly. Optional tokenOptional = - personalAccessTokenManager.get(subject, oauthProvider, null, null); + personalAccessTokenManager.getStored(subject, oauthProvider, null, null); if (tokenOptional.isPresent()) { PersonalAccessToken token = tokenOptional.get(); if (isNullOrEmpty(token.getRefreshToken())) { 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 8e02ed99629..e9b6838644d 100644 --- a/wsmaster/che-core-api-auth/src/test/java/org/eclipse/che/security/oauth/EmbeddedOAuthAPITest.java +++ b/wsmaster/che-core-api-auth/src/test/java/org/eclipse/che/security/oauth/EmbeddedOAuthAPITest.java @@ -23,6 +23,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.testng.Assert.assertEquals; @@ -361,7 +362,7 @@ public void shouldRestoreCredentialFromPersistedTokenOnRefresh() throws Exceptio "old-access-token", "refresh-token-123", 3600); - when(personalAccessTokenManager.get(any(Subject.class), eq(provider), eq(null), eq(null))) + when(personalAccessTokenManager.getStored(any(Subject.class), eq(provider), eq(null), eq(null))) .thenReturn(Optional.of(persistedToken)); ArgumentCaptor tokenResponseCaptor = ArgumentCaptor.forClass(TokenResponse.class); @@ -376,6 +377,9 @@ public void shouldRestoreCredentialFromPersistedTokenOnRefresh() throws Exceptio assertEquals(tokenResponse.getAccessToken(), "old-access-token"); assertEquals(tokenResponse.getRefreshToken(), "refresh-token-123"); assertEquals(tokenResponse.getExpiresInSeconds(), Long.valueOf(3600)); + // reading the token the regular way makes the manager refresh it, which comes back here + // through the SCM token fetcher and never terminates + verify(personalAccessTokenManager, never()).get(any(Subject.class), any(), any(), any()); } @Test @@ -409,7 +413,7 @@ public void shouldNotSetExpiresInSecondsOnRefreshWhenPersistedTokenHasNoExpiry() "old-access-token", "refresh-token-123", 0); - when(personalAccessTokenManager.get(any(Subject.class), eq(provider), eq(null), eq(null))) + when(personalAccessTokenManager.getStored(any(Subject.class), eq(provider), eq(null), eq(null))) .thenReturn(Optional.of(persistedToken)); ArgumentCaptor tokenResponseCaptor = ArgumentCaptor.forClass(TokenResponse.class); @@ -449,7 +453,7 @@ public void shouldThrowUnauthorizedOnRefreshWhenPersistedTokenHasNoRefreshToken( "old-access-token", null, 0); - when(personalAccessTokenManager.get(any(Subject.class), eq(provider), eq(null), eq(null))) + when(personalAccessTokenManager.getStored(any(Subject.class), eq(provider), eq(null), eq(null))) .thenReturn(Optional.of(persistedToken)); // when @@ -465,7 +469,7 @@ public void shouldThrowUnauthorizedOnRefreshWhenNoPersistedTokenExists() throws 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))) + when(personalAccessTokenManager.getStored(any(Subject.class), eq(provider), eq(null), eq(null))) .thenReturn(Optional.empty()); // when @@ -479,7 +483,7 @@ public void shouldWrapScmCommunicationExceptionInServerExceptionOnRefresh() thro 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))) + when(personalAccessTokenManager.getStored(any(Subject.class), eq(provider), eq(null), eq(null))) .thenThrow(new ScmCommunicationException("SCM error")); // when diff --git a/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/scm/PersonalAccessTokenManager.java b/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/scm/PersonalAccessTokenManager.java index 4089da75ecf..99997251d65 100644 --- a/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/scm/PersonalAccessTokenManager.java +++ b/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/scm/PersonalAccessTokenManager.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2012-2025 Red Hat, Inc. + * Copyright (c) 2012-2026 Red Hat, Inc. * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ @@ -82,6 +82,31 @@ Optional get( @Nullable String namespaceName) throws ScmConfigurationPersistenceException, ScmCommunicationException; + /** + * The same as {@link #get(Subject, String, String, String)}, but the token is returned exactly as + * it is stored: it is neither refreshed nor validated against the SCM provider. + * + *

This is what the OAuth token refresh flow has to use, as it needs the persisted refresh + * token to perform the refresh itself. Reading the token the regular way would make it refresh + * the very token that is already being refreshed, looping endlessly, and validating an expired + * token would drop the secret that keeps the refresh token the ongoing refresh needs. + * + * @param cheUser Che user object + * @param oAuthProviderName OAuth provider name to get token for + * @param scmServerUrl Git provider endpoint + * @param namespaceName The user's namespace name. + * @return the stored personal access token + * @throws ScmConfigurationPersistenceException - problem occurred during communication with + * permanent storage. + * @throws ScmCommunicationException - problem occurred during communication with SCM server. + */ + Optional getStored( + Subject cheUser, + @Nullable String oAuthProviderName, + @Nullable String scmServerUrl, + @Nullable String namespaceName) + throws ScmConfigurationPersistenceException, ScmCommunicationException; + /** * Gets {@link PersonalAccessToken} from permanent storage. If the token is not found try to fetch * it from scm provider and save it in a permanent storage and set (update) git-credentials. From 9381f1609f54e8a0bf9bf4d900b40553c8c766a3 Mon Sep 17 00:00:00 2001 From: Ihor Vinokur Date: Fri, 25 Sep 2026 11:41:30 +0300 Subject: [PATCH 09/12] fixup! feat(oauth): add refresh token and expiry support to OAuth token flow Throw UnauthorizedException when the refresh retried after restoring the credential from the persisted secret still yields no token, instead of returning null to callers. Co-Authored-By: Claude Opus 5 --- .../che/security/oauth/EmbeddedOAuthAPI.java | 6 +++- .../security/oauth/EmbeddedOAuthAPITest.java | 36 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) 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 ec7a9223b18..faaf767246d 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 @@ -304,7 +304,11 @@ public OAuthToken refreshToken(String oauthProvider) tokenResponse.setExpiresInSeconds(token.getExpiresIn()); } provider.flow.createAndStoreCredential(tokenResponse, userId); - return provider.refreshToken(userId); + OAuthToken refreshedToken = provider.refreshToken(userId); + if (refreshedToken == null) { + throw getUnauthorizedException(userId); + } + return refreshedToken; } else { throw getUnauthorizedException(userId); } 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 e9b6838644d..b185eaf7e8b 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 @@ -476,6 +476,42 @@ public void shouldThrowUnauthorizedOnRefreshWhenNoPersistedTokenExists() throws embeddedOAuthAPI.refreshToken(provider); } + @Test( + expectedExceptions = UnauthorizedException.class, + expectedExceptionsMessageRegExp = "OAuth token for user 0000-00-0000 was not found") + public void shouldThrowUnauthorizedWhenRefreshStillFailsAfterRestoringCredential() + throws Exception { + // given + String provider = "github"; + OAuthAuthenticator authenticator = mock(OAuthAuthenticator.class); + when(oauth2Providers.getAuthenticator(provider)).thenReturn(authenticator); + // the refresh fails both before and after the credential is restored + when(authenticator.refreshToken(anyString())).thenReturn(null); + + AuthorizationCodeFlow flow = mock(AuthorizationCodeFlow.class); + Field flowField = OAuthAuthenticator.class.getDeclaredField("flow"); + flowField.setAccessible(true); + flowField.set(authenticator, flow); + + PersonalAccessToken persistedToken = + new PersonalAccessToken( + "https://github.com", + provider, + "0000-00-0000", + null, + null, + "oauth2-token", + "id-token", + "old-access-token", + "refresh-token-123", + 3600); + when(personalAccessTokenManager.getStored(any(Subject.class), eq(provider), eq(null), eq(null))) + .thenReturn(Optional.of(persistedToken)); + + // when + embeddedOAuthAPI.refreshToken(provider); + } + @Test(expectedExceptions = ServerException.class) public void shouldWrapScmCommunicationExceptionInServerExceptionOnRefresh() throws Exception { // given From 41b5d48bb58dc3d33a7ee5ae720f432d281d0644 Mon Sep 17 00:00:00 2001 From: Ihor Vinokur Date: Fri, 25 Sep 2026 14:37:58 +0300 Subject: [PATCH 10/12] fixup! feat(oauth): add refresh token and expiry support to OAuth token flow Refresh the persisted token in getOrRefreshToken instead of handing it out as stored. The in-memory credential store is empty e.g. after a server restart, and the persisted token is read without an expiry check, so an expired token would be returned on every call with nothing to heal it. Tokens without a refresh token, e.g. user-supplied personal access tokens, are still returned directly. The restore logic is now shared with refreshToken. Co-Authored-By: Claude Opus 5 --- .../che/security/oauth/EmbeddedOAuthAPI.java | 46 ++++++-- .../security/oauth/EmbeddedOAuthAPITest.java | 110 ++++++++++++++++++ 2 files changed, 144 insertions(+), 12 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 faaf767246d..ab05acb5319 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 @@ -253,7 +253,20 @@ public OAuthToken getOrRefreshToken(String oauthProvider) subject, null, provider.getEndpointUrl(), null); } if (tokenOptional.isPresent()) { - return newDto(OAuthToken.class).withToken(tokenOptional.get().getToken()); + PersonalAccessToken persistedToken = tokenOptional.get(); + if (isNullOrEmpty(persistedToken.getRefreshToken())) { + // Nothing to refresh with, e.g. a user-supplied personal access token. + return newDto(OAuthToken.class).withToken(persistedToken.getToken()); + } + // The persisted access token is returned as stored, so it may already have expired. + // Restore the credential and refresh it, otherwise the expired token would be handed + // out on every call, as the in-memory store stays empty. + restoreCredential(provider, persistedToken, subject.getUserId()); + OAuthToken refreshedToken = provider.refreshToken(subject.getUserId()); + if (refreshedToken == null) { + throw getUnauthorizedException(subject.getUserId()); + } + return refreshedToken; } } catch (ScmConfigurationPersistenceException | ScmCommunicationException e) { throw new RuntimeException(e); @@ -293,17 +306,7 @@ public OAuthToken refreshToken(String oauthProvider) if (isNullOrEmpty(token.getRefreshToken())) { throw getUnauthorizedException(userId); } - // Re-populate the in-memory credential store from the persisted token - TokenResponse tokenResponse = - new TokenResponse() - .setAccessToken(token.getToken()) - .setRefreshToken(token.getRefreshToken()); - // leave `expires_in` unset when the persisted token carries no expiry, - // so that the credential is not treated as already expired - if (token.getExpiresIn() > 0) { - tokenResponse.setExpiresInSeconds(token.getExpiresIn()); - } - provider.flow.createAndStoreCredential(tokenResponse, userId); + restoreCredential(provider, token, userId); OAuthToken refreshedToken = provider.refreshToken(userId); if (refreshedToken == null) { throw getUnauthorizedException(userId); @@ -318,6 +321,25 @@ public OAuthToken refreshToken(String oauthProvider) } } + /** + * Re-populate the in-memory credential store from a token persisted in a Kubernetes secret, so + * that the OAuth flow can refresh it. The credential is gone from the in-memory store e.g. after + * a server restart. + */ + private void restoreCredential( + OAuthAuthenticator provider, PersonalAccessToken token, String userId) throws IOException { + TokenResponse tokenResponse = + new TokenResponse() + .setAccessToken(token.getToken()) + .setRefreshToken(token.getRefreshToken()); + // leave `expires_in` unset when the persisted token carries no expiry, + // so that the credential is not treated as already expired + if (token.getExpiresIn() > 0) { + tokenResponse.setExpiresInSeconds(token.getExpiresIn()); + } + provider.flow.createAndStoreCredential(tokenResponse, userId); + } + private UnauthorizedException getUnauthorizedException(String userId) { return new UnauthorizedException("OAuth token for user " + userId + " was not found"); } 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 b185eaf7e8b..37ae11ebe34 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 @@ -90,6 +90,116 @@ public void shouldBeAbleToGetUserToken() throws Exception { assertEquals(result.getToken(), token); } + @Test + public void shouldRestoreCredentialAndRefreshPersistedTokenOnGet() throws Exception { + // given + String provider = "github"; + OAuthAuthenticator authenticator = mock(OAuthAuthenticator.class); + when(oauth2Providers.getAuthenticator(provider)).thenReturn(authenticator); + // the in-memory credential store is empty, e.g. after a server restart + when(authenticator.getOrRefreshToken(anyString())).thenReturn(null); + when(authenticator.refreshToken("0000-00-0000")) + .thenReturn(newDto(OAuthToken.class).withToken("new-access-token")); + + AuthorizationCodeFlow flow = mock(AuthorizationCodeFlow.class); + Field flowField = OAuthAuthenticator.class.getDeclaredField("flow"); + flowField.setAccessible(true); + flowField.set(authenticator, flow); + + PersonalAccessToken persistedToken = + new PersonalAccessToken( + "https://github.com", + provider, + "0000-00-0000", + null, + null, + "oauth2-token", + "id-token", + "old-access-token", + "refresh-token-123", + 3600); + when(personalAccessTokenManager.getStored(any(Subject.class), eq(provider), eq(null), eq(null))) + .thenReturn(Optional.of(persistedToken)); + ArgumentCaptor tokenResponseCaptor = + ArgumentCaptor.forClass(TokenResponse.class); + + // when + OAuthToken result = embeddedOAuthAPI.getOrRefreshToken(provider); + + // then the persisted token is refreshed instead of being handed out as stored, since it may + // already have expired + assertEquals(result.getToken(), "new-access-token"); + verify(flow).createAndStoreCredential(tokenResponseCaptor.capture(), eq("0000-00-0000")); + assertEquals(tokenResponseCaptor.getValue().getRefreshToken(), "refresh-token-123"); + } + + @Test + public void shouldReturnPersistedTokenOnGetWhenItHasNoRefreshToken() throws Exception { + // given + String provider = "github"; + OAuthAuthenticator authenticator = mock(OAuthAuthenticator.class); + when(oauth2Providers.getAuthenticator(provider)).thenReturn(authenticator); + when(authenticator.getOrRefreshToken(anyString())).thenReturn(null); + + // a user-supplied personal access token has nothing to refresh with + PersonalAccessToken persistedToken = + new PersonalAccessToken( + "https://github.com", + provider, + "0000-00-0000", + null, + null, + "token-name", + "id-token", + "persisted-token", + null, + 0); + when(personalAccessTokenManager.getStored(any(Subject.class), eq(provider), eq(null), eq(null))) + .thenReturn(Optional.of(persistedToken)); + + // when + OAuthToken result = embeddedOAuthAPI.getOrRefreshToken(provider); + + // then + assertEquals(result.getToken(), "persisted-token"); + verify(authenticator, never()).refreshToken(anyString()); + } + + @Test( + expectedExceptions = UnauthorizedException.class, + expectedExceptionsMessageRegExp = "OAuth token for user 0000-00-0000 was not found") + public void shouldThrowUnauthorizedOnGetWhenRefreshOfPersistedTokenFails() throws Exception { + // given + String provider = "github"; + OAuthAuthenticator authenticator = mock(OAuthAuthenticator.class); + when(oauth2Providers.getAuthenticator(provider)).thenReturn(authenticator); + when(authenticator.getOrRefreshToken(anyString())).thenReturn(null); + when(authenticator.refreshToken(anyString())).thenReturn(null); + + AuthorizationCodeFlow flow = mock(AuthorizationCodeFlow.class); + Field flowField = OAuthAuthenticator.class.getDeclaredField("flow"); + flowField.setAccessible(true); + flowField.set(authenticator, flow); + + PersonalAccessToken persistedToken = + new PersonalAccessToken( + "https://github.com", + provider, + "0000-00-0000", + null, + null, + "oauth2-token", + "id-token", + "old-access-token", + "refresh-token-123", + 3600); + when(personalAccessTokenManager.getStored(any(Subject.class), eq(provider), eq(null), eq(null))) + .thenReturn(Optional.of(persistedToken)); + + // when + embeddedOAuthAPI.getOrRefreshToken(provider); + } + @Test public void shouldGetRegisteredAuthenticators() throws Exception { // given From 1cb46a9488249c8499671db47b2bd2d03defc58b Mon Sep 17 00:00:00 2001 From: Ihor Vinokur Date: Mon, 28 Sep 2026 14:14:20 +0300 Subject: [PATCH 11/12] fixup! feat(oauth): add refresh token and expiry support to OAuth token flow --- .../che/api/factory/server/scm/PersonalAccessToken.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 1be216c215c..0f9c8b3260c 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 @@ -32,7 +32,7 @@ public class PersonalAccessToken { @Nullable private final String refreshToken; /** Token expiration time in seconds. 0 if the token does not expire. */ - @Nullable private final long expiresIn; + private final long expiresIn; private final String scmTokenName; private final String scmTokenId; @@ -111,7 +111,6 @@ public String getScmOrganization() { return scmOrganization; } - @Nullable public long getExpiresIn() { return expiresIn; } From 4945cb4beadf177c3ab01b649be416cac119f808 Mon Sep 17 00:00:00 2001 From: Ihor Vinokur Date: Mon, 28 Sep 2026 15:27:33 +0300 Subject: [PATCH 12/12] fixup! feat(oauth): add refresh token and expiry support to OAuth token flow Co-Authored-By: Claude Opus 5 --- .../KubernetesPersonalAccessTokenManager.java | 79 ++------ ...ernetesPersonalAccessTokenManagerTest.java | 170 ++++-------------- 2 files changed, 51 insertions(+), 198 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 18ebaefcb71..c10d97d2b40 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,8 +25,6 @@ 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; @@ -88,12 +86,6 @@ 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; @@ -303,22 +295,6 @@ private List doGetPersonalAccessTokens( continue; } - // OAuth tokens are short-living, e.g. GitLab issues them for 2 hours. An expired one is - // refreshed in place, so that the user does not have to go through the OAuth flow - // again. If the refresh fails, the regular validation below takes over. - if (isOAuthTokenSecret(secret) && isTokenExpired(secret, personalAccessTokenParams)) { - Optional refreshedToken = - refreshExpiredOAuthToken( - cheUser, - secret, - personalAccessTokenParams.getScmProviderUrl(), - namespaceMeta.getName()); - if (refreshedToken.isPresent()) { - result.add(refreshedToken.get()); - continue; - } - } - Optional scmUsername = scmPersonalAccessTokenFetcher.getScmUsername(personalAccessTokenParams); @@ -332,6 +308,22 @@ private List doGetPersonalAccessTokens( continue; } + // OAuth tokens are short-living, e.g. GitLab issues them for 2 hours. A token that the + // provider no longer accepts is refreshed in place, so that the user does not have to + // go through the OAuth flow again. If the refresh fails, the secret is removed below. + if (isOAuthTokenSecret(secret)) { + Optional refreshedToken = + refreshExpiredOAuthToken( + cheUser, + secret, + personalAccessTokenParams.getScmProviderUrl(), + namespaceMeta.getName()); + if (refreshedToken.isPresent()) { + result.add(refreshedToken.get()); + continue; + } + } + // Removing token that is no longer valid. If several tokens exist the next one could // be valid. If no valid token can be found, the caller should react in the same way // as it reacts if no token exists. Usually, that means that process of new token @@ -405,44 +397,11 @@ private static boolean isOAuthTokenSecret(Secret secret) { } /** - * 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. + * Refreshes the OAuth token kept in the given secret that the SCM provider no longer accepts. 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 secret the secret keeping the outdated 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 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 0ae3061852b..710918274da 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 @@ -54,7 +54,6 @@ 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; @@ -785,7 +784,7 @@ public void shouldKeepPersonalAccessTokenSecretOnForceRefresh() throws Exception } @Test - public void shouldRefreshExpiredOAuthToken() throws Exception { + public void shouldRefreshOAuthTokenRejectedByScmProvider() throws Exception { // given KubernetesNamespaceMeta meta = new KubernetesNamespaceMetaImpl("test"); when(namespaceFactory.list()).thenReturn(singletonList(meta)); @@ -796,7 +795,9 @@ public void shouldRefreshExpiredOAuthToken() throws Exception { 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 + // the SCM provider does not accept the stored token anymore + when(scmPersonalAccessTokenFetcher.getScmUsername(any(PersonalAccessTokenParams.class))) + .thenReturn(Optional.empty()); ObjectMeta oauthMeta = new ObjectMetaBuilder() .withName("personal-access-token-old") @@ -854,13 +855,13 @@ public void shouldRefreshExpiredOAuthToken() throws Exception { // 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()) + // the refresh is only attempted after the stored token is rejected + verify(scmPersonalAccessTokenFetcher, times(1)) .getScmUsername(any(PersonalAccessTokenParams.class)); } @Test - public void shouldNotRefreshOAuthTokenThatIsStillValid() throws Exception { + public void shouldNotRefreshOAuthTokenThatTheScmProviderAccepts() throws Exception { // given KubernetesNamespaceMeta meta = new KubernetesNamespaceMetaImpl("test"); when(namespaceFactory.list()).thenReturn(singletonList(meta)); @@ -870,7 +871,6 @@ public void shouldNotRefreshOAuthTokenThatIsStillValid() throws Exception { 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") @@ -914,7 +914,7 @@ public void shouldNotRefreshOAuthTokenThatIsStillValid() throws Exception { } @Test - public void shouldRefreshOAuthTokenThatIsAboutToExpire() throws Exception { + public void shouldUpdateGitCredentialsWithRefreshedOAuthToken() throws Exception { // given KubernetesNamespaceMeta meta = new KubernetesNamespaceMetaImpl("test"); when(namespaceFactory.list()).thenReturn(singletonList(meta)); @@ -925,14 +925,15 @@ public void shouldRefreshOAuthTokenThatIsAboutToExpire() throws Exception { 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 + when(scmPersonalAccessTokenFetcher.getScmUsername(any(PersonalAccessTokenParams.class))) + .thenReturn(Optional.empty()); Secret oauthSecret = tokenSecret( - "personal-access-token-almost-expired", + "personal-access-token-rejected", "oauth2-abcde", - Instant.now().minusSeconds(3595).toString(), + Instant.now().toString(), "3600", - "almost-expired-token"); + "rejected-token"); when(secrets.get(any(LabelSelector.class))).thenReturn(List.of(oauthSecret)); PersonalAccessToken refreshedToken = refreshedToken(); when(scmPersonalAccessTokenFetcher.refreshPersonalAccessToken( @@ -955,47 +956,7 @@ public void shouldRefreshOAuthTokenThatIsAboutToExpire() throws Exception { } @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 { + public void shouldRemoveRejectedOAuthTokenSecretIfRefreshFails() throws Exception { // given KubernetesNamespaceMeta meta = new KubernetesNamespaceMetaImpl("test"); when(namespaceFactory.list()).thenReturn(singletonList(meta)); @@ -1008,11 +969,11 @@ public void shouldRemoveExpiredOAuthTokenSecretIfRefreshFailsAndTokenIsInvalid() when(secretsMixedOperation.inNamespace(eq(meta.getName()))).thenReturn(nonNamespaceOperation); Secret oauthSecret = tokenSecret( - "personal-access-token-expired", + "personal-access-token-rejected", "oauth2-abcde", "2021-07-01T12:00:00Z", "3600", - "expired-token"); + "rejected-token"); when(secrets.get(any(LabelSelector.class))).thenReturn(List.of(oauthSecret)); when(scmPersonalAccessTokenFetcher.refreshPersonalAccessToken( any(Subject.class), eq("http://host1"))) @@ -1077,7 +1038,7 @@ public void shouldReturnRefreshedOAuthTokenWhenOutdatedSecretRemovalFails() thro } @Test - public void shouldNotRefreshExpiredPersonalAccessToken() throws Exception { + public void shouldNotRefreshRejectedPersonalAccessToken() throws Exception { // given KubernetesNamespaceMeta meta = new KubernetesNamespaceMetaImpl("test"); when(namespaceFactory.list()).thenReturn(singletonList(meta)); @@ -1085,10 +1046,13 @@ public void shouldNotRefreshExpiredPersonalAccessToken() throws Exception { 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); 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 + .thenReturn(Optional.empty()); + // a manually configured token cannot be refreshed, it is just removed once the provider + // rejects it Secret patSecret = tokenSecret( "personal-access-token-pat", "gitlab", "2021-07-01T12:00:00Z", "3600", "pat-token"); @@ -1103,86 +1067,14 @@ public void shouldNotRefreshExpiredPersonalAccessToken() throws Exception { 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"); + assertFalse(token.isPresent()); + verify(nonNamespaceOperation, times(1)).delete(eq(patSecret)); verify(scmPersonalAccessTokenFetcher, never()) .refreshPersonalAccessToken(any(Subject.class), eq("http://host1")); } @Test - public void shouldRefreshExpiredOAuthTokenOnStoreGitCredentials() throws Exception { + public void shouldRefreshRejectedOAuthTokenOnStoreGitCredentials() throws Exception { // given Subject subject = mock(Subject.class); when(subject.getUserId()).thenReturn("user1"); @@ -1198,13 +1090,15 @@ public void shouldRefreshExpiredOAuthTokenOnStoreGitCredentials() throws Excepti when(cheServerKubernetesClientFactory.create()).thenReturn(kubeClient); when(kubeClient.secrets()).thenReturn(secretsMixedOperation); when(secretsMixedOperation.inNamespace(eq(meta.getName()))).thenReturn(nonNamespaceOperation); + when(scmPersonalAccessTokenFetcher.getScmUsername(any(PersonalAccessTokenParams.class))) + .thenReturn(Optional.empty()); Secret oauthSecret = tokenSecret( - "personal-access-token-expired", + "personal-access-token-rejected", "oauth2-abcde", "2021-07-01T12:00:00Z", "3600", - "expired-token"); + "rejected-token"); when(secrets.get(any(LabelSelector.class))).thenReturn(List.of(oauthSecret)); PersonalAccessToken refreshedToken = refreshedToken(); when(scmPersonalAccessTokenFetcher.refreshPersonalAccessToken( @@ -1277,7 +1171,7 @@ public void shouldNotRefreshOrValidateTheTokenReadWithGetStored() throws Excepti verify(cheServerKubernetesClientFactory, never()).create(); } - /** The token the SCM provider returns when the expired one gets refreshed. */ + /** The token the SCM provider returns when the outdated one gets refreshed. */ private static PersonalAccessToken refreshedToken() { return new PersonalAccessToken( "http://host1", @@ -1294,7 +1188,7 @@ private static PersonalAccessToken refreshedToken() { /** * 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. + * refresh 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