From 92a37f43deb242400905bf9bef6f860552e03e65 Mon Sep 17 00:00:00 2001 From: Vitali Butautas Date: Wed, 29 Jul 2026 17:37:37 +0300 Subject: [PATCH 1/4] CPSEC-4687 Local dev implementation --- core-utils/k8s/README.md | 25 ++ core-utils/k8s/pom.xml | 4 + .../k8s/localdev/KubeConfigCredentials.java | 13 ++ .../localdev/KubeConfigHttpClientFactory.java | 101 ++++++++ .../utils/k8s/localdev/KubeConfigLoader.java | 196 ++++++++++++++++ .../utils/k8s/localdev/LocalDevConstants.java | 84 +++++++ .../utils/k8s/localdev/LocalDevHttpUtils.java | 32 +++ .../utils/k8s/localdev/LocalDevJsonUtils.java | 55 +++++ .../k8s/localdev/LocalDevKubernetesOidc.java | 182 +++++++++++++++ .../core/utils/k8s/localdev/LocalDevMode.java | 83 +++++++ .../k8s/localdev/LocalDevTokenSource.java | 91 ++++++++ .../OidcAuthProviderTokenRefresher.java | 218 ++++++++++++++++++ .../k8s/localdev/TokenRequestClient.java | 131 +++++++++++ ....cloud.security.core.utils.k8s.TokenSource | 1 + .../k8s/TestTokenSourceHighPriorityImpl.java | 2 +- .../k8s/localdev/KubeConfigLoaderTest.java | 96 ++++++++ .../localdev/LocalDevKubernetesOidcTest.java | 111 +++++++++ .../utils/k8s/localdev/LocalDevModeTest.java | 77 +++++++ .../k8s/localdev/LocalDevTokenSourceTest.java | 74 ++++++ .../OidcAuthProviderTokenRefresherTest.java | 130 +++++++++++ .../k8s/localdev/TokenRequestClientTest.java | 81 +++++++ 21 files changed, 1786 insertions(+), 1 deletion(-) create mode 100644 core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigCredentials.java create mode 100644 core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigHttpClientFactory.java create mode 100644 core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigLoader.java create mode 100644 core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevConstants.java create mode 100644 core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevHttpUtils.java create mode 100644 core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevJsonUtils.java create mode 100644 core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevKubernetesOidc.java create mode 100644 core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevMode.java create mode 100644 core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevTokenSource.java create mode 100644 core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/OidcAuthProviderTokenRefresher.java create mode 100644 core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/TokenRequestClient.java create mode 100644 core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigLoaderTest.java create mode 100644 core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevKubernetesOidcTest.java create mode 100644 core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevModeTest.java create mode 100644 core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevTokenSourceTest.java create mode 100644 core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/OidcAuthProviderTokenRefresherTest.java create mode 100644 core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/TokenRequestClientTest.java diff --git a/core-utils/k8s/README.md b/core-utils/k8s/README.md index c29ad14ba..a75ca988e 100644 --- a/core-utils/k8s/README.md +++ b/core-utils/k8s/README.md @@ -110,6 +110,31 @@ public class KubernetesTokenVerifier { | com.netcracker.cloud.security.kubernetes.service.account.token.dir | /path/to/directory | /var/run/secrets/kubernetes.io/serviceaccount | change the directory where kubernetes service account token is located | | com.netcracker.cloud.security.kubernetes.tokens.polling.interval | 2m | 1m | change the interval of polling events from file system about token rotations | +### Local-dev TokenRequest (`LocalDevTokenSource`) + +When application profile is `dev` (Quarkus: `-Dquarkus.profile=dev`, Spring: `--spring.profiles.active=dev`), +`LocalDevTokenSource` mints real SA tokens via Kubernetes TokenRequest API using the developer kubeconfig +instead of projected-volume files. + +| requirement | value | +|---|---| +| Profile | `dev` | +| SA name | `cloud.microservice.name` (contract, no override) | +| Namespace | env `CLOUD_NAMESPACE` | +| Kube access | `KUBECONFIG` or `~/.kube/config` (static `token`, OIDC `auth-provider` with refresh via `idp-issuer-url`, or `exec` auth). IdP TLS uses trust-all in local-dev by default (`security.local-dev.insecure-idp-tls=false` to disable). | +| Token TTL | 8 hours (`expirationSeconds=28800`) | +| Audience | value passed to `KubernetesAudienceToken.getToken(audience)` | + +Framework bootstrap (security-core / security-quarkus-extensions m2m-manager) copies `cloud.microservice.name` +into a system property so `k8s-utils` can read it without depending on Spring/Quarkus config APIs. + +On HTTP 401/403 from TokenRequest the error message explains missing RBAC on `serviceaccounts/token`. + +For inbound validation, consumers rewrite the JWKS URL to `LocalDevKubernetesOidc.jwksUrl()` +(kube API + `/openid/v1/jwks`). Discovery and JWKS on the API server are public (no Bearer). +When a projected SA token is missing, `resolveIssuerClaimFromDiscovery()` reads the issuer from +kube OIDC discovery. TokenRequest and other protected API calls still use `userToken()`. + ### Custom TokenSource `com.netcracker.cloud.security.core.utils.k8s.impl.WatchingTokenSource` is the current default implementation of the diff --git a/core-utils/k8s/pom.xml b/core-utils/k8s/pom.xml index 64464bfec..9be3019b9 100644 --- a/core-utils/k8s/pom.xml +++ b/core-utils/k8s/pom.xml @@ -30,6 +30,10 @@ com.fasterxml.jackson.core jackson-databind + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + net.jodah diff --git a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigCredentials.java b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigCredentials.java new file mode 100644 index 000000000..7cafd64f3 --- /dev/null +++ b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigCredentials.java @@ -0,0 +1,13 @@ +package com.netcracker.cloud.security.core.utils.k8s.localdev; + +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class KubeConfigCredentials { + String serverUrl; + String userToken; + byte[] certificateAuthorityData; + boolean insecureSkipTlsVerify; +} diff --git a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigHttpClientFactory.java b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigHttpClientFactory.java new file mode 100644 index 000000000..671247896 --- /dev/null +++ b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigHttpClientFactory.java @@ -0,0 +1,101 @@ +package com.netcracker.cloud.security.core.utils.k8s.localdev; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; +import java.io.ByteArrayInputStream; +import java.net.http.HttpClient; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.Collection; + +final class KubeConfigHttpClientFactory { + + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(30); + + private KubeConfigHttpClientFactory() { + } + + static HttpClient create(KubeConfigCredentials credentials) { + return HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .connectTimeout(CONNECT_TIMEOUT) + .sslContext(createSslContext(credentials)) + .build(); + } + + static HttpClient createInsecureForLocalDev() { + return HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .connectTimeout(CONNECT_TIMEOUT) + .sslContext(createInsecureSslContext()) + .build(); + } + + private static SSLContext createInsecureSslContext() { + try { + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, new TrustManager[]{new InsecureTrustManager()}, new SecureRandom()); + return sslContext; + } catch (Exception e) { + throw new IllegalStateException("Failed to create insecure SSL context for local-dev IdP", e); + } + } + + private static SSLContext createSslContext(KubeConfigCredentials credentials) { + try { + if (credentials.isInsecureSkipTlsVerify()) { + TrustManager[] trustAll = new TrustManager[]{new InsecureTrustManager()}; + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, trustAll, new SecureRandom()); + return sslContext; + } + if (credentials.getCertificateAuthorityData() == null + || credentials.getCertificateAuthorityData().length == 0) { + return SSLContext.getDefault(); + } + + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + Collection certificates = + certificateFactory.generateCertificates( + new ByteArrayInputStream(credentials.getCertificateAuthorityData())); + + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + keyStore.load(null, null); + int index = 0; + for (Certificate certificate : certificates) { + keyStore.setCertificateEntry("ca-" + index++, certificate); + } + + TrustManagerFactory trustManagerFactory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init(keyStore); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, trustManagerFactory.getTrustManagers(), new SecureRandom()); + return sslContext; + } catch (Exception e) { + throw new IllegalStateException("Failed to create SSL context from kubeconfig CA", e); + } + } + + private static final class InsecureTrustManager implements X509TrustManager { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + } +} diff --git a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigLoader.java b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigLoader.java new file mode 100644 index 000000000..deb5044e2 --- /dev/null +++ b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigLoader.java @@ -0,0 +1,196 @@ +package com.netcracker.cloud.security.core.utils.k8s.localdev; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.KubeConfigFields; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevJsonUtils.*; + +@Slf4j +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class KubeConfigLoader { + + private static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()); + private static final ObjectMapper JSON_MAPPER = new ObjectMapper(); + private static final long EXEC_TIMEOUT_SECONDS = 30; + + public static KubeConfigCredentials load() { + Path kubeConfigPath = resolveKubeConfigPath(); + if (!Files.isRegularFile(kubeConfigPath)) { + throw new IllegalStateException("Kubeconfig not found at " + kubeConfigPath + + ". Set KUBECONFIG or create ~/.kube/config."); + } + try { + JsonNode root = YAML_MAPPER.readTree(kubeConfigPath.toFile()); + String currentContext = getTextField(root, KubeConfigFields.CURRENT_CONTEXT); + if (StringUtils.isBlank(currentContext)) { + throw new IllegalStateException("Kubeconfig has no current-context: " + kubeConfigPath); + } + JsonNode context = findKubeConfigEntryByName(root.path(KubeConfigFields.CONTEXTS), currentContext) + .path(KubeConfigFields.CONTEXT); + String clusterName = getTextField(context, KubeConfigFields.CLUSTER); + String userName = getTextField(context, KubeConfigFields.USER); + if (StringUtils.isBlank(clusterName) || StringUtils.isBlank(userName)) { + throw new IllegalStateException("Context '" + currentContext + + "' must define cluster and user in " + kubeConfigPath); + } + + JsonNode cluster = findKubeConfigEntryByName(root.path(KubeConfigFields.CLUSTERS), clusterName) + .path(KubeConfigFields.CLUSTER); + JsonNode user = findKubeConfigEntryByName(root.path(KubeConfigFields.USERS), userName) + .path(KubeConfigFields.USER); + + String server = getTextField(cluster, KubeConfigFields.SERVER); + if (StringUtils.isBlank(server)) { + throw new IllegalStateException("Cluster '" + clusterName + "' has no server URL"); + } + + return KubeConfigCredentials.builder() + .serverUrl(StringUtils.stripEnd(server, "/")) + .userToken(resolveUserToken(user)) + .certificateAuthorityData(decodeOptionalBase64( + getTextField(cluster, KubeConfigFields.CERTIFICATE_AUTHORITY_DATA))) + .insecureSkipTlsVerify(cluster.path(KubeConfigFields.INSECURE_SKIP_TLS_VERIFY).asBoolean(false)) + .build(); + } catch (IOException e) { + throw new IllegalStateException("Failed to parse kubeconfig: " + kubeConfigPath, e); + } + } + + static Path resolveKubeConfigPath() { + String kubeConfig = System.getenv("KUBECONFIG"); + if (StringUtils.isNotBlank(kubeConfig)) { + // KUBECONFIG may be a list; use the first entry + String first = kubeConfig.split(java.io.File.pathSeparator)[0].trim(); + return Path.of(first); + } + return Path.of(System.getProperty("user.home"), ".kube", "config"); + } + + private static String resolveUserToken(JsonNode user) { + String token = getTextField(user, KubeConfigFields.TOKEN); + if (StringUtils.isNotBlank(token)) { + return token; + } + String authProviderToken = resolveAuthProviderToken(user.path(KubeConfigFields.AUTH_PROVIDER)); + if (StringUtils.isNotBlank(authProviderToken)) { + return authProviderToken; + } + String directOidcToken = firstNonBlank( + getTextField(user, KubeConfigFields.ID_TOKEN), + getTextField(user, KubeConfigFields.ACCESS_TOKEN)); + if (StringUtils.isNotBlank(directOidcToken)) { + return directOidcToken; + } + JsonNode exec = user.path(KubeConfigFields.EXEC); + if (!exec.isMissingNode() && !exec.isNull()) { + return runExecCredential(exec); + } + throw new IllegalStateException( + "Kubeconfig user has neither 'token', OIDC auth-provider (with refresh-token/id-token), nor 'exec'. " + + "Local-dev TokenRequest supports static token, OIDC auth-provider refresh, and exec auth."); + } + + private static String resolveAuthProviderToken(JsonNode authProvider) { + if (authProvider.isMissingNode() || authProvider.isNull()) { + return null; + } + JsonNode config = authProvider.path(KubeConfigFields.CONFIG); + if (config.isMissingNode() || config.isNull()) { + return null; + } + String providerName = getTextField(authProvider, KubeConfigFields.NAME); + if (LocalDevConstants.OIDC_AUTH_PROVIDER_NAME.equalsIgnoreCase(providerName)) { + return OidcAuthProviderTokenRefresher.resolveToken(config); + } + return firstNonBlank( + getTextField(config, KubeConfigFields.ID_TOKEN), + getTextField(config, KubeConfigFields.ACCESS_TOKEN)); + } + + private static String runExecCredential(JsonNode exec) { + String command = getTextField(exec, KubeConfigFields.COMMAND); + if (StringUtils.isBlank(command)) { + throw new IllegalStateException("Kubeconfig exec.command is empty"); + } + List commandLine = new ArrayList<>(); + commandLine.add(command); + JsonNode args = exec.path(KubeConfigFields.ARGS); + if (args.isArray()) { + for (JsonNode arg : args) { + commandLine.add(arg.asText()); + } + } + + log.debug("Resolving kubeconfig credentials via exec: {}", commandLine); + try { + ProcessBuilder processBuilder = new ProcessBuilder(commandLine); + processBuilder.redirectErrorStream(true); + JsonNode env = exec.path(KubeConfigFields.ENV); + if (env.isArray()) { + for (JsonNode envVar : env) { + String name = getTextField(envVar, KubeConfigFields.NAME); + String value = getTextField(envVar, KubeConfigFields.VALUE); + if (StringUtils.isNotBlank(name)) { + processBuilder.environment().put(name, value == null ? "" : value); + } + } + } + Process process = processBuilder.start(); + boolean finished = process.waitFor(EXEC_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + throw new IllegalStateException("Kubeconfig exec timed out after " + EXEC_TIMEOUT_SECONDS + "s: " + command); + } + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + if (process.exitValue() != 0) { + throw new IllegalStateException("Kubeconfig exec failed (exit " + process.exitValue() + "): " + output); + } + JsonNode credential = JSON_MAPPER.readTree(output); + String token = getTextField(credential.path(KubeConfigFields.STATUS), KubeConfigFields.TOKEN); + if (StringUtils.isBlank(token)) { + throw new IllegalStateException("Kubeconfig exec did not return status.token"); + } + return token; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Kubeconfig exec interrupted", e); + } catch (IOException e) { + throw new IllegalStateException("Failed to run kubeconfig exec command: " + command, e); + } + } + + private static JsonNode findKubeConfigEntryByName(JsonNode array, String name) { + if (array != null && array.isArray()) { + for (Iterator it = array.elements(); it.hasNext(); ) { + JsonNode item = it.next(); + if (name.equals(getTextField(item, KubeConfigFields.NAME))) { + return item; + } + } + } + throw new IllegalStateException("Kubeconfig entry not found: " + name); + } + + private static byte[] decodeOptionalBase64(String value) { + if (StringUtils.isBlank(value)) { + return null; + } + return Base64.getDecoder().decode(value.replaceAll("\\s", "")); + } +} diff --git a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevConstants.java b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevConstants.java new file mode 100644 index 000000000..5c2105e04 --- /dev/null +++ b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevConstants.java @@ -0,0 +1,84 @@ +package com.netcracker.cloud.security.core.utils.k8s.localdev; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.time.Duration; + +/** + * Shared constants for local-dev kubeconfig / OIDC / TokenRequest code. + *

+ * Media types are defined here because k8s-utils has no JAX-RS / Spring dependency + * ({@code jakarta.ws.rs.core.MediaType} is not on the classpath). + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +final class LocalDevConstants { + + static final String APPLICATION_JSON = "application/json"; + static final String APPLICATION_FORM_URLENCODED = "application/x-www-form-urlencoded"; + + static final String AUTHORIZATION_HEADER = "Authorization"; + static final String CONTENT_TYPE_HEADER = "Content-Type"; + static final String ACCEPT_HEADER = "Accept"; + static final String BEARER_PREFIX = "Bearer "; + + static final String WELL_KNOWN_OPENID_CONFIGURATION_PATH = "/.well-known/openid-configuration"; + + static final String DEFAULT_KUBERNETES_ISSUER = "https://kubernetes.default.svc"; + static final String JWKS_PATH = "/openid/v1/jwks"; + + static final Duration HTTP_REQUEST_TIMEOUT = Duration.ofSeconds(30); + static final long TOKEN_REQUEST_EXPIRATION_SECONDS = 28800L; // 8 hours + + static final String OIDC_AUTH_PROVIDER_NAME = "oidc"; + + static final String TOKEN_REQUEST_API_VERSION = "authentication.k8s.io/v1"; + static final String TOKEN_REQUEST_KIND = "TokenRequest"; + static final String TOKEN_REQUEST_SPEC_AUDIENCES = "audiences"; + static final String TOKEN_REQUEST_SPEC_EXPIRATION_SECONDS = "expirationSeconds"; + + static final String K8S_TOKEN_STATUS_TOKEN = "token"; + static final String K8S_TOKEN_STATUS_EXPIRATION = "expirationTimestamp"; + + static final String OIDC_DISCOVERY_TOKEN_ENDPOINT = "token_endpoint"; + static final String OIDC_DISCOVERY_ISSUER = "issuer"; + static final String OIDC_TOKEN_ID_TOKEN = "id_token"; + static final String OIDC_TOKEN_ACCESS_TOKEN = "access_token"; + static final String OIDC_GRANT_REFRESH_TOKEN = "refresh_token"; + static final String OIDC_FORM_CLIENT_ID = "client_id"; + static final String OIDC_FORM_CLIENT_SECRET = "client_secret"; + static final String OIDC_FORM_GRANT_TYPE = "grant_type"; + + static final int MAX_ERROR_BODY_LENGTH = 500; + static final int JWT_BASE64_PAD_LENGTH = 4; + + @NoArgsConstructor(access = AccessLevel.PRIVATE) + static final class KubeConfigFields { + static final String CURRENT_CONTEXT = "current-context"; + static final String CONTEXTS = "contexts"; + static final String CLUSTERS = "clusters"; + static final String USERS = "users"; + static final String CONTEXT = "context"; + static final String CLUSTER = "cluster"; + static final String USER = "user"; + static final String NAME = "name"; + static final String SERVER = "server"; + static final String TOKEN = "token"; + static final String CERTIFICATE_AUTHORITY_DATA = "certificate-authority-data"; + static final String INSECURE_SKIP_TLS_VERIFY = "insecure-skip-tls-verify"; + static final String AUTH_PROVIDER = "auth-provider"; + static final String EXEC = "exec"; + static final String COMMAND = "command"; + static final String ARGS = "args"; + static final String ENV = "env"; + static final String VALUE = "value"; + static final String STATUS = "status"; + static final String CONFIG = "config"; + static final String ID_TOKEN = "id-token"; + static final String ACCESS_TOKEN = "access-token"; + static final String REFRESH_TOKEN = "refresh-token"; + static final String IDP_ISSUER_URL = "idp-issuer-url"; + static final String CLIENT_ID = "client-id"; + static final String CLIENT_SECRET = "client-secret"; + } +} diff --git a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevHttpUtils.java b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevHttpUtils.java new file mode 100644 index 000000000..ca0843be6 --- /dev/null +++ b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevHttpUtils.java @@ -0,0 +1,32 @@ +package com.netcracker.cloud.security.core.utils.k8s.localdev; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.net.http.HttpResponse; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +final class LocalDevHttpUtils { + + static boolean isUnauthorized(int statusCode) { + return statusCode == 401 || statusCode == 403; + } + + static boolean isFailed(int statusCode) { + return statusCode / 100 != 2; + } + + static void ensureSuccessful(HttpResponse response, String operationDescription) { + int status = response.statusCode(); + if (isUnauthorized(status)) { + throw new IllegalStateException( + operationDescription + " unauthorized (HTTP " + status + "). Response: " + + LocalDevJsonUtils.truncateResponseBody(response.body())); + } + if (isFailed(status)) { + throw new IllegalStateException( + operationDescription + " failed (HTTP " + status + "). Response: " + + LocalDevJsonUtils.truncateResponseBody(response.body())); + } + } +} diff --git a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevJsonUtils.java b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevJsonUtils.java new file mode 100644 index 000000000..5ff5ac313 --- /dev/null +++ b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevJsonUtils.java @@ -0,0 +1,55 @@ +package com.netcracker.cloud.security.core.utils.k8s.localdev; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.apache.commons.lang3.StringUtils; + +import java.util.Base64; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +final class LocalDevJsonUtils { + + /** + * Reads a string field from a JSON node; returns {@code null} if missing, null, or blank. + */ + static String getTextField(JsonNode node, String field) { + JsonNode value = node.path(field); + if (value.isMissingNode() || value.isNull()) { + return null; + } + String text = value.asText(null); + return StringUtils.isBlank(text) ? null : text; + } + + static String firstNonBlank(String first, String second) { + if (StringUtils.isNotBlank(first)) { + return first; + } + if (StringUtils.isNotBlank(second)) { + return second; + } + return null; + } + + static String truncateResponseBody(String body) { + if (body == null) { + return ""; + } + return body.length() <= LocalDevConstants.MAX_ERROR_BODY_LENGTH + ? body + : body.substring(0, LocalDevConstants.MAX_ERROR_BODY_LENGTH) + "..."; + } + + /** + * Pads a Base64URL JWT segment so {@link Base64#getUrlDecoder()} accepts it. + * JWT payloads use Base64URL without padding; Java's decoder requires length % 4 == 0. + */ + static String padBase64Url(String base64Url) { + int remainder = base64Url.length() % LocalDevConstants.JWT_BASE64_PAD_LENGTH; + if (remainder == 0) { + return base64Url; + } + return base64Url + "====".substring(remainder); + } +} diff --git a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevKubernetesOidc.java b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevKubernetesOidc.java new file mode 100644 index 000000000..002024a68 --- /dev/null +++ b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevKubernetesOidc.java @@ -0,0 +1,182 @@ +package com.netcracker.cloud.security.core.utils.k8s.localdev; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.VisibleForTesting; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Locale; + +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.ACCEPT_HEADER; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.APPLICATION_JSON; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.AUTHORIZATION_HEADER; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.BEARER_PREFIX; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.DEFAULT_KUBERNETES_ISSUER; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.HTTP_REQUEST_TIMEOUT; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.JWKS_PATH; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.OIDC_DISCOVERY_ISSUER; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.WELL_KNOWN_OPENID_CONFIGURATION_PATH; + +/** + * Local-dev helpers for Kubernetes OIDC JWKS URL rewrite and kubeconfig credentials. + */ +@Slf4j +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class LocalDevKubernetesOidc { + + /** @see LocalDevConstants#DEFAULT_KUBERNETES_ISSUER */ + public static final String DEFAULT_KUBERNETES_ISSUER = LocalDevConstants.DEFAULT_KUBERNETES_ISSUER; + /** @see LocalDevConstants#JWKS_PATH */ + public static final String JWKS_PATH = LocalDevConstants.JWKS_PATH; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final Object LOCK = new Object(); + + private static volatile KubeConfigCredentials cachedCredentials; + private static volatile HttpClient cachedHttpClient; + + public static boolean isKubernetesIssuer(String issuerOrUrl) { + if (StringUtils.isBlank(issuerOrUrl)) { + return false; + } + String normalized = issuerOrUrl.toLowerCase(Locale.ROOT); + return normalized.contains("kubernetes.default.svc") + || normalized.contains("kubernetes.default"); + } + + public static String apiServerUrl() { + return credentials().getServerUrl(); + } + + public static String userToken() { + return credentials().getUserToken(); + } + + public static String jwksUrl() { + return apiServerUrl() + JWKS_PATH; + } + + /** + * Kubernetes API OIDC discovery and JWKS are served without authentication. + */ + public static boolean isPublicOidcEndpoint(String url) { + if (StringUtils.isBlank(url)) { + return false; + } + try { + String path = URI.create(url).getRawPath(); + if (StringUtils.isBlank(path)) { + return false; + } + return path.endsWith(WELL_KNOWN_OPENID_CONFIGURATION_PATH) + || path.endsWith(JWKS_PATH) + || path.contains("/openid/v1/jwks"); + } catch (IllegalArgumentException e) { + return url.contains(WELL_KNOWN_OPENID_CONFIGURATION_PATH) || url.contains(JWKS_PATH); + } + } + + /** + * Resolves the Kubernetes token issuer claim from OIDC discovery when a projected SA token is unavailable. + */ + public static String resolveIssuerClaimFromDiscovery() { + String discoveryUrl = apiServerUrl() + WELL_KNOWN_OPENID_CONFIGURATION_PATH; + try { + JsonNode discovery = MAPPER.readTree(get(discoveryUrl)); + String issuer = discovery.path(OIDC_DISCOVERY_ISSUER).asText(null); + if (StringUtils.isNotBlank(issuer)) { + return issuer; + } + } catch (Exception e) { + log.warn("Failed to resolve Kubernetes issuer from discovery at {} in local-dev, using default {}", + discoveryUrl, DEFAULT_KUBERNETES_ISSUER, e); + } + return DEFAULT_KUBERNETES_ISSUER; + } + + private static String get(String url) throws Exception { + return getWithRetry(url, true); + } + + private static String getWithRetry(String url, boolean retryOnIo) throws Exception { + try { + return sendGet(url); + } catch (IOException e) { + if (retryOnIo) { + log.debug("Retrying Kubernetes OIDC request after I/O failure for {}", url, e); + resetHttpClient(); + return sendGet(url); + } + throw e; + } + } + + private static String sendGet(String url) throws Exception { + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(HTTP_REQUEST_TIMEOUT) + .header(ACCEPT_HEADER, APPLICATION_JSON) + .GET(); + if (!isPublicOidcEndpoint(url)) { + requestBuilder.header(AUTHORIZATION_HEADER, BEARER_PREFIX + userToken()); + } + HttpRequest request = requestBuilder.build(); + // HttpClient is cached and reused (connection pool); not closed per request (see httpClient()). + HttpResponse response = httpClient().send(request, HttpResponse.BodyHandlers.ofString()); + LocalDevHttpUtils.ensureSuccessful(response, "Local-dev Kubernetes OIDC request for " + url); + return response.body(); + } + + private static KubeConfigCredentials credentials() { + KubeConfigCredentials existing = cachedCredentials; + if (existing != null) { + return existing; + } + synchronized (LOCK) { + if (cachedCredentials == null) { + cachedCredentials = KubeConfigLoader.load(); + log.info("Local-dev kubeconfig: API server {}", cachedCredentials.getServerUrl()); + } + return cachedCredentials; + } + } + + /** + * Returns a process-wide cached {@link HttpClient} (TLS from kubeconfig). + * Not used in try-with-resources: the client is long-lived and shared across OIDC calls. + */ + private static HttpClient httpClient() { + HttpClient existing = cachedHttpClient; + if (existing != null) { + return existing; + } + synchronized (LOCK) { + if (cachedHttpClient == null) { + cachedHttpClient = KubeConfigHttpClientFactory.create(credentials()); + } + return cachedHttpClient; + } + } + + @VisibleForTesting + static void resetCache() { + synchronized (LOCK) { + cachedCredentials = null; + cachedHttpClient = null; + } + } + + private static void resetHttpClient() { + synchronized (LOCK) { + cachedHttpClient = null; + } + } +} diff --git a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevMode.java b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevMode.java new file mode 100644 index 000000000..4bbe536ff --- /dev/null +++ b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevMode.java @@ -0,0 +1,83 @@ +package com.netcracker.cloud.security.core.utils.k8s.localdev; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.apache.commons.lang3.StringUtils; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class LocalDevMode { + + public static final String ENABLED_PROPERTY = "security.local-dev.enabled"; + public static final String ENABLED_ENV = "SECURITY_LOCAL_DEV_ENABLED"; + + public static final String MICROSERVICE_NAME_PROPERTY = "cloud.microservice.name"; + public static final String MICROSERVICE_NAME_ENV = "CLOUD_MICROSERVICE_NAME"; + + public static final String NAMESPACE_ENV = "CLOUD_NAMESPACE"; + + public static final String QUARKUS_PROFILE_PROPERTY = "quarkus.profile"; + public static final String QUARKUS_PROFILE_ENV = "QUARKUS_PROFILE"; + + public static final String SPRING_PROFILES_ACTIVE_PROPERTY = "spring.profiles.active"; + public static final String SPRING_PROFILES_ACTIVE_ENV = "SPRING_PROFILES_ACTIVE"; + + public static final String DEV_PROFILE = "dev"; + + public static boolean isEnabled() { + if (isTrue(firstNonBlank(System.getProperty(ENABLED_PROPERTY), System.getenv(ENABLED_ENV)))) { + return true; + } + if (DEV_PROFILE.equalsIgnoreCase(firstNonBlank( + System.getProperty(QUARKUS_PROFILE_PROPERTY), + System.getenv(QUARKUS_PROFILE_ENV)))) { + return true; + } + return containsProfile(firstNonBlank( + System.getProperty(SPRING_PROFILES_ACTIVE_PROPERTY), + System.getenv(SPRING_PROFILES_ACTIVE_ENV)), DEV_PROFILE); + } + + public static String requireMicroserviceName() { + String name = firstNonBlank( + System.getProperty(MICROSERVICE_NAME_PROPERTY), + System.getenv(MICROSERVICE_NAME_ENV)); + if (StringUtils.isBlank(name)) { + throw new IllegalStateException( + "Local-dev M2M requires '" + MICROSERVICE_NAME_PROPERTY + + "' (system property or " + MICROSERVICE_NAME_ENV + + " env). Set it in application config and ensure framework bootstrap runs, " + + "or pass -D" + MICROSERVICE_NAME_PROPERTY + "=."); + } + return name.trim(); + } + + public static String requireNamespace() { + String namespace = System.getenv(NAMESPACE_ENV); + if (StringUtils.isBlank(namespace)) { + throw new IllegalStateException( + "Local-dev M2M requires env '" + NAMESPACE_ENV + + "' with the Kubernetes namespace of the service account."); + } + return namespace.trim(); + } + + private static boolean containsProfile(String profiles, String expected) { + if (StringUtils.isBlank(profiles)) { + return false; + } + for (String profile : profiles.split(",")) { + if (expected.equalsIgnoreCase(profile.trim())) { + return true; + } + } + return false; + } + + private static boolean isTrue(String value) { + return "true".equalsIgnoreCase(value) || "1".equals(value); + } + + private static String firstNonBlank(String first, String second) { + return LocalDevJsonUtils.firstNonBlank(first, second); + } +} diff --git a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevTokenSource.java b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevTokenSource.java new file mode 100644 index 000000000..eb9959f05 --- /dev/null +++ b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevTokenSource.java @@ -0,0 +1,91 @@ +package com.netcracker.cloud.security.core.utils.k8s.localdev; + +import com.netcracker.cloud.security.core.utils.k8s.Priority; +import com.netcracker.cloud.security.core.utils.k8s.TokenSource; +import com.netcracker.cloud.security.core.utils.k8s.impl.CachingTokenSource; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.VisibleForTesting; + +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.function.Supplier; + +@Slf4j +@Priority(100) +public class LocalDevTokenSource implements TokenSource { + + private static final Duration EXPIRY_SKEW = Duration.ofMinutes(5); + + private final TokenSource fallback; + private final Supplier clientSupplier; + private final ConcurrentMap cache = new ConcurrentHashMap<>(); + + private volatile TokenRequestClient client; + + public LocalDevTokenSource() { + this(new CachingTokenSource(), () -> new TokenRequestClient(KubeConfigLoader.load())); + } + + @VisibleForTesting + LocalDevTokenSource(TokenSource fallback, Supplier clientSupplier) { + this.fallback = Objects.requireNonNull(fallback, "fallback"); + this.clientSupplier = Objects.requireNonNull(clientSupplier, "clientSupplier"); + } + + @Override + public String getToken(String audience) { + if (!LocalDevMode.isEnabled()) { + return fallback.getToken(audience); + } + Objects.requireNonNull(audience, "audience"); + CachedToken cached = cache.get(audience); + if (cached != null && cached.isValid()) { + return cached.token(); + } + synchronized (this) { + cached = cache.get(audience); + if (cached != null && cached.isValid()) { + return cached.token(); + } + TokenRequestClient.TokenRequestResult result = request(audience); + cache.put(audience, new CachedToken(result.token(), result.expiresAt().minus(EXPIRY_SKEW))); + return result.token(); + } + } + + private TokenRequestClient.TokenRequestResult request(String audience) { + String namespace = LocalDevMode.requireNamespace(); + String serviceAccount = LocalDevMode.requireMicroserviceName(); + log.info("Local-dev TokenSource active: requesting token for audience={}, sa={}, namespace={}", + audience, serviceAccount, namespace); + return client().requestToken(namespace, serviceAccount, audience); + } + + private TokenRequestClient client() { + TokenRequestClient existing = client; + if (existing != null) { + return existing; + } + synchronized (this) { + if (client == null) { + client = clientSupplier.get(); + } + return client; + } + } + + @Override + public void close() throws Exception { + cache.clear(); + fallback.close(); + } + + private record CachedToken(String token, Instant refreshAfter) { + boolean isValid() { + return Instant.now().isBefore(refreshAfter); + } + } +} diff --git a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/OidcAuthProviderTokenRefresher.java b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/OidcAuthProviderTokenRefresher.java new file mode 100644 index 000000000..391163f30 --- /dev/null +++ b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/OidcAuthProviderTokenRefresher.java @@ -0,0 +1,218 @@ +package com.netcracker.cloud.security.core.utils.k8s.localdev; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Objects; + +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.ACCEPT_HEADER; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.APPLICATION_FORM_URLENCODED; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.APPLICATION_JSON; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.CONTENT_TYPE_HEADER; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.OIDC_DISCOVERY_TOKEN_ENDPOINT; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.OIDC_FORM_CLIENT_ID; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.OIDC_FORM_CLIENT_SECRET; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.OIDC_FORM_GRANT_TYPE; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.OIDC_GRANT_REFRESH_TOKEN; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.OIDC_TOKEN_ACCESS_TOKEN; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.OIDC_TOKEN_ID_TOKEN; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.WELL_KNOWN_OPENID_CONFIGURATION_PATH; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.KubeConfigFields.CLIENT_ID; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.KubeConfigFields.CLIENT_SECRET; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.KubeConfigFields.IDP_ISSUER_URL; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.KubeConfigFields.ID_TOKEN; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.KubeConfigFields.ACCESS_TOKEN; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.HTTP_REQUEST_TIMEOUT; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.KubeConfigFields.REFRESH_TOKEN; + +@Slf4j +@NoArgsConstructor(access = AccessLevel.PRIVATE) +final class OidcAuthProviderTokenRefresher { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final Duration EXPIRY_SKEW = Duration.ofSeconds(60); + private static final String INSECURE_IDP_TLS_PROPERTY = "security.local-dev.insecure-idp-tls"; + private static final String INSECURE_IDP_TLS_ENV = "SECURITY_LOCAL_DEV_INSECURE_IDP_TLS"; + + static String resolveToken(JsonNode authProviderConfig) { + return resolveToken(authProviderConfig, createHttpClient()); + } + + // visible for tests + static String resolveToken(JsonNode authProviderConfig, HttpClient httpClient) { + Objects.requireNonNull(authProviderConfig, "authProviderConfig"); + Objects.requireNonNull(httpClient, "httpClient"); + + String cachedIdToken = LocalDevJsonUtils.firstNonBlank( + LocalDevJsonUtils.getTextField(authProviderConfig, ID_TOKEN), + LocalDevJsonUtils.getTextField(authProviderConfig, ACCESS_TOKEN)); + if (StringUtils.isNotBlank(cachedIdToken) && !isExpired(cachedIdToken)) { + log.debug("Using non-expired OIDC id-token from kubeconfig auth-provider"); + return cachedIdToken; + } + + String issuerUrl = LocalDevJsonUtils.getTextField(authProviderConfig, IDP_ISSUER_URL); + String refreshToken = LocalDevJsonUtils.getTextField(authProviderConfig, REFRESH_TOKEN); + String clientId = LocalDevJsonUtils.getTextField(authProviderConfig, CLIENT_ID); + String clientSecret = LocalDevJsonUtils.getTextField(authProviderConfig, CLIENT_SECRET); + + if (StringUtils.isAnyBlank(issuerUrl, refreshToken, clientId)) { + if (StringUtils.isNotBlank(cachedIdToken)) { + log.warn("OIDC auth-provider id-token is expired/missing refresh fields; " + + "falling back to cached token (TokenRequest may fail with 401)"); + return cachedIdToken; + } + return null; + } + + try { + log.info("Refreshing OIDC kubeconfig token via idp-issuer-url={}", issuerUrl); + String tokenEndpoint = discoverTokenEndpoint(httpClient, issuerUrl); + return refreshIdToken(httpClient, tokenEndpoint, clientId, clientSecret, refreshToken); + } catch (RuntimeException e) { + if (StringUtils.isNotBlank(cachedIdToken)) { + log.warn("OIDC token refresh failed; falling back to cached id-token from kubeconfig. " + + "If TokenRequest fails with 401, refresh kubeconfig (kubectl login) or import IdP CA into JVM trust store.", e); + return cachedIdToken; + } + throw e; + } + } + + private static HttpClient createHttpClient() { + if (LocalDevMode.isEnabled() && isInsecureIdpTlsEnabled()) { + return KubeConfigHttpClientFactory.createInsecureForLocalDev(); + } + return HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .connectTimeout(HTTP_REQUEST_TIMEOUT) + .build(); + } + + private static boolean isInsecureIdpTlsEnabled() { + String configured = LocalDevJsonUtils.firstNonBlank( + System.getProperty(INSECURE_IDP_TLS_PROPERTY), + System.getenv(INSECURE_IDP_TLS_ENV)); + if (StringUtils.isBlank(configured)) { + return true; + } + return !"false".equalsIgnoreCase(configured) && !"0".equals(configured); + } + + private static String discoverTokenEndpoint(HttpClient httpClient, String issuerUrl) { + String discoveryUrl = StringUtils.stripEnd(issuerUrl, "/") + WELL_KNOWN_OPENID_CONFIGURATION_PATH; + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(discoveryUrl)) + .timeout(HTTP_REQUEST_TIMEOUT) + .header(ACCEPT_HEADER, APPLICATION_JSON) + .GET() + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + LocalDevHttpUtils.ensureSuccessful(response, "OIDC discovery for " + discoveryUrl); + + String tokenEndpoint = LocalDevJsonUtils.getTextField( + MAPPER.readTree(response.body()), OIDC_DISCOVERY_TOKEN_ENDPOINT); + if (StringUtils.isBlank(tokenEndpoint)) { + throw new IllegalStateException("OIDC discovery response has no token_endpoint: " + discoveryUrl); + } + return tokenEndpoint; + } catch (IllegalStateException e) { + throw e; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("OIDC discovery interrupted: " + discoveryUrl, e); + } catch (Exception e) { + throw new IllegalStateException("OIDC discovery failed for " + discoveryUrl, e); + } + } + + private static String refreshIdToken(HttpClient httpClient, + String tokenEndpoint, + String clientId, + String clientSecret, + String refreshToken) { + try { + StringBuilder form = new StringBuilder(); + appendForm(form, OIDC_FORM_GRANT_TYPE, OIDC_GRANT_REFRESH_TOKEN); + appendForm(form, OIDC_GRANT_REFRESH_TOKEN, refreshToken); + appendForm(form, OIDC_FORM_CLIENT_ID, clientId); + if (StringUtils.isNotBlank(clientSecret)) { + appendForm(form, OIDC_FORM_CLIENT_SECRET, clientSecret); + } + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(tokenEndpoint)) + .timeout(HTTP_REQUEST_TIMEOUT) + .header(CONTENT_TYPE_HEADER, APPLICATION_FORM_URLENCODED) + .header(ACCEPT_HEADER, APPLICATION_JSON) + .POST(HttpRequest.BodyPublishers.ofString(form.toString())) + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + LocalDevHttpUtils.ensureSuccessful(response, "OIDC refresh_token grant for " + tokenEndpoint); + + JsonNode body = MAPPER.readTree(response.body()); + String token = LocalDevJsonUtils.firstNonBlank( + LocalDevJsonUtils.getTextField(body, OIDC_TOKEN_ID_TOKEN), + LocalDevJsonUtils.getTextField(body, OIDC_TOKEN_ACCESS_TOKEN)); + if (StringUtils.isBlank(token)) { + throw new IllegalStateException( + "OIDC token response has neither id_token nor access_token: " + tokenEndpoint); + } + return token; + } catch (IllegalStateException e) { + throw e; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("OIDC refresh_token grant interrupted: " + tokenEndpoint, e); + } catch (Exception e) { + throw new IllegalStateException("OIDC refresh_token grant failed for " + tokenEndpoint, e); + } + } + + /** + * Checks JWT {@code exp} by decoding the payload segment only (no signature verification). + * jose4j is on the classpath but a minimal parse avoids full JWT validation for a kubeconfig cache hint. + */ + private static boolean isExpired(String jwt) { + try { + String[] parts = jwt.split("\\."); + if (parts.length < 2) { + return true; + } + byte[] payload = Base64.getUrlDecoder().decode(LocalDevJsonUtils.padBase64Url(parts[1])); + JsonNode claims = MAPPER.readTree(payload); + JsonNode expNode = claims.path("exp"); + if (!expNode.isNumber()) { + return true; + } + Instant exp = Instant.ofEpochSecond(expNode.asLong()); + return Instant.now().plus(EXPIRY_SKEW).isAfter(exp); + } catch (Exception e) { + log.debug("Failed to parse JWT exp from kubeconfig OIDC token, treating as expired: {}", e.toString()); + return true; + } + } + + private static void appendForm(StringBuilder form, String key, String value) { + if (!form.isEmpty()) { + form.append('&'); + } + form.append(URLEncoder.encode(key, StandardCharsets.UTF_8)) + .append('=') + .append(URLEncoder.encode(value, StandardCharsets.UTF_8)); + } +} diff --git a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/TokenRequestClient.java b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/TokenRequestClient.java new file mode 100644 index 000000000..2f8f99201 --- /dev/null +++ b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/TokenRequestClient.java @@ -0,0 +1,131 @@ +package com.netcracker.cloud.security.core.utils.k8s.localdev; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.VisibleForTesting; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Instant; + +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.ACCEPT_HEADER; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.APPLICATION_JSON; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.AUTHORIZATION_HEADER; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.BEARER_PREFIX; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.CONTENT_TYPE_HEADER; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.HTTP_REQUEST_TIMEOUT; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.K8S_TOKEN_STATUS_EXPIRATION; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.K8S_TOKEN_STATUS_TOKEN; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.TOKEN_REQUEST_API_VERSION; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.TOKEN_REQUEST_EXPIRATION_SECONDS; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.TOKEN_REQUEST_KIND; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.TOKEN_REQUEST_SPEC_AUDIENCES; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.TOKEN_REQUEST_SPEC_EXPIRATION_SECONDS; +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.KubeConfigFields.STATUS; + +@Slf4j +public class TokenRequestClient { + + /** @see LocalDevConstants#TOKEN_REQUEST_EXPIRATION_SECONDS */ + public static final long DEFAULT_EXPIRATION_SECONDS = LocalDevConstants.TOKEN_REQUEST_EXPIRATION_SECONDS; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final HttpClient httpClient; + private final String serverUrl; + private final String userToken; + + public TokenRequestClient(KubeConfigCredentials credentials) { + this(credentials.getServerUrl(), credentials.getUserToken(), KubeConfigHttpClientFactory.create(credentials)); + } + + @VisibleForTesting + TokenRequestClient(String serverUrl, String userToken, HttpClient httpClient) { + this.serverUrl = StringUtils.stripEnd(serverUrl, "/"); + this.userToken = userToken; + this.httpClient = httpClient; + } + + public TokenRequestResult requestToken(String namespace, String serviceAccountName, String audience) { + String url = serverUrl + "/api/v1/namespaces/" + namespace + + "/serviceaccounts/" + serviceAccountName + "/token"; + try { + String body = buildRequestBody(audience); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(HTTP_REQUEST_TIMEOUT) + .header(AUTHORIZATION_HEADER, BEARER_PREFIX + userToken) + .header(CONTENT_TYPE_HEADER, APPLICATION_JSON) + .header(ACCEPT_HEADER, APPLICATION_JSON) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + + log.info("Requesting local-dev SA token: namespace={}, sa={}, audience={}, ttl={}s", + namespace, serviceAccountName, audience, TOKEN_REQUEST_EXPIRATION_SECONDS); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + handleTokenRequestResponse(response, serviceAccountName, namespace); + + JsonNode root = MAPPER.readTree(response.body()); + String token = root.path(STATUS).path(K8S_TOKEN_STATUS_TOKEN).asText(null); + if (StringUtils.isBlank(token)) { + throw new IllegalStateException("TokenRequest response has no status.token"); + } + Instant expiresAt = parseExpiration(root.path(STATUS).path(K8S_TOKEN_STATUS_EXPIRATION).asText(null)); + return new TokenRequestResult(token, expiresAt); + } catch (IllegalStateException e) { + throw e; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Local-dev TokenRequest interrupted", e); + } catch (Exception e) { + throw new IllegalStateException("Local-dev TokenRequest failed for SA '" + + serviceAccountName + "' in namespace '" + namespace + "'", e); + } + } + + private static void handleTokenRequestResponse(HttpResponse response, + String serviceAccountName, + String namespace) { + int status = response.statusCode(); + if (LocalDevHttpUtils.isUnauthorized(status)) { + throw new IllegalStateException( + "Local-dev TokenRequest unauthorized (HTTP " + status + ") for SA '" + + serviceAccountName + "' in namespace '" + namespace + + "'. Check RBAC: permission to create serviceaccounts/token. Response: " + + LocalDevJsonUtils.truncateResponseBody(response.body())); + } + if (LocalDevHttpUtils.isFailed(status)) { + throw new IllegalStateException( + "Local-dev TokenRequest failed (HTTP " + status + ") for SA '" + + serviceAccountName + "' in namespace '" + namespace + + "'. Response: " + LocalDevJsonUtils.truncateResponseBody(response.body())); + } + } + + private static String buildRequestBody(String audience) throws Exception { + ObjectNode root = MAPPER.createObjectNode(); + root.put("apiVersion", TOKEN_REQUEST_API_VERSION); + root.put("kind", TOKEN_REQUEST_KIND); + ObjectNode spec = root.putObject("spec"); + ArrayNode audiences = spec.putArray(TOKEN_REQUEST_SPEC_AUDIENCES); + audiences.add(audience); + spec.put(TOKEN_REQUEST_SPEC_EXPIRATION_SECONDS, TOKEN_REQUEST_EXPIRATION_SECONDS); + return MAPPER.writeValueAsString(root); + } + + private static Instant parseExpiration(String expirationTimestamp) { + if (StringUtils.isNotBlank(expirationTimestamp)) { + return Instant.parse(expirationTimestamp); + } + return Instant.now().plusSeconds(TOKEN_REQUEST_EXPIRATION_SECONDS); + } + + public record TokenRequestResult(String token, Instant expiresAt) { + } +} diff --git a/core-utils/k8s/src/main/resources/META-INF/services/com.netcracker.cloud.security.core.utils.k8s.TokenSource b/core-utils/k8s/src/main/resources/META-INF/services/com.netcracker.cloud.security.core.utils.k8s.TokenSource index 34b1e0eae..673a54bc2 100644 --- a/core-utils/k8s/src/main/resources/META-INF/services/com.netcracker.cloud.security.core.utils.k8s.TokenSource +++ b/core-utils/k8s/src/main/resources/META-INF/services/com.netcracker.cloud.security.core.utils.k8s.TokenSource @@ -1 +1,2 @@ +com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevTokenSource com.netcracker.cloud.security.core.utils.k8s.impl.CachingTokenSource diff --git a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/TestTokenSourceHighPriorityImpl.java b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/TestTokenSourceHighPriorityImpl.java index bc30dc0f1..ecb6d744c 100644 --- a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/TestTokenSourceHighPriorityImpl.java +++ b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/TestTokenSourceHighPriorityImpl.java @@ -1,6 +1,6 @@ package com.netcracker.cloud.security.core.utils.k8s; -@Priority(10) +@Priority(1000) public class TestTokenSourceHighPriorityImpl implements TokenSource { @Override public String getToken(String audience) { diff --git a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigLoaderTest.java b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigLoaderTest.java new file mode 100644 index 000000000..53f383f81 --- /dev/null +++ b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigLoaderTest.java @@ -0,0 +1,96 @@ +package com.netcracker.cloud.security.core.utils.k8s.localdev; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import uk.org.webcompere.systemstubs.environment.EnvironmentVariables; +import uk.org.webcompere.systemstubs.jupiter.SystemStub; +import uk.org.webcompere.systemstubs.jupiter.SystemStubsExtension; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@ExtendWith(SystemStubsExtension.class) +class KubeConfigLoaderTest { + + @SystemStub + private EnvironmentVariables environmentVariables; + + @TempDir + Path tempDir; + + @Test + void loadsTokenAndCaFromKubeConfig() throws Exception { + String ca = Base64.getEncoder().encodeToString("dummy-ca".getBytes(StandardCharsets.UTF_8)); + Path kubeConfig = tempDir.resolve("config"); + Files.writeString(kubeConfig, """ + apiVersion: v1 + kind: Config + current-context: test-ctx + contexts: + - name: test-ctx + context: + cluster: test-cluster + user: test-user + clusters: + - name: test-cluster + cluster: + server: https://127.0.0.1:6443 + certificate-authority-data: %s + users: + - name: test-user + user: + token: user-token-123 + """.formatted(ca)); + + environmentVariables.set("KUBECONFIG", kubeConfig.toString()); + + KubeConfigCredentials credentials = KubeConfigLoader.load(); + assertEquals("https://127.0.0.1:6443", credentials.getServerUrl()); + assertEquals("user-token-123", credentials.getUserToken()); + assertNotNull(credentials.getCertificateAuthorityData()); + assertFalse(credentials.isInsecureSkipTlsVerify()); + } + + @Test + void loadsIdTokenFromOidcAuthProviderWhenTokenNotExpiredJwt() throws Exception { + // non-JWT cached token cannot be parsed for exp → treated as expired, + // but without refresh-token fields loader falls back to cached id-token + Path kubeConfig = tempDir.resolve("config"); + Files.writeString(kubeConfig, """ + apiVersion: v1 + kind: Config + current-context: test-ctx + contexts: + - name: test-ctx + context: + cluster: test-cluster + user: test-user + clusters: + - name: test-cluster + cluster: + server: https://127.0.0.1:6443 + insecure-skip-tls-verify: true + users: + - name: test-user + user: + auth-provider: + name: oidc + config: + client-id: oauth-client + client-secret: oauth-secret + id-token: oidc-id-token-123 + """); + + environmentVariables.set("KUBECONFIG", kubeConfig.toString()); + + KubeConfigCredentials credentials = KubeConfigLoader.load(); + assertEquals("oidc-id-token-123", credentials.getUserToken()); + } +} diff --git a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevKubernetesOidcTest.java b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevKubernetesOidcTest.java new file mode 100644 index 000000000..8ce6ef2c0 --- /dev/null +++ b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevKubernetesOidcTest.java @@ -0,0 +1,111 @@ +package com.netcracker.cloud.security.core.utils.k8s.localdev; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import uk.org.webcompere.systemstubs.environment.EnvironmentVariables; +import uk.org.webcompere.systemstubs.jupiter.SystemStub; +import uk.org.webcompere.systemstubs.jupiter.SystemStubsExtension; +import uk.org.webcompere.systemstubs.properties.SystemProperties; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@ExtendWith(SystemStubsExtension.class) +class LocalDevKubernetesOidcTest { + + @SystemStub + private SystemProperties systemProperties; + + @SystemStub + private EnvironmentVariables environmentVariables; + + @TempDir + Path tempDir; + + private HttpServer server; + private String baseUrl; + private final AtomicReference lastAuth = new AtomicReference<>(); + + @BeforeEach + void setUp() throws IOException { + LocalDevKubernetesOidc.resetCache(); + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/.well-known/openid-configuration", exchange -> { + lastAuth.set(exchange.getRequestHeaders().getFirst("Authorization")); + byte[] body = """ + {"issuer":"https://kubernetes.default.svc","jwks_uri":"https://kubernetes.default.svc/openid/v1/jwks"} + """.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + }); + server.start(); + baseUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + } + + @AfterEach + void tearDown() { + server.stop(0); + LocalDevKubernetesOidc.resetCache(); + System.clearProperty(LocalDevMode.ENABLED_PROPERTY); + } + + @Test + void resolveIssuerFromDiscoveryWithoutAuth() throws Exception { + Path kubeConfig = tempDir.resolve("config"); + Files.writeString(kubeConfig, """ + apiVersion: v1 + kind: Config + current-context: test-ctx + contexts: + - name: test-ctx + context: + cluster: test-cluster + user: test-user + clusters: + - name: test-cluster + cluster: + server: %s + insecure-skip-tls-verify: true + users: + - name: test-user + user: + token: kube-user-token + """.formatted(baseUrl)); + environmentVariables.set("KUBECONFIG", kubeConfig.toString()); + systemProperties.set(LocalDevMode.ENABLED_PROPERTY, "true"); + + assertEquals("https://kubernetes.default.svc", LocalDevKubernetesOidc.resolveIssuerClaimFromDiscovery()); + assertEquals(null, lastAuth.get()); + assertEquals(baseUrl + LocalDevKubernetesOidc.JWKS_PATH, LocalDevKubernetesOidc.jwksUrl()); + assertEquals("kube-user-token", LocalDevKubernetesOidc.userToken()); + } + + @Test + void isPublicOidcEndpointDetectsDiscoveryAndJwks() { + assertTrue(LocalDevKubernetesOidc.isPublicOidcEndpoint("https://api.example:6443/.well-known/openid-configuration")); + assertTrue(LocalDevKubernetesOidc.isPublicOidcEndpoint("https://api.example:6443/openid/v1/jwks")); + assertFalse(LocalDevKubernetesOidc.isPublicOidcEndpoint("https://api.example:6443/api/v1/namespaces/default")); + } + + @Test + void isKubernetesIssuerDetectsDefaultHosts() { + assertTrue(LocalDevKubernetesOidc.isKubernetesIssuer("https://kubernetes.default.svc")); + assertTrue(LocalDevKubernetesOidc.isKubernetesIssuer("https://kubernetes.default.svc.cluster.local/openid/v1/jwks")); + } +} diff --git a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevModeTest.java b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevModeTest.java new file mode 100644 index 000000000..8bf81908a --- /dev/null +++ b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevModeTest.java @@ -0,0 +1,77 @@ +package com.netcracker.cloud.security.core.utils.k8s.localdev; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import uk.org.webcompere.systemstubs.environment.EnvironmentVariables; +import uk.org.webcompere.systemstubs.jupiter.SystemStub; +import uk.org.webcompere.systemstubs.jupiter.SystemStubsExtension; +import uk.org.webcompere.systemstubs.properties.SystemProperties; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@ExtendWith(SystemStubsExtension.class) +class LocalDevModeTest { + + @SystemStub + private SystemProperties systemProperties; + + @SystemStub + private EnvironmentVariables environmentVariables; + + @AfterEach + void clear() { + System.clearProperty(LocalDevMode.ENABLED_PROPERTY); + System.clearProperty(LocalDevMode.QUARKUS_PROFILE_PROPERTY); + System.clearProperty(LocalDevMode.SPRING_PROFILES_ACTIVE_PROPERTY); + System.clearProperty(LocalDevMode.MICROSERVICE_NAME_PROPERTY); + } + + @Test + void disabledByDefault() { + assertFalse(LocalDevMode.isEnabled()); + } + + @Test + void enabledByExplicitProperty() { + systemProperties.set(LocalDevMode.ENABLED_PROPERTY, "true"); + assertTrue(LocalDevMode.isEnabled()); + } + + @Test + void enabledByQuarkusProfile() { + systemProperties.set(LocalDevMode.QUARKUS_PROFILE_PROPERTY, "dev"); + assertTrue(LocalDevMode.isEnabled()); + } + + @Test + void enabledBySpringProfilesActive() { + systemProperties.set(LocalDevMode.SPRING_PROFILES_ACTIVE_PROPERTY, "local,dev"); + assertTrue(LocalDevMode.isEnabled()); + } + + @Test + void requireMicroserviceNameFromProperty() { + systemProperties.set(LocalDevMode.MICROSERVICE_NAME_PROPERTY, "my-service"); + assertEquals("my-service", LocalDevMode.requireMicroserviceName()); + } + + @Test + void requireMicroserviceNameFailsWhenMissing() { + assertThrows(IllegalStateException.class, LocalDevMode::requireMicroserviceName); + } + + @Test + void requireNamespaceFromEnv() throws Exception { + environmentVariables.set(LocalDevMode.NAMESPACE_ENV, "my-ns"); + assertEquals("my-ns", LocalDevMode.requireNamespace()); + } + + @Test + void requireNamespaceFailsWhenMissing() { + assertThrows(IllegalStateException.class, LocalDevMode::requireNamespace); + } +} diff --git a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevTokenSourceTest.java b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevTokenSourceTest.java new file mode 100644 index 000000000..647f3a17b --- /dev/null +++ b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevTokenSourceTest.java @@ -0,0 +1,74 @@ +package com.netcracker.cloud.security.core.utils.k8s.localdev; + +import com.netcracker.cloud.security.core.utils.k8s.TokenSource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import uk.org.webcompere.systemstubs.environment.EnvironmentVariables; +import uk.org.webcompere.systemstubs.jupiter.SystemStub; +import uk.org.webcompere.systemstubs.jupiter.SystemStubsExtension; +import uk.org.webcompere.systemstubs.properties.SystemProperties; + +import java.time.Instant; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(SystemStubsExtension.class) +class LocalDevTokenSourceTest { + + @SystemStub + private SystemProperties systemProperties; + + @SystemStub + private EnvironmentVariables environmentVariables; + + @AfterEach + void clear() { + System.clearProperty(LocalDevMode.ENABLED_PROPERTY); + System.clearProperty(LocalDevMode.MICROSERVICE_NAME_PROPERTY); + System.clearProperty(LocalDevMode.QUARKUS_PROFILE_PROPERTY); + } + + @Test + void delegatesToFallbackWhenDisabled() throws Exception { + TokenSource fallback = mock(TokenSource.class); + when(fallback.getToken("netcracker")).thenReturn("file-token"); + TokenRequestClient client = mock(TokenRequestClient.class); + + try (LocalDevTokenSource source = new LocalDevTokenSource(fallback, () -> client)) { + assertEquals("file-token", source.getToken("netcracker")); + } + verify(fallback).getToken("netcracker"); + } + + @Test + void requestsAndCachesTokenWhenEnabled() throws Exception { + systemProperties.set(LocalDevMode.ENABLED_PROPERTY, "true"); + systemProperties.set(LocalDevMode.MICROSERVICE_NAME_PROPERTY, "my-sa"); + environmentVariables.set(LocalDevMode.NAMESPACE_ENV, "my-ns"); + + TokenSource fallback = mock(TokenSource.class); + TokenRequestClient client = mock(TokenRequestClient.class); + when(client.requestToken(eq("my-ns"), eq("my-sa"), eq("netcracker"))) + .thenReturn(new TokenRequestClient.TokenRequestResult( + "minted", Instant.now().plusSeconds(3600))); + + AtomicInteger supplierCalls = new AtomicInteger(); + try (LocalDevTokenSource source = new LocalDevTokenSource(fallback, () -> { + supplierCalls.incrementAndGet(); + return client; + })) { + assertEquals("minted", source.getToken("netcracker")); + assertEquals("minted", source.getToken("netcracker")); + } + + verify(client, times(1)).requestToken(eq("my-ns"), eq("my-sa"), eq("netcracker")); + assertEquals(1, supplierCalls.get()); + } +} diff --git a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/OidcAuthProviderTokenRefresherTest.java b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/OidcAuthProviderTokenRefresherTest.java new file mode 100644 index 000000000..b6d28df4c --- /dev/null +++ b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/OidcAuthProviderTokenRefresherTest.java @@ -0,0 +1,130 @@ +package com.netcracker.cloud.security.core.utils.k8s.localdev; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OidcAuthProviderTokenRefresherTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private MockWebServer server; + private HttpClient httpClient; + + @BeforeEach + void setUp() throws Exception { + server = new MockWebServer(); + server.start(); + httpClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .build(); + } + + @AfterEach + void tearDown() throws Exception { + server.shutdown(); + } + + @Test + void usesCachedIdTokenWhenNotExpired() { + ObjectNode config = MAPPER.createObjectNode(); + config.put("id-token", jwtWithExp(Instant.now().plusSeconds(3600))); + config.put("idp-issuer-url", server.url("/").toString()); + config.put("refresh-token", "refresh"); + config.put("client-id", "kubernetes"); + + String token = OidcAuthProviderTokenRefresher.resolveToken(config, httpClient); + assertEquals(config.get("id-token").asText(), token); + assertEquals(0, server.getRequestCount()); + } + + @Test + void refreshesExpiredIdTokenViaDiscoveryAndRefreshGrant() throws Exception { + String issuer = server.url("/auth/realms/kubernetes").toString().replaceAll("/$", ""); + String tokenEndpoint = server.url("/auth/realms/kubernetes/protocol/openid-connect/token").toString(); + + server.enqueue(new MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("{\"token_endpoint\":\"" + tokenEndpoint + "\"}")); + server.enqueue(new MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("{\"id_token\":\"fresh-id-token\",\"access_token\":\"fresh-access\",\"refresh_token\":\"new-refresh\"}")); + + ObjectNode config = MAPPER.createObjectNode(); + config.put("id-token", jwtWithExp(Instant.now().minusSeconds(3600))); + config.put("idp-issuer-url", issuer); + config.put("refresh-token", "stored-refresh-token"); + config.put("client-id", "kubernetes"); + config.put("client-secret", "secret-value"); + + String token = OidcAuthProviderTokenRefresher.resolveToken(config, httpClient); + assertEquals("fresh-id-token", token); + + RecordedRequest discovery = server.takeRequest(1, TimeUnit.SECONDS); + assertTrue(discovery.getPath().endsWith("/.well-known/openid-configuration")); + + RecordedRequest refresh = server.takeRequest(1, TimeUnit.SECONDS); + assertEquals("POST", refresh.getMethod()); + String body = refresh.getBody().readUtf8(); + assertTrue(body.contains("grant_type=refresh_token")); + assertTrue(body.contains("refresh_token=stored-refresh-token")); + assertTrue(body.contains("client_id=kubernetes")); + assertTrue(body.contains("client_secret=secret-value")); + } + + @Test + void fallsBackToCachedIdTokenWhenRefreshFails() { + ObjectNode config = MAPPER.createObjectNode(); + config.put("id-token", jwtWithExp(Instant.now().minusSeconds(3600))); + config.put("idp-issuer-url", "https://idp.example.com/auth/realms/kubernetes"); + config.put("refresh-token", "refresh"); + config.put("client-id", "kubernetes"); + + String token = OidcAuthProviderTokenRefresher.resolveToken(config, HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .build()); + assertEquals(config.get("id-token").asText(), token); + } + + @Test + void prefersIdTokenFromRefreshResponse() throws Exception { + String issuer = server.url("/realms/kubernetes").toString().replaceAll("/$", ""); + String tokenEndpoint = server.url("/realms/kubernetes/token").toString(); + + server.enqueue(new MockResponse() + .setBody("{\"token_endpoint\":\"" + tokenEndpoint + "\"}")); + server.enqueue(new MockResponse() + .setBody("{\"access_token\":\"only-access\"}")); + + ObjectNode config = MAPPER.createObjectNode(); + config.put("idp-issuer-url", issuer); + config.put("refresh-token", "refresh"); + config.put("client-id", "kubernetes"); + + assertEquals("only-access", OidcAuthProviderTokenRefresher.resolveToken(config, httpClient)); + } + + private static String jwtWithExp(Instant exp) { + String header = Base64.getUrlEncoder().withoutPadding() + .encodeToString("{\"alg\":\"none\"}".getBytes(StandardCharsets.UTF_8)); + String payload = Base64.getUrlEncoder().withoutPadding() + .encodeToString(("{\"exp\":" + exp.getEpochSecond() + "}").getBytes(StandardCharsets.UTF_8)); + return header + "." + payload + ".sig"; + } +} diff --git a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/TokenRequestClientTest.java b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/TokenRequestClientTest.java new file mode 100644 index 000000000..3657f4ab2 --- /dev/null +++ b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/TokenRequestClientTest.java @@ -0,0 +1,81 @@ +package com.netcracker.cloud.security.core.utils.k8s.localdev; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TokenRequestClientTest { + + private HttpServer server; + private String baseUrl; + private final AtomicReference lastBody = new AtomicReference<>(); + private final AtomicReference lastAuth = new AtomicReference<>(); + private int responseStatus = 201; + private String responseBody = """ + { + "status": { + "token": "minted-token", + "expirationTimestamp": "2099-01-01T00:00:00Z" + } + } + """; + + @BeforeEach + void setUp() throws IOException { + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/", exchange -> { + lastAuth.set(exchange.getRequestHeaders().getFirst("Authorization")); + lastBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + byte[] bytes = responseBody.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(responseStatus, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + }); + server.start(); + baseUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + } + + @AfterEach + void tearDown() { + server.stop(0); + } + + @Test + void requestTokenSuccess() { + TokenRequestClient client = new TokenRequestClient(baseUrl, "kube-user-token", HttpClient.newHttpClient()); + TokenRequestClient.TokenRequestResult result = client.requestToken("ns1", "my-sa", "netcracker"); + + assertEquals("minted-token", result.token()); + assertEquals(Instant.parse("2099-01-01T00:00:00Z"), result.expiresAt()); + assertEquals("Bearer kube-user-token", lastAuth.get()); + assertTrue(lastBody.get().contains("\"netcracker\"")); + assertTrue(lastBody.get().contains("28800")); + } + + @Test + void requestTokenUnauthorized() { + responseStatus = 403; + responseBody = "{\"message\":\"forbidden\"}"; + TokenRequestClient client = new TokenRequestClient(baseUrl, "kube-user-token", HttpClient.newHttpClient()); + + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> client.requestToken("ns1", "my-sa", "netcracker")); + assertTrue(ex.getMessage().contains("unauthorized") || ex.getMessage().contains("403")); + assertTrue(ex.getMessage().contains("RBAC")); + } +} From f8a7a8e06b0c62ced2dcf7bfc83bc95842974e6c Mon Sep 17 00:00:00 2001 From: Vitali Butautas Date: Wed, 29 Jul 2026 18:43:28 +0300 Subject: [PATCH 2/4] feat: Local dev implementation --- .../localdev/KubeConfigHttpClientFactory.java | 12 +- .../utils/k8s/localdev/KubeConfigLoader.java | 94 ++-- .../utils/k8s/localdev/LocalDevConstants.java | 3 - .../k8s/localdev/LocalDevKubernetesOidc.java | 65 ++- .../k8s/localdev/LocalDevTokenSource.java | 13 +- .../k8s/localdev/TokenRequestClient.java | 9 +- .../k8s/TestTokenSourceHighPriorityImpl.java | 1 + .../KubeConfigHttpClientFactoryTest.java | 120 +++++ .../k8s/localdev/KubeConfigLoaderTest.java | 422 ++++++++++++++++++ .../k8s/localdev/LocalDevHttpUtilsTest.java | 50 +++ .../k8s/localdev/LocalDevJsonUtilsTest.java | 51 +++ .../localdev/LocalDevKubernetesOidcTest.java | 76 ++++ .../utils/k8s/localdev/LocalDevModeTest.java | 14 +- .../k8s/localdev/LocalDevTokenSourceTest.java | 25 +- .../OidcAuthProviderTokenRefresherTest.java | 125 +++++- .../k8s/localdev/TokenRequestClientTest.java | 49 ++ 16 files changed, 1044 insertions(+), 85 deletions(-) create mode 100644 core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigHttpClientFactoryTest.java create mode 100644 core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevHttpUtilsTest.java create mode 100644 core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevJsonUtilsTest.java diff --git a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigHttpClientFactory.java b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigHttpClientFactory.java index 671247896..fecd92f3f 100644 --- a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigHttpClientFactory.java +++ b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigHttpClientFactory.java @@ -5,7 +5,9 @@ import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; import java.io.ByteArrayInputStream; +import java.io.IOException; import java.net.http.HttpClient; +import java.security.GeneralSecurityException; import java.security.KeyStore; import java.security.SecureRandom; import java.security.cert.Certificate; @@ -42,7 +44,7 @@ private static SSLContext createInsecureSslContext() { SSLContext sslContext = SSLContext.getInstance("TLS"); sslContext.init(null, new TrustManager[]{new InsecureTrustManager()}, new SecureRandom()); return sslContext; - } catch (Exception e) { + } catch (GeneralSecurityException e) { throw new IllegalStateException("Failed to create insecure SSL context for local-dev IdP", e); } } @@ -79,18 +81,24 @@ private static SSLContext createSslContext(KubeConfigCredentials credentials) { SSLContext sslContext = SSLContext.getInstance("TLS"); sslContext.init(null, trustManagerFactory.getTrustManagers(), new SecureRandom()); return sslContext; - } catch (Exception e) { + } catch (GeneralSecurityException | IOException e) { throw new IllegalStateException("Failed to create SSL context from kubeconfig CA", e); } } + /** + * Local-dev only: accepts any server certificate when kubeconfig sets insecure-skip-tls-verify + * or the IdP uses a private CA not in the JVM trust store. + */ private static final class InsecureTrustManager implements X509TrustManager { @Override public void checkClientTrusted(X509Certificate[] chain, String authType) { + // Local-dev: client certificates are not used for TokenRequest / OIDC discovery. } @Override public void checkServerTrusted(X509Certificate[] chain, String authType) { + // Local-dev: kubeconfig insecure-skip-tls-verify or private IdP CA — validation intentionally skipped. } @Override diff --git a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigLoader.java b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigLoader.java index deb5044e2..a2db96c7e 100644 --- a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigLoader.java +++ b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigLoader.java @@ -124,10 +124,29 @@ private static String resolveAuthProviderToken(JsonNode authProvider) { } private static String runExecCredential(JsonNode exec) { + String command = requireExecCommand(exec); + List commandLine = buildExecCommandLine(exec, command); + log.debug("Resolving kubeconfig credentials via exec: {}", commandLine); + try { + String output = runExecProcess(exec, commandLine, command); + return parseExecToken(output); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Kubeconfig exec interrupted", e); + } catch (IOException e) { + throw new IllegalStateException("Failed to run kubeconfig exec command: " + command, e); + } + } + + private static String requireExecCommand(JsonNode exec) { String command = getTextField(exec, KubeConfigFields.COMMAND); if (StringUtils.isBlank(command)) { throw new IllegalStateException("Kubeconfig exec.command is empty"); } + return command; + } + + private static List buildExecCommandLine(JsonNode exec, String command) { List commandLine = new ArrayList<>(); commandLine.add(command); JsonNode args = exec.path(KubeConfigFields.ARGS); @@ -136,45 +155,50 @@ private static String runExecCredential(JsonNode exec) { commandLine.add(arg.asText()); } } + return commandLine; + } - log.debug("Resolving kubeconfig credentials via exec: {}", commandLine); - try { - ProcessBuilder processBuilder = new ProcessBuilder(commandLine); - processBuilder.redirectErrorStream(true); - JsonNode env = exec.path(KubeConfigFields.ENV); - if (env.isArray()) { - for (JsonNode envVar : env) { - String name = getTextField(envVar, KubeConfigFields.NAME); - String value = getTextField(envVar, KubeConfigFields.VALUE); - if (StringUtils.isNotBlank(name)) { - processBuilder.environment().put(name, value == null ? "" : value); - } - } - } - Process process = processBuilder.start(); - boolean finished = process.waitFor(EXEC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - if (!finished) { - process.destroyForcibly(); - throw new IllegalStateException("Kubeconfig exec timed out after " + EXEC_TIMEOUT_SECONDS + "s: " + command); - } - String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); - if (process.exitValue() != 0) { - throw new IllegalStateException("Kubeconfig exec failed (exit " + process.exitValue() + "): " + output); - } - JsonNode credential = JSON_MAPPER.readTree(output); - String token = getTextField(credential.path(KubeConfigFields.STATUS), KubeConfigFields.TOKEN); - if (StringUtils.isBlank(token)) { - throw new IllegalStateException("Kubeconfig exec did not return status.token"); + private static void applyExecEnvironment(ProcessBuilder processBuilder, JsonNode exec) { + JsonNode env = exec.path(KubeConfigFields.ENV); + if (!env.isArray()) { + return; + } + for (JsonNode envVar : env) { + String name = getTextField(envVar, KubeConfigFields.NAME); + String value = getTextField(envVar, KubeConfigFields.VALUE); + if (StringUtils.isNotBlank(name)) { + processBuilder.environment().put(name, value == null ? "" : value); } - return token; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IllegalStateException("Kubeconfig exec interrupted", e); - } catch (IOException e) { - throw new IllegalStateException("Failed to run kubeconfig exec command: " + command, e); } } + private static String runExecProcess(JsonNode exec, List commandLine, String command) + throws IOException, InterruptedException { + ProcessBuilder processBuilder = new ProcessBuilder(commandLine); + processBuilder.redirectErrorStream(true); + applyExecEnvironment(processBuilder, exec); + Process process = processBuilder.start(); + boolean finished = process.waitFor(EXEC_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + throw new IllegalStateException("Kubeconfig exec timed out after " + EXEC_TIMEOUT_SECONDS + "s: " + command); + } + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + if (process.exitValue() != 0) { + throw new IllegalStateException("Kubeconfig exec failed (exit " + process.exitValue() + "): " + output); + } + return output; + } + + private static String parseExecToken(String output) throws IOException { + JsonNode credential = JSON_MAPPER.readTree(output); + String token = getTextField(credential.path(KubeConfigFields.STATUS), KubeConfigFields.TOKEN); + if (StringUtils.isBlank(token)) { + throw new IllegalStateException("Kubeconfig exec did not return status.token"); + } + return token; + } + private static JsonNode findKubeConfigEntryByName(JsonNode array, String name) { if (array != null && array.isArray()) { for (Iterator it = array.elements(); it.hasNext(); ) { @@ -189,7 +213,7 @@ private static JsonNode findKubeConfigEntryByName(JsonNode array, String name) { private static byte[] decodeOptionalBase64(String value) { if (StringUtils.isBlank(value)) { - return null; + return new byte[0]; } return Base64.getDecoder().decode(value.replaceAll("\\s", "")); } diff --git a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevConstants.java b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevConstants.java index 5c2105e04..c4798c237 100644 --- a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevConstants.java +++ b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevConstants.java @@ -24,9 +24,6 @@ final class LocalDevConstants { static final String WELL_KNOWN_OPENID_CONFIGURATION_PATH = "/.well-known/openid-configuration"; - static final String DEFAULT_KUBERNETES_ISSUER = "https://kubernetes.default.svc"; - static final String JWKS_PATH = "/openid/v1/jwks"; - static final Duration HTTP_REQUEST_TIMEOUT = Duration.ofSeconds(30); static final long TOKEN_REQUEST_EXPIRATION_SECONDS = 28800L; // 8 hours diff --git a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevKubernetesOidc.java b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevKubernetesOidc.java index 002024a68..e7f7fd3a4 100644 --- a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevKubernetesOidc.java +++ b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevKubernetesOidc.java @@ -14,14 +14,13 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.Locale; +import java.util.concurrent.atomic.AtomicReference; import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.ACCEPT_HEADER; import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.APPLICATION_JSON; import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.AUTHORIZATION_HEADER; import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.BEARER_PREFIX; -import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.DEFAULT_KUBERNETES_ISSUER; import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.HTTP_REQUEST_TIMEOUT; -import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.JWKS_PATH; import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.OIDC_DISCOVERY_ISSUER; import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.WELL_KNOWN_OPENID_CONFIGURATION_PATH; @@ -32,16 +31,16 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class LocalDevKubernetesOidc { - /** @see LocalDevConstants#DEFAULT_KUBERNETES_ISSUER */ - public static final String DEFAULT_KUBERNETES_ISSUER = LocalDevConstants.DEFAULT_KUBERNETES_ISSUER; - /** @see LocalDevConstants#JWKS_PATH */ - public static final String JWKS_PATH = LocalDevConstants.JWKS_PATH; + /** Default Kubernetes issuer when OIDC discovery is unavailable. */ + public static final String DEFAULT_KUBERNETES_ISSUER = "https://kubernetes.default.svc"; + /** JWKS path on the Kubernetes API server. */ + public static final String JWKS_PATH = "/openid/v1/jwks"; private static final ObjectMapper MAPPER = new ObjectMapper(); private static final Object LOCK = new Object(); - private static volatile KubeConfigCredentials cachedCredentials; - private static volatile HttpClient cachedHttpClient; + private static final AtomicReference cachedCredentials = new AtomicReference<>(); + private static final AtomicReference cachedHttpClient = new AtomicReference<>(); public static boolean isKubernetesIssuer(String issuerOrUrl) { if (StringUtils.isBlank(issuerOrUrl)) { @@ -77,8 +76,7 @@ public static boolean isPublicOidcEndpoint(String url) { return false; } return path.endsWith(WELL_KNOWN_OPENID_CONFIGURATION_PATH) - || path.endsWith(JWKS_PATH) - || path.contains("/openid/v1/jwks"); + || path.contains(JWKS_PATH); } catch (IllegalArgumentException e) { return url.contains(WELL_KNOWN_OPENID_CONFIGURATION_PATH) || url.contains(JWKS_PATH); } @@ -95,31 +93,28 @@ public static String resolveIssuerClaimFromDiscovery() { if (StringUtils.isNotBlank(issuer)) { return issuer; } - } catch (Exception e) { + } catch (IOException | InterruptedException | IllegalStateException e) { log.warn("Failed to resolve Kubernetes issuer from discovery at {} in local-dev, using default {}", discoveryUrl, DEFAULT_KUBERNETES_ISSUER, e); } return DEFAULT_KUBERNETES_ISSUER; } - private static String get(String url) throws Exception { - return getWithRetry(url, true); + private static String get(String url) throws IOException, InterruptedException { + return getWithRetry(url); } - private static String getWithRetry(String url, boolean retryOnIo) throws Exception { + private static String getWithRetry(String url) throws IOException, InterruptedException { try { return sendGet(url); } catch (IOException e) { - if (retryOnIo) { - log.debug("Retrying Kubernetes OIDC request after I/O failure for {}", url, e); - resetHttpClient(); - return sendGet(url); - } - throw e; + log.debug("Retrying Kubernetes OIDC request after I/O failure for {}", url, e); + resetHttpClient(); + return sendGet(url); } } - private static String sendGet(String url) throws Exception { + private static String sendGet(String url) throws IOException, InterruptedException { HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(HTTP_REQUEST_TIMEOUT) @@ -136,16 +131,18 @@ private static String sendGet(String url) throws Exception { } private static KubeConfigCredentials credentials() { - KubeConfigCredentials existing = cachedCredentials; + KubeConfigCredentials existing = cachedCredentials.get(); if (existing != null) { return existing; } synchronized (LOCK) { - if (cachedCredentials == null) { - cachedCredentials = KubeConfigLoader.load(); - log.info("Local-dev kubeconfig: API server {}", cachedCredentials.getServerUrl()); + KubeConfigCredentials resolved = cachedCredentials.get(); + if (resolved == null) { + resolved = KubeConfigLoader.load(); + cachedCredentials.set(resolved); + log.info("Local-dev kubeconfig: API server {}", resolved.getServerUrl()); } - return cachedCredentials; + return resolved; } } @@ -154,29 +151,31 @@ private static KubeConfigCredentials credentials() { * Not used in try-with-resources: the client is long-lived and shared across OIDC calls. */ private static HttpClient httpClient() { - HttpClient existing = cachedHttpClient; + HttpClient existing = cachedHttpClient.get(); if (existing != null) { return existing; } synchronized (LOCK) { - if (cachedHttpClient == null) { - cachedHttpClient = KubeConfigHttpClientFactory.create(credentials()); + HttpClient resolved = cachedHttpClient.get(); + if (resolved == null) { + resolved = KubeConfigHttpClientFactory.create(credentials()); + cachedHttpClient.set(resolved); } - return cachedHttpClient; + return resolved; } } @VisibleForTesting static void resetCache() { synchronized (LOCK) { - cachedCredentials = null; - cachedHttpClient = null; + cachedCredentials.set(null); + cachedHttpClient.set(null); } } private static void resetHttpClient() { synchronized (LOCK) { - cachedHttpClient = null; + cachedHttpClient.set(null); } } } diff --git a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevTokenSource.java b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevTokenSource.java index eb9959f05..e39594434 100644 --- a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevTokenSource.java +++ b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevTokenSource.java @@ -11,6 +11,7 @@ import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Supplier; @Slf4j @@ -23,7 +24,7 @@ public class LocalDevTokenSource implements TokenSource { private final Supplier clientSupplier; private final ConcurrentMap cache = new ConcurrentHashMap<>(); - private volatile TokenRequestClient client; + private final AtomicReference client = new AtomicReference<>(); public LocalDevTokenSource() { this(new CachingTokenSource(), () -> new TokenRequestClient(KubeConfigLoader.load())); @@ -65,15 +66,17 @@ private TokenRequestClient.TokenRequestResult request(String audience) { } private TokenRequestClient client() { - TokenRequestClient existing = client; + TokenRequestClient existing = client.get(); if (existing != null) { return existing; } synchronized (this) { - if (client == null) { - client = clientSupplier.get(); + TokenRequestClient resolved = client.get(); + if (resolved == null) { + resolved = clientSupplier.get(); + client.set(resolved); } - return client; + return resolved; } } diff --git a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/TokenRequestClient.java b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/TokenRequestClient.java index 2f8f99201..ddc8d3781 100644 --- a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/TokenRequestClient.java +++ b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/TokenRequestClient.java @@ -1,5 +1,6 @@ package com.netcracker.cloud.security.core.utils.k8s.localdev; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -108,7 +109,7 @@ private static void handleTokenRequestResponse(HttpResponse response, } } - private static String buildRequestBody(String audience) throws Exception { + private static String buildRequestBody(String audience) { ObjectNode root = MAPPER.createObjectNode(); root.put("apiVersion", TOKEN_REQUEST_API_VERSION); root.put("kind", TOKEN_REQUEST_KIND); @@ -116,7 +117,11 @@ private static String buildRequestBody(String audience) throws Exception { ArrayNode audiences = spec.putArray(TOKEN_REQUEST_SPEC_AUDIENCES); audiences.add(audience); spec.put(TOKEN_REQUEST_SPEC_EXPIRATION_SECONDS, TOKEN_REQUEST_EXPIRATION_SECONDS); - return MAPPER.writeValueAsString(root); + try { + return MAPPER.writeValueAsString(root); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to build local-dev TokenRequest body", e); + } } private static Instant parseExpiration(String expirationTimestamp) { diff --git a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/TestTokenSourceHighPriorityImpl.java b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/TestTokenSourceHighPriorityImpl.java index ecb6d744c..c071d87c6 100644 --- a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/TestTokenSourceHighPriorityImpl.java +++ b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/TestTokenSourceHighPriorityImpl.java @@ -9,6 +9,7 @@ public String getToken(String audience) { @Override public void close() { + // Test TokenSource: no resources to release. } } diff --git a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigHttpClientFactoryTest.java b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigHttpClientFactoryTest.java new file mode 100644 index 000000000..63e7f5d9a --- /dev/null +++ b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigHttpClientFactoryTest.java @@ -0,0 +1,120 @@ +package com.netcracker.cloud.security.core.utils.k8s.localdev; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class KubeConfigHttpClientFactoryTest { + + @TempDir + Path tempDir; + + @Test + void createInsecureForLocalDevBuildsHttpClient() { + HttpClient client = KubeConfigHttpClientFactory.createInsecureForLocalDev(); + assertNotNull(client); + } + + @Test + void createWithInsecureSkipTlsVerify() { + KubeConfigCredentials credentials = KubeConfigCredentials.builder() + .serverUrl("https://api.example") + .userToken("token") + .insecureSkipTlsVerify(true) + .build(); + assertNotNull(KubeConfigHttpClientFactory.create(credentials)); + } + + @Test + void createWithEmptyCaUsesDefaultSslContext() { + KubeConfigCredentials credentials = KubeConfigCredentials.builder() + .serverUrl("https://api.example") + .userToken("token") + .certificateAuthorityData(new byte[0]) + .build(); + assertNotNull(KubeConfigHttpClientFactory.create(credentials)); + } + + @Test + void createWithInvalidCaThrows() { + KubeConfigCredentials credentials = KubeConfigCredentials.builder() + .serverUrl("https://api.example") + .userToken("token") + .certificateAuthorityData("not-a-certificate".getBytes(StandardCharsets.UTF_8)) + .build(); + assertThrows(IllegalStateException.class, () -> KubeConfigHttpClientFactory.create(credentials)); + } + + @Test + void createInsecureForLocalDevAcceptsSelfSignedHttps() throws Exception { + MockWebServer server = new MockWebServer(); + server.start(); + try { + server.enqueue(new MockResponse().setBody("ok")); + HttpClient client = KubeConfigHttpClientFactory.createInsecureForLocalDev(); + HttpRequest request = HttpRequest.newBuilder(server.url("/").uri()).GET().build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode()); + assertEquals("ok", response.body()); + } finally { + server.shutdown(); + } + } + + @Test + void createWithValidCaFromKeytool() throws Exception { + Path keystore = tempDir.resolve("test.p12"); + Path certFile = tempDir.resolve("ca.pem"); + runKeytool(new String[]{ + "-genkeypair", "-alias", "test", "-keyalg", "RSA", + "-storetype", "PKCS12", "-keystore", keystore.toString(), + "-storepass", "changeit", "-keypass", "changeit", + "-dname", "CN=local-dev-test", "-validity", "1" + }); + runKeytool(new String[]{ + "-exportcert", "-alias", "test", "-keystore", keystore.toString(), + "-storepass", "changeit", "-rfc", "-file", certFile.toString() + }); + + byte[] caPem = Files.readAllBytes(certFile); + KubeConfigCredentials credentials = KubeConfigCredentials.builder() + .serverUrl("https://api.example") + .userToken("token") + .certificateAuthorityData(caPem) + .build(); + assertNotNull(KubeConfigHttpClientFactory.create(credentials)); + } + + private static void runKeytool(String[] args) throws Exception { + String javaHome = System.getProperty("java.home"); + Path keytool = Path.of(javaHome, "bin", "keytool"); + if (!Files.isRegularFile(keytool)) { + keytool = Path.of(javaHome, "bin", "keytool.exe"); + } + String[] command = new String[args.length + 1]; + command[0] = keytool.toString(); + System.arraycopy(args, 0, command, 1, args.length); + Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + boolean finished = process.waitFor(30, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + throw new IllegalStateException("keytool timed out"); + } + if (process.exitValue() != 0) { + throw new IllegalStateException("keytool failed with exit code " + process.exitValue()); + } + } +} diff --git a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigLoaderTest.java b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigLoaderTest.java index 53f383f81..058adcf7a 100644 --- a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigLoaderTest.java +++ b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigLoaderTest.java @@ -15,6 +15,7 @@ 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.assertThrows; @ExtendWith(SystemStubsExtension.class) class KubeConfigLoaderTest { @@ -93,4 +94,425 @@ void loadsIdTokenFromOidcAuthProviderWhenTokenNotExpiredJwt() throws Exception { KubeConfigCredentials credentials = KubeConfigLoader.load(); assertEquals("oidc-id-token-123", credentials.getUserToken()); } + + @Test + void loadsAccessTokenFromUser() throws Exception { + Path kubeConfig = tempDir.resolve("config"); + Files.writeString(kubeConfig, """ + apiVersion: v1 + kind: Config + current-context: test-ctx + contexts: + - name: test-ctx + context: + cluster: test-cluster + user: test-user + clusters: + - name: test-cluster + cluster: + server: https://127.0.0.1:6443 + users: + - name: test-user + user: + access-token: direct-access-token + """); + environmentVariables.set("KUBECONFIG", kubeConfig.toString()); + + assertEquals("direct-access-token", KubeConfigLoader.load().getUserToken()); + } + + @Test + void loadsAccessTokenFromNonOidcAuthProvider() throws Exception { + Path kubeConfig = tempDir.resolve("config"); + Files.writeString(kubeConfig, """ + apiVersion: v1 + kind: Config + current-context: test-ctx + contexts: + - name: test-ctx + context: + cluster: test-cluster + user: test-user + clusters: + - name: test-cluster + cluster: + server: https://127.0.0.1:6443 + users: + - name: test-user + user: + auth-provider: + name: gcp + config: + access-token: provider-access-token + """); + environmentVariables.set("KUBECONFIG", kubeConfig.toString()); + + assertEquals("provider-access-token", KubeConfigLoader.load().getUserToken()); + } + + @Test + void loadsTokenViaExecCredential() throws Exception { + Path tokenJson = tempDir.resolve("token.json"); + Files.writeString(tokenJson, "{\"status\":{\"token\":\"exec-token\"}}"); + + Path kubeConfig = tempDir.resolve("config"); + String execCommand; + String execArg; + if (System.getProperty("os.name").toLowerCase().contains("win")) { + execCommand = "cmd"; + execArg = "/c type " + tokenJson.toString(); + } else { + execCommand = "cat"; + execArg = tokenJson.toString(); + } + Files.writeString(kubeConfig, """ + apiVersion: v1 + kind: Config + current-context: test-ctx + contexts: + - name: test-ctx + context: + cluster: test-cluster + user: test-user + clusters: + - name: test-cluster + cluster: + server: https://127.0.0.1:6443 + users: + - name: test-user + user: + exec: + command: %s + args: + - %s + """.formatted(execCommand, execArg)); + environmentVariables.set("KUBECONFIG", kubeConfig.toString()); + + assertEquals("exec-token", KubeConfigLoader.load().getUserToken()); + } + + @Test + void resolveKubeConfigPathUsesFirstEntryFromList() { + Path first = tempDir.resolve("first-config"); + Path second = tempDir.resolve("second-config"); + environmentVariables.set("KUBECONFIG", first + java.io.File.pathSeparator + second); + assertEquals(first, KubeConfigLoader.resolveKubeConfigPath()); + } + + @Test + void failsWhenKubeconfigMissing() { + environmentVariables.set("KUBECONFIG", tempDir.resolve("missing").toString()); + assertThrows(IllegalStateException.class, KubeConfigLoader::load); + } + + @Test + void failsWhenCurrentContextMissing() throws Exception { + Path kubeConfig = tempDir.resolve("config"); + Files.writeString(kubeConfig, """ + apiVersion: v1 + kind: Config + contexts: [] + clusters: [] + users: [] + """); + environmentVariables.set("KUBECONFIG", kubeConfig.toString()); + assertThrows(IllegalStateException.class, KubeConfigLoader::load); + } + + @Test + void failsWhenClusterHasNoServer() throws Exception { + Path kubeConfig = tempDir.resolve("config"); + Files.writeString(kubeConfig, """ + apiVersion: v1 + kind: Config + current-context: test-ctx + contexts: + - name: test-ctx + context: + cluster: test-cluster + user: test-user + clusters: + - name: test-cluster + cluster: {} + users: + - name: test-user + user: + token: token + """); + environmentVariables.set("KUBECONFIG", kubeConfig.toString()); + assertThrows(IllegalStateException.class, KubeConfigLoader::load); + } + + @Test + void resolveKubeConfigPathUsesDefaultWhenUnset() { + environmentVariables.remove("KUBECONFIG"); + assertEquals(Path.of(System.getProperty("user.home"), ".kube", "config"), + KubeConfigLoader.resolveKubeConfigPath()); + } + + @Test + void stripsTrailingSlashFromServerUrl() throws Exception { + Path kubeConfig = tempDir.resolve("config"); + Files.writeString(kubeConfig, """ + apiVersion: v1 + kind: Config + current-context: test-ctx + contexts: + - name: test-ctx + context: + cluster: test-cluster + user: test-user + clusters: + - name: test-cluster + cluster: + server: https://127.0.0.1:6443/ + users: + - name: test-user + user: + token: token + """); + environmentVariables.set("KUBECONFIG", kubeConfig.toString()); + assertEquals("https://127.0.0.1:6443", KubeConfigLoader.load().getServerUrl()); + } + + @Test + void failsWhenContextMissingClusterOrUser() throws Exception { + Path kubeConfig = tempDir.resolve("config"); + Files.writeString(kubeConfig, """ + apiVersion: v1 + kind: Config + current-context: test-ctx + contexts: + - name: test-ctx + context: + user: test-user + clusters: + - name: test-cluster + cluster: + server: https://127.0.0.1:6443 + users: + - name: test-user + user: + token: token + """); + environmentVariables.set("KUBECONFIG", kubeConfig.toString()); + assertThrows(IllegalStateException.class, KubeConfigLoader::load); + } + + @Test + void failsWhenKubeconfigEntryNotFound() throws Exception { + Path kubeConfig = tempDir.resolve("config"); + Files.writeString(kubeConfig, """ + apiVersion: v1 + kind: Config + current-context: test-ctx + contexts: + - name: test-ctx + context: + cluster: missing-cluster + user: test-user + clusters: + - name: test-cluster + cluster: + server: https://127.0.0.1:6443 + users: + - name: test-user + user: + token: token + """); + environmentVariables.set("KUBECONFIG", kubeConfig.toString()); + assertThrows(IllegalStateException.class, KubeConfigLoader::load); + } + + @Test + void failsWhenKubeconfigIsInvalid() throws Exception { + Path kubeConfig = tempDir.resolve("config"); + Files.writeString(kubeConfig, "{ not valid yaml [[["); + environmentVariables.set("KUBECONFIG", kubeConfig.toString()); + assertThrows(IllegalStateException.class, KubeConfigLoader::load); + } + + @Test + void failsWhenUserHasNoCredentials() throws Exception { + Path kubeConfig = tempDir.resolve("config"); + Files.writeString(kubeConfig, """ + apiVersion: v1 + kind: Config + current-context: test-ctx + contexts: + - name: test-ctx + context: + cluster: test-cluster + user: test-user + clusters: + - name: test-cluster + cluster: + server: https://127.0.0.1:6443 + users: + - name: test-user + user: {} + """); + environmentVariables.set("KUBECONFIG", kubeConfig.toString()); + assertThrows(IllegalStateException.class, KubeConfigLoader::load); + } + + @Test + void skipsAuthProviderWhenConfigMissing() throws Exception { + Path kubeConfig = tempDir.resolve("config"); + Files.writeString(kubeConfig, """ + apiVersion: v1 + kind: Config + current-context: test-ctx + contexts: + - name: test-ctx + context: + cluster: test-cluster + user: test-user + clusters: + - name: test-cluster + cluster: + server: https://127.0.0.1:6443 + users: + - name: test-user + user: + auth-provider: + name: oidc + id-token: direct-id-token + """); + environmentVariables.set("KUBECONFIG", kubeConfig.toString()); + assertEquals("direct-id-token", KubeConfigLoader.load().getUserToken()); + } + + @Test + void failsWhenExecCommandEmpty() throws Exception { + Path kubeConfig = tempDir.resolve("config"); + Files.writeString(kubeConfig, """ + apiVersion: v1 + kind: Config + current-context: test-ctx + contexts: + - name: test-ctx + context: + cluster: test-cluster + user: test-user + clusters: + - name: test-cluster + cluster: + server: https://127.0.0.1:6443 + users: + - name: test-user + user: + exec: + command: "" + """); + environmentVariables.set("KUBECONFIG", kubeConfig.toString()); + assertThrows(IllegalStateException.class, KubeConfigLoader::load); + } + + @Test + void failsWhenExecReturnsNonZeroExitCode() throws Exception { + Path kubeConfig = tempDir.resolve("config"); + String execCommand = System.getProperty("os.name").toLowerCase().contains("win") ? "cmd" : "sh"; + String execArg = System.getProperty("os.name").toLowerCase().contains("win") ? "/c exit 1" : "-c exit 1"; + Files.writeString(kubeConfig, """ + apiVersion: v1 + kind: Config + current-context: test-ctx + contexts: + - name: test-ctx + context: + cluster: test-cluster + user: test-user + clusters: + - name: test-cluster + cluster: + server: https://127.0.0.1:6443 + users: + - name: test-user + user: + exec: + command: %s + args: + - %s + """.formatted(execCommand, execArg)); + environmentVariables.set("KUBECONFIG", kubeConfig.toString()); + assertThrows(IllegalStateException.class, KubeConfigLoader::load); + } + + @Test + void failsWhenExecReturnsNoToken() throws Exception { + Path kubeConfig = tempDir.resolve("config"); + String execCommand = System.getProperty("os.name").toLowerCase().contains("win") ? "cmd" : "echo"; + String execArg = System.getProperty("os.name").toLowerCase().contains("win") + ? "/c echo {}" + : "{}"; + Files.writeString(kubeConfig, """ + apiVersion: v1 + kind: Config + current-context: test-ctx + contexts: + - name: test-ctx + context: + cluster: test-cluster + user: test-user + clusters: + - name: test-cluster + cluster: + server: https://127.0.0.1:6443 + users: + - name: test-user + user: + exec: + command: %s + args: + - %s + """.formatted(execCommand, execArg)); + environmentVariables.set("KUBECONFIG", kubeConfig.toString()); + assertThrows(IllegalStateException.class, KubeConfigLoader::load); + } + + @Test + void loadsTokenViaExecWithEnvVars() throws Exception { + Path tokenJson = tempDir.resolve("token.json"); + Files.writeString(tokenJson, "{\"status\":{\"token\":\"env-exec-token\"}}"); + + Path kubeConfig = tempDir.resolve("config"); + String execCommand; + String execArg; + if (System.getProperty("os.name").toLowerCase().contains("win")) { + execCommand = "cmd"; + execArg = "/c type " + tokenJson.toString(); + } else { + execCommand = "cat"; + execArg = tokenJson.toString(); + } + Files.writeString(kubeConfig, """ + apiVersion: v1 + kind: Config + current-context: test-ctx + contexts: + - name: test-ctx + context: + cluster: test-cluster + user: test-user + clusters: + - name: test-cluster + cluster: + server: https://127.0.0.1:6443 + users: + - name: test-user + user: + exec: + command: %s + args: + - %s + env: + - name: LOCAL_DEV_TEST + value: marker + - name: EMPTY_VALUE + """.formatted(execCommand, execArg)); + environmentVariables.set("KUBECONFIG", kubeConfig.toString()); + + assertEquals("env-exec-token", KubeConfigLoader.load().getUserToken()); + } } diff --git a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevHttpUtilsTest.java b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevHttpUtilsTest.java new file mode 100644 index 000000000..187521674 --- /dev/null +++ b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevHttpUtilsTest.java @@ -0,0 +1,50 @@ +package com.netcracker.cloud.security.core.utils.k8s.localdev; + +import org.junit.jupiter.api.Test; + +import java.net.http.HttpResponse; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LocalDevHttpUtilsTest { + + @Test + void isUnauthorizedDetects401And403() { + assertTrue(LocalDevHttpUtils.isUnauthorized(401)); + assertTrue(LocalDevHttpUtils.isUnauthorized(403)); + assertFalse(LocalDevHttpUtils.isUnauthorized(200)); + } + + @Test + void isFailedDetectsNon2xx() { + assertFalse(LocalDevHttpUtils.isFailed(200)); + assertFalse(LocalDevHttpUtils.isFailed(204)); + assertTrue(LocalDevHttpUtils.isFailed(400)); + assertTrue(LocalDevHttpUtils.isFailed(500)); + } + + @Test + void ensureSuccessfulThrowsOnUnauthorizedAndFailed() { + HttpResponse unauthorized = mock(HttpResponse.class); + when(unauthorized.statusCode()).thenReturn(401); + when(unauthorized.body()).thenReturn("denied"); + + IllegalStateException unauthorizedEx = assertThrows(IllegalStateException.class, + () -> LocalDevHttpUtils.ensureSuccessful(unauthorized, "test operation")); + assertTrue(unauthorizedEx.getMessage().contains("unauthorized")); + assertTrue(unauthorizedEx.getMessage().contains("denied")); + + HttpResponse failed = mock(HttpResponse.class); + when(failed.statusCode()).thenReturn(500); + when(failed.body()).thenReturn("boom"); + + IllegalStateException failedEx = assertThrows(IllegalStateException.class, + () -> LocalDevHttpUtils.ensureSuccessful(failed, "test operation")); + assertTrue(failedEx.getMessage().contains("failed")); + assertTrue(failedEx.getMessage().contains("boom")); + } +} diff --git a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevJsonUtilsTest.java b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevJsonUtilsTest.java new file mode 100644 index 000000000..c94f23f80 --- /dev/null +++ b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevJsonUtilsTest.java @@ -0,0 +1,51 @@ +package com.netcracker.cloud.security.core.utils.k8s.localdev; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; + +import static com.netcracker.cloud.security.core.utils.k8s.localdev.LocalDevConstants.MAX_ERROR_BODY_LENGTH; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class LocalDevJsonUtilsTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + void getTextFieldHandlesMissingBlankAndNonText() { + ObjectNode node = MAPPER.createObjectNode(); + assertNull(LocalDevJsonUtils.getTextField(node, "missing")); + node.putNull("nullField"); + assertNull(LocalDevJsonUtils.getTextField(node, "nullField")); + node.put("blank", " "); + assertNull(LocalDevJsonUtils.getTextField(node, "blank")); + node.put("number", 42); + assertEquals("42", LocalDevJsonUtils.getTextField(node, "number")); + node.put("value", " text "); + assertEquals(" text ", LocalDevJsonUtils.getTextField(node, "value")); + } + + @Test + void firstNonBlankReturnsFirstNonBlankValue() { + assertEquals("first", LocalDevJsonUtils.firstNonBlank("first", "second")); + assertEquals("second", LocalDevJsonUtils.firstNonBlank(null, "second")); + assertNull(LocalDevJsonUtils.firstNonBlank(null, null)); + } + + @Test + void truncateResponseBodyHandlesNullAndLongBodies() { + assertEquals("", LocalDevJsonUtils.truncateResponseBody(null)); + assertEquals("short", LocalDevJsonUtils.truncateResponseBody("short")); + String longBody = "x".repeat(MAX_ERROR_BODY_LENGTH + 10); + String truncated = LocalDevJsonUtils.truncateResponseBody(longBody); + assertEquals(MAX_ERROR_BODY_LENGTH + 3, truncated.length()); + assertEquals("...", truncated.substring(truncated.length() - 3)); + } + + @Test + void padBase64UrlPadsToMultipleOfFour() { + assertEquals("abcd", LocalDevJsonUtils.padBase64Url("abcd")); + assertEquals("abc=", LocalDevJsonUtils.padBase64Url("abc")); + } +} diff --git a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevKubernetesOidcTest.java b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevKubernetesOidcTest.java index 8ce6ef2c0..e1b3afc78 100644 --- a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevKubernetesOidcTest.java +++ b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevKubernetesOidcTest.java @@ -107,5 +107,81 @@ void isPublicOidcEndpointDetectsDiscoveryAndJwks() { void isKubernetesIssuerDetectsDefaultHosts() { assertTrue(LocalDevKubernetesOidc.isKubernetesIssuer("https://kubernetes.default.svc")); assertTrue(LocalDevKubernetesOidc.isKubernetesIssuer("https://kubernetes.default.svc.cluster.local/openid/v1/jwks")); + assertFalse(LocalDevKubernetesOidc.isKubernetesIssuer("https://accounts.google.com")); + assertFalse(LocalDevKubernetesOidc.isKubernetesIssuer("")); + } + + @Test + void resolveIssuerFallsBackToDefaultOnDiscoveryFailure() throws Exception { + server.stop(0); + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/", exchange -> { + exchange.sendResponseHeaders(500, -1); + exchange.close(); + }); + server.start(); + String failingUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + + Path kubeConfig = tempDir.resolve("config"); + Files.writeString(kubeConfig, """ + apiVersion: v1 + kind: Config + current-context: test-ctx + contexts: + - name: test-ctx + context: + cluster: test-cluster + user: test-user + clusters: + - name: test-cluster + cluster: + server: %s + insecure-skip-tls-verify: true + users: + - name: test-user + user: + token: kube-user-token + """.formatted(failingUrl)); + environmentVariables.set("KUBECONFIG", kubeConfig.toString()); + systemProperties.set(LocalDevMode.ENABLED_PROPERTY, "true"); + + assertEquals(LocalDevKubernetesOidc.DEFAULT_KUBERNETES_ISSUER, + LocalDevKubernetesOidc.resolveIssuerClaimFromDiscovery()); + } + + @Test + void isPublicOidcEndpointHandlesInvalidUrl() { + assertFalse(LocalDevKubernetesOidc.isPublicOidcEndpoint("")); + assertTrue(LocalDevKubernetesOidc.isPublicOidcEndpoint("not-a-valid-uri:///.well-known/openid-configuration")); + } + + @Test + void apiServerUrlIsCachedUntilReset() throws Exception { + Path kubeConfig = tempDir.resolve("config"); + Files.writeString(kubeConfig, """ + apiVersion: v1 + kind: Config + current-context: test-ctx + contexts: + - name: test-ctx + context: + cluster: test-cluster + user: test-user + clusters: + - name: test-cluster + cluster: + server: %s + insecure-skip-tls-verify: true + users: + - name: test-user + user: + token: kube-user-token + """.formatted(baseUrl)); + environmentVariables.set("KUBECONFIG", kubeConfig.toString()); + + assertEquals(baseUrl, LocalDevKubernetesOidc.apiServerUrl()); + assertEquals(baseUrl, LocalDevKubernetesOidc.apiServerUrl()); + LocalDevKubernetesOidc.resetCache(); + assertEquals(baseUrl, LocalDevKubernetesOidc.apiServerUrl()); } } diff --git a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevModeTest.java b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevModeTest.java index 8bf81908a..56e970db5 100644 --- a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevModeTest.java +++ b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevModeTest.java @@ -65,11 +65,23 @@ void requireMicroserviceNameFailsWhenMissing() { } @Test - void requireNamespaceFromEnv() throws Exception { + void requireNamespaceFromEnv() { environmentVariables.set(LocalDevMode.NAMESPACE_ENV, "my-ns"); assertEquals("my-ns", LocalDevMode.requireNamespace()); } + @Test + void enabledByEnvVariable() { + environmentVariables.set(LocalDevMode.ENABLED_ENV, "true"); + assertTrue(LocalDevMode.isEnabled()); + } + + @Test + void requireMicroserviceNameFromEnv() { + environmentVariables.set(LocalDevMode.MICROSERVICE_NAME_ENV, "env-service"); + assertEquals("env-service", LocalDevMode.requireMicroserviceName()); + } + @Test void requireNamespaceFailsWhenMissing() { assertThrows(IllegalStateException.class, LocalDevMode::requireNamespace); diff --git a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevTokenSourceTest.java b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevTokenSourceTest.java index 647f3a17b..5794dc3d3 100644 --- a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevTokenSourceTest.java +++ b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevTokenSourceTest.java @@ -13,7 +13,7 @@ import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.eq; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -55,7 +55,7 @@ void requestsAndCachesTokenWhenEnabled() throws Exception { TokenSource fallback = mock(TokenSource.class); TokenRequestClient client = mock(TokenRequestClient.class); - when(client.requestToken(eq("my-ns"), eq("my-sa"), eq("netcracker"))) + when(client.requestToken("my-ns", "my-sa", "netcracker")) .thenReturn(new TokenRequestClient.TokenRequestResult( "minted", Instant.now().plusSeconds(3600))); @@ -68,7 +68,26 @@ void requestsAndCachesTokenWhenEnabled() throws Exception { assertEquals("minted", source.getToken("netcracker")); } - verify(client, times(1)).requestToken(eq("my-ns"), eq("my-sa"), eq("netcracker")); + verify(client, times(1)).requestToken("my-ns", "my-sa", "netcracker"); assertEquals(1, supplierCalls.get()); } + + @Test + void throwsWhenAudienceIsNull() throws Exception { + systemProperties.set(LocalDevMode.ENABLED_PROPERTY, "true"); + systemProperties.set(LocalDevMode.MICROSERVICE_NAME_PROPERTY, "my-sa"); + environmentVariables.set(LocalDevMode.NAMESPACE_ENV, "my-ns"); + + try (LocalDevTokenSource source = new LocalDevTokenSource(mock(TokenSource.class), () -> mock(TokenRequestClient.class))) { + assertThrows(NullPointerException.class, () -> source.getToken(null)); + } + } + + @Test + void closeClearsCacheAndClosesFallback() throws Exception { + TokenSource fallback = mock(TokenSource.class); + LocalDevTokenSource source = new LocalDevTokenSource(fallback, () -> mock(TokenRequestClient.class)); + source.close(); + verify(fallback).close(); + } } diff --git a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/OidcAuthProviderTokenRefresherTest.java b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/OidcAuthProviderTokenRefresherTest.java index b6d28df4c..d49c2c387 100644 --- a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/OidcAuthProviderTokenRefresherTest.java +++ b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/OidcAuthProviderTokenRefresherTest.java @@ -8,6 +8,8 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import uk.org.webcompere.systemstubs.jupiter.SystemStubsExtension; import java.net.http.HttpClient; import java.nio.charset.StandardCharsets; @@ -16,10 +18,16 @@ import java.util.concurrent.TimeUnit; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +@ExtendWith(SystemStubsExtension.class) class OidcAuthProviderTokenRefresherTest { + private static final String INSECURE_IDP_TLS_PROPERTY = "security.local-dev.insecure-idp-tls"; + private static final ObjectMapper MAPPER = new ObjectMapper(); private MockWebServer server; @@ -120,7 +128,122 @@ void prefersIdTokenFromRefreshResponse() throws Exception { assertEquals("only-access", OidcAuthProviderTokenRefresher.resolveToken(config, httpClient)); } - private static String jwtWithExp(Instant exp) { + @Test + void returnsNullWhenRefreshFieldsMissingAndNoCachedToken() { + ObjectNode config = MAPPER.createObjectNode(); + config.put("idp-issuer-url", server.url("/").toString()); + assertNull(OidcAuthProviderTokenRefresher.resolveToken(config, httpClient)); + } + + @Test + void discoveryUnauthorizedFailsWithoutCachedToken() { + server.enqueue(new MockResponse().setResponseCode(401).setBody("unauthorized")); + + ObjectNode config = MAPPER.createObjectNode(); + config.put("idp-issuer-url", server.url("/issuer").toString().replaceAll("/$", "")); + config.put("refresh-token", "refresh"); + config.put("client-id", "kubernetes"); + + assertThrows(IllegalStateException.class, + () -> OidcAuthProviderTokenRefresher.resolveToken(config, httpClient)); + } + + @Test + void refreshResponseWithoutTokensFails() throws Exception { + String issuer = server.url("/realms/kubernetes").toString().replaceAll("/$", ""); + String tokenEndpoint = server.url("/realms/kubernetes/token").toString(); + server.enqueue(new MockResponse().setBody("{\"token_endpoint\":\"" + tokenEndpoint + "\"}")); + server.enqueue(new MockResponse().setBody("{}")); + + ObjectNode config = MAPPER.createObjectNode(); + config.put("idp-issuer-url", issuer); + config.put("refresh-token", "refresh"); + config.put("client-id", "kubernetes"); + + assertThrows(IllegalStateException.class, + () -> OidcAuthProviderTokenRefresher.resolveToken(config, httpClient)); + } + + @Test + void createHttpClientUsesInsecureFactoryWhenLocalDevEnabled() { + System.setProperty(LocalDevMode.ENABLED_PROPERTY, "true"); + try { + ObjectNode config = MAPPER.createObjectNode(); + config.put("id-token", jwtWithExp(java.time.Instant.now().plusSeconds(3600))); + assertNotNull(OidcAuthProviderTokenRefresher.resolveToken(config)); + } finally { + System.clearProperty(LocalDevMode.ENABLED_PROPERTY); + } + } + + @Test + void createHttpClientUsesDefaultSslWhenInsecureIdpTlsDisabled() { + System.setProperty(LocalDevMode.ENABLED_PROPERTY, "true"); + System.setProperty(INSECURE_IDP_TLS_PROPERTY, "false"); + try { + ObjectNode config = MAPPER.createObjectNode(); + config.put("id-token", jwtWithExp(Instant.now().plusSeconds(3600))); + assertNotNull(OidcAuthProviderTokenRefresher.resolveToken(config)); + } finally { + System.clearProperty(LocalDevMode.ENABLED_PROPERTY); + System.clearProperty(INSECURE_IDP_TLS_PROPERTY); + } + } + + @Test + void discoveryWithoutTokenEndpointFails() { + server.enqueue(new MockResponse().setBody("{\"issuer\":\"https://example\"}")); + + ObjectNode config = MAPPER.createObjectNode(); + config.put("idp-issuer-url", server.url("/issuer").toString().replaceAll("/$", "")); + config.put("refresh-token", "refresh"); + config.put("client-id", "kubernetes"); + + assertThrows(IllegalStateException.class, + () -> OidcAuthProviderTokenRefresher.resolveToken(config, httpClient)); + } + + @Test + void treatsJwtWithoutExpAsExpired() throws Exception { + String issuer = server.url("/realms/kubernetes").toString().replaceAll("/$", ""); + String tokenEndpoint = server.url("/realms/kubernetes/token").toString(); + server.enqueue(new MockResponse().setBody("{\"token_endpoint\":\"" + tokenEndpoint + "\"}")); + server.enqueue(new MockResponse().setBody("{\"id_token\":\"jwt-without-exp\"}")); + + ObjectNode config = MAPPER.createObjectNode(); + config.put("id-token", jwtWithoutExp()); + config.put("idp-issuer-url", issuer); + config.put("refresh-token", "refresh"); + config.put("client-id", "kubernetes"); + + assertEquals("jwt-without-exp", OidcAuthProviderTokenRefresher.resolveToken(config, httpClient)); + } + + @Test + void treatsUnparseableJwtAsExpired() throws Exception { + String issuer = server.url("/realms/kubernetes").toString().replaceAll("/$", ""); + String tokenEndpoint = server.url("/realms/kubernetes/token").toString(); + server.enqueue(new MockResponse().setBody("{\"token_endpoint\":\"" + tokenEndpoint + "\"}")); + server.enqueue(new MockResponse().setBody("{\"id_token\":\"fresh-after-bad-jwt\"}")); + + ObjectNode config = MAPPER.createObjectNode(); + config.put("id-token", "not-a-jwt"); + config.put("idp-issuer-url", issuer); + config.put("refresh-token", "refresh"); + config.put("client-id", "kubernetes"); + + assertEquals("fresh-after-bad-jwt", OidcAuthProviderTokenRefresher.resolveToken(config, httpClient)); + } + + private static String jwtWithoutExp() { + String header = Base64.getUrlEncoder().withoutPadding() + .encodeToString("{\"alg\":\"none\"}".getBytes(StandardCharsets.UTF_8)); + String payload = Base64.getUrlEncoder().withoutPadding() + .encodeToString("{\"sub\":\"user\"}".getBytes(StandardCharsets.UTF_8)); + return header + "." + payload + ".sig"; + } + + private static String jwtWithExp(java.time.Instant exp) { String header = Base64.getUrlEncoder().withoutPadding() .encodeToString("{\"alg\":\"none\"}".getBytes(StandardCharsets.UTF_8)); String payload = Base64.getUrlEncoder().withoutPadding() diff --git a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/TokenRequestClientTest.java b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/TokenRequestClientTest.java index 3657f4ab2..b8ebc9ec6 100644 --- a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/TokenRequestClientTest.java +++ b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/TokenRequestClientTest.java @@ -14,6 +14,7 @@ import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -78,4 +79,52 @@ void requestTokenUnauthorized() { assertTrue(ex.getMessage().contains("unauthorized") || ex.getMessage().contains("403")); assertTrue(ex.getMessage().contains("RBAC")); } + + @Test + void requestTokenFailsOnServerError() { + responseStatus = 500; + responseBody = "{\"message\":\"server error\"}"; + TokenRequestClient client = new TokenRequestClient(baseUrl, "kube-user-token", HttpClient.newHttpClient()); + + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> client.requestToken("ns1", "my-sa", "netcracker")); + assertTrue(ex.getMessage().contains("failed")); + } + + @Test + void requestTokenFailsWhenResponseHasNoToken() { + responseBody = "{\"status\":{}}"; + TokenRequestClient client = new TokenRequestClient(baseUrl, "kube-user-token", HttpClient.newHttpClient()); + + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> client.requestToken("ns1", "my-sa", "netcracker")); + assertTrue(ex.getMessage().contains("status.token")); + } + + @Test + void requestTokenUsesDefaultExpirationWhenTimestampMissing() { + responseBody = """ + { + "status": { + "token": "minted-token" + } + } + """; + TokenRequestClient client = new TokenRequestClient(baseUrl, "kube-user-token", HttpClient.newHttpClient()); + TokenRequestClient.TokenRequestResult result = client.requestToken("ns1", "my-sa", "netcracker"); + assertEquals("minted-token", result.token()); + assertNotNull(result.expiresAt()); + } + + @Test + void constructorFromCredentialsUsesKubeConfigHttpClient() { + KubeConfigCredentials credentials = KubeConfigCredentials.builder() + .serverUrl(baseUrl) + .userToken("kube-user-token") + .insecureSkipTlsVerify(true) + .build(); + TokenRequestClient client = new TokenRequestClient(credentials); + TokenRequestClient.TokenRequestResult result = client.requestToken("ns1", "my-sa", "netcracker"); + assertEquals("minted-token", result.token()); + } } From f0a936ef660adcd2df243002ae974e07c9a3c1fe Mon Sep 17 00:00:00 2001 From: Vitali Butautas Date: Thu, 30 Jul 2026 10:09:25 +0300 Subject: [PATCH 3/4] feat: Local dev implementation --- .../core/utils/k8s/localdev/KubeConfigHttpClientFactory.java | 4 ++-- .../core/utils/k8s/localdev/LocalDevKubernetesOidc.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigHttpClientFactory.java b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigHttpClientFactory.java index fecd92f3f..ebfae54ff 100644 --- a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigHttpClientFactory.java +++ b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigHttpClientFactory.java @@ -92,12 +92,12 @@ private static SSLContext createSslContext(KubeConfigCredentials credentials) { */ private static final class InsecureTrustManager implements X509TrustManager { @Override - public void checkClientTrusted(X509Certificate[] chain, String authType) { + public void checkClientTrusted(X509Certificate[] chain, String authType) { // NOSONAR // Local-dev: client certificates are not used for TokenRequest / OIDC discovery. } @Override - public void checkServerTrusted(X509Certificate[] chain, String authType) { + public void checkServerTrusted(X509Certificate[] chain, String authType) { // NOSONAR // Local-dev: kubeconfig insecure-skip-tls-verify or private IdP CA — validation intentionally skipped. } diff --git a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevKubernetesOidc.java b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevKubernetesOidc.java index e7f7fd3a4..4edb7047d 100644 --- a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevKubernetesOidc.java +++ b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevKubernetesOidc.java @@ -93,7 +93,7 @@ public static String resolveIssuerClaimFromDiscovery() { if (StringUtils.isNotBlank(issuer)) { return issuer; } - } catch (IOException | InterruptedException | IllegalStateException e) { + } catch (IOException | InterruptedException | IllegalStateException e) { // NOSONAR log.warn("Failed to resolve Kubernetes issuer from discovery at {} in local-dev, using default {}", discoveryUrl, DEFAULT_KUBERNETES_ISSUER, e); } From d2b96fc9d2a4b59c0fc8c9da60a098410038c521 Mon Sep 17 00:00:00 2001 From: Vitali Butautas Date: Thu, 30 Jul 2026 14:37:16 +0300 Subject: [PATCH 4/4] feat: Local dev implementation --- .../core/utils/k8s/localdev/LocalDevMode.java | 10 -------- .../localdev/LocalDevKubernetesOidcTest.java | 6 ++--- .../utils/k8s/localdev/LocalDevModeTest.java | 23 +++++++++---------- .../k8s/localdev/LocalDevTokenSourceTest.java | 5 ++-- .../OidcAuthProviderTokenRefresherTest.java | 8 +++---- 5 files changed, 20 insertions(+), 32 deletions(-) diff --git a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevMode.java b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevMode.java index 4bbe536ff..ce4f53afe 100644 --- a/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevMode.java +++ b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevMode.java @@ -7,9 +7,6 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class LocalDevMode { - public static final String ENABLED_PROPERTY = "security.local-dev.enabled"; - public static final String ENABLED_ENV = "SECURITY_LOCAL_DEV_ENABLED"; - public static final String MICROSERVICE_NAME_PROPERTY = "cloud.microservice.name"; public static final String MICROSERVICE_NAME_ENV = "CLOUD_MICROSERVICE_NAME"; @@ -24,9 +21,6 @@ public final class LocalDevMode { public static final String DEV_PROFILE = "dev"; public static boolean isEnabled() { - if (isTrue(firstNonBlank(System.getProperty(ENABLED_PROPERTY), System.getenv(ENABLED_ENV)))) { - return true; - } if (DEV_PROFILE.equalsIgnoreCase(firstNonBlank( System.getProperty(QUARKUS_PROFILE_PROPERTY), System.getenv(QUARKUS_PROFILE_ENV)))) { @@ -73,10 +67,6 @@ private static boolean containsProfile(String profiles, String expected) { return false; } - private static boolean isTrue(String value) { - return "true".equalsIgnoreCase(value) || "1".equals(value); - } - private static String firstNonBlank(String first, String second) { return LocalDevJsonUtils.firstNonBlank(first, second); } diff --git a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevKubernetesOidcTest.java b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevKubernetesOidcTest.java index e1b3afc78..a9707adee 100644 --- a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevKubernetesOidcTest.java +++ b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevKubernetesOidcTest.java @@ -62,7 +62,7 @@ void setUp() throws IOException { void tearDown() { server.stop(0); LocalDevKubernetesOidc.resetCache(); - System.clearProperty(LocalDevMode.ENABLED_PROPERTY); + System.clearProperty(LocalDevMode.QUARKUS_PROFILE_PROPERTY); } @Test @@ -88,7 +88,7 @@ void resolveIssuerFromDiscoveryWithoutAuth() throws Exception { token: kube-user-token """.formatted(baseUrl)); environmentVariables.set("KUBECONFIG", kubeConfig.toString()); - systemProperties.set(LocalDevMode.ENABLED_PROPERTY, "true"); + systemProperties.set(LocalDevMode.QUARKUS_PROFILE_PROPERTY, "dev"); assertEquals("https://kubernetes.default.svc", LocalDevKubernetesOidc.resolveIssuerClaimFromDiscovery()); assertEquals(null, lastAuth.get()); @@ -143,7 +143,7 @@ void resolveIssuerFallsBackToDefaultOnDiscoveryFailure() throws Exception { token: kube-user-token """.formatted(failingUrl)); environmentVariables.set("KUBECONFIG", kubeConfig.toString()); - systemProperties.set(LocalDevMode.ENABLED_PROPERTY, "true"); + systemProperties.set(LocalDevMode.QUARKUS_PROFILE_PROPERTY, "dev"); assertEquals(LocalDevKubernetesOidc.DEFAULT_KUBERNETES_ISSUER, LocalDevKubernetesOidc.resolveIssuerClaimFromDiscovery()); diff --git a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevModeTest.java b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevModeTest.java index 56e970db5..ac39ce1a9 100644 --- a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevModeTest.java +++ b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevModeTest.java @@ -24,7 +24,6 @@ class LocalDevModeTest { @AfterEach void clear() { - System.clearProperty(LocalDevMode.ENABLED_PROPERTY); System.clearProperty(LocalDevMode.QUARKUS_PROFILE_PROPERTY); System.clearProperty(LocalDevMode.SPRING_PROFILES_ACTIVE_PROPERTY); System.clearProperty(LocalDevMode.MICROSERVICE_NAME_PROPERTY); @@ -36,23 +35,29 @@ void disabledByDefault() { } @Test - void enabledByExplicitProperty() { - systemProperties.set(LocalDevMode.ENABLED_PROPERTY, "true"); + void enabledByQuarkusProfileProperty() { + systemProperties.set(LocalDevMode.QUARKUS_PROFILE_PROPERTY, "dev"); assertTrue(LocalDevMode.isEnabled()); } @Test - void enabledByQuarkusProfile() { - systemProperties.set(LocalDevMode.QUARKUS_PROFILE_PROPERTY, "dev"); + void enabledByQuarkusProfileEnv() { + environmentVariables.set(LocalDevMode.QUARKUS_PROFILE_ENV, "dev"); assertTrue(LocalDevMode.isEnabled()); } @Test - void enabledBySpringProfilesActive() { + void enabledBySpringProfilesActiveProperty() { systemProperties.set(LocalDevMode.SPRING_PROFILES_ACTIVE_PROPERTY, "local,dev"); assertTrue(LocalDevMode.isEnabled()); } + @Test + void enabledBySpringProfilesActiveEnv() { + environmentVariables.set(LocalDevMode.SPRING_PROFILES_ACTIVE_ENV, "local,dev"); + assertTrue(LocalDevMode.isEnabled()); + } + @Test void requireMicroserviceNameFromProperty() { systemProperties.set(LocalDevMode.MICROSERVICE_NAME_PROPERTY, "my-service"); @@ -70,12 +75,6 @@ void requireNamespaceFromEnv() { assertEquals("my-ns", LocalDevMode.requireNamespace()); } - @Test - void enabledByEnvVariable() { - environmentVariables.set(LocalDevMode.ENABLED_ENV, "true"); - assertTrue(LocalDevMode.isEnabled()); - } - @Test void requireMicroserviceNameFromEnv() { environmentVariables.set(LocalDevMode.MICROSERVICE_NAME_ENV, "env-service"); diff --git a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevTokenSourceTest.java b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevTokenSourceTest.java index 5794dc3d3..02d583ca6 100644 --- a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevTokenSourceTest.java +++ b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevTokenSourceTest.java @@ -30,7 +30,6 @@ class LocalDevTokenSourceTest { @AfterEach void clear() { - System.clearProperty(LocalDevMode.ENABLED_PROPERTY); System.clearProperty(LocalDevMode.MICROSERVICE_NAME_PROPERTY); System.clearProperty(LocalDevMode.QUARKUS_PROFILE_PROPERTY); } @@ -49,7 +48,7 @@ void delegatesToFallbackWhenDisabled() throws Exception { @Test void requestsAndCachesTokenWhenEnabled() throws Exception { - systemProperties.set(LocalDevMode.ENABLED_PROPERTY, "true"); + systemProperties.set(LocalDevMode.QUARKUS_PROFILE_PROPERTY, "dev"); systemProperties.set(LocalDevMode.MICROSERVICE_NAME_PROPERTY, "my-sa"); environmentVariables.set(LocalDevMode.NAMESPACE_ENV, "my-ns"); @@ -74,7 +73,7 @@ void requestsAndCachesTokenWhenEnabled() throws Exception { @Test void throwsWhenAudienceIsNull() throws Exception { - systemProperties.set(LocalDevMode.ENABLED_PROPERTY, "true"); + systemProperties.set(LocalDevMode.QUARKUS_PROFILE_PROPERTY, "dev"); systemProperties.set(LocalDevMode.MICROSERVICE_NAME_PROPERTY, "my-sa"); environmentVariables.set(LocalDevMode.NAMESPACE_ENV, "my-ns"); diff --git a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/OidcAuthProviderTokenRefresherTest.java b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/OidcAuthProviderTokenRefresherTest.java index d49c2c387..89cc110cb 100644 --- a/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/OidcAuthProviderTokenRefresherTest.java +++ b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/OidcAuthProviderTokenRefresherTest.java @@ -166,26 +166,26 @@ void refreshResponseWithoutTokensFails() throws Exception { @Test void createHttpClientUsesInsecureFactoryWhenLocalDevEnabled() { - System.setProperty(LocalDevMode.ENABLED_PROPERTY, "true"); + System.setProperty(LocalDevMode.QUARKUS_PROFILE_PROPERTY, "dev"); try { ObjectNode config = MAPPER.createObjectNode(); config.put("id-token", jwtWithExp(java.time.Instant.now().plusSeconds(3600))); assertNotNull(OidcAuthProviderTokenRefresher.resolveToken(config)); } finally { - System.clearProperty(LocalDevMode.ENABLED_PROPERTY); + System.clearProperty(LocalDevMode.QUARKUS_PROFILE_PROPERTY); } } @Test void createHttpClientUsesDefaultSslWhenInsecureIdpTlsDisabled() { - System.setProperty(LocalDevMode.ENABLED_PROPERTY, "true"); + System.setProperty(LocalDevMode.QUARKUS_PROFILE_PROPERTY, "dev"); System.setProperty(INSECURE_IDP_TLS_PROPERTY, "false"); try { ObjectNode config = MAPPER.createObjectNode(); config.put("id-token", jwtWithExp(Instant.now().plusSeconds(3600))); assertNotNull(OidcAuthProviderTokenRefresher.resolveToken(config)); } finally { - System.clearProperty(LocalDevMode.ENABLED_PROPERTY); + System.clearProperty(LocalDevMode.QUARKUS_PROFILE_PROPERTY); System.clearProperty(INSECURE_IDP_TLS_PROPERTY); } }