Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions sdk/identity/azure-identity/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 1.18.0-beta.1 (Unreleased)

- Added claims challenge support to `AzureDeveloperCliCredential`. Claims provided in `TokenRequestContext` are now passed to Azure Developer CLI via the `--claims` parameter, requiring azd CLI 1.18.1 or higher. Also enhanced error handling to extract user-friendly messages from JSON output and provide clear version compatibility warnings when the `--claims` flag is unsupported.
- Added claims challenge handling support to `AzureCliCredential`. When a token request includes claims, the credential will now throw a `CredentialUnavailableException` with instructions to use Azure PowerShell directly with the appropriate `-ClaimsChallenge` parameter.
- Added claims challenge handling support to `AzurePowerShellCredential`. When a token request includes claims, the credential will now throw a `CredentialUnavailableException` with instructions to use Azure PowerShell directly with the appropriate `-ClaimsChallenge` parameter.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,11 @@ public Mono<AccessToken> authenticateWithAzureDeveloperCli(TokenRequestContext r
if (!CoreUtils.isNullOrEmpty(tenant) && !tenant.equals(IdentityUtil.DEFAULT_TENANT)) {
azdCommand.append(" --tenant-id ").append(tenant);
}

if (request.getClaims() != null && !request.getClaims().trim().isEmpty()) {
String encodedClaims = IdentityUtil.ensureBase64Encoded(request.getClaims());
azdCommand.append(" --claims ").append(shellEscape(encodedClaims));
}
} catch (ClientAuthenticationException | IllegalArgumentException e) {
return Mono.error(e);
}
Expand Down Expand Up @@ -488,9 +493,12 @@ private Mono<AccessToken> getAccessTokenFromPowerShell(TokenRequestContext reque
}

private String buildPowerShellClaimsChallengeErrorMessage(TokenRequestContext request) {
StringBuilder connectAzCommand
= new StringBuilder("Connect-AzAccount -ClaimsChallenge '").append(request.getClaims().replace("'", "''")) // Escape single quotes for PowerShell
.append("'");
StringBuilder connectAzCommand = new StringBuilder("Connect-AzAccount -ClaimsChallenge '");

// Use IdentityUtil.ensureBase64Encoded for the claims
String encodedClaims = IdentityUtil.ensureBase64Encoded(request.getClaims());
connectAzCommand.append(encodedClaims.replace("'", "''")) // Escape single quotes for PowerShell
.append("'");

// Add tenant if available
String tenant = IdentityUtil.resolveTenantId(tenantId, request, options);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
import com.azure.identity.implementation.util.LoggingUtil;
import com.azure.json.JsonProviders;
import com.azure.json.JsonReader;
import com.azure.json.JsonToken;
import com.microsoft.aad.msal4j.AppTokenProviderParameters;
import com.microsoft.aad.msal4j.ClaimsRequest;
import com.microsoft.aad.msal4j.ClientCredentialFactory;
Expand Down Expand Up @@ -734,7 +735,23 @@ AccessToken getTokenFromAzureDeveloperCLIAuthentication(StringBuilder azdCommand
if (process.exitValue() != 0) {
if (processOutput.length() > 0) {
String redactedOutput = redactInfo(processOutput);

if (redactedOutput.contains("unknown flag: --claims")
|| redactedOutput.contains("flag provided but not defined: -claims")) {
throw LoggingUtil.logCredentialUnavailableException(LOGGER, options,
new CredentialUnavailableException("Claims challenges are not supported by the "
+ "currently installed Azure Developer CLI. Please update to azd CLI version 1.18.1 or higher "
+ "to support claims challenges."));
}

if (redactedOutput.contains("azd auth login") || redactedOutput.contains("not logged in")) {
if (azdCommand.toString().contains("claims")) {
String userFriendlyError = extractUserFriendlyErrorFromAzdOutput(redactedOutput);
if (userFriendlyError != null) {
throw LOGGER
.logExceptionAsError(new ClientAuthenticationException(userFriendlyError, null));
}
}
throw LoggingUtil.logCredentialUnavailableException(LOGGER, options,
new CredentialUnavailableException("AzureDeveloperCliCredential authentication unavailable."
+ " Please run 'azd auth login' to set up account."));
Expand Down Expand Up @@ -772,6 +789,132 @@ AccessToken getTokenFromAzureDeveloperCLIAuthentication(StringBuilder azdCommand
return token;
}

/**
* Extract a single, user-friendly message from azd consoleMessage JSON output.
*
* @param output The output from the Azure Developer CLI command.
* @return A user-friendly error message if found, otherwise null.
*
* Preference order:
* 1) A message containing "Suggestion" (case-insensitive)
* 2) The second message if multiple are present
* 3) The first message if only one exists
* Returns null if no messages can be parsed.
*/
private String extractUserFriendlyErrorFromAzdOutput(String output) {
if (output == null || output.isEmpty()) {
return null;
}

List<String> messages = new ArrayList<>();

for (String line : output.split("\\R")) { // split on any line break
String trimmed = line.trim();
if (trimmed.isEmpty()) {
continue;
}

// Handle multiple JSON objects in a single line
try (JsonReader reader = JsonProviders.createReader(trimmed)) {
while (reader.nextToken() != null) {
if (reader.currentToken() == JsonToken.START_OBJECT) {
Map<String, Object> obj = reader.readMap(JsonReader::readUntyped);

// check "data.message"
Object data = obj.get("data");
if (data instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> dataMap = (Map<String, Object>) data;
Object message = dataMap.get("message");
if (message instanceof String) {
String msg = ((String) message).trim();
if (!msg.isEmpty()) {
messages.add(msg);
continue;
}
}
}

// check "message"
Object message = obj.get("message");
if (message instanceof String) {
String msg = ((String) message).trim();
if (!msg.isEmpty()) {
messages.add(msg);
}
}
} else {
break; // Not a JSON object, stop processing this line
}
}
} catch (IOException e) {
// not JSON -> ignore
}
}

if (messages.isEmpty()) {
return null;
}

// Prefer the suggestion line if present
for (String msg : messages) {
if (msg.toLowerCase().contains("suggestion")) {
return sanitizeOutput(msg);
}
}

// If more than one message exists, return the last one
if (messages.size() > 1) {
return sanitizeOutput(messages.get(messages.size() - 1));
}

return sanitizeOutput(messages.get(0));
}

/**
* Redacts tokens from CLI output to prevent error messages revealing them.
*
* @param output The output of the Azure Developer CLI command
* @return The output with tokens redacted
*/
private String sanitizeOutput(String output) {
if (output == null) {
return "";
}

// Find and redact token values without using regex
StringBuilder result = new StringBuilder();
String[] lines = output.split("\\r?\\n");

for (String line : lines) {
String processedLine = line;

// Look for "token": " pattern and redact the value
int tokenIndex = processedLine.indexOf("\"token\":");
if (tokenIndex >= 0) {
int valueStart = processedLine.indexOf("\"", tokenIndex + 8); // Skip past "token":
if (valueStart >= 0) {
int valueEnd = processedLine.indexOf("\"", valueStart + 1);
if (valueEnd >= 0) {
// Replace the token value with ****
processedLine
= processedLine.substring(0, valueStart + 1) + "****" + processedLine.substring(valueEnd);
} else {
// Handle case where quote is at end of string/line
processedLine = processedLine.substring(0, valueStart + 1) + "****";
}
}
}

if (result.length() > 0) {
result.append("\n");
}
result.append(processedLine);
}

return result.toString();
}

AccessToken authenticateWithExchangeTokenHelper(TokenRequestContext request, String assertionToken)
throws IOException {
String authorityUrl = TRAILING_FORWARD_SLASHES.matcher(options.getAuthorityHost()).replaceAll("") + "/"
Expand Down Expand Up @@ -921,8 +1064,9 @@ HttpPipeline getPipeline() {
String buildClaimsChallengeErrorMessage(TokenRequestContext request) {
StringBuilder azLoginCommand = new StringBuilder("az login --claims-challenge ");

// Properly escape the claims content for shell safety
String escapedClaims = shellEscape(request.getClaims());
// Use IdentityUtil.ensureBase64Encoded and then escape for shell safety
String encodedClaims = IdentityUtil.ensureBase64Encoded(request.getClaims());
String escapedClaims = shellEscape(encodedClaims);
azLoginCommand.append("\"").append(escapedClaims).append("\"");

// Add tenant if available
Expand All @@ -947,7 +1091,7 @@ String buildClaimsChallengeErrorMessage(TokenRequestContext request) {
/**
* Properly escape a string for shell command usage.
*/
private String shellEscape(String input) {
String shellEscape(String input) {
if (input == null) {
return "";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,11 @@ public AccessToken authenticateWithAzureDeveloperCli(TokenRequestContext request
azdCommand.append(" --tenant-id ").append(tenant);
}

if (request.getClaims() != null && !request.getClaims().trim().isEmpty()) {
String encodedClaims = IdentityUtil.ensureBase64Encoded(request.getClaims());
azdCommand.append(" --claims ").append(shellEscape(encodedClaims));
}

try {
return getTokenFromAzureDeveloperCLIAuthentication(azdCommand);
} catch (RuntimeException e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
Expand Down Expand Up @@ -232,4 +233,19 @@ public static boolean isKeyRingAccessible() {
return false;
}
}

/**
* Ensures the claims string is base64 encoded.
*
* @param claims The claims string to encode if needed
* @return Base64 encoded claims string
*/
public static String ensureBase64Encoded(String claims) {
if (claims == null || claims.trim().isEmpty()) {
return claims;
}

// Always base64 encode - let azd handle decoding
return java.util.Base64.getEncoder().encodeToString(claims.getBytes(StandardCharsets.UTF_8));
}
}
Loading