From 9a8a803139d334715ee4d0257974e5c33768b4f8 Mon Sep 17 00:00:00 2001 From: Thomas Obereder Date: Tue, 20 Jan 2026 10:21:45 +0100 Subject: [PATCH 1/4] add support for Workload Identity federated token authentication --- .../azure-security-keyvault-jca/CHANGELOG.md | 1 + .../jca/implementation/KeyVaultClient.java | 2 + .../implementation/utils/AccessTokenUtil.java | 106 ++++++++++++++++++ .../utils}/AccessTokenUtilTest.java | 22 +++- 4 files changed, 127 insertions(+), 4 deletions(-) rename sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/{ => implementation/utils}/AccessTokenUtilTest.java (71%) diff --git a/sdk/keyvault/azure-security-keyvault-jca/CHANGELOG.md b/sdk/keyvault/azure-security-keyvault-jca/CHANGELOG.md index c406c10f73fb..536ee2db0703 100644 --- a/sdk/keyvault/azure-security-keyvault-jca/CHANGELOG.md +++ b/sdk/keyvault/azure-security-keyvault-jca/CHANGELOG.md @@ -3,6 +3,7 @@ ## 2.11.0-beta.1 (Unreleased) ### Features Added +- Add support for Workload Identity authentication in Azure Kubernetes Service (AKS). ### Breaking Changes diff --git a/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/KeyVaultClient.java b/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/KeyVaultClient.java index af460d746385..180afe92c0ff 100644 --- a/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/KeyVaultClient.java +++ b/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/KeyVaultClient.java @@ -204,6 +204,8 @@ private AccessToken getAccessTokenByHttpRequest() { disableChallengeResourceVerification); accessToken = AccessTokenUtil.getAccessToken(resource, aadAuthenticationUri, tenantId, clientId, clientSecret); + } else if (AccessTokenUtil.isFederatedTokenFileConfigured()) { + accessToken = AccessTokenUtil.getAccessTokenUsingWorkloadIdentity(keyVaultBaseUri, tenantId, clientId); } else { accessToken = AccessTokenUtil.getAccessToken(resource, managedIdentity); } diff --git a/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtil.java b/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtil.java index e7895c39958b..9d7f13e610c9 100644 --- a/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtil.java +++ b/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtil.java @@ -10,10 +10,15 @@ import java.net.URI; import java.net.URISyntaxException; import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Collections; import java.util.HashMap; import java.util.Locale; import java.util.Map; +import java.util.function.Supplier; import java.util.logging.Logger; import static com.azure.security.keyvault.jca.implementation.utils.HttpUtil.addTrailingSlashIfRequired; @@ -78,6 +83,11 @@ public final class AccessTokenUtil { private static final String PROPERTY_IDENTITY_ENDPOINT = "IDENTITY_ENDPOINT"; private static final String PROPERTY_IDENTITY_HEADER = "IDENTITY_HEADER"; + private static final String ENV_AZURE_FEDERATED_TOKEN_FILE = "AZURE_FEDERATED_TOKEN_FILE"; + private static final String ENV_AZURE_CLIENT_ID = "AZURE_CLIENT_ID"; + private static final String ENV_AZURE_TENANT_ID = "AZURE_TENANT_ID"; + private static final String ENV_AZURE_AUTHORITY_HOST = "AZURE_AUTHORITY_HOST"; + /** * Get an access token for a managed identity. * @@ -168,6 +178,102 @@ public static AccessToken getAccessToken(String resource, String aadAuthenticati return result; } + public static boolean isFederatedTokenFileConfigured() { + String federatedTokenFilePath = System.getenv(ENV_AZURE_FEDERATED_TOKEN_FILE); + return federatedTokenFilePath != null && !federatedTokenFilePath.isBlank(); + } + + /** + * Get an access token via client creds grant flow + * using Microsoft Entra Workload ID with AKS. + * Uses the federate token in file located at environment variable AZURE_FEDERATED_TOKEN_FILE + * and provided clientId and tenantId to issue an access token HTTP request. + * + * @param keyVaultBaseUri Base URI of the keyvault. + * @param tenantId Tenant ID to use. If blank fallback to environment variable AZURE_TENANT_ID + * @param clientId Client ID of the managed identity to use. If blank fallback to environment variable AZURE_CLIENT_ID + * @return An access token. + */ + public static AccessToken getAccessTokenUsingWorkloadIdentity(String keyVaultBaseUri, String tenantId, String clientId) { + LOGGER.entering("AccessTokenUtil", "getAccessTokenUsingWorkloadIdentity", + new Object[] { keyVaultBaseUri, tenantId, clientId }); + LOGGER.info("Getting access token using federated Workload Identity token"); + + String tokenFilePath = System.getenv(ENV_AZURE_FEDERATED_TOKEN_FILE); + LOGGER.log(INFO, "Using federated token file: {0}", tokenFilePath); + + tenantId = useDefaultIfBlank(tenantId, () -> System.getenv(ENV_AZURE_TENANT_ID)); + clientId = useDefaultIfBlank(clientId, () -> System.getenv(ENV_AZURE_CLIENT_ID)); + LOGGER.log(INFO, "Using clientId {0} in tenantId {1}", new Object[] { clientId, tenantId }); + + // scope is required to end with "/.default" + if (!keyVaultBaseUri.endsWith(".default")) { + keyVaultBaseUri = addTrailingSlashIfRequired(keyVaultBaseUri) + ".default"; + } + + // allow override of authority host via environment variable + String authorityHost = useDefaultIfBlank(System.getenv(ENV_AZURE_AUTHORITY_HOST), + () -> OAUTH2_TOKEN_BASE_URL); + + AccessToken result = null; + + String federatedToken = readFile(tokenFilePath); + if (federatedToken != null && !federatedToken.isBlank()) { + String requestUrl = addTrailingSlashIfRequired(authorityHost) + tenantId + "/oauth2/v2.0/token"; + String requestBody = "grant_type=client_credentials" + + "&client_id=" + urlEncode(clientId) + + "&client_assertion_type=" + urlEncode("urn:ietf:params:oauth:client-assertion-type:jwt-bearer") + + "&client_assertion=" + urlEncode(federatedToken) + + "&scope=" + urlEncode(keyVaultBaseUri); + + String response = HttpUtil.post(requestUrl, requestBody, "application/x-www-form-urlencoded"); + result = parseAccessTokenResponse(response); + } else { + LOGGER.log(WARNING, "Failed to read federated token from file: {0}", tokenFilePath); + } + + LOGGER.exiting("AccessTokenUtil", "getAccessTokenUsingWorkloadIdentity", result); + + return result; + } + + private static String useDefaultIfBlank(String value, Supplier defaultValueSupplier) { + if (value == null || value.isBlank()) { + return defaultValueSupplier.get(); + } + return value; + } + + private static String urlEncode(String text) { + if (text == null) { + return null; + } + return URLEncoder.encode(text, StandardCharsets.UTF_8); + } + + private static AccessToken parseAccessTokenResponse(String response) { + if (response == null) { + return null; + } + + try { + return JsonConverterUtil.fromJson(AccessToken::fromJson, response); + } catch (IOException e) { + LOGGER.log(WARNING, "Failed to parse access token from response.", e); + return null; + } + } + + static String readFile(String filePath) { + try { + Path path = Paths.get(filePath); + return Files.readString(path).trim(); + } catch (IOException e) { + LOGGER.log(WARNING, "Failed to read file.", e); + return null; + } + } + /** * Get the access token on Azure App Service. * diff --git a/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/AccessTokenUtilTest.java b/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtilTest.java similarity index 71% rename from sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/AccessTokenUtilTest.java rename to sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtilTest.java index a7dcadfd53a7..76ccbfc5a078 100644 --- a/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/AccessTokenUtilTest.java +++ b/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtilTest.java @@ -1,20 +1,23 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -package com.azure.security.keyvault.jca; +package com.azure.security.keyvault.jca.implementation.utils; +import com.azure.security.keyvault.jca.PropertyConvertorUtils; import com.azure.security.keyvault.jca.implementation.model.AccessToken; -import com.azure.security.keyvault.jca.implementation.utils.AccessTokenUtil; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.api.io.TempDir; import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import static com.azure.security.keyvault.jca.implementation.utils.AccessTokenUtil.getLoginUri; import static com.azure.security.keyvault.jca.implementation.utils.HttpUtil.API_VERSION_POSTFIX; import static com.azure.security.keyvault.jca.implementation.utils.HttpUtil.addTrailingSlashIfRequired; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.*; /** * The JUnit test for the AuthClient. @@ -46,4 +49,15 @@ public void testGetLoginUri() { assertNotNull(result); assertDoesNotThrow(() -> new URI(result)); } + + @Test + void testReadFile(@TempDir Path tempDir) throws Exception { + Path tempFile = Files.createTempFile(tempDir, "simple_text_file_", ".txt"); + String expectedContent = "Just a dummy string"; + Files.writeString(tempFile, expectedContent, StandardCharsets.UTF_8); + + String actualContent = AccessTokenUtil.readFile(tempFile.toAbsolutePath().toString()); + assertNotNull(actualContent); + assertEquals(expectedContent, actualContent); + } } From 890ad2329891f2d698d8343782032115f5698fa9 Mon Sep 17 00:00:00 2001 From: Thomas Obereder Date: Tue, 20 Jan 2026 11:21:41 +0100 Subject: [PATCH 2/4] rewrite Java 8 incompatible code --- .../implementation/utils/AccessTokenUtil.java | 20 ++++++++++++++----- .../utils/AccessTokenUtilTest.java | 2 +- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtil.java b/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtil.java index 9d7f13e610c9..26ca220aba53 100644 --- a/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtil.java +++ b/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtil.java @@ -180,7 +180,7 @@ public static AccessToken getAccessToken(String resource, String aadAuthenticati public static boolean isFederatedTokenFileConfigured() { String federatedTokenFilePath = System.getenv(ENV_AZURE_FEDERATED_TOKEN_FILE); - return federatedTokenFilePath != null && !federatedTokenFilePath.isBlank(); + return !isNullOrBlank(federatedTokenFilePath); } /** @@ -218,7 +218,7 @@ public static AccessToken getAccessTokenUsingWorkloadIdentity(String keyVaultBas AccessToken result = null; String federatedToken = readFile(tokenFilePath); - if (federatedToken != null && !federatedToken.isBlank()) { + if (!isNullOrBlank(federatedToken)) { String requestUrl = addTrailingSlashIfRequired(authorityHost) + tenantId + "/oauth2/v2.0/token"; String requestBody = "grant_type=client_credentials" + "&client_id=" + urlEncode(clientId) + @@ -238,17 +238,27 @@ public static AccessToken getAccessTokenUsingWorkloadIdentity(String keyVaultBas } private static String useDefaultIfBlank(String value, Supplier defaultValueSupplier) { - if (value == null || value.isBlank()) { + if (isNullOrBlank(value)) { return defaultValueSupplier.get(); } return value; } + private static boolean isNullOrBlank(String value) { + return value == null || value.trim().isEmpty(); + } + private static String urlEncode(String text) { if (text == null) { return null; } - return URLEncoder.encode(text, StandardCharsets.UTF_8); + + try { + return URLEncoder.encode(text, "UTF-8"); + } catch (UnsupportedEncodingException e) { + LOGGER.log(WARNING, "Failed to encode text.", e); + return null; + } } private static AccessToken parseAccessTokenResponse(String response) { @@ -267,7 +277,7 @@ private static AccessToken parseAccessTokenResponse(String response) { static String readFile(String filePath) { try { Path path = Paths.get(filePath); - return Files.readString(path).trim(); + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8).trim(); } catch (IOException e) { LOGGER.log(WARNING, "Failed to read file.", e); return null; diff --git a/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtilTest.java b/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtilTest.java index 76ccbfc5a078..6488cab0c537 100644 --- a/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtilTest.java +++ b/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtilTest.java @@ -54,7 +54,7 @@ public void testGetLoginUri() { void testReadFile(@TempDir Path tempDir) throws Exception { Path tempFile = Files.createTempFile(tempDir, "simple_text_file_", ".txt"); String expectedContent = "Just a dummy string"; - Files.writeString(tempFile, expectedContent, StandardCharsets.UTF_8); + Files.write(tempFile, expectedContent.getBytes(StandardCharsets.UTF_8)); String actualContent = AccessTokenUtil.readFile(tempFile.toAbsolutePath().toString()); assertNotNull(actualContent); From a592c289a4102f5b83710a5c9ce65858cfbeb544 Mon Sep 17 00:00:00 2001 From: Thomas Obereder Date: Tue, 20 Jan 2026 12:32:59 +0100 Subject: [PATCH 3/4] fix checkstyle findings --- .../jca/implementation/utils/AccessTokenUtil.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtil.java b/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtil.java index 26ca220aba53..e7672d54f41b 100644 --- a/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtil.java +++ b/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtil.java @@ -220,11 +220,11 @@ public static AccessToken getAccessTokenUsingWorkloadIdentity(String keyVaultBas String federatedToken = readFile(tokenFilePath); if (!isNullOrBlank(federatedToken)) { String requestUrl = addTrailingSlashIfRequired(authorityHost) + tenantId + "/oauth2/v2.0/token"; - String requestBody = "grant_type=client_credentials" + - "&client_id=" + urlEncode(clientId) + - "&client_assertion_type=" + urlEncode("urn:ietf:params:oauth:client-assertion-type:jwt-bearer") + - "&client_assertion=" + urlEncode(federatedToken) + - "&scope=" + urlEncode(keyVaultBaseUri); + String requestBody = "grant_type=client_credentials" + + "&client_id=" + urlEncode(clientId) + + "&client_assertion_type=" + urlEncode("urn:ietf:params:oauth:client-assertion-type:jwt-bearer") + + "&client_assertion=" + urlEncode(federatedToken) + + "&scope=" + urlEncode(keyVaultBaseUri); String response = HttpUtil.post(requestUrl, requestBody, "application/x-www-form-urlencoded"); result = parseAccessTokenResponse(response); From fad20262ff43289cc475f64a6fe90865c240dd3d Mon Sep 17 00:00:00 2001 From: Thomas Obereder Date: Tue, 20 Jan 2026 12:50:25 +0100 Subject: [PATCH 4/4] fix spotless check findings --- .../jca/implementation/utils/AccessTokenUtil.java | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtil.java b/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtil.java index e7672d54f41b..3271fc2adcbd 100644 --- a/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtil.java +++ b/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtil.java @@ -194,7 +194,8 @@ public static boolean isFederatedTokenFileConfigured() { * @param clientId Client ID of the managed identity to use. If blank fallback to environment variable AZURE_CLIENT_ID * @return An access token. */ - public static AccessToken getAccessTokenUsingWorkloadIdentity(String keyVaultBaseUri, String tenantId, String clientId) { + public static AccessToken getAccessTokenUsingWorkloadIdentity(String keyVaultBaseUri, String tenantId, + String clientId) { LOGGER.entering("AccessTokenUtil", "getAccessTokenUsingWorkloadIdentity", new Object[] { keyVaultBaseUri, tenantId, clientId }); LOGGER.info("Getting access token using federated Workload Identity token"); @@ -212,19 +213,16 @@ public static AccessToken getAccessTokenUsingWorkloadIdentity(String keyVaultBas } // allow override of authority host via environment variable - String authorityHost = useDefaultIfBlank(System.getenv(ENV_AZURE_AUTHORITY_HOST), - () -> OAUTH2_TOKEN_BASE_URL); + String authorityHost = useDefaultIfBlank(System.getenv(ENV_AZURE_AUTHORITY_HOST), () -> OAUTH2_TOKEN_BASE_URL); AccessToken result = null; String federatedToken = readFile(tokenFilePath); if (!isNullOrBlank(federatedToken)) { String requestUrl = addTrailingSlashIfRequired(authorityHost) + tenantId + "/oauth2/v2.0/token"; - String requestBody = "grant_type=client_credentials" - + "&client_id=" + urlEncode(clientId) - + "&client_assertion_type=" + urlEncode("urn:ietf:params:oauth:client-assertion-type:jwt-bearer") - + "&client_assertion=" + urlEncode(federatedToken) - + "&scope=" + urlEncode(keyVaultBaseUri); + String requestBody = "grant_type=client_credentials" + "&client_id=" + urlEncode(clientId) + + "&client_assertion_type=" + urlEncode("urn:ietf:params:oauth:client-assertion-type:jwt-bearer") + + "&client_assertion=" + urlEncode(federatedToken) + "&scope=" + urlEncode(keyVaultBaseUri); String response = HttpUtil.post(requestUrl, requestBody, "application/x-www-form-urlencoded"); result = parseAccessTokenResponse(response);