Skip to content

Commit baeaf38

Browse files
authored
Add MFA support to Identity Pwsh credential (#46480)
1 parent e415b2a commit baeaf38

File tree

4 files changed

+171
-41
lines changed

4 files changed

+171
-41
lines changed

sdk/identity/azure-identity/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## 1.18.0-beta.1 (Unreleased)
44

5+
- 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.
6+
57
### Features Added
68

79
### Breaking Changes

sdk/identity/azure-identity/src/main/java/com/azure/identity/AzurePowerShellCredential.java

Lines changed: 47 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -15,47 +15,53 @@
1515
import reactor.core.publisher.Mono;
1616

1717
/**
18-
* <p>The Azure Powershell is a command-line tool that allows users to manage Azure resources from their local machine
19-
* or terminal. It allows users to
20-
* <a href="https://learn.microsoft.com/powershell/azure/authenticate-azureps">authenticate interactively</a>
21-
* as a user and/or a service principal against
22-
* <a href="https://learn.microsoft.com/entra/fundamentals/">Microsoft Entra ID</a>.
23-
* The AzurePowershellCredential authenticates in a development environment and acquires a token on behalf of the
24-
* logged-in user or service principal in Azure Powershell. It acts as the Azure Powershell logged in user or
25-
* service principal and executes an Azure Powershell command underneath to authenticate the application against
26-
* Microsoft Entra ID.</p>
27-
*
28-
* <h2>Configure AzurePowershellCredential</h2>
29-
*
30-
* <p> To use this credential, the developer needs to authenticate locally in Azure Powershell using one of the
31-
* commands below:</p>
32-
*
33-
* <ol>
34-
* <li>Run "Connect-AzAccount" in Azure Powershell to authenticate as a user.</li>
35-
* <li>Run "Connect-AzAccount -ServicePrincipal -ApplicationId {servicePrincipalId} -Tenant {tenantId}
36-
* -CertificateThumbprint {thumbprint} to authenticate as a service principal."</li>
37-
* </ol>
38-
*
39-
* <p>You may need to repeat this process after a certain time period, depending on the refresh token validity in your
40-
* organization. Generally, the refresh token validity period is a few weeks to a few months. AzurePowershellCredential
41-
* will prompt you to sign in again.</p>
42-
*
43-
* <p><strong>Sample: Construct AzurePowershellCredential</strong></p>
44-
*
45-
* <p>The following code sample demonstrates the creation of a {@link com.azure.identity.AzurePowerShellCredential},
46-
* using the {@link com.azure.identity.AzurePowerShellCredentialBuilder} to configure it. Once this credential is
47-
* created, it may be passed into the builder of many of the Azure SDK for Java client builders as the 'credential'
48-
* parameter.</p>
49-
*
50-
* <!-- src_embed com.azure.identity.credential.azurepowershellcredential.construct -->
51-
* <pre>
52-
* TokenCredential powerShellCredential = new AzurePowerShellCredentialBuilder&#40;&#41;.build&#40;&#41;;
53-
* </pre>
54-
* <!-- end com.azure.identity.credential.azurepowershellcredential.construct -->
55-
*
56-
* @see com.azure.identity
57-
* @see AzurePowerShellCredentialBuilder
58-
*/
18+
* <p>The Azure Powershell is a command-line tool that allows users to manage Azure resources from their local machine
19+
* or terminal. It allows users to
20+
* <a href="https://learn.microsoft.com/powershell/azure/authenticate-azureps">authenticate interactively</a>
21+
* as a user and/or a service principal against
22+
* <a href="https://learn.microsoft.com/entra/fundamentals/">Microsoft Entra ID</a>.
23+
* The AzurePowershellCredential authenticates in a development environment and acquires a token on behalf of the
24+
* logged-in user or service principal in Azure Powershell. It acts as the Azure Powershell logged in user or
25+
* service principal and executes an Azure Powershell command underneath to authenticate the application against
26+
* Microsoft Entra ID.</p>
27+
*
28+
* <h2>Configure AzurePowershellCredential</h2>
29+
*
30+
* <p> To use this credential, the developer needs to authenticate locally in Azure Powershell using one of the
31+
* commands below:</p>
32+
*
33+
* <ol>
34+
* <li>Run "Connect-AzAccount" in Azure Powershell to authenticate as a user.</li>
35+
* <li>Run "Connect-AzAccount -ServicePrincipal -ApplicationId {servicePrincipalId} -Tenant {tenantId}
36+
* -CertificateThumbprint {thumbprint} to authenticate as a service principal."</li>
37+
* </ol>
38+
*
39+
* <p>You may need to repeat this process after a certain time period, depending on the refresh token validity in your
40+
* organization. Generally, the refresh token validity period is a few weeks to a few months. AzurePowershellCredential
41+
* will prompt you to sign in again.</p>
42+
*
43+
* <h2>Claims Challenges</h2>
44+
*
45+
* <p>Claims challenges are not supported by AzurePowerShellCredential. If a token request includes claims,
46+
* a {@link CredentialUnavailableException} will be thrown with instructions to use Azure PowerShell
47+
* directly with the appropriate -ClaimsChallenge parameter.</p>
48+
*
49+
* <p><strong>Sample: Construct AzurePowershellCredential</strong></p>
50+
*
51+
* <p>The following code sample demonstrates the creation of a {@link com.azure.identity.AzurePowerShellCredential},
52+
* using the {@link com.azure.identity.AzurePowerShellCredentialBuilder} to configure it. Once this credential is
53+
* created, it may be passed into the builder of many of the Azure SDK for Java client builders as the 'credential'
54+
* parameter.</p>
55+
*
56+
* <!-- src_embed com.azure.identity.credential.azurepowershellcredential.construct -->
57+
* <pre>
58+
* TokenCredential powerShellCredential = new AzurePowerShellCredentialBuilder&#40;&#41;.build&#40;&#41;;
59+
* </pre>
60+
* <!-- end com.azure.identity.credential.azurepowershellcredential.construct -->
61+
*
62+
* @see com.azure.identity
63+
* @see AzurePowerShellCredentialBuilder
64+
*/
5965
@Immutable
6066
public class AzurePowerShellCredential implements TokenCredential {
6167
private static final ClientLogger LOGGER = new ClientLogger(AzurePowerShellCredential.class);

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,13 @@ public Mono<AccessToken> authenticateWithOBO(TokenRequestContext request) {
434434

435435
private Mono<AccessToken> getAccessTokenFromPowerShell(TokenRequestContext request,
436436
PowershellManager powershellManager) {
437+
// Check for claims challenge - if claims are provided, this credential cannot handle them
438+
if (request.getClaims() != null && !request.getClaims().trim().isEmpty()) {
439+
String errorMessage = buildPowerShellClaimsChallengeErrorMessage(request);
440+
return Mono.error(LoggingUtil.logCredentialUnavailableException(LOGGER, options,
441+
new CredentialUnavailableException(errorMessage)));
442+
}
443+
437444
String scope = ScopeUtil.scopesToResource(request.getScopes());
438445
try {
439446
ScopeUtil.validateScope(scope);
@@ -474,6 +481,22 @@ private Mono<AccessToken> getAccessTokenFromPowerShell(TokenRequestContext reque
474481
});
475482
}
476483

484+
private String buildPowerShellClaimsChallengeErrorMessage(TokenRequestContext request) {
485+
StringBuilder connectAzCommand
486+
= new StringBuilder("Connect-AzAccount -ClaimsChallenge '").append(request.getClaims().replace("'", "''")) // Escape single quotes for PowerShell
487+
.append("'");
488+
489+
// Add tenant if available
490+
String tenant = IdentityUtil.resolveTenantId(tenantId, request, options);
491+
if (!CoreUtils.isNullOrEmpty(tenant) && !tenant.equals(IdentityUtil.DEFAULT_TENANT)) {
492+
connectAzCommand.append(" -Tenant ").append(tenant);
493+
}
494+
495+
return String.format(
496+
"Failed to get token. Claims challenges are not supported by AzurePowerShellCredential. Run %s to handle the claims challenge.",
497+
connectAzCommand.toString());
498+
}
499+
477500
/**
478501
* Asynchronously acquire a token from Active Directory with a client secret.
479502
*

sdk/identity/azure-identity/src/test/java/com/azure/identity/AzurePowerShellCredentialTest.java

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import com.azure.identity.util.TestUtils;
1010
import org.junit.jupiter.api.Assertions;
1111
import org.junit.jupiter.api.Test;
12+
import org.junit.jupiter.params.ParameterizedTest;
13+
import org.junit.jupiter.params.provider.ValueSource;
1214
import org.mockito.MockedConstruction;
1315
import reactor.core.publisher.Mono;
1416
import reactor.test.StepVerifier;
@@ -68,4 +70,101 @@ public void azurePowerShellCredentialNotInstalledException() {
6870
Assertions.assertNotNull(identityClientMock);
6971
}
7072
}
73+
74+
@Test
75+
public void testClaimsChallengeThrowsCredentialUnavailableException() {
76+
// Test with claims provided
77+
TokenRequestContext requestWithClaims
78+
= new TokenRequestContext().addScopes("https://graph.microsoft.com/.default")
79+
.setClaims("{\"access_token\":{\"essential\":true}}");
80+
81+
AzurePowerShellCredential credential = new AzurePowerShellCredentialBuilder().build();
82+
83+
// Test async version
84+
StepVerifier.create(credential.getToken(requestWithClaims))
85+
.expectErrorMatches(throwable -> throwable instanceof CredentialUnavailableException
86+
&& throwable.getMessage().contains("Claims challenges are not supported")
87+
&& throwable.getMessage().contains("Connect-AzAccount -ClaimsChallenge")
88+
&& throwable.getMessage().contains("access_token"))
89+
.verify();
90+
}
91+
92+
@Test
93+
public void testPowerShellClaimsChallengeWithTenantAndScopes() {
94+
TokenRequestContext requestWithClaims = new TokenRequestContext()
95+
.addScopes("https://graph.microsoft.com/.default", "https://vault.azure.net/.default")
96+
.setClaims("{\"access_token\":{\"essential\":true}}")
97+
.setTenantId("tenant-id-123");
98+
99+
AzurePowerShellCredential credential = new AzurePowerShellCredentialBuilder().tenantId("tenant-id-123").build();
100+
101+
// Test that error message includes tenant and mentions scopes
102+
StepVerifier.create(credential.getToken(requestWithClaims))
103+
.expectErrorMatches(throwable -> throwable instanceof CredentialUnavailableException
104+
&& throwable.getMessage().contains("-Tenant tenant-id-123"))
105+
.verify();
106+
}
107+
108+
@ParameterizedTest
109+
@ValueSource(strings = { "", " ", "\t", "\n" })
110+
public void testEmptyClaimsDoesNotThrowException(String claims) {
111+
TokenRequestContext request
112+
= new TokenRequestContext().addScopes("https://graph.microsoft.com/.default").setClaims(claims);
113+
114+
// Mock successful token acquisition for empty claims
115+
try (MockedConstruction<IdentityClient> identityClientMock
116+
= mockConstruction(IdentityClient.class, (identityClient, context) -> {
117+
when(identityClient.authenticateWithAzurePowerShell(request))
118+
.thenReturn(TestUtils.getMockAccessToken("token", OffsetDateTime.now().plusHours(1)));
119+
})) {
120+
121+
AzurePowerShellCredential credential = new AzurePowerShellCredentialBuilder().build();
122+
123+
// Should not throw exception for empty/whitespace claims
124+
StepVerifier.create(credential.getToken(request))
125+
.expectNextMatches(accessToken -> "token".equals(accessToken.getToken()))
126+
.verifyComplete();
127+
Assertions.assertNotNull(identityClientMock);
128+
}
129+
}
130+
131+
@Test
132+
public void testNullClaimsDoesNotThrowException() {
133+
TokenRequestContext request
134+
= new TokenRequestContext().addScopes("https://graph.microsoft.com/.default").setClaims(null);
135+
136+
// Mock successful token acquisition for null claims
137+
try (MockedConstruction<IdentityClient> identityClientMock
138+
= mockConstruction(IdentityClient.class, (identityClient, context) -> {
139+
when(identityClient.authenticateWithAzurePowerShell(request))
140+
.thenReturn(TestUtils.getMockAccessToken("token", OffsetDateTime.now().plusHours(1)));
141+
})) {
142+
143+
AzurePowerShellCredential credential = new AzurePowerShellCredentialBuilder().build();
144+
145+
// Should not throw exception for null claims
146+
StepVerifier.create(credential.getToken(request))
147+
.expectNextMatches(accessToken -> "token".equals(accessToken.getToken()))
148+
.verifyComplete();
149+
Assertions.assertNotNull(identityClientMock);
150+
}
151+
}
152+
153+
@Test
154+
public void testClaimsChallengeEscapesSingleQuotes() {
155+
// Test with claims that contain single quotes (needs proper PowerShell escaping)
156+
TokenRequestContext requestWithClaims
157+
= new TokenRequestContext().addScopes("https://graph.microsoft.com/.default")
158+
.setClaims("{\"access_token\":{\"claim\":\"value's test\"}}");
159+
160+
AzurePowerShellCredential credential = new AzurePowerShellCredentialBuilder().build();
161+
162+
// Test that single quotes are properly escaped for PowerShell
163+
StepVerifier.create(credential.getToken(requestWithClaims))
164+
.expectErrorMatches(throwable -> throwable instanceof CredentialUnavailableException
165+
&& throwable.getMessage().contains("Connect-AzAccount -ClaimsChallenge")
166+
&& throwable.getMessage().contains("value''s test") // Single quote should be escaped to ''
167+
)
168+
.verify();
169+
}
71170
}

0 commit comments

Comments
 (0)