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());
+ }
+}