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..ebfae54ff --- /dev/null +++ b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigHttpClientFactory.java @@ -0,0 +1,109 @@ +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.io.IOException; +import java.net.http.HttpClient; +import java.security.GeneralSecurityException; +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 (GeneralSecurityException 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 (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) { // NOSONAR + // Local-dev: client certificates are not used for TokenRequest / OIDC discovery. + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { // NOSONAR + // Local-dev: kubeconfig insecure-skip-tls-verify or private IdP CA — validation intentionally skipped. + } + + @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..a2db96c7e --- /dev/null +++ b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigLoader.java @@ -0,0 +1,220 @@ +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 = 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); + if (args.isArray()) { + for (JsonNode arg : args) { + commandLine.add(arg.asText()); + } + } + return commandLine; + } + + 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); + } + } + } + + 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(); ) { + 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 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 new file mode 100644 index 000000000..c4798c237 --- /dev/null +++ b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevConstants.java @@ -0,0 +1,81 @@ +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 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..4edb7047d --- /dev/null +++ b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevKubernetesOidc.java @@ -0,0 +1,181 @@ +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 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.HTTP_REQUEST_TIMEOUT; +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 { + + /** 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 final AtomicReference cachedCredentials = new AtomicReference<>(); + private static final AtomicReference cachedHttpClient = new AtomicReference<>(); + + 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.contains(JWKS_PATH); + } 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 (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); + } + return DEFAULT_KUBERNETES_ISSUER; + } + + private static String get(String url) throws IOException, InterruptedException { + return getWithRetry(url); + } + + private static String getWithRetry(String url) throws IOException, InterruptedException { + try { + return sendGet(url); + } catch (IOException 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 IOException, InterruptedException { + 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.get(); + if (existing != null) { + return existing; + } + synchronized (LOCK) { + KubeConfigCredentials resolved = cachedCredentials.get(); + if (resolved == null) { + resolved = KubeConfigLoader.load(); + cachedCredentials.set(resolved); + log.info("Local-dev kubeconfig: API server {}", resolved.getServerUrl()); + } + return resolved; + } + } + + /** + * 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.get(); + if (existing != null) { + return existing; + } + synchronized (LOCK) { + HttpClient resolved = cachedHttpClient.get(); + if (resolved == null) { + resolved = KubeConfigHttpClientFactory.create(credentials()); + cachedHttpClient.set(resolved); + } + return resolved; + } + } + + @VisibleForTesting + static void resetCache() { + synchronized (LOCK) { + cachedCredentials.set(null); + cachedHttpClient.set(null); + } + } + + private static void resetHttpClient() { + synchronized (LOCK) { + cachedHttpClient.set(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..ce4f53afe --- /dev/null +++ b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevMode.java @@ -0,0 +1,73 @@ +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 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 (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 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..e39594434 --- /dev/null +++ b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevTokenSource.java @@ -0,0 +1,94 @@ +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.concurrent.atomic.AtomicReference; +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 final AtomicReference client = new AtomicReference<>(); + + 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.get(); + if (existing != null) { + return existing; + } + synchronized (this) { + TokenRequestClient resolved = client.get(); + if (resolved == null) { + resolved = clientSupplier.get(); + client.set(resolved); + } + return resolved; + } + } + + @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..ddc8d3781 --- /dev/null +++ b/core-utils/k8s/src/main/java/com/netcracker/cloud/security/core/utils/k8s/localdev/TokenRequestClient.java @@ -0,0 +1,136 @@ +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; +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) { + 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); + 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) { + 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..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 @@ -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) { @@ -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 new file mode 100644 index 000000000..058adcf7a --- /dev/null +++ b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/KubeConfigLoaderTest.java @@ -0,0 +1,518 @@ +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; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@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()); + } + + @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 new file mode 100644 index 000000000..a9707adee --- /dev/null +++ b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevKubernetesOidcTest.java @@ -0,0 +1,187 @@ +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.QUARKUS_PROFILE_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.QUARKUS_PROFILE_PROPERTY, "dev"); + + 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")); + 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.QUARKUS_PROFILE_PROPERTY, "dev"); + + 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 new file mode 100644 index 000000000..ac39ce1a9 --- /dev/null +++ b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevModeTest.java @@ -0,0 +1,88 @@ +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.QUARKUS_PROFILE_PROPERTY); + System.clearProperty(LocalDevMode.SPRING_PROFILES_ACTIVE_PROPERTY); + System.clearProperty(LocalDevMode.MICROSERVICE_NAME_PROPERTY); + } + + @Test + void disabledByDefault() { + assertFalse(LocalDevMode.isEnabled()); + } + + @Test + void enabledByQuarkusProfileProperty() { + systemProperties.set(LocalDevMode.QUARKUS_PROFILE_PROPERTY, "dev"); + assertTrue(LocalDevMode.isEnabled()); + } + + @Test + void enabledByQuarkusProfileEnv() { + environmentVariables.set(LocalDevMode.QUARKUS_PROFILE_ENV, "dev"); + assertTrue(LocalDevMode.isEnabled()); + } + + @Test + 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"); + assertEquals("my-service", LocalDevMode.requireMicroserviceName()); + } + + @Test + void requireMicroserviceNameFailsWhenMissing() { + assertThrows(IllegalStateException.class, LocalDevMode::requireMicroserviceName); + } + + @Test + void requireNamespaceFromEnv() { + environmentVariables.set(LocalDevMode.NAMESPACE_ENV, "my-ns"); + assertEquals("my-ns", LocalDevMode.requireNamespace()); + } + + @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 new file mode 100644 index 000000000..02d583ca6 --- /dev/null +++ b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/LocalDevTokenSourceTest.java @@ -0,0 +1,92 @@ +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.junit.jupiter.api.Assertions.assertThrows; +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.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.QUARKUS_PROFILE_PROPERTY, "dev"); + 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("my-ns", "my-sa", "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("my-ns", "my-sa", "netcracker"); + assertEquals(1, supplierCalls.get()); + } + + @Test + void throwsWhenAudienceIsNull() throws Exception { + systemProperties.set(LocalDevMode.QUARKUS_PROFILE_PROPERTY, "dev"); + 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 new file mode 100644 index 000000000..89cc110cb --- /dev/null +++ b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/OidcAuthProviderTokenRefresherTest.java @@ -0,0 +1,253 @@ +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 org.junit.jupiter.api.extension.ExtendWith; +import uk.org.webcompere.systemstubs.jupiter.SystemStubsExtension; + +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.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; + 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)); + } + + @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.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.QUARKUS_PROFILE_PROPERTY); + } + } + + @Test + void createHttpClientUsesDefaultSslWhenInsecureIdpTlsDisabled() { + 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.QUARKUS_PROFILE_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() + .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..b8ebc9ec6 --- /dev/null +++ b/core-utils/k8s/src/test/java/com/netcracker/cloud/security/core/utils/k8s/localdev/TokenRequestClientTest.java @@ -0,0 +1,130 @@ +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.assertNotNull; +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")); + } + + @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()); + } +}