feat(get-token): add --device-code flag and auto-fallback for Exchange Graph scopes#423
Conversation
…e Graph scopes
WAM (Windows Account Manager) rejects Exchange-specific Microsoft Graph delegated
scopes — MailboxSettings.ReadWrite, ExchangeMessageTrace.Read.All, Mail.ReadWrite,
etc. — with ApiContractViolation / "declined scopes are present". This is distinct
from a consent-not-granted failure (0xcaa90019): admin consent HAS been granted
via oauth2PermissionGrants, but WAM's internal scope validator does not recognise
Exchange scope strings. Device code flow bypasses the WAM broker and succeeds.
Changes:
- AuthenticationConstants: add WamDeclinedScopesError and WamApiContractViolation
constants that identify the "declined scopes" error pattern from WAM output
- MsalBrowserCredential: extend the WAM catch clause to also match the declined-
scopes ApiContractViolation error; automatically fall back to device code flow
(same path already used for CAP / device-compliance WAM errors)
- GetTokenSubcommand: add --device-code flag that forces device code flow
(useInteractiveBrowser: false) as an explicit escape hatch for Exchange scopes
- Tests: add option-existence tests for --device-code; add constant tests
documenting the WAM declined-scopes error pattern and its distinction from
the consent-not-granted error
Usage after this change:
# Automatic: WAM fails, CLI falls back to device code silently
a365 develop get-token --resource-id 00000003-0000-0000-c000-000000000000 \
--scopes "MailboxSettings.ReadWrite ExchangeMessageTrace.Read.All"
# Explicit: skip WAM entirely, go straight to device code
a365 develop get-token --resource-id 00000003-0000-0000-c000-000000000000 \
--scopes "MailboxSettings.ReadWrite ExchangeMessageTrace.Read.All" \
--device-code
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@lwokeray please read the following Contributor License Agreement(CLA). If you agree with the CLA, please reply with the following information.
Contributor License AgreementContribution License AgreementThis Contribution License Agreement (“Agreement”) is agreed to by the party signing below (“You”),
|
| /// 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 |
There was a problem hiding this comment.
Doc says 0x236496A2 (593742242 decimal). Actual conversion is 593794722. Replace 593742242 with 593794722. (PR description body already has the correct value.)
| /// 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) |
There was a problem hiding this comment.
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 static → internal 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(validatesOrdinalIgnoreCase) - Only
ApiContractViolationpresent →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)
| { | ||
| // Explicit scopes — single token against the resolved resource | ||
| logger.LogInformation("Using user-specified scopes: {Scopes}", string.Join(", ", scopes)); | ||
| if (deviceCode) |
There was a problem hiding this comment.
WAM is Windows-only (MsalBrowserCredential.cs:386,418-426 — if (_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 whenOperatingSystem.IsWindows()
| // broker entirely and succeeds for these scopes. | ||
| if (IsWamDeclinedScopesError(ex)) | ||
| { | ||
| _logger?.LogWarning( |
There was a problem hiding this comment.
- 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. - 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.
| @@ -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)); | |||
There was a problem hiding this comment.
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.
| catch (MsalException ex) when (ex.Message.Contains(AuthenticationConstants.WamErrorPrefix, StringComparison.OrdinalIgnoreCase)) | ||
| catch (MsalException ex) when ( | ||
| ex.Message.Contains(AuthenticationConstants.WamErrorPrefix, StringComparison.OrdinalIgnoreCase) | ||
| || IsWamDeclinedScopesError(ex)) |
There was a problem hiding this comment.
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.
Summary
--device-codeflag toa365 develop get-tokenso users can bypass WAM for Exchange-specific Graph scopes that WAM rejectsMsalBrowserCredentialto automatically detect WAMApiContractViolation+ "declined scopes are present" errors and fall back to device code, rather than throwingWamDeclinedScopesError,WamApiContractViolation) toAuthenticationConstantsto document and centralize the known WAM error signaturesBackground
WAM (Windows Account Manager) rejects Exchange-specific Microsoft Graph scopes such as
MailboxSettings.ReadWriteandExchangeMessageTrace.Read.Allwith anApiContractViolationerror containing "declined scopes are present". This internal error code (593794722 decimal) does not begin with0xcaa, so the existing WAM fallback logic inMsalBrowserCredentialdoes not trigger — the exception is rethrown and the user sees an unhelpful MSAL error.Changes
AuthenticationConstants.csWamDeclinedScopesError = "declined scopes are present"— substring matched against WAMApiContractViolationmessagesWamApiContractViolation = "ApiContractViolation"— the WAM error classification that accompanies declined scopesMsalBrowserCredential.csIsWamDeclinedScopesError(MsalException)private static helper — returnstruewhen both WAM error strings are present in the exception message|| IsWamDeclinedScopesError(ex)AcquireTokenWithDeviceCodeFallbackAsync, so Exchange-scope requests automatically retry via device code without user interventionGetTokenSubcommand.cs--device-codeoption (bool, defaultfalse) with description explaining WAM limitation and Exchange scope use-caseAcquireAndDisplayTokenAsync,AcquireAndDisplayManifestTokensAsync, andAcquireTokenAsyncAcquireTokenAsyncpassesuseInteractiveBrowser: !useDeviceCodetoAuthenticationService.GetAccessTokenWithScopesAsyncTests
GetTokenSubcommandTests.cs: addedCreateCommand_ShouldHaveDeviceCodeOption,CreateCommand_DeviceCodeOption_DescriptionShouldMentionExchangeScopes; updatedCreateCommand_ShouldHaveAllRequiredOptions(count 8 → 9)AuthenticationConstantsTests.cs: addedWamDeclinedScopesError_ShouldMatchKnownWamErrorSubstring,WamApiContractViolation_ShouldMatchKnownWamErrorClassification,WamDeclinedScopesError_ShouldBeDifferentFromWamConsentRequiredErrorTest plan
dotnet build dirs.proj --configuration Release— 0 errors, 0 warningsdotnet test tests.proj --configuration Release— all 90 tests passa365 develop get-token --resource-id 00000003-0000-0000-c000-000000000000 --scopes "MailboxSettings.ReadWrite ExchangeMessageTrace.Read.All" --device-code— prompts with device code URL, acquires token successfully--device-codeon a machine where WAM rejects Exchange scopes: confirm automatic fallback warning appears and device code flow is used🤖 Generated with Claude Code