diff --git a/CHANGELOG.md b/CHANGELOG.md index 20f5c241f8f..656de65f935 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### 5.12-SNAPSHOT #### Bugs +* Fix #2271: Support periodic refresh of access tokens before they expire * Fix #3733: The authentication command from the .kube/config won't be discarded if no arguments are specified * Fix #4365: backport of stopped future for informers to obtain the termination exception * Fix #4383: bump snakeyaml from 1.28 to 1.33 diff --git a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/utils/TokenRefreshInterceptor.java b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/utils/TokenRefreshInterceptor.java index 83301aa4a80..f1d2c52895e 100644 --- a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/utils/TokenRefreshInterceptor.java +++ b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/utils/TokenRefreshInterceptor.java @@ -18,52 +18,78 @@ import io.fabric8.kubernetes.client.Config; import io.fabric8.kubernetes.client.http.BasicBuilder; import io.fabric8.kubernetes.client.http.HttpClient; +import io.fabric8.kubernetes.client.http.HttpHeaders; import io.fabric8.kubernetes.client.http.HttpResponse; import io.fabric8.kubernetes.client.http.Interceptor; import java.net.HttpURLConnection; +import java.time.Instant; +import java.time.temporal.ChronoUnit; /** * Interceptor for handling expired OIDC tokens. */ public class TokenRefreshInterceptor implements Interceptor { - - public static final String NAME = "TOKEN"; - + + public static final String NAME = "TOKEN"; + private final Config config; private HttpClient.Factory factory; - + + private Instant lastRefresh; + public TokenRefreshInterceptor(Config config, HttpClient.Factory factory) { this.config = config; + this.lastRefresh = Instant.now(); this.factory = factory; } - + + @Override + public void before(BasicBuilder headerBuilder, HttpHeaders headers) { + if (timeToRefresh()) { + refreshToken(headerBuilder); + } + } + @Override public boolean afterFailure(BasicBuilder headerBuilder, HttpResponse response) { - boolean resubmit = false; if (response.code() == HttpURLConnection.HTTP_UNAUTHORIZED) { - String currentContextName = null; - String newAccessToken = null; + return refreshToken(headerBuilder); + } + return false; + } - if (config.getCurrentContext() != null) { - currentContextName = config.getCurrentContext().getName(); - } - Config newestConfig = Config.autoConfigure(currentContextName); - if (newestConfig.getAuthProvider() != null && newestConfig.getAuthProvider().getName().equalsIgnoreCase("oidc")) { - newAccessToken = OpenIDConnectionUtils.resolveOIDCTokenFromAuthConfig(newestConfig.getAuthProvider().getConfig(), factory.newBuilder()); - } else { - newAccessToken = newestConfig.getOauthToken(); - } + private boolean refreshToken(BasicBuilder headerBuilder) { + boolean resubmit = false; + String currentContextName = null; + if (config.getCurrentContext() != null) { + currentContextName = config.getCurrentContext().getName(); + } + String newAccessToken; + Config newestConfig = Config.autoConfigure(currentContextName); + if (newestConfig.getAuthProvider() != null && newestConfig.getAuthProvider().getName().equalsIgnoreCase("oidc")) { + newAccessToken = OpenIDConnectionUtils.resolveOIDCTokenFromAuthConfig(newestConfig.getAuthProvider().getConfig(), + factory.newBuilder()); + } else { + newAccessToken = newestConfig.getOauthToken(); + } - if (newAccessToken != null) { - // Delete old Authorization header and append new one - headerBuilder - .setHeader("Authorization", "Bearer " + newAccessToken); - config.setOauthToken(newAccessToken); - resubmit = true; - } + if (newAccessToken != null) { + // Delete old Authorization header and append new one + headerBuilder + .setHeader("Authorization", "Bearer " + newAccessToken); + config.setOauthToken(newAccessToken); + resubmit = true; } return resubmit; } + private boolean timeToRefresh() { + return lastRefresh.plus(1, ChronoUnit.MINUTES).isBefore(Instant.now()); + } + + // For testing only + void setLastRefresh(Instant lastRefresh) { + this.lastRefresh = lastRefresh; + } } diff --git a/kubernetes-client/src/test/java/io/fabric8/kubernetes/client/utils/TokenRefreshInterceptorTest.java b/kubernetes-client/src/test/java/io/fabric8/kubernetes/client/utils/TokenRefreshInterceptorTest.java index ce6a059d7d5..e6cfae5ea99 100644 --- a/kubernetes-client/src/test/java/io/fabric8/kubernetes/client/utils/TokenRefreshInterceptorTest.java +++ b/kubernetes-client/src/test/java/io/fabric8/kubernetes/client/utils/TokenRefreshInterceptorTest.java @@ -27,6 +27,8 @@ import java.nio.file.Files; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Objects; import static io.fabric8.kubernetes.client.Config.KUBERNETES_AUTH_SERVICEACCOUNT_TOKEN_FILE_SYSTEM_PROPERTY; @@ -60,6 +62,31 @@ public void shouldAutoconfigureAfter401() throws IOException { } } + @Test + void shouldAutoconfigureAfter1Minute() throws Exception { + try { + // Prepare kubeconfig for autoconfiguration + File tempFile = Files.createTempFile("test", "kubeconfig").toFile(); + Files.copy(Objects.requireNonNull(getClass().getResourceAsStream("/token-refresh-interceptor/kubeconfig")), + Paths.get(tempFile.getPath()), StandardCopyOption.REPLACE_EXISTING); + System.setProperty(KUBERNETES_KUBECONFIG_FILE, tempFile.getAbsolutePath()); + + HttpRequest.Builder builder = Mockito.mock(HttpRequest.Builder.class, Mockito.RETURNS_SELF); + + // Call + TokenRefreshInterceptor tokenRefreshInterceptor = new TokenRefreshInterceptor(Config.autoConfigure(null), null); + // Replace kubeconfig file + Files.copy(Objects.requireNonNull(getClass().getResourceAsStream("/token-refresh-interceptor/kubeconfig.new")), + Paths.get(tempFile.getPath()), StandardCopyOption.REPLACE_EXISTING); + tokenRefreshInterceptor.setLastRefresh(Instant.now().minus(61, ChronoUnit.SECONDS)); + tokenRefreshInterceptor.before(builder, null); + Mockito.verify(builder).setHeader("Authorization", "Bearer new token"); + } finally { + // Remove any side effect + System.clearProperty(KUBERNETES_KUBECONFIG_FILE); + } + } + @Test void shouldReloadInClusterServiceAccount() throws IOException { try { diff --git a/kubernetes-client/src/test/resources/token-refresh-interceptor/kubeconfig b/kubernetes-client/src/test/resources/token-refresh-interceptor/kubeconfig new file mode 100644 index 00000000000..c99f34b5ac2 --- /dev/null +++ b/kubernetes-client/src/test/resources/token-refresh-interceptor/kubeconfig @@ -0,0 +1,20 @@ +apiVersion: v1 +clusters: +- cluster: + certificate-authority: testns/ca.pem + insecure-skip-tls-verify: true + server: https://172.28.128.4:8443 + name: 172-28-128-4:8443 +contexts: +- context: + cluster: 172-28-128-4:8443 + namespace: testns + user: user/172-28-128-4:8443 + name: testns/172-28-128-4:8443/user +current-context: testns/172-28-128-4:8443/user +kind: Config +preferences: {} +users: +- name: user/172-28-128-4:8443 + user: + token: token diff --git a/kubernetes-client/src/test/resources/token-refresh-interceptor/kubeconfig.new b/kubernetes-client/src/test/resources/token-refresh-interceptor/kubeconfig.new new file mode 100644 index 00000000000..56d8e35bc05 --- /dev/null +++ b/kubernetes-client/src/test/resources/token-refresh-interceptor/kubeconfig.new @@ -0,0 +1,20 @@ +apiVersion: v1 +clusters: +- cluster: + certificate-authority: testns/ca.pem + insecure-skip-tls-verify: true + server: https://172.28.128.4:8443 + name: 172-28-128-4:8443 +contexts: +- context: + cluster: 172-28-128-4:8443 + namespace: testns + user: user/172-28-128-4:8443 + name: testns/172-28-128-4:8443/user +current-context: testns/172-28-128-4:8443/user +kind: Config +preferences: {} +users: +- name: user/172-28-128-4:8443 + user: + token: new token