Skip to content

Commit 98d332b

Browse files
Fix Graph authentication for non-interactive terminals
- Remove -NonInteractive flag from PowerShell arguments to allow browser windows to open for WAM authentication - Add ExtractJwtFromOutput() to filter WARNING/ERROR lines from PowerShell output that caused 'newline in header' errors - Use interactive browser flow as device code has module bugs Fixes authentication failures with errors: 'A window handle must be configured' and 'New-line characters are not allowed in header values' Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent d842342 commit 98d332b

2 files changed

Lines changed: 60 additions & 10 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ private async Task<bool> EnsureGraphHeadersAsync(string tenantId, CancellationTo
188188

189189
// When specific scopes are required, use custom client app if configured
190190
// CustomClientAppId should be set by callers who have access to config
191+
// Use interactive browser flow (false) as device code has module bugs; -NonInteractive removed separately
191192
var token = (scopes != null && _tokenProvider != null)
192193
? await _tokenProvider.GetMgGraphAccessTokenAsync(tenantId, scopes, false, CustomClientAppId, ct)
193194
: await GetGraphAccessTokenAsync(tenantId, ct);

src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ public MicrosoftGraphTokenProvider(
108108
useDeviceCode);
109109

110110
var script = BuildPowerShellScript(tenantId, validatedScopes, useDeviceCode, clientAppId);
111-
var result = await ExecuteWithFallbackAsync(script, ct);
111+
var result = await ExecuteWithFallbackAsync(script, useDeviceCode, ct);
112112
var token = ProcessResult(result);
113113

114114
if (string.IsNullOrWhiteSpace(token))
@@ -216,16 +216,17 @@ private static string BuildScopesArray(string[] scopes)
216216

217217
private async Task<CommandResult> ExecuteWithFallbackAsync(
218218
string script,
219+
bool useDeviceCode,
219220
CancellationToken ct)
220221
{
221222
// Try PowerShell Core first (cross-platform)
222-
var result = await ExecutePowerShellAsync("pwsh", script, ct);
223+
var result = await ExecutePowerShellAsync("pwsh", script, useDeviceCode, ct);
223224

224225
// Fallback to Windows PowerShell if pwsh is not available
225226
if (!result.Success && IsPowerShellNotFoundError(result))
226227
{
227228
_logger.LogDebug("PowerShell Core not found, falling back to Windows PowerShell");
228-
result = await ExecutePowerShellAsync("powershell", script, ct);
229+
result = await ExecutePowerShellAsync("powershell", script, useDeviceCode, ct);
229230
}
230231

231232
return result;
@@ -234,9 +235,10 @@ private async Task<CommandResult> ExecuteWithFallbackAsync(
234235
private async Task<CommandResult> ExecutePowerShellAsync(
235236
string shell,
236237
string script,
238+
bool useDeviceCode,
237239
CancellationToken ct)
238240
{
239-
var arguments = BuildPowerShellArguments(shell, script);
241+
var arguments = BuildPowerShellArguments(shell, script, useDeviceCode);
240242

241243
return await _executor.ExecuteWithStreamingAsync(
242244
command: shell,
@@ -247,11 +249,12 @@ private async Task<CommandResult> ExecutePowerShellAsync(
247249
cancellationToken: ct);
248250
}
249251

250-
private static string BuildPowerShellArguments(string shell, string script)
252+
private static string BuildPowerShellArguments(string shell, string script, bool useDeviceCode)
251253
{
252-
var baseArgs = shell == "pwsh"
253-
? "-NoProfile -NonInteractive"
254-
: "-NoLogo -NoProfile -NonInteractive";
254+
// Never use -NonInteractive for Graph authentication as it prevents both:
255+
// - Device code prompts from being displayed
256+
// - Interactive browser windows from opening (WAM)
257+
var baseArgs = shell == "pwsh" ? "-NoProfile" : "-NoLogo -NoProfile";
255258

256259
var wrappedScript = $"try {{ {script} }} catch {{ Write-Error $_.Exception.Message; exit 1 }}";
257260

@@ -268,14 +271,24 @@ private static string BuildPowerShellArguments(string shell, string script)
268271
return null;
269272
}
270273

271-
var token = result.StandardOutput?.Trim();
274+
var output = result.StandardOutput?.Trim();
272275

273-
if (string.IsNullOrWhiteSpace(token))
276+
if (string.IsNullOrWhiteSpace(output))
274277
{
275278
_logger.LogWarning("PowerShell succeeded but returned empty output");
276279
return null;
277280
}
278281

282+
// Extract the JWT token from output - PowerShell may include WARNING lines
283+
// JWT tokens start with "eyJ" (base64 encoded '{"')
284+
var token = ExtractJwtFromOutput(output);
285+
286+
if (string.IsNullOrWhiteSpace(token))
287+
{
288+
_logger.LogWarning("Could not extract JWT token from PowerShell output");
289+
return null;
290+
}
291+
279292
if (!IsValidJwtFormat(token))
280293
{
281294
_logger.LogWarning("Returned token does not appear to be a valid JWT");
@@ -285,6 +298,42 @@ private static string BuildPowerShellArguments(string shell, string script)
285298
return token;
286299
}
287300

301+
private static string? ExtractJwtFromOutput(string output)
302+
{
303+
// Split output into lines and find the JWT token
304+
// JWT tokens start with "eyJ" and contain exactly two dots
305+
var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
306+
307+
foreach (var line in lines)
308+
{
309+
var trimmed = line.Trim();
310+
// Skip WARNING, ERROR, and other PowerShell output lines
311+
if (trimmed.StartsWith("WARNING:", StringComparison.OrdinalIgnoreCase) ||
312+
trimmed.StartsWith("ERROR:", StringComparison.OrdinalIgnoreCase) ||
313+
trimmed.StartsWith("VERBOSE:", StringComparison.OrdinalIgnoreCase) ||
314+
trimmed.StartsWith("DEBUG:", StringComparison.OrdinalIgnoreCase))
315+
{
316+
continue;
317+
}
318+
319+
// Check if this line looks like a JWT token
320+
if (IsValidJwtFormat(trimmed))
321+
{
322+
return trimmed;
323+
}
324+
}
325+
326+
// Fallback: if no line matches, return the trimmed output
327+
// (in case the token is on a single line without prefixes)
328+
var trimmedOutput = output.Trim();
329+
if (IsValidJwtFormat(trimmedOutput))
330+
{
331+
return trimmedOutput;
332+
}
333+
334+
return null;
335+
}
336+
288337
private static bool IsPowerShellNotFoundError(CommandResult result)
289338
{
290339
if (string.IsNullOrWhiteSpace(result.StandardError))

0 commit comments

Comments
 (0)