Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ private async Task<bool> EnsureGraphHeadersAsync(string tenantId, CancellationTo

// When specific scopes are required, use custom client app if configured
// CustomClientAppId should be set by callers who have access to config
// Use interactive browser flow (false) as device code has module bugs; -NonInteractive removed separately
var token = (scopes != null && _tokenProvider != null)
? await _tokenProvider.GetMgGraphAccessTokenAsync(tenantId, scopes, false, CustomClientAppId, ct)
: await GetGraphAccessTokenAsync(tenantId, ct);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ public MicrosoftGraphTokenProvider(
useDeviceCode);

var script = BuildPowerShellScript(tenantId, validatedScopes, useDeviceCode, clientAppId);
var result = await ExecuteWithFallbackAsync(script, ct);
var result = await ExecuteWithFallbackAsync(script, useDeviceCode, ct);
var token = ProcessResult(result);

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

private async Task<CommandResult> ExecuteWithFallbackAsync(
string script,
bool useDeviceCode,
CancellationToken ct)
{
// Try PowerShell Core first (cross-platform)
var result = await ExecutePowerShellAsync("pwsh", script, ct);
var result = await ExecutePowerShellAsync("pwsh", script, useDeviceCode, ct);

// Fallback to Windows PowerShell if pwsh is not available
if (!result.Success && IsPowerShellNotFoundError(result))
{
_logger.LogDebug("PowerShell Core not found, falling back to Windows PowerShell");
result = await ExecutePowerShellAsync("powershell", script, ct);
result = await ExecutePowerShellAsync("powershell", script, useDeviceCode, ct);
}

return result;
Expand All @@ -234,9 +235,10 @@ private async Task<CommandResult> ExecuteWithFallbackAsync(
private async Task<CommandResult> ExecutePowerShellAsync(
string shell,
string script,
bool useDeviceCode,
CancellationToken ct)
{
var arguments = BuildPowerShellArguments(shell, script);
var arguments = BuildPowerShellArguments(shell, script, useDeviceCode);

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

private static string BuildPowerShellArguments(string shell, string script)
private static string BuildPowerShellArguments(string shell, string script, bool useDeviceCode)
Copy link

Copilot AI Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useDeviceCode parameter is added to the method signature but is never used within the method body. This creates confusion about the parameter's purpose and suggests incomplete implementation. Either use the parameter or remove it from the signature if it's not needed.

Copilot uses AI. Check for mistakes.
{
var baseArgs = shell == "pwsh"
? "-NoProfile -NonInteractive"
: "-NoLogo -NoProfile -NonInteractive";
// Never use -NonInteractive for Graph authentication as it prevents both:
// - Device code prompts from being displayed
// - Interactive browser windows from opening (WAM)
var baseArgs = shell == "pwsh" ? "-NoProfile" : "-NoLogo -NoProfile";

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

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

var token = result.StandardOutput?.Trim();
var output = result.StandardOutput?.Trim();

if (string.IsNullOrWhiteSpace(token))
if (string.IsNullOrWhiteSpace(output))
{
_logger.LogWarning("PowerShell succeeded but returned empty output");
return null;
}

// Extract the JWT token from output - PowerShell may include WARNING lines
// JWT tokens start with "eyJ" (base64 encoded '{"')
var token = ExtractJwtFromOutput(output);

if (string.IsNullOrWhiteSpace(token))
{
_logger.LogWarning("Could not extract JWT token from PowerShell output");
return null;
}

if (!IsValidJwtFormat(token))
{
_logger.LogWarning("Returned token does not appear to be a valid JWT");
Expand All @@ -285,6 +298,42 @@ private static string BuildPowerShellArguments(string shell, string script)
return token;
}

private static string? ExtractJwtFromOutput(string output)
Copy link

Copilot AI Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new ExtractJwtFromOutput() method lacks test coverage. This method contains important logic for filtering PowerShell output lines and extracting JWT tokens, which is critical for authentication to work correctly. Add test cases covering: (1) JWT token with WARNING prefix lines, (2) JWT token with ERROR prefix lines, (3) JWT token on single line without prefixes, (4) no valid JWT in output, and (5) multiple lines with only one valid JWT.

Copilot uses AI. Check for mistakes.
{
// Split output into lines and find the JWT token
// JWT tokens start with "eyJ" and contain exactly two dots
var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);

foreach (var line in lines)
{
var trimmed = line.Trim();
// Skip WARNING, ERROR, and other PowerShell output lines
if (trimmed.StartsWith("WARNING:", StringComparison.OrdinalIgnoreCase) ||
trimmed.StartsWith("ERROR:", StringComparison.OrdinalIgnoreCase) ||
trimmed.StartsWith("VERBOSE:", StringComparison.OrdinalIgnoreCase) ||
trimmed.StartsWith("DEBUG:", StringComparison.OrdinalIgnoreCase))
{
continue;
}

// Check if this line looks like a JWT token
if (IsValidJwtFormat(trimmed))
{
return trimmed;
}
}
Comment on lines +307 to +324
Copy link

Copilot AI Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop immediately maps its iteration variable to another variable - consider mapping the sequence explicitly using '.Select(...)'.

Copilot uses AI. Check for mistakes.

// Fallback: if no line matches, return the trimmed output
// (in case the token is on a single line without prefixes)
var trimmedOutput = output.Trim();
if (IsValidJwtFormat(trimmedOutput))
{
return trimmedOutput;
}

return null;
}

private static bool IsPowerShellNotFoundError(CommandResult result)
{
if (string.IsNullOrWhiteSpace(result.StandardError))
Expand Down
Loading