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..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 @@ -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,110 @@ public static AccessToken getAccessToken(String resource, String aadAuthenticati return result; } + public static boolean isFederatedTokenFileConfigured() { + String federatedTokenFilePath = System.getenv(ENV_AZURE_FEDERATED_TOKEN_FILE); + return !isNullOrBlank(federatedTokenFilePath); + } + + /** + * 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 (!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 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 (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; + } + + 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) { + 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 new String(Files.readAllBytes(path), StandardCharsets.UTF_8).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 70% 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..6488cab0c537 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.write(tempFile, expectedContent.getBytes(StandardCharsets.UTF_8)); + + String actualContent = AccessTokenUtil.readFile(tempFile.toAbsolutePath().toString()); + assertNotNull(actualContent); + assertEquals(expectedContent, actualContent); + } }