diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs index 91751a43..141d56b2 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs @@ -58,6 +58,13 @@ public static Command CreateCommand( ["--force-refresh"], description: "Force token refresh even if cached token is valid"); + var deviceCodeOption = new Option( + ["--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( ["--resource"], description: "Resource keyword to get token for. Available: mcp (default), powerplatform. " + @@ -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); @@ -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); @@ -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)); + if (deviceCode) + 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)); @@ -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) { @@ -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) @@ -249,6 +260,7 @@ private static async Task AcquireTokenAsync( string? loginHint, string clientAppId, bool forceRefresh, + bool useDeviceCode, AuthenticationService authService, ILogger logger) { @@ -264,7 +276,7 @@ private static async Task AcquireTokenAsync( tenantId, forceRefresh, clientAppId, - useInteractiveBrowser: true, + useInteractiveBrowser: !useDeviceCode, userId: loginHint); if (string.IsNullOrWhiteSpace(token)) @@ -326,6 +338,7 @@ private static async Task AcquireAndDisplayTokenAsync( string outputFormat, bool verbose, bool forceRefresh, + bool useDeviceCode, AuthenticationService authService, ILogger logger) { @@ -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) { @@ -363,6 +376,7 @@ private static async Task AcquireAndDisplayManifestTokensAsync( string outputFormat, bool verbose, bool forceRefresh, + bool useDeviceCode, AuthenticationService authService, ILogger logger) { @@ -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); } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs index b18db277..7a1a077a 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs @@ -384,4 +384,28 @@ public static string GetPerServerBearerTokenEnvVar(string serverUniqueName) => /// Instead, print the admin consent URL and exit cleanly. /// public const string WamConsentRequiredError = "0xcaa90019"; + + /// + /// 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 : + /// - 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 + /// does NOT match ("0xcaa"), so a separate check is required. + /// + public const string WamDeclinedScopesError = "declined scopes are present"; + + /// + /// WAM error classification that accompanies . + /// WAM surfaces this as "Error Message: ApiContractViolation" when scope validation fails. + /// Used together with to distinguish this specific + /// fallback-eligible failure from other ApiContractViolation variants. + /// + public const string WamApiContractViolation = "ApiContractViolation"; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs index 4d275bd7..0b40ae3b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs @@ -457,7 +457,9 @@ public override async ValueTask 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)) { // 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 @@ -465,6 +467,20 @@ public override async ValueTask GetTokenAsync( 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( + "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( @@ -488,6 +504,17 @@ public override async ValueTask GetTokenAsync( } } + /// + /// 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. + /// + private static bool IsWamDeclinedScopesError(MsalException ex) + => ex.Message.Contains(AuthenticationConstants.WamApiContractViolation, StringComparison.OrdinalIgnoreCase) + && ex.Message.Contains(AuthenticationConstants.WamDeclinedScopesError, StringComparison.OrdinalIgnoreCase); + /// /// 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. diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/GetTokenSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/GetTokenSubcommandTests.cs index f4133b74..8c356b8c 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/GetTokenSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/GetTokenSubcommandTests.cs @@ -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() { @@ -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[] { @@ -173,6 +199,7 @@ public void CreateCommand_ShouldHaveAllRequiredOptions() "output", "verbose", "force-refresh", + "device-code", "resource", "resource-id" }); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Constants/AuthenticationConstantsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Constants/AuthenticationConstantsTests.cs index 57d44cf7..d42aeaf6 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Constants/AuthenticationConstantsTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Constants/AuthenticationConstantsTests.cs @@ -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"); + } }