Skip to content

Commit 76d2b9c

Browse files
authored
Fix edge case response parsing for pwsh credential (#46572)
1 parent cac627e commit 76d2b9c

File tree

6 files changed

+255
-9
lines changed

6 files changed

+255
-9
lines changed

eng/versioning/version_client.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,7 @@ io.clientcore:optional-dependency-tests;1.0.0-beta.1;1.0.0-beta.1
542542
unreleased_com.azure.v2:azure-core;2.0.0-beta.1
543543
unreleased_com.azure.v2:azure-identity;2.0.0-beta.1
544544
unreleased_io.clientcore:http-netty4;1.0.0-beta.1
545+
unreleased_com.azure:azure-identity;1.18.0-beta.1
545546

546547
# Released Beta dependencies: Copy the entry from above, prepend "beta_", remove the current
547548
# version and set the version to the released beta. Released beta dependencies are only valid

sdk/documentintelligence/azure-ai-documentintelligence/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
<dependency>
6767
<groupId>com.azure</groupId>
6868
<artifactId>azure-identity</artifactId>
69-
<version>1.17.0</version> <!-- {x-version-update;com.azure:azure-identity;dependency} -->
69+
<version>1.18.0-beta.1</version> <!-- {x-version-update;unreleased_com.azure:azure-identity;dependency} -->
7070
<scope>test</scope>
7171
</dependency>
7272
</dependencies>

sdk/identity/azure-identity/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
### Breaking Changes
1111

1212
### Bugs Fixed
13+
- 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)
1314
- Fixed `AzureDeveloperCliCredential` hanging when `AZD_DEBUG` environment variable is set by adding `--no-prompt` flag to the `azd auth token` command.
1415

1516
### Other Changes

sdk/identity/azure-identity/src/main/java/com/azure/identity/implementation/IdentityClient.java

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@
5757
import java.nio.file.Paths;
5858
import java.time.Duration;
5959
import java.time.OffsetDateTime;
60-
import java.time.ZoneOffset;
6160
import java.util.ArrayList;
6261
import java.util.HashMap;
6362
import java.util.HashSet;
@@ -453,10 +452,15 @@ private Mono<AccessToken> getAccessTokenFromPowerShell(TokenRequestContext reque
453452
} catch (IllegalArgumentException ex) {
454453
throw LOGGER.logExceptionAsError(ex);
455454
}
455+
456+
String resolvedTenant = IdentityUtil.resolveTenantId(tenantId, request, options);
457+
String tenant = resolvedTenant.equals(IdentityUtil.DEFAULT_TENANT) ? "" : resolvedTenant;
458+
ValidationUtil.validateTenantIdCharacterRange(tenant, LOGGER);
459+
456460
return Mono.defer(() -> {
457461
String sep = System.lineSeparator();
458462

459-
String command = PowerShellUtil.getPwshCommand(tenantId, scope, sep);
463+
String command = PowerShellUtil.getPwshCommand(tenant, scope, sep);
460464

461465
return powershellManager.runCommand(command).flatMap(output -> {
462466
if (output.contains("VersionTooOld")) {
@@ -476,7 +480,12 @@ private Mono<AccessToken> getAccessTokenFromPowerShell(TokenRequestContext reque
476480
Map<String, String> objectMap = reader.readMap(JsonReader::getString);
477481
String accessToken = objectMap.get("Token");
478482
String time = objectMap.get("ExpiresOn");
479-
OffsetDateTime expiresOn = OffsetDateTime.parse(time).withOffsetSameInstant(ZoneOffset.UTC);
483+
OffsetDateTime expiresOn = PowerShellUtil.parseExpiresOn(time);
484+
if (expiresOn == null) {
485+
return Mono.error(LoggingUtil.logCredentialUnavailableException(LOGGER, options,
486+
new CredentialUnavailableException(
487+
"Encountered error when deserializing ExpiresOn time from PowerShell response.")));
488+
}
480489
return Mono.just(new AccessToken(accessToken, expiresOn));
481490
} catch (IOException e) {
482491
return Mono.error(LoggingUtil.logCredentialUnavailableException(LOGGER, options,

sdk/identity/azure-identity/src/main/java/com/azure/identity/implementation/util/PowerShellUtil.java

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,26 @@
33

44
package com.azure.identity.implementation.util;
55

6+
import java.time.Instant;
7+
import java.time.OffsetDateTime;
8+
import java.time.ZoneOffset;
9+
import java.time.format.DateTimeParseException;
10+
11+
import com.azure.core.util.CoreUtils;
12+
613
/**
714
* Utility class for powershell auth related ops .
815
*/
916
public class PowerShellUtil {
17+
private static final String DOTNET_DATE_PREFIX = "/Date(";
18+
private static final String DOTNET_DATE_SUFFIX = ")/";
19+
1020
public static String getPwshCommand(String tenantId, String scope, String sep) {
1121
return "$ErrorActionPreference = 'Stop'" + sep
22+
+ "$ProgressPreference = 'SilentlyContinue'" + sep
23+
+ "$VerbosePreference = 'SilentlyContinue'" + sep
24+
+ "$WarningPreference = 'SilentlyContinue'" + sep
25+
+ "$InformationPreference = 'SilentlyContinue'" + sep
1226
+ "[version]$minimumVersion = '2.2.0'" + sep
1327
+ "$m = Import-Module Az.Accounts -MinimumVersion $minimumVersion -PassThru -ErrorAction SilentlyContinue" + sep
1428
+ "if (! $m) {" + sep
@@ -38,10 +52,45 @@ public static String getPwshCommand(String tenantId, String scope, String sep) {
3852
+ " $tokenValue = $tokenValue | ConvertFrom-SecureString -AsPlainText" + sep
3953
+ " }" + sep
4054
+ "}" + sep
41-
+ "$customToken = [PSCustomObject]@{" + sep
42-
+ " Token = $tokenValue" + sep
43-
+ " ExpiresOn = $token.ExpiresOn" + sep
44-
+ "}" + sep
45-
+ "$customToken | ConvertTo-Json -Compress";
55+
+ "$customToken = New-Object -TypeName PSObject" + sep
56+
+ "$customToken | Add-Member -MemberType NoteProperty -Name Token -Value $tokenValue" + sep
57+
+ "$customToken | Add-Member -MemberType NoteProperty -Name ExpiresOn -Value $token.ExpiresOn" + sep
58+
+ "$customToken | ConvertTo-Json -Compress -Depth 10";
59+
}
60+
61+
/**
62+
* Parse ExpiresOn returned from PowerShell. Supports ISO timestamps and the .NET "/Date(ms)/" form.
63+
*
64+
* @param time the string value returned by PowerShell
65+
* @return parsed OffsetDateTime in UTC or null if unable to parse
66+
*/
67+
public static OffsetDateTime parseExpiresOn(String time) {
68+
if (CoreUtils.isNullOrEmpty(time)) {
69+
return null;
70+
}
71+
72+
// Try ISO first
73+
try {
74+
return OffsetDateTime.parse(time).withOffsetSameInstant(ZoneOffset.UTC);
75+
} catch (DateTimeParseException ignore) {
76+
// fall through to .NET style parsing
77+
}
78+
79+
if (time.length() > DOTNET_DATE_PREFIX.length() + DOTNET_DATE_SUFFIX.length()
80+
&& time.startsWith(DOTNET_DATE_PREFIX) && time.endsWith(DOTNET_DATE_SUFFIX)) {
81+
String digits = time.substring(DOTNET_DATE_PREFIX.length(), time.length() - DOTNET_DATE_SUFFIX.length());
82+
for (int i = 0; i < digits.length(); i++) {
83+
if (!Character.isDigit(digits.charAt(i))) {
84+
return null;
85+
}
86+
}
87+
try {
88+
long epochMs = Long.parseLong(digits);
89+
return OffsetDateTime.ofInstant(Instant.ofEpochMilli(epochMs), ZoneOffset.UTC);
90+
} catch (NumberFormatException ignore) {
91+
return null;
92+
}
93+
}
94+
return null;
4695
}
4796
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package com.azure.identity.implementation.util;
5+
6+
import org.junit.jupiter.api.Test;
7+
import org.junit.jupiter.params.ParameterizedTest;
8+
import org.junit.jupiter.params.provider.MethodSource;
9+
import org.junit.jupiter.params.provider.ValueSource;
10+
11+
import java.time.Instant;
12+
import java.time.OffsetDateTime;
13+
import java.time.ZoneOffset;
14+
import java.util.stream.Stream;
15+
16+
import static org.junit.jupiter.api.Assertions.*;
17+
18+
public class PowerShellUtilTests {
19+
20+
// Test data for valid ISO timestamps
21+
static Stream<String> validISOTimestamps() {
22+
return Stream.of("2023-05-15T10:30:00Z", "2023-05-15T10:30:00.123Z", "2023-05-15T10:30:00+00:00",
23+
"2023-05-15T10:30:00-05:00", "2023-05-15T10:30:00.123456+02:00", "2023-12-31T23:59:59Z",
24+
"2000-01-01T00:00:00Z");
25+
}
26+
27+
// Test data for valid .NET Date format
28+
static Stream<String> validDotNetDates() {
29+
return Stream.of("/Date(1684145400000)/", // 2023-05-15 10:30:00 UTC
30+
"/Date(0)/", // Unix epoch
31+
"/Date(1640995200000)/", // 2022-01-01 00:00:00 UTC
32+
"/Date(253402300799000)/" // Very far future date
33+
);
34+
}
35+
36+
// Test data for invalid inputs
37+
static Stream<String> invalidInputs() {
38+
return Stream.of("", " ", "invalid-date", "/Date(/", "/Date())/", "/Date(abc)/", "/Date(123abc)/",
39+
"/Date(123.456)/", "/Date(-123)/", "/Date(123", "Date(123)/", "/Date(123)//", "2023-13-01T10:30:00Z", // Invalid month
40+
"2023-05-32T10:30:00Z", // Invalid day
41+
"not-a-date-at-all");
42+
}
43+
44+
@Test
45+
public void testParseExpiresOnWithNull() {
46+
assertNull(PowerShellUtil.parseExpiresOn(null));
47+
}
48+
49+
@Test
50+
public void testParseExpiresOnWithEmptyString() {
51+
assertNull(PowerShellUtil.parseExpiresOn(""));
52+
}
53+
54+
@Test
55+
public void testParseExpiresOnWithWhitespace() {
56+
assertNull(PowerShellUtil.parseExpiresOn(" "));
57+
}
58+
59+
@ParameterizedTest
60+
@MethodSource("validISOTimestamps")
61+
public void testParseExpiresOnWithValidISOTimestamps(String isoTimestamp) {
62+
OffsetDateTime result = PowerShellUtil.parseExpiresOn(isoTimestamp);
63+
64+
assertNotNull(result, "Should parse valid ISO timestamp: " + isoTimestamp);
65+
assertEquals(ZoneOffset.UTC, result.getOffset(), "Result should be converted to UTC");
66+
67+
// Verify it's a valid timestamp by converting back
68+
OffsetDateTime original = OffsetDateTime.parse(isoTimestamp);
69+
assertEquals(original.withOffsetSameInstant(ZoneOffset.UTC), result);
70+
}
71+
72+
@ParameterizedTest
73+
@MethodSource("validDotNetDates")
74+
public void testParseExpiresOnWithValidDotNetDates(String dotNetDate) {
75+
OffsetDateTime result = PowerShellUtil.parseExpiresOn(dotNetDate);
76+
77+
assertNotNull(result, "Should parse valid .NET date: " + dotNetDate);
78+
assertEquals(ZoneOffset.UTC, result.getOffset(), "Result should be in UTC");
79+
80+
// Extract the epoch milliseconds and verify
81+
String digits = dotNetDate.substring(6, dotNetDate.length() - 2);
82+
long expectedEpochMs = Long.parseLong(digits);
83+
OffsetDateTime expected = OffsetDateTime.ofInstant(Instant.ofEpochMilli(expectedEpochMs), ZoneOffset.UTC);
84+
85+
assertEquals(expected, result);
86+
}
87+
88+
@ParameterizedTest
89+
@MethodSource("invalidInputs")
90+
public void testParseExpiresOnWithInvalidInputs(String invalidInput) {
91+
OffsetDateTime result = PowerShellUtil.parseExpiresOn(invalidInput);
92+
assertNull(result, "Should return null for invalid input: " + invalidInput);
93+
}
94+
95+
@Test
96+
public void testParseExpiresOnWithSpecificDotNetDate() {
97+
// Test a specific known date: 2023-05-15 10:10:00 UTC = 1684145400000 ms
98+
String dotNetDate = "/Date(1684145400000)/";
99+
OffsetDateTime result = PowerShellUtil.parseExpiresOn(dotNetDate);
100+
101+
assertNotNull(result);
102+
assertEquals(2023, result.getYear());
103+
assertEquals(5, result.getMonthValue());
104+
assertEquals(15, result.getDayOfMonth());
105+
assertEquals(10, result.getHour());
106+
assertEquals(10, result.getMinute());
107+
assertEquals(0, result.getSecond());
108+
assertEquals(ZoneOffset.UTC, result.getOffset());
109+
}
110+
111+
@Test
112+
public void testParseExpiresOnWithSpecificISODate() {
113+
// Test a specific ISO date with timezone conversion
114+
String isoDate = "2023-05-15T10:30:00-05:00"; // Eastern time
115+
OffsetDateTime result = PowerShellUtil.parseExpiresOn(isoDate);
116+
117+
assertNotNull(result);
118+
assertEquals(ZoneOffset.UTC, result.getOffset());
119+
120+
// Should be converted to 15:30 UTC (10:30 - 5 hours = 15:30 UTC)
121+
assertEquals(15, result.getHour());
122+
assertEquals(30, result.getMinute());
123+
}
124+
125+
@Test
126+
public void testParseExpiresOnTriesISOFirst() {
127+
// Test that ISO parsing is attempted first by using a string that could be ambiguous
128+
String timestamp = "2023-05-15T10:30:00Z";
129+
OffsetDateTime result = PowerShellUtil.parseExpiresOn(timestamp);
130+
131+
assertNotNull(result);
132+
// Verify it was parsed as ISO (not as .NET date format)
133+
assertEquals(2023, result.getYear());
134+
assertEquals(5, result.getMonthValue());
135+
assertEquals(15, result.getDayOfMonth());
136+
}
137+
138+
@Test
139+
public void testParseExpiresOnWithDotNetDateContainingNonDigits() {
140+
// Test .NET date format with non-digit characters in the number part
141+
String invalidDotNetDate = "/Date(123abc456)/";
142+
OffsetDateTime result = PowerShellUtil.parseExpiresOn(invalidDotNetDate);
143+
144+
assertNull(result, "Should return null when .NET date contains non-digits");
145+
}
146+
147+
@Test
148+
public void testParseExpiresOnWithDotNetDateNumberFormatException() {
149+
// Test a .NET date that would cause NumberFormatException (too large number)
150+
String largeDotNetDate = "/Date(999999999999999999999)/";
151+
OffsetDateTime result = PowerShellUtil.parseExpiresOn(largeDotNetDate);
152+
153+
assertNull(result, "Should return null when .NET date number is too large");
154+
}
155+
156+
@Test
157+
public void testParseExpiresOnWithEpochZero() {
158+
// Test Unix epoch (January 1, 1970, 00:00:00 UTC)
159+
String epochDate = "/Date(0)/";
160+
OffsetDateTime result = PowerShellUtil.parseExpiresOn(epochDate);
161+
162+
assertNotNull(result);
163+
assertEquals(1970, result.getYear());
164+
assertEquals(1, result.getMonthValue());
165+
assertEquals(1, result.getDayOfMonth());
166+
assertEquals(0, result.getHour());
167+
assertEquals(0, result.getMinute());
168+
assertEquals(0, result.getSecond());
169+
assertEquals(ZoneOffset.UTC, result.getOffset());
170+
}
171+
172+
@ParameterizedTest
173+
@ValueSource(
174+
strings = {
175+
"/Date(/",
176+
"/Date()/",
177+
"Date(123)/",
178+
"/Date(123",
179+
"/Date(123)//",
180+
"/Date(123)/extra",
181+
"prefix/Date(123)/" })
182+
public void testParseExpiresOnWithMalformedDotNetDates(String malformedDate) {
183+
OffsetDateTime result = PowerShellUtil.parseExpiresOn(malformedDate);
184+
assertNull(result, "Should return null for malformed .NET date: " + malformedDate);
185+
}
186+
}

0 commit comments

Comments
 (0)