-
Notifications
You must be signed in to change notification settings - Fork 22
feat(get-token): add --device-code flag and auto-fallback for Exchange Graph scopes #423
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. " + | ||
|
|
@@ -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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WAM is Windows-only (MsalBrowserCredential.cs:386,418-426 — Either:
|
||
| 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<McpServerTokenResult> AcquireTokenAsync( | |
| string? loginHint, | ||
| string clientAppId, | ||
| bool forceRefresh, | ||
| bool useDeviceCode, | ||
| AuthenticationService authService, | ||
| ILogger logger) | ||
| { | ||
|
|
@@ -264,7 +276,7 @@ private static async Task<McpServerTokenResult> 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); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Doc says |
||
| /// 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 |
|---|---|---|
|
|
@@ -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)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Called once in the |
||
| { | ||
| // 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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| "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<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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The existing Change
|
||
| && 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. | ||
|
|
||
There was a problem hiding this comment.
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) routesdeviceCodethrough but never logs it.Move the
if (deviceCode) logger.LogInformation(...)up to immediately aftervar deviceCode = ...is read so it fires for both branches.