diff --git a/sdk/identity/azure-identity/src/main/java/com/azure/identity/WorkloadIdentityCredential.java b/sdk/identity/azure-identity/src/main/java/com/azure/identity/WorkloadIdentityCredential.java index f44be71b9e57..c22f5a8fd0a8 100644 --- a/sdk/identity/azure-identity/src/main/java/com/azure/identity/WorkloadIdentityCredential.java +++ b/sdk/identity/azure-identity/src/main/java/com/azure/identity/WorkloadIdentityCredential.java @@ -9,14 +9,16 @@ import com.azure.core.util.Configuration; import com.azure.core.util.CoreUtils; import com.azure.core.util.logging.ClientLogger; -import com.azure.identity.implementation.IdentityClient; -import com.azure.identity.implementation.IdentityClientBuilder; import com.azure.identity.implementation.IdentityClientOptions; -import com.azure.identity.implementation.IdentitySyncClient; import com.azure.identity.implementation.util.LoggingUtil; import com.azure.identity.implementation.util.ValidationUtil; import reactor.core.publisher.Mono; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; + import static com.azure.identity.ManagedIdentityCredential.AZURE_FEDERATED_TOKEN_FILE; /** @@ -54,9 +56,9 @@ */ public class WorkloadIdentityCredential implements TokenCredential { private static final ClientLogger LOGGER = new ClientLogger(WorkloadIdentityCredential.class); - private final IdentityClient identityClient; - private final IdentitySyncClient identitySyncClient; + private final ClientAssertionCredential clientAssertionCredential; private final IdentityClientOptions identityClientOptions; + private final String clientId; /** * WorkloadIdentityCredential supports Azure workload identity on Kubernetes. @@ -70,6 +72,10 @@ public class WorkloadIdentityCredential implements TokenCredential { IdentityClientOptions identityClientOptions) { ValidationUtil.validateTenantIdCharacterRange(tenantId, LOGGER); + if (identityClientOptions == null) { + throw LOGGER.logExceptionAsError(new IllegalArgumentException("identityClientOptions cannot be null")); + } + Configuration configuration = identityClientOptions.getConfiguration() == null ? Configuration.getGlobalConfiguration().clone() : identityClientOptions.getConfiguration(); @@ -88,44 +94,76 @@ public class WorkloadIdentityCredential implements TokenCredential { || CoreUtils.isNullOrEmpty(federatedTokenFilePathInput) || CoreUtils.isNullOrEmpty(clientIdInput) || CoreUtils.isNullOrEmpty(identityClientOptions.getAuthorityHost()))) { - IdentityClientBuilder builder = new IdentityClientBuilder().clientAssertionPath(federatedTokenFilePathInput) - .clientId(clientIdInput) - .tenantId(tenantIdInput) - .identityClientOptions(identityClientOptions); - identityClient = builder.build(); - identitySyncClient = builder.buildSyncClient(); + + clientAssertionCredential = buildClientAssertionCredential(tenantIdInput, clientIdInput, + federatedTokenFilePathInput, identityClientOptions); + this.clientId = clientIdInput; } else { - identityClient = null; - identitySyncClient = null; + clientAssertionCredential = null; + this.clientId = null; } this.identityClientOptions = identityClientOptions; } @Override public Mono getToken(TokenRequestContext request) { - if (identityClient == null) { + if (clientAssertionCredential == null) { return Mono.error(LoggingUtil.logCredentialUnavailableException(LOGGER, identityClientOptions, new CredentialUnavailableException("WorkloadIdentityCredential" + " authentication unavailable. The workload options are not fully configured. See the troubleshooting" + " guide for more information." + " https://aka.ms/azsdk/java/identity/workloadidentitycredential/troubleshoot"))); } - return identityClient.authenticateWithWorkloadIdentityConfidentialClient(request); + return clientAssertionCredential.getToken(request); } @Override public AccessToken getTokenSync(TokenRequestContext request) { - if (identitySyncClient == null) { + if (clientAssertionCredential == null) { throw LoggingUtil.logCredentialUnavailableException(LOGGER, identityClientOptions, new CredentialUnavailableException("WorkloadIdentityCredential" + " authentication unavailable. The workload options are not fully configured. See the troubleshooting" + " guide for more information." + " https://aka.ms/azsdk/java/identity/workloadidentitycredential/troubleshoot")); } - return identitySyncClient.authenticateWithWorkloadIdentityConfidentialClient(request); + return clientAssertionCredential.getTokenSync(request); } String getClientId() { - return this.identityClient.getClientId(); + return this.clientId; + } + + /** + * Builds a ClientAssertionCredential with all applicable configuration options from IdentityClientOptions. + * + * @param tenantId The tenant ID for the credential + * @param clientId The client ID for the credential + * @param federatedTokenFilePath The path to the federated token file + * @param identityClientOptions The identity client options containing configuration + * @return A configured ClientAssertionCredential instance + */ + private ClientAssertionCredential buildClientAssertionCredential(String tenantId, String clientId, + String federatedTokenFilePath, IdentityClientOptions identityClientOptions) { + + ClientAssertionCredential credential = new ClientAssertionCredential(clientId, tenantId, + () -> readFederatedTokenFromFile(federatedTokenFilePath), identityClientOptions); + + return credential; + } + + /** + * Reads the federated token from the specified file path. + * This token will be used as a client assertion for authentication. + */ + private String readFederatedTokenFromFile(String filePath) { + if (filePath == null) { + throw LOGGER.logExceptionAsError(new IllegalArgumentException("Federated token file path cannot be null")); + } + try { + byte[] bytes = Files.readAllBytes(Paths.get(filePath)); + return new String(bytes, StandardCharsets.UTF_8).trim(); + } catch (IOException e) { + throw LOGGER.logExceptionAsError(new RuntimeException("Failed to read federated token from file. ", e)); + } } } diff --git a/sdk/identity/azure-identity/src/test/java/com/azure/identity/WorkloadIdentityCredentialTest.java b/sdk/identity/azure-identity/src/test/java/com/azure/identity/WorkloadIdentityCredentialTest.java index f193389063a0..55879062175d 100644 --- a/sdk/identity/azure-identity/src/test/java/com/azure/identity/WorkloadIdentityCredentialTest.java +++ b/sdk/identity/azure-identity/src/test/java/com/azure/identity/WorkloadIdentityCredentialTest.java @@ -7,20 +7,25 @@ import com.azure.core.credential.TokenRequestContext; import com.azure.core.test.utils.TestConfigurationSource; import com.azure.core.util.Configuration; -import com.azure.identity.implementation.IdentityClient; -import com.azure.identity.implementation.IdentitySyncClient; import com.azure.identity.util.TestUtils; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.mockito.MockedConstruction; + +import reactor.core.publisher.Mono; import reactor.test.StepVerifier; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.UUID; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mockConstruction; import static org.mockito.Mockito.when; @@ -29,65 +34,71 @@ public class WorkloadIdentityCredentialTest { private static final String CLIENT_ID = UUID.randomUUID().toString(); @Test - public void testWorkloadIdentityFlow() { + public void testWorkloadIdentityFlow(@TempDir Path tempDir) throws IOException { // setup String endpoint = "https://localhost"; String token1 = "token1"; - TokenRequestContext request1 = new TokenRequestContext().addScopes("https://management.azure.com"); + TokenRequestContext request1 = new TokenRequestContext().addScopes("https://management.azure.com/.default"); OffsetDateTime expiresAt = OffsetDateTime.now(ZoneOffset.UTC).plusHours(1); Configuration configuration = TestUtils.createTestConfiguration( new TestConfigurationSource().put(Configuration.PROPERTY_AZURE_AUTHORITY_HOST, endpoint)); + // Create a temporary token file + Path tokenFile = tempDir.resolve("token.txt"); + Files.write(tokenFile, "dummy-token".getBytes()); + // mock - try (MockedConstruction identityClientMock - = mockConstruction(IdentityClient.class, (identityClient, context) -> { - when(identityClient.authenticateWithWorkloadIdentityConfidentialClient(request1)) + try (MockedConstruction clientAssertionMock + = mockConstruction(ClientAssertionCredential.class, (clientAssertion, context) -> { + when(clientAssertion.getToken(any(TokenRequestContext.class))) .thenReturn(TestUtils.getMockAccessToken(token1, expiresAt)); })) { // test WorkloadIdentityCredential credential = new WorkloadIdentityCredentialBuilder().tenantId("dummy-tenantid") - .clientId("dummy-clientid") - .tokenFilePath("dummy-path") - .configuration(configuration) .clientId(CLIENT_ID) + .tokenFilePath(tokenFile.toString()) + .configuration(configuration) .build(); StepVerifier.create(credential.getToken(request1)) .expectNextMatches(token -> token1.equals(token.getToken()) && expiresAt.getSecond() == token.getExpiresAt().getSecond()) .verifyComplete(); - assertNotNull(identityClientMock); + assertNotNull(clientAssertionMock); } } @Test - public void testWorkloadIdentityFlowSync() { + public void testWorkloadIdentityFlowSync(@TempDir Path tempDir) throws IOException { // setup String endpoint = "https://localhost"; String token1 = "token1"; - TokenRequestContext request1 = new TokenRequestContext().addScopes("https://management.azure.com"); + TokenRequestContext request1 = new TokenRequestContext().addScopes("https://management.azure.com/.default"); OffsetDateTime expiresAt = OffsetDateTime.now(ZoneOffset.UTC).plusHours(1); Configuration configuration = TestUtils.createTestConfiguration( new TestConfigurationSource().put(Configuration.PROPERTY_AZURE_AUTHORITY_HOST, endpoint)); + // Create a temporary token file + Path tokenFile = tempDir.resolve("token.txt"); + Files.write(tokenFile, "dummy-token".getBytes()); + // mock - try (MockedConstruction identityClientMock - = mockConstruction(IdentitySyncClient.class, (identityClient, context) -> { - when(identityClient.authenticateWithWorkloadIdentityConfidentialClient(request1)) + try (MockedConstruction clientAssertionMock + = mockConstruction(ClientAssertionCredential.class, (clientAssertion, context) -> { + when(clientAssertion.getTokenSync(any(TokenRequestContext.class))) .thenReturn(TestUtils.getMockAccessTokenSync(token1, expiresAt)); })) { // test WorkloadIdentityCredential credential = new WorkloadIdentityCredentialBuilder().tenantId("dummy-tenantid") - .clientId("dummy-clientid") - .tokenFilePath("dummy-path") - .configuration(configuration) .clientId(CLIENT_ID) + .tokenFilePath(tokenFile.toString()) + .configuration(configuration) .build(); AccessToken token = credential.getTokenSync(request1); assertTrue(token1.equals(token.getToken())); assertTrue(expiresAt.getSecond() == token.getExpiresAt().getSecond()); - assertNotNull(identityClientMock); + assertNotNull(clientAssertionMock); } } @@ -135,4 +146,44 @@ public void testWorkloadIdentityFlowFailureNoTokenPath() { .clientId("client-id") .build()); } + + @Test + public void testGetClientId(@TempDir Path tempDir) throws IOException { + // setup + String endpoint = "https://localhost"; + Configuration configuration = TestUtils.createTestConfiguration( + new TestConfigurationSource().put(Configuration.PROPERTY_AZURE_AUTHORITY_HOST, endpoint)); + + // test + WorkloadIdentityCredential credential = new WorkloadIdentityCredentialBuilder().tenantId("dummy-tenantid") + .clientId(CLIENT_ID) + .tokenFilePath("dummy-path") + .configuration(configuration) + .build(); + + Assertions.assertEquals(CLIENT_ID, credential.getClientId()); + } + + @Test + public void testFileReadingError(@TempDir Path tempDir) { + // setup + String endpoint = "https://localhost"; + Configuration configuration = TestUtils.createTestConfiguration( + new TestConfigurationSource().put(Configuration.PROPERTY_AZURE_AUTHORITY_HOST, endpoint)); + TokenRequestContext request = new TokenRequestContext().addScopes("https://management.azure.com/.default"); + + String nonExistentFile = tempDir.resolve("non-existent-file.txt").toString(); + + WorkloadIdentityCredential credential = new WorkloadIdentityCredentialBuilder().tenantId("dummy-tenantid") + .clientId(CLIENT_ID) + .tokenFilePath(nonExistentFile) + .configuration(configuration) + .build(); + + StepVerifier.create(credential.getToken(request)).expectErrorSatisfies(error -> { + assertTrue(error instanceof RuntimeException); + assertTrue(error.getMessage().contains("Failed to read federated token from file")); + assertTrue(error.getCause() instanceof IOException); // Original IOException from Files.readAllBytes + }).verify(); + } }