Skip to content
Merged
1 change: 1 addition & 0 deletions eng/versioning/version_client.txt
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,7 @@ io.clientcore:optional-dependency-tests;1.0.0-beta.1;1.0.0-beta.1
unreleased_com.azure.v2:azure-core;2.0.0-beta.1
unreleased_com.azure.v2:azure-identity;2.0.0-beta.1
unreleased_io.clientcore:http-netty4;1.0.0-beta.1
unreleased_com.azure:azure-identity;1.18.0-beta.1

# Released Beta dependencies: Copy the entry from above, prepend "beta_", remove the current
# version and set the version to the released beta. Released beta dependencies are only valid
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
<dependency>
<groupId>com.azure</groupId>
<artifactId>azure-identity</artifactId>
<version>1.17.0</version> <!-- {x-version-update;com.azure:azure-identity;dependency} -->
<version>1.18.0-beta.1</version> <!-- {x-version-update;unreleased_com.azure:azure-identity;dependency} -->
<scope>test</scope>
</dependency>
</dependencies>
Expand Down
1 change: 1 addition & 0 deletions sdk/identity/azure-identity/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
### Breaking Changes

### Bugs Fixed
- Fixed `AzurePowerShellCredential` handling of XML header responses and `/Date(epochTime)/` time format parsing that previously caused `JsonParsingException`. [#46572](https://github.com/Azure/azure-sdk-for-java/pull/46572)
- Fixed `AzureDeveloperCliCredential` hanging when `AZD_DEBUG` environment variable is set by adding `--no-prompt` flag to the `azd auth token` command.

### Other Changes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@
import java.nio.file.Paths;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
Expand Down Expand Up @@ -453,10 +452,15 @@ private Mono<AccessToken> getAccessTokenFromPowerShell(TokenRequestContext reque
} catch (IllegalArgumentException ex) {
throw LOGGER.logExceptionAsError(ex);
}

String resolvedTenant = IdentityUtil.resolveTenantId(tenantId, request, options);
String tenant = resolvedTenant.equals(IdentityUtil.DEFAULT_TENANT) ? "" : resolvedTenant;
ValidationUtil.validateTenantIdCharacterRange(tenant, LOGGER);

return Mono.defer(() -> {
String sep = System.lineSeparator();

String command = PowerShellUtil.getPwshCommand(tenantId, scope, sep);
String command = PowerShellUtil.getPwshCommand(tenant, scope, sep);

return powershellManager.runCommand(command).flatMap(output -> {
if (output.contains("VersionTooOld")) {
Expand All @@ -476,7 +480,12 @@ private Mono<AccessToken> getAccessTokenFromPowerShell(TokenRequestContext reque
Map<String, String> objectMap = reader.readMap(JsonReader::getString);
String accessToken = objectMap.get("Token");
String time = objectMap.get("ExpiresOn");
OffsetDateTime expiresOn = OffsetDateTime.parse(time).withOffsetSameInstant(ZoneOffset.UTC);
OffsetDateTime expiresOn = PowerShellUtil.parseExpiresOn(time);
if (expiresOn == null) {
return Mono.error(LoggingUtil.logCredentialUnavailableException(LOGGER, options,
new CredentialUnavailableException(
"Encountered error when deserializing ExpiresOn time from PowerShell response.")));
}
return Mono.just(new AccessToken(accessToken, expiresOn));
} catch (IOException e) {
return Mono.error(LoggingUtil.logCredentialUnavailableException(LOGGER, options,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,26 @@

package com.azure.identity.implementation.util;

import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeParseException;

import com.azure.core.util.CoreUtils;

/**
* Utility class for powershell auth related ops .
*/
public class PowerShellUtil {
private static final String DOTNET_DATE_PREFIX = "/Date(";
private static final String DOTNET_DATE_SUFFIX = ")/";

public static String getPwshCommand(String tenantId, String scope, String sep) {
return "$ErrorActionPreference = 'Stop'" + sep
+ "$ProgressPreference = 'SilentlyContinue'" + sep
+ "$VerbosePreference = 'SilentlyContinue'" + sep
+ "$WarningPreference = 'SilentlyContinue'" + sep
+ "$InformationPreference = 'SilentlyContinue'" + sep
+ "[version]$minimumVersion = '2.2.0'" + sep
+ "$m = Import-Module Az.Accounts -MinimumVersion $minimumVersion -PassThru -ErrorAction SilentlyContinue" + sep
+ "if (! $m) {" + sep
Expand Down Expand Up @@ -38,10 +52,45 @@ public static String getPwshCommand(String tenantId, String scope, String sep) {
+ " $tokenValue = $tokenValue | ConvertFrom-SecureString -AsPlainText" + sep
+ " }" + sep
+ "}" + sep
+ "$customToken = [PSCustomObject]@{" + sep
+ " Token = $tokenValue" + sep
+ " ExpiresOn = $token.ExpiresOn" + sep
+ "}" + sep
+ "$customToken | ConvertTo-Json -Compress";
+ "$customToken = New-Object -TypeName PSObject" + sep
+ "$customToken | Add-Member -MemberType NoteProperty -Name Token -Value $tokenValue" + sep
+ "$customToken | Add-Member -MemberType NoteProperty -Name ExpiresOn -Value $token.ExpiresOn" + sep
+ "$customToken | ConvertTo-Json -Compress -Depth 10";
}

/**
* Parse ExpiresOn returned from PowerShell. Supports ISO timestamps and the .NET "/Date(ms)/" form.
*
* @param time the string value returned by PowerShell
* @return parsed OffsetDateTime in UTC or null if unable to parse
*/
public static OffsetDateTime parseExpiresOn(String time) {
if (CoreUtils.isNullOrEmpty(time)) {
return null;
}

// Try ISO first
try {
return OffsetDateTime.parse(time).withOffsetSameInstant(ZoneOffset.UTC);
} catch (DateTimeParseException ignore) {
// fall through to .NET style parsing
}

if (time.length() > DOTNET_DATE_PREFIX.length() + DOTNET_DATE_SUFFIX.length()
&& time.startsWith(DOTNET_DATE_PREFIX) && time.endsWith(DOTNET_DATE_SUFFIX)) {
String digits = time.substring(DOTNET_DATE_PREFIX.length(), time.length() - DOTNET_DATE_SUFFIX.length());
for (int i = 0; i < digits.length(); i++) {
if (!Character.isDigit(digits.charAt(i))) {
return null;
}
}
try {
long epochMs = Long.parseLong(digits);
return OffsetDateTime.ofInstant(Instant.ofEpochMilli(epochMs), ZoneOffset.UTC);
} catch (NumberFormatException ignore) {
return null;
}
}
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package com.azure.identity.implementation.util;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;

import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.stream.Stream;

import static org.junit.jupiter.api.Assertions.*;

public class PowerShellUtilTests {

// Test data for valid ISO timestamps
static Stream<String> validISOTimestamps() {
return Stream.of("2023-05-15T10:30:00Z", "2023-05-15T10:30:00.123Z", "2023-05-15T10:30:00+00:00",
"2023-05-15T10:30:00-05:00", "2023-05-15T10:30:00.123456+02:00", "2023-12-31T23:59:59Z",
"2000-01-01T00:00:00Z");
}

// Test data for valid .NET Date format
static Stream<String> validDotNetDates() {
return Stream.of("/Date(1684145400000)/", // 2023-05-15 10:30:00 UTC
"/Date(0)/", // Unix epoch
"/Date(1640995200000)/", // 2022-01-01 00:00:00 UTC
"/Date(253402300799000)/" // Very far future date
);
}

// Test data for invalid inputs
static Stream<String> invalidInputs() {
return Stream.of("", " ", "invalid-date", "/Date(/", "/Date())/", "/Date(abc)/", "/Date(123abc)/",
"/Date(123.456)/", "/Date(-123)/", "/Date(123", "Date(123)/", "/Date(123)//", "2023-13-01T10:30:00Z", // Invalid month
"2023-05-32T10:30:00Z", // Invalid day
"not-a-date-at-all");
}

@Test
public void testParseExpiresOnWithNull() {
assertNull(PowerShellUtil.parseExpiresOn(null));
}

@Test
public void testParseExpiresOnWithEmptyString() {
assertNull(PowerShellUtil.parseExpiresOn(""));
}

@Test
public void testParseExpiresOnWithWhitespace() {
assertNull(PowerShellUtil.parseExpiresOn(" "));
}

@ParameterizedTest
@MethodSource("validISOTimestamps")
public void testParseExpiresOnWithValidISOTimestamps(String isoTimestamp) {
OffsetDateTime result = PowerShellUtil.parseExpiresOn(isoTimestamp);

assertNotNull(result, "Should parse valid ISO timestamp: " + isoTimestamp);
assertEquals(ZoneOffset.UTC, result.getOffset(), "Result should be converted to UTC");

// Verify it's a valid timestamp by converting back
OffsetDateTime original = OffsetDateTime.parse(isoTimestamp);
assertEquals(original.withOffsetSameInstant(ZoneOffset.UTC), result);
}

@ParameterizedTest
@MethodSource("validDotNetDates")
public void testParseExpiresOnWithValidDotNetDates(String dotNetDate) {
OffsetDateTime result = PowerShellUtil.parseExpiresOn(dotNetDate);

assertNotNull(result, "Should parse valid .NET date: " + dotNetDate);
assertEquals(ZoneOffset.UTC, result.getOffset(), "Result should be in UTC");

// Extract the epoch milliseconds and verify
String digits = dotNetDate.substring(6, dotNetDate.length() - 2);
long expectedEpochMs = Long.parseLong(digits);
OffsetDateTime expected = OffsetDateTime.ofInstant(Instant.ofEpochMilli(expectedEpochMs), ZoneOffset.UTC);

assertEquals(expected, result);
}

@ParameterizedTest
@MethodSource("invalidInputs")
public void testParseExpiresOnWithInvalidInputs(String invalidInput) {
OffsetDateTime result = PowerShellUtil.parseExpiresOn(invalidInput);
assertNull(result, "Should return null for invalid input: " + invalidInput);
}

@Test
public void testParseExpiresOnWithSpecificDotNetDate() {
// Test a specific known date: 2023-05-15 10:10:00 UTC = 1684145400000 ms
String dotNetDate = "/Date(1684145400000)/";
OffsetDateTime result = PowerShellUtil.parseExpiresOn(dotNetDate);

assertNotNull(result);
assertEquals(2023, result.getYear());
assertEquals(5, result.getMonthValue());
assertEquals(15, result.getDayOfMonth());
assertEquals(10, result.getHour());
assertEquals(10, result.getMinute());
assertEquals(0, result.getSecond());
assertEquals(ZoneOffset.UTC, result.getOffset());
}

@Test
public void testParseExpiresOnWithSpecificISODate() {
// Test a specific ISO date with timezone conversion
String isoDate = "2023-05-15T10:30:00-05:00"; // Eastern time
OffsetDateTime result = PowerShellUtil.parseExpiresOn(isoDate);

assertNotNull(result);
assertEquals(ZoneOffset.UTC, result.getOffset());

// Should be converted to 15:30 UTC (10:30 - 5 hours = 15:30 UTC)
assertEquals(15, result.getHour());
assertEquals(30, result.getMinute());
}

@Test
public void testParseExpiresOnTriesISOFirst() {
// Test that ISO parsing is attempted first by using a string that could be ambiguous
String timestamp = "2023-05-15T10:30:00Z";
OffsetDateTime result = PowerShellUtil.parseExpiresOn(timestamp);

assertNotNull(result);
// Verify it was parsed as ISO (not as .NET date format)
assertEquals(2023, result.getYear());
assertEquals(5, result.getMonthValue());
assertEquals(15, result.getDayOfMonth());
}

@Test
public void testParseExpiresOnWithDotNetDateContainingNonDigits() {
// Test .NET date format with non-digit characters in the number part
String invalidDotNetDate = "/Date(123abc456)/";
OffsetDateTime result = PowerShellUtil.parseExpiresOn(invalidDotNetDate);

assertNull(result, "Should return null when .NET date contains non-digits");
}

@Test
public void testParseExpiresOnWithDotNetDateNumberFormatException() {
// Test a .NET date that would cause NumberFormatException (too large number)
String largeDotNetDate = "/Date(999999999999999999999)/";
OffsetDateTime result = PowerShellUtil.parseExpiresOn(largeDotNetDate);

assertNull(result, "Should return null when .NET date number is too large");
}

@Test
public void testParseExpiresOnWithEpochZero() {
// Test Unix epoch (January 1, 1970, 00:00:00 UTC)
String epochDate = "/Date(0)/";
OffsetDateTime result = PowerShellUtil.parseExpiresOn(epochDate);

assertNotNull(result);
assertEquals(1970, result.getYear());
assertEquals(1, result.getMonthValue());
assertEquals(1, result.getDayOfMonth());
assertEquals(0, result.getHour());
assertEquals(0, result.getMinute());
assertEquals(0, result.getSecond());
assertEquals(ZoneOffset.UTC, result.getOffset());
}

@ParameterizedTest
@ValueSource(
strings = {
"/Date(/",
"/Date()/",
"Date(123)/",
"/Date(123",
"/Date(123)//",
"/Date(123)/extra",
"prefix/Date(123)/" })
public void testParseExpiresOnWithMalformedDotNetDates(String malformedDate) {
OffsetDateTime result = PowerShellUtil.parseExpiresOn(malformedDate);
assertNull(result, "Should return null for malformed .NET date: " + malformedDate);
}
}