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 @@ -58,6 +58,13 @@ public static Command CreateCommand(
["--force-refresh"],
description: "Force token refresh even if cached token is valid");

var deviceCodeOption = new Option<bool>(
["--device-code"],
description: "Use device code authentication flow instead of browser/WAM. " +
"Required for Exchange-specific Graph scopes (e.g. MailboxSettings.ReadWrite, " +
"ExchangeMessageTrace.Read.All) that WAM does not support. " +
"Opens https://microsoft.com/devicelogin in your browser instead of a WAM popup.");

var resourceOption = new Option<string?>(
["--resource"],
description: "Resource keyword to get token for. Available: mcp (default), powerplatform. " +
Expand All @@ -80,6 +87,7 @@ public static Command CreateCommand(
command.AddOption(outputFormatOption);
command.AddOption(verboseOption);
command.AddOption(forceRefreshOption);
command.AddOption(deviceCodeOption);
command.AddOption(resourceOption);
command.AddOption(resourceIdOption);

Expand All @@ -93,6 +101,7 @@ public static Command CreateCommand(
var outputFormat = context.ParseResult.GetValueForOption(outputFormatOption)!;
var verbose = context.ParseResult.GetValueForOption(verboseOption);
var forceRefresh = context.ParseResult.GetValueForOption(forceRefreshOption);
var deviceCode = context.ParseResult.GetValueForOption(deviceCodeOption);
var resource = context.ParseResult.GetValueForOption(resourceOption);
var resourceId = context.ParseResult.GetValueForOption(resourceIdOption);

Expand Down Expand Up @@ -173,6 +182,8 @@ public static Command CreateCommand(
{
// Explicit scopes — single token against the resolved resource
logger.LogInformation("Using user-specified scopes: {Scopes}", string.Join(", ", scopes));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The if (deviceCode) logger.LogInformation("Authentication mode: device code (WAM bypassed)") line lives inside the explicit-scopes branch. The manifest branch (line 217 / AcquireAndDisplayManifestTokensAsync) routes deviceCode through but never logs it.

Move the if (deviceCode) logger.LogInformation(...) up to immediately after var deviceCode = ... is read so it fires for both branches.

if (deviceCode)

@sellakumaran sellakumaran Jun 1, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

WAM is Windows-only (MsalBrowserCredential.cs:386,418-426if (_useWam) vs. "System browser on Mac/Linux"). On macOS/Linux there is no WAM to bypass.

Either:

  • Drop the parenthetical: Authentication mode: device code
  • Append (WAM bypassed) only when OperatingSystem.IsWindows()

logger.LogInformation("Authentication mode: device code (WAM bypassed)");
logger.LogInformation("");
logger.LogInformation("Resource App ID: {AppId}", resourceAppId);
logger.LogInformation("Requesting scopes: {Scopes}", string.Join(", ", scopes));
Expand All @@ -181,7 +192,7 @@ public static Command CreateCommand(
await AcquireAndDisplayTokenAsync(
resourceAppId, resourceDisplayName, resourceUrl,
scopes, appId, setupConfig,
outputFormat, verbose, forceRefresh, authService, logger);
outputFormat, verbose, forceRefresh, deviceCode, authService, logger);
}
else if (isCustomResource)
{
Expand Down Expand Up @@ -216,7 +227,7 @@ await AcquireAndDisplayTokenAsync(

await AcquireAndDisplayManifestTokensAsync(
manifestPath, appId, setupConfig,
outputFormat, verbose, forceRefresh, authService, logger);
outputFormat, verbose, forceRefresh, deviceCode, authService, logger);
}
}
catch (Exceptions.CleanExitException)
Expand Down Expand Up @@ -249,6 +260,7 @@ private static async Task<McpServerTokenResult> AcquireTokenAsync(
string? loginHint,
string clientAppId,
bool forceRefresh,
bool useDeviceCode,
AuthenticationService authService,
ILogger logger)
{
Expand All @@ -264,7 +276,7 @@ private static async Task<McpServerTokenResult> AcquireTokenAsync(
tenantId,
forceRefresh,
clientAppId,
useInteractiveBrowser: true,
useInteractiveBrowser: !useDeviceCode,
userId: loginHint);

if (string.IsNullOrWhiteSpace(token))
Expand Down Expand Up @@ -326,6 +338,7 @@ private static async Task AcquireAndDisplayTokenAsync(
string outputFormat,
bool verbose,
bool forceRefresh,
bool useDeviceCode,
AuthenticationService authService,
ILogger logger)
{
Expand All @@ -340,7 +353,7 @@ private static async Task AcquireAndDisplayTokenAsync(
var result = await AcquireTokenAsync(
resourceAppId, resourceDisplayName, resourceUrl,
requestedScopes, appId, setupConfig,
tenantId, loginHint, clientAppId, forceRefresh, authService, logger);
tenantId, loginHint, clientAppId, forceRefresh, useDeviceCode, authService, logger);

if (!result.Success)
{
Expand All @@ -363,6 +376,7 @@ private static async Task AcquireAndDisplayManifestTokensAsync(
string outputFormat,
bool verbose,
bool forceRefresh,
bool useDeviceCode,
AuthenticationService authService,
ILogger logger)
{
Expand Down Expand Up @@ -390,7 +404,7 @@ private static async Task AcquireAndDisplayManifestTokensAsync(
var result = await AcquireTokenAsync(
audience, $"MCP Resource ({audience})", null,
scopes, appId, setupConfig,
tenantId, loginHint, clientAppId, forceRefresh, authService, logger);
tenantId, loginHint, clientAppId, forceRefresh, useDeviceCode, authService, logger);

tokenResults.Add(result);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -384,4 +384,28 @@ public static string GetPerServerBearerTokenEnvVar(string serverUniqueName) =>
/// Instead, print the admin consent URL and exit cleanly.
/// </summary>
public const string WamConsentRequiredError = "0xcaa90019";

/// <summary>
/// WAM error string indicating that the WAM broker rejected one or more requested scopes.
/// Appears in the WAM error message as "declined scopes are present".
///
/// This is a distinct failure mode from <see cref="WamConsentRequiredError"/>:
/// - WamConsentRequiredError (0xcaa90019): consent has NOT been granted — admin must grant it.
/// - WamDeclinedScopesError: consent HAS been granted, but WAM's internal scope validator
/// does not recognise the scope set (e.g. Exchange-specific delegated Graph scopes such as
/// MailboxSettings.ReadWrite, ExchangeMessageTrace.Read.All, Mail.ReadWrite).
/// Device code flow bypasses the WAM broker and succeeds for these scopes.
///
/// The WAM internal error code for this condition is 0x236496A2 (593742242 decimal), which

@sellakumaran sellakumaran Jun 1, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Doc says 0x236496A2 (593742242 decimal). Actual conversion is 593794722. Replace 593742242 with 593794722. (PR description body already has the correct value.)

/// does NOT match <see cref="WamErrorPrefix"/> ("0xcaa"), so a separate check is required.
/// </summary>
public const string WamDeclinedScopesError = "declined scopes are present";

/// <summary>
/// WAM error classification that accompanies <see cref="WamDeclinedScopesError"/>.
/// WAM surfaces this as "Error Message: ApiContractViolation" when scope validation fails.
/// Used together with <see cref="WamDeclinedScopesError"/> to distinguish this specific
/// fallback-eligible failure from other ApiContractViolation variants.
/// </summary>
public const string WamApiContractViolation = "ApiContractViolation";
}
Original file line number Diff line number Diff line change
Expand Up @@ -457,14 +457,30 @@ public override async ValueTask<AccessToken> GetTokenAsync(
aadErrorCode);
return await AcquireTokenWithDeviceCodeFallbackAsync(scopes, cancellationToken);
}
catch (MsalException ex) when (ex.Message.Contains(AuthenticationConstants.WamErrorPrefix, StringComparison.OrdinalIgnoreCase))
catch (MsalException ex) when (
ex.Message.Contains(AuthenticationConstants.WamErrorPrefix, StringComparison.OrdinalIgnoreCase)
|| IsWamDeclinedScopesError(ex))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Called once in the catch (...) when (...) filter (line 462) and again in the body (line 476). Hoist bool isDeclined = IsWamDeclinedScopesError(ex); at the top of the catch body and reuse it.

{
// WAM error 0xcaa90019 = "Need admin approval" (admin consent not granted).
// Do NOT fall back to device code — device code shows the same browser consent page
// and hangs if the user clicks "Return to application without granting consent".
if (ex.Message.Contains(AuthenticationConstants.WamConsentRequiredError, StringComparison.OrdinalIgnoreCase))
LogConsentRequiredAndThrow(ex);

// "Declined scopes" (ApiContractViolation): WAM's internal scope validator rejected one
// or more scopes — typically Exchange-specific Graph delegated scopes such as
// MailboxSettings.ReadWrite or ExchangeMessageTrace.Read.All. Consent HAS been granted;
// WAM simply does not recognise those scope strings. Device code flow bypasses the WAM
// broker entirely and succeeds for these scopes.
if (IsWamDeclinedScopesError(ex))
{
_logger?.LogWarning(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

  1. Change _logger?.LogWarning(...)_logger?.LogInformation(...). The branch is a successful auto-recovery on the documented, intended path — the message itself says "This is expected for Exchange-specific Graph scopes". Warning level for an expected event is noise.
  2. Append a hint to the message: "Tip: pass --device-code on subsequent runs to skip the WAM probe." so users discover the flag the first time they hit the fallback.

"WAM declined one or more requested scopes (ApiContractViolation). " +
"This is expected for Exchange-specific Graph scopes (e.g. MailboxSettings.ReadWrite, " +
"ExchangeMessageTrace.Read.All). Falling back to device code authentication.");
return await AcquireTokenWithDeviceCodeFallbackAsync(scopes, cancellationToken);
}

// Other WAM errors (e.g. Conditional Access Policy, device compliance policy)
// are not consent-related — device code flow bypasses the WAM broker and may succeed.
_logger?.LogWarning(
Expand All @@ -488,6 +504,17 @@ public override async ValueTask<AccessToken> GetTokenAsync(
}
}

/// <summary>
/// Returns true when the MSAL exception represents WAM's "declined scopes" failure:
/// the WAM broker rejected one or more scopes with ApiContractViolation because its internal
/// scope validator does not recognise them (common for Exchange-specific Graph delegated scopes).
/// This is distinct from a consent-not-granted failure (0xcaa90019): consent HAS been granted,
/// but WAM cannot process the scope. Device code flow bypasses WAM and succeeds.
/// </summary>
private static bool IsWamDeclinedScopesError(MsalException ex)
=> ex.Message.Contains(AuthenticationConstants.WamApiContractViolation, StringComparison.OrdinalIgnoreCase)

@sellakumaran sellakumaran Jun 1, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The existing AuthenticationConstantsTests only asserts the literal string values of the two constants — the matcher that gates the fallback branch has no direct coverage.

Change private staticinternal static on the helper (InternalsVisibleTo for the test project is already in Microsoft.Agents.A365.DevTools.Cli.csproj:22, no csproj edit needed). Cover:

  • Both strings present, exact case → true
  • Both strings present, mixed case → true (validates OrdinalIgnoreCase)
  • Only ApiContractViolation present → false
  • Only "declined scopes are present"false
  • Neither present → false
  • Verbatim live WAM error message from the PR's test plan run → true (pins the matcher to MSAL's actual output so a future MSAL reword fails this test instead of silently regressing live behavior)

&& ex.Message.Contains(AuthenticationConstants.WamDeclinedScopesError, StringComparison.OrdinalIgnoreCase);

/// <summary>
/// Logs a consistent "admin consent required" message with the admin consent URL and throws.
/// Used by all three consent-detection points: silent path, WAM OS probe, and WAM error backstop.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,32 @@ public void CreateCommand_ShouldHaveForceRefreshOption()
forceRefreshOption!.Aliases.Should().Contain("--force-refresh");
}

[Fact]
public void CreateCommand_ShouldHaveDeviceCodeOption()
{
// Act
var command = GetTokenSubcommand.CreateCommand(_mockLogger, _mockConfigService, _mockAuthService);

// Assert
var deviceCodeOption = command.Options.FirstOrDefault(o => o.Name == "device-code");
deviceCodeOption.Should().NotBeNull();
deviceCodeOption!.Aliases.Should().Contain("--device-code");
}

[Fact]
public void CreateCommand_DeviceCodeOption_DescriptionShouldMentionExchangeScopes()
{
// The description must explain WHY this option exists — Exchange-specific Graph scopes
// that WAM does not support. This prevents the option from being misunderstood as
// a general "headless mode" flag.
var command = GetTokenSubcommand.CreateCommand(_mockLogger, _mockConfigService, _mockAuthService);

var deviceCodeOption = command.Options.FirstOrDefault(o => o.Name == "device-code");
deviceCodeOption.Should().NotBeNull();
deviceCodeOption!.Description.Should().Contain("Exchange");
deviceCodeOption.Description.Should().Contain("WAM");
}

[Fact]
public void CreateCommand_ShouldHaveResourceOption()
{
Expand Down Expand Up @@ -163,7 +189,7 @@ public void CreateCommand_ShouldHaveAllRequiredOptions()
var command = GetTokenSubcommand.CreateCommand(_mockLogger, _mockConfigService, _mockAuthService);

// Assert
command.Options.Should().HaveCount(8);
command.Options.Should().HaveCount(9);
var optionNames = command.Options.Select(opt => opt.Name).ToList();
optionNames.Should().Contain(new[]
{
Expand All @@ -173,6 +199,7 @@ public void CreateCommand_ShouldHaveAllRequiredOptions()
"output",
"verbose",
"force-refresh",
"device-code",
"resource",
"resource-id"
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,36 @@ public void TokenExpirationBufferMinutes_ShouldBeReasonable()
// Should be between 1 and 60 minutes
AuthenticationConstants.TokenExpirationBufferMinutes.Should().BeInRange(1, 60);
}

[Fact]
public void WamDeclinedScopesError_ShouldMatchKnownWamErrorSubstring()
{
// This constant is matched against the WAM error message that reads:
// "Token response failed because declined scopes are present:'(pii)'"
// Verified against live WAM output when requesting Exchange-specific Graph scopes
// (MailboxSettings.ReadWrite, ExchangeMessageTrace.Read.All) through the a365 CLI.
AuthenticationConstants.WamDeclinedScopesError.Should().Be("declined scopes are present");
}

[Fact]
public void WamApiContractViolation_ShouldMatchKnownWamErrorClassification()
{
// WAM surfaces this as "Error Message: ApiContractViolation" in the MSAL exception message
// when its internal scope validator rejects a scope set it does not recognise.
// Used alongside WamDeclinedScopesError to trigger device code fallback.
AuthenticationConstants.WamApiContractViolation.Should().Be("ApiContractViolation");
}

[Fact]
public void WamDeclinedScopesError_ShouldBeDifferentFromWamConsentRequiredError()
{
// These are two distinct failure modes:
// - WamConsentRequiredError (0xcaa90019): admin consent NOT granted — do not fall back to device code
// - WamDeclinedScopesError (ApiContractViolation + declined scopes): consent granted, WAM
// cannot process the scope — fall back to device code
AuthenticationConstants.WamDeclinedScopesError.Should()
.NotBe(AuthenticationConstants.WamConsentRequiredError);
AuthenticationConstants.WamDeclinedScopesError.Should()
.NotContain("0xcaa");
}
}