From aacd0e62fac149c5aae57b0713ea00c8b3778a41 Mon Sep 17 00:00:00 2001 From: Rohan Kumar Date: Fri, 28 Aug 2020 09:48:12 +0530 Subject: [PATCH] Fix #2111: Add Support for OIDC token refresh --- CHANGELOG.md | 1 + .../io/fabric8/kubernetes/client/Config.java | 2 +- .../client/internal/KubeConfigUtils.java | 51 ++- .../client/utils/HttpClientUtils.java | 23 +- .../client/utils/OpenIDConnectionUtils.java | 313 ++++++++++++++++++ .../client/utils/Serialization.java | 11 +- .../client/internal/KubeConfigUtilsTest.java | 172 ++++++++++ .../utils/OpenIDConnectionUtilsTest.java | 184 ++++++++++ .../src/test/resources/test-kubeconfig-oidc | 44 +++ 9 files changed, 780 insertions(+), 21 deletions(-) create mode 100644 kubernetes-client/src/main/java/io/fabric8/kubernetes/client/utils/OpenIDConnectionUtils.java create mode 100644 kubernetes-client/src/test/java/io/fabric8/kubernetes/client/internal/KubeConfigUtilsTest.java create mode 100644 kubernetes-client/src/test/java/io/fabric8/kubernetes/client/utils/OpenIDConnectionUtilsTest.java create mode 100644 kubernetes-client/src/test/resources/test-kubeconfig-oidc diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a37aeca523..0c5342d78b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ #### Dependency Upgrade #### New Features +* Fix #2111: Support automatic refreshing for expired OIDC tokens ### 4.11.0 (2020-08-26) #### Bugs diff --git a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/Config.java b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/Config.java index d15bae64f4d..286fc71bf6f 100644 --- a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/Config.java +++ b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/Config.java @@ -525,7 +525,7 @@ private static boolean tryKubeConfig(Config config, String context) { return true; } - private static String getKubeconfigFilename() { + public static String getKubeconfigFilename() { String fileName = Utils.getSystemPropertyOrEnvVar(KUBERNETES_KUBECONFIG_FILE, new File(getHomeDir(), ".kube" + File.separator + "config").toString()); // if system property/env var contains multiple files take the first one based on the environment diff --git a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/internal/KubeConfigUtils.java b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/internal/KubeConfigUtils.java index bd127366404..6710da6d87b 100644 --- a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/internal/KubeConfigUtils.java +++ b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/internal/KubeConfigUtils.java @@ -35,6 +35,8 @@ * like osc login and osc project myproject */ public class KubeConfigUtils { + private KubeConfigUtils() {} + public static Config parseConfig(File file) throws IOException { ObjectMapper mapper = Serialization.yamlMapper(); return mapper.readValue(file, Config.class); @@ -66,8 +68,6 @@ public static NamedContext getCurrentContext(Config config) { return null; } - /** - */ /** * Returns the current user token for the config and current context * @@ -97,11 +97,11 @@ public static AuthInfo getUserAuthInfo(Config config, Context context) { if (user != null) { List users = config.getUsers(); if (users != null) { - for (NamedAuthInfo namedAuthInfo : users) { - if (user.equals(namedAuthInfo.getName())) { - authInfo = namedAuthInfo.getUser(); - } - } + authInfo = users.stream() + .filter(u -> u.getName().equals(user)) + .findAny() + .map(NamedAuthInfo::getUser) + .orElse(null); } } } @@ -122,14 +122,41 @@ public static Cluster getCluster(Config config, Context context) { if (clusterName != null) { List clusters = config.getClusters(); if (clusters != null) { - for (NamedCluster namedCluster : clusters) { - if (clusterName.equals(namedCluster.getName())) { - cluster = namedCluster.getCluster(); - } - } + cluster = clusters.stream() + .filter(c -> c.getName().equals(clusterName)) + .findAny() + .map(NamedCluster::getCluster) + .orElse(null); } } } return cluster; } + + /** + * Get User index from Config object + * + * @param config {@link io.fabric8.kubernetes.api.model.Config} Kube Config + * @param userName username inside Config + * @return index of user in users array + */ + public static int getNamedUserIndexFromConfig(Config config, String userName) { + for (int i = 0; i < config.getUsers().size(); i++) { + if (config.getUsers().get(i).getName().equals(userName)) { + return i; + } + } + return -1; + } + + /** + * Modify KUBECONFIG file + * + * @param kubeConfig modified {@link io.fabric8.kubernetes.api.model.Config} object + * @param kubeConfigPath path to KUBECONFIG + * @throws IOException in case of failure while writing to file + */ + public static void persistKubeConfigIntoFile(Config kubeConfig, String kubeConfigPath) throws IOException { + Serialization.yamlMapper().writeValue(new File(kubeConfigPath), kubeConfig); + } } diff --git a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/utils/HttpClientUtils.java b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/utils/HttpClientUtils.java index 6ecd0f17f34..b89f66d3ff0 100644 --- a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/utils/HttpClientUtils.java +++ b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/utils/HttpClientUtils.java @@ -15,9 +15,11 @@ */ package io.fabric8.kubernetes.client.utils; +import io.fabric8.kubernetes.api.model.AuthInfo; import io.fabric8.kubernetes.api.model.ListOptions; import io.fabric8.kubernetes.client.Config; import io.fabric8.kubernetes.client.KubernetesClientException; +import io.fabric8.kubernetes.client.internal.KubeConfigUtils; import io.fabric8.kubernetes.client.internal.SSLUtils; import okhttp3.*; import okhttp3.logging.HttpLoggingInterceptor; @@ -28,6 +30,8 @@ import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManager; import javax.net.ssl.X509TrustManager; +import java.io.File; +import java.net.HttpURLConnection; import java.net.InetSocketAddress; import java.net.MalformedURLException; import java.net.Proxy; @@ -47,6 +51,7 @@ public class HttpClientUtils { + public static final String AUTHORIZATION = "Authorization"; private static Pattern VALID_IPV4_PATTERN = null; public static final String ipv4Pattern = "(http:\\/\\/|https:\\/\\/)?(([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\.){3}([01]?\\d\\d?|2[0-4]\\d|25[0-5])(\\/[0-9]\\d|1[0-9]\\d|2[0-9]\\d|3[0-2]\\d)?"; private static final Logger logger = LoggerFactory.getLogger(HttpClientUtils.class); @@ -140,11 +145,23 @@ private static OkHttpClient createHttpClient(final Config config, final Consumer httpClientBuilder.addInterceptor(chain -> { Request request = chain.request(); if (Utils.isNotNullOrEmpty(config.getUsername()) && Utils.isNotNullOrEmpty(config.getPassword())) { - Request authReq = chain.request().newBuilder().addHeader("Authorization", Credentials.basic(config.getUsername(), config.getPassword())).build(); + Request authReq = chain.request().newBuilder().addHeader(AUTHORIZATION, Credentials.basic(config.getUsername(), config.getPassword())).build(); return chain.proceed(authReq); } else if (Utils.isNotNullOrEmpty(config.getOauthToken())) { - Request authReq = chain.request().newBuilder().addHeader("Authorization", "Bearer " + config.getOauthToken()).build(); - return chain.proceed(authReq); + Request authReq = chain.request().newBuilder().addHeader(AUTHORIZATION, "Bearer " + config.getOauthToken()).build(); + Response response = chain.proceed(authReq); + if (response.code() == HttpURLConnection.HTTP_UNAUTHORIZED) { + io.fabric8.kubernetes.api.model.Config kubeConfig = KubeConfigUtils.parseConfig(new File(Config.getKubeconfigFilename())); + AuthInfo currentAuthInfo = KubeConfigUtils.getUserAuthInfo(kubeConfig, config.getCurrentContext().getContext()); + // Check if AuthProvider is set or not + if (currentAuthInfo.getAuthProvider() != null) { + String newAccessToken = OpenIDConnectionUtils.resolveOIDCTokenFromAuthConfig(currentAuthInfo.getAuthProvider().getConfig()); + config.setOauthToken(newAccessToken); + Request authReqWithUpdatedToken = chain.request().newBuilder().addHeader(AUTHORIZATION, "Bearer " + config.getOauthToken()).build(); + return chain.proceed(authReqWithUpdatedToken); + } + } + return response; } return chain.proceed(request); }).addInterceptor(new ImpersonatorInterceptor(config)) diff --git a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/utils/OpenIDConnectionUtils.java b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/utils/OpenIDConnectionUtils.java new file mode 100644 index 00000000000..9cd409f2c9d --- /dev/null +++ b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/utils/OpenIDConnectionUtils.java @@ -0,0 +1,313 @@ +/** + * Copyright (C) 2015 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.fabric8.kubernetes.client.utils; + +import com.fasterxml.jackson.core.JsonProcessingException; +import io.fabric8.kubernetes.api.model.NamedContext; +import io.fabric8.kubernetes.client.internal.KubeConfigUtils; +import io.fabric8.kubernetes.client.internal.SSLUtils; +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.apache.commons.lang.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.KeyManagementException; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableKeyException; +import java.security.cert.CertificateException; +import java.security.spec.InvalidKeySpecException; +import java.time.Instant; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public class OpenIDConnectionUtils { + private static final Logger LOGGER = LoggerFactory.getLogger(OpenIDConnectionUtils.class); + public static final String ID_TOKEN_KUBECONFIG = "id-token"; + public static final String ISSUER_KUBECONFIG = "idp-issuer-url"; + public static final String REFRESH_TOKEN_KUBECONFIG = "refresh-token"; + public static final String REFRESH_TOKEN_PARAM = "refresh_token"; + public static final String GRANT_TYPE_PARAM = "grant_type"; + public static final String CLIENT_ID_PARAM = "client_id"; + public static final String CLIENT_SECRET_PARAM = "client_secret"; + public static final String ID_TOKEN_PARAM = "id_token"; + public static final String ACCESS_TOKEN_PARAM = "access_token"; + public static final String CLIENT_ID_KUBECONFIG = "client-id"; + public static final String CLIENT_SECRET_KUBECONFIG = "client-secret"; + public static final String IDP_CERT_DATA = "idp-certificate-authority-data"; + public static final String EXPIRATION_TIME = "exp"; + public static final String TOKEN_ENDPOINT_PARAM = "token_endpoint"; + public static final String WELL_KNOWN_OPENID_CONFIGURATION = ".well-known/openid-configuration"; + public static final String GRANT_TYPE_REFRESH_TOKEN = "refresh_token"; + + private OpenIDConnectionUtils() { } + + /** + * Fetch OpenID Connect token from Kubeconfig, check whether it's still valid or not; If expired handle + * token refresh with OpenID Connection provider APIs + * + * @param currentAuthProviderConfig current AuthInfo's AuthProvider config as a map + * @return access token for interacting with Kubernetes API + * @throws JsonProcessingException in case of exception while parsing tokens + */ + public static String resolveOIDCTokenFromAuthConfig(Map currentAuthProviderConfig) throws JsonProcessingException { + String accessToken = currentAuthProviderConfig.get(ID_TOKEN_KUBECONFIG); + String issuer = currentAuthProviderConfig.get(ISSUER_KUBECONFIG); + String clientId = currentAuthProviderConfig.get(CLIENT_ID_KUBECONFIG); + String refreshToken = currentAuthProviderConfig.get(REFRESH_TOKEN_KUBECONFIG); + String clientSecret = currentAuthProviderConfig.getOrDefault(CLIENT_SECRET_KUBECONFIG, ""); + String idpCert = currentAuthProviderConfig.get(IDP_CERT_DATA); + + if (isTokenExpired(accessToken) && isTokenRefreshSupported(currentAuthProviderConfig)) { + try { + String freshAccessToken = OpenIDConnectionUtils.refreshToken(issuer, clientId, refreshToken, clientSecret, idpCert); + if (freshAccessToken != null) { + accessToken = freshAccessToken; + } + } catch (Exception e) { + LOGGER.warn("Could not refresh OIDC token: {}", e.getMessage()); + } + } + return accessToken; + } + + private static boolean isTokenRefreshSupported(Map currentAuthProviderConfig) { + return Utils.isNotNull(currentAuthProviderConfig.get(REFRESH_TOKEN_KUBECONFIG)); + } + + protected static String refreshToken(String issuer, String clientId, String refreshToken, String clientSecret, String idpCert) { + // check the identity provider's configuration url for a token endpoint + OkHttpClient client = getOkHttpClient(getSSLContext(idpCert), idpCert); + String tokenURL = loadTokenEndpoint(client, issuer); + + // get the refreshed tokens + try { + Map response = refreshOidcToken(client, clientId, refreshToken, clientSecret, tokenURL); + + // id_token isn't a required part of a refresh token response, so some + // providers (Okta) don't return this value. + // + // See https://github.com/kubernetes/kubernetes/issues/36847 + if (!response.containsKey(ID_TOKEN_PARAM)) { + LOGGER.warn("token response did not contain an id_token, either the scope \\\"openid\\\" wasn't " + + "requested upon login, or the provider doesn't support id_tokens as part of the refresh response."); + return null; + } + + // Persist new config and if successful, update the in memory config. + if (!persistKubeConfigWithUpdatedToken(response)) { + LOGGER.warn("oidc: failure while persisting new tokens into KUBECONFIG"); + } + return String.valueOf(response.get(ID_TOKEN_PARAM)); + } catch (IOException e) { + LOGGER.warn("Failure in fetching refresh token: ", e); + } + } + + protected static Map refreshOidcToken(OkHttpClient client, String clientId, String refreshToken, String clientSecret, String tokenURL) throws IOException { + HttpUrl.Builder httpUrlBuilder = HttpUrl.get(tokenURL).newBuilder(); + + RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), getRequestBodyContentForRefresh(clientId, refreshToken, clientSecret)); + Request.Builder requestBuilder = new Request.Builder().post(requestBody).url(httpUrlBuilder.build()); + String credentials = + java.util.Base64.getEncoder() + .encodeToString((clientId + ':' + clientSecret).getBytes(StandardCharsets.UTF_8)); + requestBuilder.addHeader("Authorization", "Basic " + credentials); + requestBuilder.addHeader("Content-Type", "application/x-www-form-urlencoded"); + + Response response = client.newCall(requestBuilder.build()).execute(); + if (response.isSuccessful() && response.body() != null) { + return convertJsonStringToMap(response.body().string()); + } + return Collections.emptyMap(); + } + + private static String getRequestBodyContentForRefresh(String clientId, String refreshToken, String clientSecret) throws JsonProcessingException { + Map requestBody = new HashMap<>(); + requestBody.put(REFRESH_TOKEN_PARAM, refreshToken); + requestBody.put(GRANT_TYPE_PARAM, GRANT_TYPE_REFRESH_TOKEN); + requestBody.put(CLIENT_ID_PARAM, clientId); + requestBody.put(CLIENT_SECRET_PARAM, clientSecret); + return Serialization.jsonMapper().writeValueAsString(requestBody); + } + + /** + * TokenEndpoint uses OpenID Connect discovery to determine the OAuth2 token + * endpoint for the provider, the endpoint the client will use the refresh + * token against. + */ + protected static String loadTokenEndpoint(OkHttpClient client, String issuer) { + try { + URL wellKnown = new URL(getWellKnownUrlForOpenIDIssuer(issuer)); + Request request = new Request.Builder() + .url(wellKnown) + .build(); + Response response = client.newCall(request).execute(); + if (response.isSuccessful() && response.body() != null) { + return getTokenEndpointFromResponse(response.body().string()); + } else { + // Don't produce an error that's too huge (e.g. if we get HTML back for some reason). + String responseBody = response.body() != null ? response.body().string() : null; + LOGGER.warn("oidc: failed to query metadata endpoint: {} {}", response.code(), responseBody); + } + } catch (IOException e) { + LOGGER.warn("Could not refresh OIDC token, failure in getting refresh URL", e); + } + return null; + } + + /** + * Well known URL for getting OpenID Connect metadata. + * https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig + * + * @param issuer issuing authority URL + * @return well known URL for corresponding OpenID provider + */ + protected static String getWellKnownUrlForOpenIDIssuer(String issuer) { + return URLUtils.join(issuer, "/", WELL_KNOWN_OPENID_CONFIGURATION); + } + + private static String getTokenEndpointFromResponse(String responseBodyAsString) { + try { + Map responseAsJson = convertJsonStringToMap(responseBodyAsString); + // Metadata object. We only care about the token_endpoint, the thing endpoint + // we'll be refreshing against. + // + // https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + if (responseAsJson.containsKey(TOKEN_ENDPOINT_PARAM)) { + return String.valueOf(responseAsJson.get(TOKEN_ENDPOINT_PARAM)); + } else { + LOGGER.warn("oidc: oidc: discovery object doesn't contain a token_endpoint"); + } + } catch (JsonProcessingException jsonMappingException) { + LOGGER.warn("oidc: failed to decode provider discovery object: ", jsonMappingException); + } + return StringUtils.EMPTY; + } + + public static boolean isTokenExpired(String jwtToken) throws JsonProcessingException { + // Split token based on periods + String[] webTokenComponents = jwtToken.split("\\."); + if (webTokenComponents.length >= 3) { + // Get middle element + String base64EncodedBody = webTokenComponents[1]; + + // decode JWT Body(middle element of token) + String body = new String(Base64.getDecoder().decode(base64EncodedBody)); + if (Utils.isNotNullOrEmpty(body)) { + // Get exp element to find out token expiry epoch + Map jwtBodyAsMap = convertJsonStringToMap(body); + Integer expiryDate = (Integer) jwtBodyAsMap.get(EXPIRATION_TIME); + return Instant.now().isAfter(Instant.ofEpochSecond(expiryDate)); + } + } else { + LOGGER.warn("oidc: ID Token is not a valid JWT"); + } + // In case of any failure; assume token is expired + return true; + } + + private static boolean persistKubeConfigWithUpdatedToken(Map updatedAuthProviderConfig) throws IOException { + return persistKubeConfigWithUpdatedToken(io.fabric8.kubernetes.client.Config.getKubeconfigFilename(), updatedAuthProviderConfig); + } + + protected static boolean persistKubeConfigWithUpdatedToken(String kubeConfigPath, Map updatedAuthProviderConfig) throws IOException { + io.fabric8.kubernetes.api.model.Config config = KubeConfigUtils.parseConfig(new File(kubeConfigPath)); + NamedContext currentNamedContext = KubeConfigUtils.getCurrentContext(config); + + if (currentNamedContext != null) { + // Update users > auth-provider > config + int currentUserIndex = KubeConfigUtils.getNamedUserIndexFromConfig(config, currentNamedContext.getContext().getUser()); + Map authProviderConfig = config.getUsers().get(currentUserIndex).getUser().getAuthProvider().getConfig(); + authProviderConfig.put(ID_TOKEN_KUBECONFIG, String.valueOf(updatedAuthProviderConfig.get(ID_TOKEN_PARAM))); + authProviderConfig.put(REFRESH_TOKEN_KUBECONFIG, String.valueOf(updatedAuthProviderConfig.get(ACCESS_TOKEN_PARAM))); + config.getUsers().get(currentUserIndex).getUser().getAuthProvider().setConfig(authProviderConfig); + + // Persist changes to KUBECONFIG + try { + KubeConfigUtils.persistKubeConfigIntoFile(config, kubeConfigPath); + return true; + } catch (IOException exception) { + LOGGER.warn("failed to write file {}", kubeConfigPath, exception); + } + } + return false; + } + + private static SSLContext getSSLContext(String idpCert) { + SSLContext sslContext = null; + + if (idpCert != null) { + // fist, lets get the pem + String pemCert = new String(java.util.Base64.getDecoder().decode(idpCert)); + + try { + TrustManager[] trustManagers = SSLUtils.trustManagers(pemCert, null, false, null, null); + KeyManager[] keyManagers = SSLUtils.keyManagers(pemCert, null, null, null, null, null, null, null); + sslContext = SSLUtils.sslContext(keyManagers, trustManagers); + } catch (KeyStoreException | + KeyManagementException | + InvalidKeySpecException | + NoSuchAlgorithmException | + IOException | + UnrecoverableKeyException | + CertificateException e) { + LOGGER.warn("Could not import idp certificate", e); + } + } + return sslContext; + } + + private static OkHttpClient getOkHttpClient(SSLContext sslContext, String pemCert) { + OkHttpClient.Builder clientBuilder = new OkHttpClient.Builder(); + if (sslContext != null) { + clientBuilder.sslSocketFactory(sslContext.getSocketFactory(), getTrustManagerForAllCerts(pemCert)); + } + return clientBuilder.build(); + } + + private static X509TrustManager getTrustManagerForAllCerts(String pemCert) { + X509TrustManager trustManager = null; + try { + TrustManager[] trustManagers = SSLUtils.trustManagers(pemCert, null, false, null, null); + if (trustManagers != null && trustManagers.length == 1) { + trustManager = (X509TrustManager) trustManagers[0]; + } + } catch (CertificateException | NoSuchAlgorithmException | KeyStoreException | IOException e) { + LOGGER.warn("Could not get trust manager"); + } + return trustManager; + } + + private static Map convertJsonStringToMap(String jsonString) throws JsonProcessingException { + return Serialization.jsonMapper().readValue(jsonString, Map.class); + } +} diff --git a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/utils/Serialization.java b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/utils/Serialization.java index 96c1067b3b3..45c773b3eb6 100644 --- a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/utils/Serialization.java +++ b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/utils/Serialization.java @@ -39,6 +39,7 @@ import java.util.stream.Collectors; public class Serialization { + private Serialization() { } private static final ObjectMapper JSON_MAPPER = new ObjectMapper(); static { @@ -55,7 +56,7 @@ public static ObjectMapper yamlMapper() { return YAML_MAPPER; } - public static String asJson(T object) throws KubernetesClientException { + public static String asJson(T object) { try { return JSON_MAPPER.writeValueAsString(object); } catch (JsonProcessingException e) { @@ -63,7 +64,7 @@ public static String asJson(T object) throws KubernetesClientException { } } - public static String asYaml(T object) throws KubernetesClientException { + public static String asYaml(T object) { try { return YAML_MAPPER.writeValueAsString(object); } catch (JsonProcessingException e) { @@ -80,7 +81,7 @@ public static String asYaml(T object) throws KubernetesClientException { * @return returns de-serialized object * @throws KubernetesClientException KubernetesClientException */ - public static T unmarshal(InputStream is) throws KubernetesClientException { + public static T unmarshal(InputStream is) { return unmarshal(is, JSON_MAPPER); } @@ -162,7 +163,7 @@ public static T unmarshal(String str, final Class type) { * @return returns de-serialized object * @throws KubernetesClientException KubernetesClientException */ - public static T unmarshal(String str, final Class type, Map parameters) throws KubernetesClientException { + public static T unmarshal(String str, final Class type, Map parameters) { try (InputStream is = new ByteArrayInputStream(str.getBytes(StandardCharsets.UTF_8))) { return unmarshal(is, new TypeReference() { @Override @@ -195,7 +196,7 @@ public static T unmarshal(InputStream is, final Class type) { * @return returns de-serialized object * @throws KubernetesClientException KubernetesClientException */ - public static T unmarshal(InputStream is, final Class type, Map parameters) throws KubernetesClientException { + public static T unmarshal(InputStream is, final Class type, Map parameters) { return unmarshal(is, new TypeReference() { @Override public Type getType() { diff --git a/kubernetes-client/src/test/java/io/fabric8/kubernetes/client/internal/KubeConfigUtilsTest.java b/kubernetes-client/src/test/java/io/fabric8/kubernetes/client/internal/KubeConfigUtilsTest.java new file mode 100644 index 00000000000..3d7a310b6df --- /dev/null +++ b/kubernetes-client/src/test/java/io/fabric8/kubernetes/client/internal/KubeConfigUtilsTest.java @@ -0,0 +1,172 @@ +/** + * Copyright (C) 2015 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.fabric8.kubernetes.client.internal; + +import io.fabric8.kubernetes.api.model.AuthInfo; +import io.fabric8.kubernetes.api.model.Cluster; +import io.fabric8.kubernetes.api.model.Config; +import io.fabric8.kubernetes.api.model.ConfigBuilder; +import io.fabric8.kubernetes.api.model.Context; +import io.fabric8.kubernetes.api.model.NamedContext; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.util.Collections; +import java.util.Objects; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class KubeConfigUtilsTest { + @Test + void testGetNamedUserIndexFromConfig() { + // Given + Config config = getTestKubeConfig(); + + // When + int index = KubeConfigUtils.getNamedUserIndexFromConfig(config, "test/test-cluster:443"); + + // Then + assertEquals(2, index); + } + + @Test + void testGetCurrentContext() { + // Given + Config config = getTestKubeConfig(); + + // When + NamedContext namedContext = KubeConfigUtils.getCurrentContext(config); + + // Then + assertNotNull(namedContext); + assertEquals("test-context", namedContext.getName()); + assertEquals("ns1", namedContext.getContext().getNamespace()); + assertEquals("system:admin/api-testing:6334", namedContext.getContext().getUser()); + assertEquals("api-testing:6334", namedContext.getContext().getCluster()); + } + + @Test + void testParseConfig() throws IOException { + // Given + File configFile = new File(getClass().getResource("/test-kubeconfig").getPath()); + + // When + Config config = KubeConfigUtils.parseConfig(configFile); + + // Then + assertNotNull(config); + assertEquals(1, config.getClusters().size()); + assertEquals(3, config.getContexts().size()); + assertEquals(3, config.getUsers().size()); + } + + @Test + void testGetUserToken() { + // Given + Config config = getTestKubeConfig(); + Context context = Objects.requireNonNull(KubeConfigUtils.getCurrentContext(config)).getContext(); + + // When + String token = KubeConfigUtils.getUserToken(config, context); + + // Then + assertEquals("test-token-2", token); + } + + @Test + void testGetCluster() { + // Given + Config config = getTestKubeConfig(); + Context context = Objects.requireNonNull(KubeConfigUtils.getCurrentContext(config)).getContext(); + + // When + Cluster cluster = KubeConfigUtils.getCluster(config, context); + + // Then + assertNotNull(cluster); + } + + @Test + void testGetUserAuthInfo() { + // Given + Config config = getTestKubeConfig(); + Context context = config.getContexts().get(0).getContext(); + + // When + AuthInfo authInfo = KubeConfigUtils.getUserAuthInfo(config, context); + + // Then + assertNotNull(authInfo); + assertEquals("test-token-2", authInfo.getToken()); + } + + private Config getTestKubeConfig() { + return new ConfigBuilder() + .withCurrentContext("test-context") + .addNewCluster() + .withName("api-testing:6334") + .withNewCluster() + .withServer("https://api-testing:6334") + .withInsecureSkipTlsVerify(true) + .endCluster() + .endCluster() + .addNewContext() + .withName("test-context") + .withNewContext() + .withCluster("api-testing:6334") + .withNamespace("ns1") + .withUser("system:admin/api-testing:6334") + .endContext() + .endContext() + .addNewContext() + .withNewContext() + .withCluster("minikube") + .withUser("minikube") + .endContext() + .withName("minikube") + .endContext() + .addNewUser() + .withName("test/api-test-com:443") + .withNewUser() + .withToken("token") + .endUser() + .endUser() + .addNewUser() + .withName("minikube") + .withNewUser() + .withClientCertificate("/home/.minikube/profiles/minikube/client.crt") + .withClientKey("/home/.minikube/profiles/minikube/client.key") + .endUser() + .endUser() + .addNewUser() + .withName("test/test-cluster:443") + .withNewUser() + .withNewAuthProvider() + .withConfig(Collections.singletonMap("id-token", "token")) + .endAuthProvider() + .endUser() + .endUser() + .addNewUser() + .withName("system:admin/api-testing:6334") + .withNewUser() + .withToken("test-token-2") + .endUser() + .endUser() + .build(); + } +} diff --git a/kubernetes-client/src/test/java/io/fabric8/kubernetes/client/utils/OpenIDConnectionUtilsTest.java b/kubernetes-client/src/test/java/io/fabric8/kubernetes/client/utils/OpenIDConnectionUtilsTest.java new file mode 100644 index 00000000000..393c9b3c046 --- /dev/null +++ b/kubernetes-client/src/test/java/io/fabric8/kubernetes/client/utils/OpenIDConnectionUtilsTest.java @@ -0,0 +1,184 @@ +/** + * Copyright (C) 2015 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.fabric8.kubernetes.client.utils; + +import com.fasterxml.jackson.core.JsonProcessingException; +import io.fabric8.kubernetes.api.model.NamedContext; +import io.fabric8.kubernetes.client.internal.KubeConfigUtils; +import okhttp3.Call; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.io.File; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.HashMap; +import java.util.Map; + +import static io.fabric8.kubernetes.client.utils.OpenIDConnectionUtils.ACCESS_TOKEN_PARAM; +import static io.fabric8.kubernetes.client.utils.OpenIDConnectionUtils.ID_TOKEN_KUBECONFIG; +import static io.fabric8.kubernetes.client.utils.OpenIDConnectionUtils.ID_TOKEN_PARAM; +import static io.fabric8.kubernetes.client.utils.OpenIDConnectionUtils.REFRESH_TOKEN_KUBECONFIG; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class OpenIDConnectionUtilsTest { + OkHttpClient mockClient = Mockito.mock(OkHttpClient.class, Mockito.RETURNS_DEEP_STUBS); + + @Test + void testIsTokenExpiredReturnsFalseOnExpiredToken() throws JsonProcessingException { + // Given + String jwtToken = "eyJraWQiOiJDTj1vaWRjaWRwLnRyZW1vbG8ubGFuLCBPVT1EZW1vLCBPPVRybWVvbG8gU2VjdXJpdHksIEw9QXJsaW5ndG9uLCBTVD1WaXJnaW5pYSwgQz1VUy1DTj1rdWJlLWNhLTEyMDIxNDc5MjEwMzYwNzMyMTUyIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL29pZGNpZHAudHJlbW9sby5sYW46ODQ0My9hdXRoL2lkcC9PaWRjSWRQIiwiYXVkIjoia3ViZXJuZXRlcyIsImV4cCI6MTQ4MzU0OTUxMSwianRpIjoiMm96US15TXdFcHV4WDlHZUhQdy1hZyIsImlhdCI6MTQ4MzU0OTQ1MSwibmJmIjoxNDgzNTQ5MzMxLCJzdWIiOiI0YWViMzdiYS1iNjQ1LTQ4ZmQtYWIzMC0xYTAxZWU0MWUyMTgifQ.w6p4J_6qQ1HzTG9nrEOrubxIMb9K5hzcMPxc9IxPx2K4xO9l-oFiUw93daH3m5pluP6K7eOE6txBuRVfEcpJSwlelsOsW8gb8VJcnzMS9EnZpeA0tW_p-mnkFc3VcfyXuhe5R3G7aa5d8uHv70yJ9Y3-UhjiN9EhpMdfPAoEB9fYKKkJRzF7utTTIPGrSaSU6d2pcpfYKaxIwePzEkT4DfcQthoZdy9ucNvvLoi1DIC-UocFD8HLs8LYKEqSxQvOcvnThbObJ9af71EwmuE21fO5KzMW20KtAeget1gnldOosPtz1G5EwvaQ401-RPQzPGMVBld0_zMCAwZttJ4knw"; + + // When + boolean result = OpenIDConnectionUtils.isTokenExpired(jwtToken); + + // Then + assertTrue(result); + } + + @Test + void testIsTokenExpiredReturnsFalseOnInvalidToken() throws JsonProcessingException { + // Given + String jwtToken = "invalidtoken"; + + // When + boolean result = OpenIDConnectionUtils.isTokenExpired(jwtToken); + + // Then + assertTrue(result); + } + + @Test + void testLoadTokenURL() throws IOException { + // Given + String openIdIssuer = "https://accounts.example.com"; + String tokenEndpointResponse = "{\"issuer\": \"https://accounts.example.com\"," + + " \"token_endpoint\": \"https://oauth2.exampleapis.com/token\"}"; + mockOkHttpClient(HttpURLConnection.HTTP_OK, tokenEndpointResponse); + + // When + String tokenEndpoint = OpenIDConnectionUtils.loadTokenEndpoint(mockClient, openIdIssuer); + + // Then + assertEquals("https://oauth2.exampleapis.com/token", tokenEndpoint); + } + + @Test + void testLoadTokenURLWhenNotFound() throws IOException { + // Given + String openIdIssuer = "https://accounts.example.com"; + String tokenEndpointResponse = "{}"; + mockOkHttpClient(HttpURLConnection.HTTP_NOT_FOUND, tokenEndpointResponse); + + // When + String tokenEndpoint = OpenIDConnectionUtils.loadTokenEndpoint(mockClient, openIdIssuer); + + // Then + assertNull(tokenEndpoint); + } + + @Test + void testGetWellKnownUrlForOpenIDIssuer() { + // Given + String openIdIssuer = "https://accounts.example.com"; + + // When + String wellKnownUrl = OpenIDConnectionUtils.getWellKnownUrlForOpenIDIssuer(openIdIssuer); + + // Then + assertEquals("https://accounts.example.com/.well-known/openid-configuration", wellKnownUrl); + } + + @Test + void testRefreshOidcToken() throws IOException { + // Given + String clientId = "test-client-id"; + String refreshToken = "test-refresh-token"; + String clientSecret = "test-client-secret"; + String tokenEndpointUrl = "https://oauth2.exampleapis.com/token"; + mockOkHttpClient(HttpURLConnection.HTTP_OK, "{\""+ ID_TOKEN_PARAM +"\":\"thisisatesttoken\",\"access_token\": \"thisisrefreshtoken\"," + + "\"expires_in\": 3599," + + "\"scope\": \"openid https://www.exampleapis.com/auth/userinfo.email\"," + + "\"token_type\": \"Bearer\"}"); + + // When + Map response = OpenIDConnectionUtils.refreshOidcToken(mockClient, clientId, refreshToken, clientSecret, tokenEndpointUrl); + + // Then + assertNotNull(response); + assertEquals("thisisatesttoken", response.get(ID_TOKEN_PARAM)); + } + + @Test + void testPersistKubeConfigWithUpdatedToken() throws IOException { + // Given + Map openIdProviderResponse = new HashMap<>(); + openIdProviderResponse.put(ID_TOKEN_PARAM, "id-token-updated"); + openIdProviderResponse.put(ACCESS_TOKEN_PARAM, "refresh-token-updated"); + File tempFile = Files.createTempFile("test", "kubeconfig").toFile(); + Files.copy(getClass().getResourceAsStream("/test-kubeconfig-oidc"), Paths.get(tempFile.getPath()), StandardCopyOption.REPLACE_EXISTING); + + // When + boolean isPersisted = OpenIDConnectionUtils.persistKubeConfigWithUpdatedToken(tempFile.getAbsolutePath(), openIdProviderResponse); + + // Then + assertTrue(isPersisted); + io.fabric8.kubernetes.api.model.Config config = KubeConfigUtils.parseConfig(tempFile); + assertNotNull(config); + NamedContext currentNamedContext = KubeConfigUtils.getCurrentContext(config); + assertNotNull(currentNamedContext); + int currentUserIndex = KubeConfigUtils.getNamedUserIndexFromConfig(config, currentNamedContext.getContext().getUser()); + assertTrue(currentUserIndex > 0); + Map authProviderConfig = config.getUsers().get(currentUserIndex).getUser().getAuthProvider().getConfig(); + assertFalse(authProviderConfig.isEmpty()); + assertEquals("id-token-updated", authProviderConfig.get(ID_TOKEN_KUBECONFIG)); + assertEquals("refresh-token-updated", authProviderConfig.get(REFRESH_TOKEN_KUBECONFIG)); + } + + private void mockOkHttpClient(int responseCode, String responseAsStr) throws IOException { + Call mockCall = mock(Call.class); + Response mockSuccessResponse = mockResponse(responseCode, responseAsStr); + when(mockCall.execute()) + .thenReturn(mockSuccessResponse); + when(mockClient.newCall(any())).thenReturn(mockCall); + } + + private Response mockResponse(int responseCode, String responseBody) { + return new Response.Builder() + .request(new Request.Builder().url("http://mock").build()) + .protocol(Protocol.HTTP_1_1) + .code(responseCode) + .body(ResponseBody.create(MediaType.get("application/json"), responseBody)) + .message("mock") + .build(); + } +} diff --git a/kubernetes-client/src/test/resources/test-kubeconfig-oidc b/kubernetes-client/src/test/resources/test-kubeconfig-oidc new file mode 100644 index 00000000000..dbc0fae0f52 --- /dev/null +++ b/kubernetes-client/src/test/resources/test-kubeconfig-oidc @@ -0,0 +1,44 @@ +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 +- context: + cluster: 172-28-128-4:8443 + namespace: production + user: root/172-28-128-4:8443 + name: production/172-28-128-4:8443/root +- context: + cluster: 172-28-128-4:8443 + namespace: production + user: mmosley + name: production/172-28-128-4:8443/mmosley +current-context: production/172-28-128-4:8443/mmosley +kind: Config +preferences: {} +users: +- name: user/172-28-128-4:8443 + user: + token: token +- name: root/172-28-128-4:8443 + user: + token: supertoken +- name: mmosley + user: + auth-provider: + config: + client-id: kubernetes + client-secret: 1db158f6-177d-4d9c-8a8b-d36869918ec5 + id-token: eyJraWQiOiJDTj1vaWRjaWRwLnRyZW1vbG8ubGFuLCBPVT1EZW1vLCBPPVRybWVvbG8gU2VjdXJpdHksIEw9QXJsaW5ndG9uLCBTVD1WaXJnaW5pYSwgQz1VUy1DTj1rdWJlLWNhLTEyMDIxNDc5MjEwMzYwNzMyMTUyIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL29pZGNpZHAudHJlbW9sby5sYW46ODQ0My9hdXRoL2lkcC9PaWRjSWRQIiwiYXVkIjoia3ViZXJuZXRlcyIsImV4cCI6MTQ4MzU0OTUxMSwianRpIjoiMm96US15TXdFcHV4WDlHZUhQdy1hZyIsImlhdCI6MTQ4MzU0OTQ1MSwibmJmIjoxNDgzNTQ5MzMxLCJzdWIiOiI0YWViMzdiYS1iNjQ1LTQ4ZmQtYWIzMC0xYTAxZWU0MWUyMTgifQ.w6p4J_6qQ1HzTG9nrEOrubxIMb9K5hzcMPxc9IxPx2K4xO9l-oFiUw93daH3m5pluP6K7eOE6txBuRVfEcpJSwlelsOsW8gb8VJcnzMS9EnZpeA0tW_p-mnkFc3VcfyXuhe5R3G7aa5d8uHv70yJ9Y3-UhjiN9EhpMdfPAoEB9fYKKkJRzF7utTTIPGrSaSU6d2pcpfYKaxIwePzEkT4DfcQthoZdy9ucNvvLoi1DIC-UocFD8HLs8LYKEqSxQvOcvnThbObJ9af71EwmuE21fO5KzMW20KtAeget1gnldOosPtz1G5EwvaQ401-RPQzPGMVBld0_zMCAwZttJ4knw + idp-certificate-authority: /root/ca.pem + idp-issuer-url: https://oidcidp.tremolo.lan:8443/auth/idp/OidcIdP + refresh-token: q1bKLFOyUiosTfawzA93TzZIDzH2TNa2SMm0zEiPKTUwME6BkEo6Sql5yUWVBSWpKUGphaWpxSVAfekBOZbBhaEW+VlFUeVRGcluyVF5JT4+haZmPsluFoFu5XkpXk5BXq + name: oidc