Skip to content

Commit 45e271f

Browse files
authored
fix: replace az CLI token acquisition with MSAL.NET to eliminate proy resets (#324)
* fix: replace az CLI token acquisition with MSAL.NET to eliminate proxy resets (#321) Users on corporate networks with TLS inspection proxies intermittently saw ConnectionResetError (10054) during 'a365 setup all'. The failure was non-deterministic — different steps each run, restarting sometimes helped. Root cause: all token acquisition called 'az account get-access-token' as a subprocess. The Azure CLI is Python-based and uses an HTTP connection pool that the TLS proxy would reset mid-execution during longer commands. Fix: replaced all subprocess token acquisition with direct MSAL.NET calls via AuthenticationService. On Windows, MSAL uses WAM — an OS-level broker that communicates over IPC, bypassing the proxy entirely. On macOS/Linux, MSAL uses browser or device code flow. Changes: - GraphApiService and ArmApiService now acquire tokens via IAuthenticationService (MSAL) instead of spawning 'az account get-access-token' subprocesses - AzCliHelper retains only login-hint resolution (az account show); token acquisition methods removed - BotConfigurator reads tenantId from config instead of 'az account show' - NetworkHelper centralises connection-reset detection and warning message - RetryHelper accepts constructor-level defaults; injectable in tests to skip backoff delays - AuthenticationService gains ResolveLoginHintFromCacheAsync as fallback when az CLI is not available - Added tests for ResolveLoginHintFromCacheAsync and NetworkHelper * Fixed PR comments. Add forceRefresh to Graph token flow; improve testability Introduce a forceRefresh parameter to Microsoft Graph token acquisition, allowing explicit cache bypass and fresh token retrieval. Update IMicrosoftGraphTokenProvider and MicrosoftGraphTokenProvider to support this, and refactor GraphApiService to propagate forceRefresh throughout token and header acquisition. Update all call sites to use named arguments for clarity. Enhance ClientAppValidator and related tests to exercise token refresh logic. Improve test setup for GraphApiService and fix JWT Base64Url encoding/decoding in AuthenticationService and tests. These changes increase reliability and testability, especially for scenarios involving token revocation or conditional access. * Lower log level to Debug; add forceRefresh cache bypass test Changed log statements in AuthenticationService from Information to Debug to reduce log verbosity. Added a unit test to ensure forceRefresh bypasses the token cache in MicrosoftGraphTokenProvider.
1 parent d93e060 commit 45e271f

33 files changed

Lines changed: 1472 additions & 1643 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
1515
- `a365 publish` updates manifest IDs, creates `manifest.zip`, and prints concise upload instructions for Microsoft 365 Admin Center (Agents > All agents > Upload custom agent). Interactive prompts only occur in interactive terminals; redirect stdin to suppress them in scripts.
1616

1717
### Fixed
18+
- Intermittent `ConnectionResetError (10054)` failures on corporate networks with TLS inspection proxies (Zscaler, Netskope) — Graph and ARM API calls now use direct MSAL.NET token acquisition instead of `az account get-access-token` subprocesses, bypassing the Python HTTP stack that triggered proxy resets (#321)
1819
- `a365 cleanup` blueprint deletion now succeeds for Global Administrators even when the blueprint was created by a different user
1920
- `a365 setup all` no longer times out for non-admin users — the CLI immediately surfaces a consent URL to share with an administrator instead of waiting for a browser prompt
2021
- `a365 setup all` requests admin consent once for all resources instead of prompting once per resource

src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,7 @@ public static async Task<BlueprintCreationResult> CreateBlueprintImplementationA
386386
new GraphApiService(
387387
cleanLoggerFactory.CreateLogger<GraphApiService>(),
388388
executor,
389+
new AuthenticationService(cleanLoggerFactory.CreateLogger<AuthenticationService>()),
389390
graphBaseUrl: setupConfig.GraphBaseUrl));
390391

391392
// Use DI-provided GraphApiService which already has MicrosoftGraphTokenProvider configured
@@ -761,7 +762,7 @@ public static async Task<bool> EnsureDelegatedConsentWithRetriesAsync(
761762
existingServicePrincipalId = null;
762763
// SP missing for an existing app — attempt creation so downstream steps have a valid SP.
763764
logger.LogInformation("Service principal not found for existing blueprint — attempting to create it...");
764-
var spToken = await graphApiService.GetGraphAccessTokenAsync(tenantId, ct);
765+
var spToken = await graphApiService.GetGraphAccessTokenAsync(tenantId, ct: ct);
765766
if (!string.IsNullOrWhiteSpace(spToken))
766767
{
767768
using var spHttpClient = Services.Internal.HttpClientFactory.CreateAuthenticatedClient(spToken);

src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs

Lines changed: 4 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -300,42 +300,10 @@ public static async Task<bool> ValidateAzureCliAuthenticationAsync(
300300
logger.LogDebug("Azure CLI already authenticated as {LoginHint}", loginHint);
301301
}
302302

303-
// Verify we have the management scope (token is cached at process level by AzCliHelper).
304-
logger.LogDebug("Verifying access to Azure management resources...");
305-
var managementToken = await AzCliHelper.AcquireAzCliTokenAsync(ArmApiService.ArmResource, tenantId);
306-
307-
if (string.IsNullOrWhiteSpace(managementToken))
308-
{
309-
logger.LogWarning("Unable to acquire management scope token. Attempting re-authentication...");
310-
logger.LogInformation("A browser window will open for authentication.");
311-
312-
var loginResult = await executor.ExecuteAsync("az", $"login --tenant {tenantId}", cancellationToken: cancellationToken);
313-
314-
if (!loginResult.Success)
315-
{
316-
logger.LogError("Azure CLI login with management scope failed. Please run manually: az login --scope https://management.core.windows.net//.default");
317-
return false;
318-
}
319-
320-
logger.LogInformation("Azure CLI re-authentication successful!");
321-
AzCliHelper.InvalidateAzCliTokenCache();
322-
await Task.Delay(2000, cancellationToken);
323-
324-
var retryToken = await AzCliHelper.AcquireAzCliTokenAsync(ArmApiService.ArmResource, tenantId);
325-
if (string.IsNullOrWhiteSpace(retryToken))
326-
{
327-
logger.LogWarning("Still unable to acquire management scope token after re-authentication.");
328-
logger.LogWarning("Continuing anyway - you may encounter permission errors later.");
329-
}
330-
else
331-
{
332-
logger.LogDebug("Management scope token acquired successfully!");
333-
}
334-
}
335-
else
336-
{
337-
logger.LogDebug("Management scope verified successfully");
338-
}
303+
// ARM token acquisition is handled lazily by ArmApiService via MSAL (WAM/browser/device-code).
304+
// No eager token pre-fetch is needed here — MSAL will prompt for sign-in when the
305+
// first ARM call is made if no valid cached token exists.
306+
logger.LogDebug("Azure authentication verified. ARM token will be acquired on first resource call.");
339307
return true;
340308
}
341309

src/Microsoft.Agents.A365.DevTools.Cli/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ private static void ConfigureServices(IServiceCollection services, LogLevel mini
254254
services.AddSingleton<IConfigService, ConfigService>();
255255
services.AddSingleton<CommandExecutor>();
256256
services.AddSingleton<AuthenticationService>();
257+
services.AddSingleton<IAuthenticationService>(sp => sp.GetRequiredService<AuthenticationService>());
257258
services.AddSingleton<IClientAppValidator, ClientAppValidator>();
258259
services.AddSingleton<IVersionCheckService, VersionCheckService>();
259260
services.AddSingleton<INoticeService, NoticeService>();

src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -483,7 +483,7 @@ string GetConfig(string name) =>
483483
try
484484
{
485485
// Use Azure CLI token to get current user (this requires delegated context)
486-
var delegatedToken = await _graphService.GetGraphAccessTokenAsync(tenantId, ct);
486+
var delegatedToken = await _graphService.GetGraphAccessTokenAsync(tenantId, ct: ct);
487487
if (!string.IsNullOrWhiteSpace(delegatedToken))
488488
{
489489
using var delegatedClient = HttpClientFactory.CreateAuthenticatedClient(delegatedToken, correlationId: correlationId);
@@ -680,7 +680,7 @@ string GetConfig(string name) =>
680680
_logger.LogInformation(" - Agent Identity ID: {Id}", agenticAppId);
681681

682682
// Get Graph access token
683-
var graphToken = await _graphService.GetGraphAccessTokenAsync(tenantId, ct);
683+
var graphToken = await _graphService.GetGraphAccessTokenAsync(tenantId, ct: ct);
684684
if (string.IsNullOrWhiteSpace(graphToken))
685685
{
686686
_logger.LogError("Failed to acquire Graph API access token");
@@ -940,7 +940,7 @@ private async Task AssignLicensesAsync(
940940
_logger.LogInformation("Assigning licenses to user {UserId} using Graph API (CorrelationId: {CorrelationId})", userId, correlationId);
941941

942942
// Get Graph access token
943-
var graphToken = await _graphService.GetGraphAccessTokenAsync(tenantId, cancellationToken);
943+
var graphToken = await _graphService.GetGraphAccessTokenAsync(tenantId, ct: cancellationToken);
944944
if (string.IsNullOrWhiteSpace(graphToken))
945945
{
946946
_logger.LogError("Failed to acquire Graph API access token for license assignment");
@@ -1151,7 +1151,7 @@ private async Task<bool> VerifyServicePrincipalExistsAsync(
11511151
try
11521152
{
11531153
// Use Graph API to check if service principal exists
1154-
var graphToken = await _graphService.GetGraphAccessTokenAsync(tenantId, ct);
1154+
var graphToken = await _graphService.GetGraphAccessTokenAsync(tenantId, ct: ct);
11551155
if (string.IsNullOrWhiteSpace(graphToken))
11561156
{
11571157
_logger.LogWarning("Failed to acquire Graph token for service principal verification");

src/Microsoft.Agents.A365.DevTools.Cli/Services/ArmApiService.cs

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Services;
1515
/// Service for Azure Resource Manager (ARM) existence checks via direct HTTP.
1616
/// Replaces subprocess-based 'az group exists', 'az appservice plan show', and
1717
/// 'az webapp show' calls — each drops from ~15-20s to ~0.5s.
18-
/// Token acquisition is handled by AzCliHelper (process-level cache shared with
19-
/// other services using the management endpoint).
18+
/// Token acquisition uses MSAL via AuthenticationService (WAM on Windows,
19+
/// browser/device-code on macOS/Linux) — no az CLI subprocess involved.
2020
/// </summary>
2121
public class ArmApiService : IDisposable
2222
{
@@ -27,25 +27,29 @@ public class ArmApiService : IDisposable
2727

2828
private readonly ILogger<ArmApiService> _logger;
2929
private readonly HttpClient _httpClient;
30+
private readonly IAuthenticationService _authService;
31+
private readonly RetryHelper _retryHelper;
3032

31-
// Allow injecting a custom HttpMessageHandler for unit testing.
32-
public ArmApiService(ILogger<ArmApiService> logger, HttpMessageHandler? handler = null)
33+
// Allow injecting a custom HttpMessageHandler and RetryHelper for unit testing.
34+
public ArmApiService(ILogger<ArmApiService> logger, IAuthenticationService authService, HttpMessageHandler? handler = null, RetryHelper? retryHelper = null)
3335
{
3436
_logger = logger;
37+
_authService = authService;
3538
_httpClient = handler != null ? new HttpClient(handler) : HttpClientFactory.CreateAuthenticatedClient();
39+
_retryHelper = retryHelper ?? new RetryHelper(_logger);
3640
}
3741

3842
// Parameterless constructor to ease test mocking/substitution frameworks.
3943
public ArmApiService()
40-
: this(NullLogger<ArmApiService>.Instance, null)
44+
: this(NullLogger<ArmApiService>.Instance, new AuthenticationService(NullLogger<AuthenticationService>.Instance), null)
4145
{
4246
}
4347

4448
public void Dispose() => _httpClient.Dispose();
4549

4650
private async Task<bool> EnsureArmHeadersAsync(string tenantId, CancellationToken ct)
4751
{
48-
var token = await AzCliHelper.AcquireAzCliTokenAsync(ArmResource, tenantId);
52+
var token = await _authService.GetAccessTokenAsync(ArmResource, tenantId);
4953
if (string.IsNullOrWhiteSpace(token))
5054
{
5155
_logger.LogWarning("Unable to acquire ARM access token for tenant {TenantId}", tenantId);
@@ -74,15 +78,19 @@ private async Task<bool> EnsureArmHeadersAsync(string tenantId, CancellationToke
7478

7579
try
7680
{
77-
using var response = await _httpClient.GetAsync(url, ct);
81+
using var response = await _retryHelper.ExecuteWithRetryAsync(
82+
ct => _httpClient.GetAsync(url, ct), cancellationToken: ct);
7883
_logger.LogDebug("ARM resource group check: {StatusCode}", response.StatusCode);
7984
if (response.StatusCode == HttpStatusCode.OK) return true;
8085
if (response.StatusCode == HttpStatusCode.NotFound) return false;
8186
return null; // 401/403/5xx — caller falls back to az CLI
8287
}
8388
catch (Exception ex)
8489
{
85-
_logger.LogDebug(ex, "ARM resource group check failed — will fall back to az CLI");
90+
if (NetworkHelper.IsConnectionResetByProxy(ex))
91+
_logger.LogWarning(NetworkHelper.ConnectionResetWarning);
92+
else
93+
_logger.LogDebug(ex, "ARM resource group check failed — will fall back to az CLI");
8694
return null;
8795
}
8896
}
@@ -106,15 +114,19 @@ private async Task<bool> EnsureArmHeadersAsync(string tenantId, CancellationToke
106114

107115
try
108116
{
109-
using var response = await _httpClient.GetAsync(url, ct);
117+
using var response = await _retryHelper.ExecuteWithRetryAsync(
118+
ct => _httpClient.GetAsync(url, ct), cancellationToken: ct);
110119
_logger.LogDebug("ARM app service plan check: {StatusCode}", response.StatusCode);
111120
if (response.StatusCode == HttpStatusCode.OK) return true;
112121
if (response.StatusCode == HttpStatusCode.NotFound) return false;
113122
return null; // 401/403/5xx — caller falls back to az CLI
114123
}
115124
catch (Exception ex)
116125
{
117-
_logger.LogDebug(ex, "ARM app service plan check failed — will fall back to az CLI");
126+
if (NetworkHelper.IsConnectionResetByProxy(ex))
127+
_logger.LogWarning(NetworkHelper.ConnectionResetWarning);
128+
else
129+
_logger.LogDebug(ex, "ARM app service plan check failed — will fall back to az CLI");
118130
return null;
119131
}
120132
}
@@ -138,15 +150,19 @@ private async Task<bool> EnsureArmHeadersAsync(string tenantId, CancellationToke
138150

139151
try
140152
{
141-
using var response = await _httpClient.GetAsync(url, ct);
153+
using var response = await _retryHelper.ExecuteWithRetryAsync(
154+
ct => _httpClient.GetAsync(url, ct), cancellationToken: ct);
142155
_logger.LogDebug("ARM web app check: {StatusCode}", response.StatusCode);
143156
if (response.StatusCode == HttpStatusCode.OK) return true;
144157
if (response.StatusCode == HttpStatusCode.NotFound) return false;
145158
return null; // 401/403/5xx — caller falls back to az CLI
146159
}
147160
catch (Exception ex)
148161
{
149-
_logger.LogDebug(ex, "ARM web app check failed — will fall back to az CLI");
162+
if (NetworkHelper.IsConnectionResetByProxy(ex))
163+
_logger.LogWarning(NetworkHelper.ConnectionResetWarning);
164+
else
165+
_logger.LogDebug(ex, "ARM web app check failed — will fall back to az CLI");
150166
return null;
151167
}
152168
}
@@ -186,7 +202,8 @@ private async Task<bool> EnsureArmHeadersAsync(string tenantId, CancellationToke
186202

187203
try
188204
{
189-
using var response = await _httpClient.GetAsync(url, ct);
205+
using var response = await _retryHelper.ExecuteWithRetryAsync(
206+
ct => _httpClient.GetAsync(url, ct), cancellationToken: ct);
190207
if (!response.IsSuccessStatusCode)
191208
{
192209
_logger.LogDebug("ARM role assignment check returned {StatusCode}", response.StatusCode);
@@ -218,8 +235,12 @@ private async Task<bool> EnsureArmHeadersAsync(string tenantId, CancellationToke
218235
}
219236
catch (Exception ex)
220237
{
221-
_logger.LogDebug(ex, "ARM role assignment check failed — will fall back to az CLI");
238+
if (NetworkHelper.IsConnectionResetByProxy(ex))
239+
_logger.LogWarning(NetworkHelper.ConnectionResetWarning);
240+
else
241+
_logger.LogDebug(ex, "ARM role assignment check failed — will fall back to az CLI");
222242
return null;
223243
}
224244
}
245+
225246
}

0 commit comments

Comments
 (0)