diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs index c0dcf99b..d0754def 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs @@ -465,7 +465,7 @@ await CreateBlueprintClientSecretAsync( logger.LogWarning("Setup will continue to configure Bot API permissions"); logger.LogWarning(""); logger.LogWarning("To resolve endpoint registration issues:"); - logger.LogWarning(" 1. Delete existing endpoint: a365 cleanup blueprint"); + logger.LogWarning(" 1. Delete existing endpoint: a365 cleanup blueprint --endpoint-only"); logger.LogWarning(" 2. Register endpoint again: a365 setup blueprint --endpoint-only"); logger.LogWarning(" Or rerun full setup: a365 setup blueprint"); logger.LogWarning(""); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs index 2c592f74..55d7e64f 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs @@ -12,7 +12,29 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Services; /// -/// Service for handling authentication to Agent 365 Tools +/// Service for handling authentication to Agent 365 Tools and Microsoft Graph API. +/// +/// AUTHENTICATION STRATEGY: +/// - Uses interactive authentication by default (no device code flow) +/// - Implements comprehensive token caching to minimize authentication prompts +/// - Typical user experience: 1-2 authentication prompts for entire CLI workflow +/// +/// TOKEN CACHING: +/// - Cache Location: %LocalApplicationData%\Agent365\token-cache.json (Windows) +/// - Cache Key Format: {resourceUrl}:tenant:{tenantId} +/// - Cache Expiration: Validated with 5-minute buffer before token expiry +/// - Reuse Across Commands: All CLI commands share the same token cache +/// +/// AUTHENTICATION FLOW: +/// 1. Check cache for valid token (tenant-specific) +/// 2. If cache miss or expired: Prompt for interactive authentication +/// 3. Cache new token for future CLI command invocations +/// 4. Token persists across CLI sessions until expiration +/// +/// MULTI-COMMAND WORKFLOW: +/// - First command (e.g., 'setup all'): 1-2 authentication prompts +/// - Subsequent commands: 0 prompts (uses cached tokens) +/// - Token refresh: Automatic when within 5 minutes of expiration /// public class AuthenticationService { @@ -37,12 +59,12 @@ public AuthenticationService(ILogger logger) /// Optional client ID for authentication. If not provided, uses PowerShell client ID /// Optional explicit scopes to request. If not provided, uses .default scope pattern public async Task GetAccessTokenAsync( - string resourceUrl, - string? tenantId = null, - bool forceRefresh = false, + string resourceUrl, + string? tenantId = null, + bool forceRefresh = false, string? clientId = null, IEnumerable? scopes = null, - bool useInteractiveBrowser = false) + bool useInteractiveBrowser = true) { // Build cache key based on resource and tenant only // Azure AD returns tokens with all consented scopes regardless of which scopes are requested, @@ -338,12 +360,12 @@ private bool IsTokenExpired(TokenInfo token) /// Optional client ID for authentication. If not provided, uses PowerShell client ID /// Access token with the requested scopes public async Task GetAccessTokenWithScopesAsync( - string resourceAppId, - IEnumerable scopes, - string? tenantId = null, + string resourceAppId, + IEnumerable scopes, + string? tenantId = null, bool forceRefresh = false, string? clientId = null, - bool useInteractiveBrowser = false) + bool useInteractiveBrowser = true) { if (string.IsNullOrWhiteSpace(resourceAppId)) throw new ArgumentException("Resource App ID cannot be empty", nameof(resourceAppId)); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureWebAppCreator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureWebAppCreator.cs index dcf3c354..41f284d0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureWebAppCreator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureWebAppCreator.cs @@ -33,13 +33,15 @@ public async Task CreateWebAppAsync( try { ArmClient armClient; - // Use DefaultAzureCredential with InteractiveBrowserCredential excluded to avoid - // Windows Authentication Broker (WAM) issues in console apps. - // Users should run 'az login' before using this command. - // See GitHub issues #146 and #151. + // Use DefaultAzureCredential which tries credentials in this order: + // 1. Environment variables + // 2. Managed Identity + // 3. Visual Studio / VS Code + // 4. Azure CLI (az login) + // 5. Interactive Browser (if needed) var credentialOptions = new DefaultAzureCredentialOptions { - ExcludeInteractiveBrowserCredential = true + ExcludeInteractiveBrowserCredential = false }; if (!string.IsNullOrWhiteSpace(tenantId)) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs index 6e49d8a5..d1608d52 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs @@ -182,20 +182,47 @@ public GraphApiService(ILogger logger, CommandExecutor executor private async Task EnsureGraphHeadersAsync(string tenantId, CancellationToken ct = default, IEnumerable? scopes = null) { - // When specific scopes are required, token provider must be configured - if (scopes != null && _tokenProvider == null) + // Authentication Strategy: + // 1. If specific scopes required AND token provider configured: Use interactive browser auth (cached) + // 2. Otherwise: Use Azure CLI token (requires 'az login') + // This dual approach minimizes auth prompts while supporting special scope requirements + + string? token; + + if (scopes != null && _tokenProvider != null) + { + // Use token provider with delegated scopes (interactive browser auth with caching) + _logger.LogDebug("Acquiring Graph token with specific scopes via token provider: {Scopes}", string.Join(", ", scopes)); + token = await _tokenProvider.GetMgGraphAccessTokenAsync(tenantId, scopes, false, CustomClientAppId, ct); + + if (string.IsNullOrWhiteSpace(token)) + { + _logger.LogError("Failed to acquire Graph token with scopes: {Scopes}", string.Join(", ", scopes)); + return false; + } + + _logger.LogDebug("Successfully acquired Graph token with specific scopes (cached or new)"); + } + else if (scopes != null && _tokenProvider == null) { + // Scopes required but no token provider - this is a configuration issue _logger.LogError("Token provider is not configured, but specific scopes are required: {Scopes}", string.Join(", ", scopes)); return false; } + else + { + // Use Azure CLI token (default fallback for operations that don't need special scopes) + _logger.LogDebug("Acquiring Graph token via Azure CLI (no specific scopes required)"); + token = await GetGraphAccessTokenAsync(tenantId, ct); - // When specific scopes are required, use custom client app if configured - // CustomClientAppId should be set by callers who have access to config - var token = (scopes != null && _tokenProvider != null) - ? await _tokenProvider.GetMgGraphAccessTokenAsync(tenantId, scopes, false, CustomClientAppId, ct) - : await GetGraphAccessTokenAsync(tenantId, ct); - - if (string.IsNullOrWhiteSpace(token)) return false; + if (string.IsNullOrWhiteSpace(token)) + { + _logger.LogError("Failed to acquire Graph token via Azure CLI. Ensure 'az login' is completed."); + return false; + } + + _logger.LogDebug("Successfully acquired Graph token via Azure CLI"); + } // Trim token to remove any newline characters that may cause header validation errors token = token.Trim(); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs index 7112c1fe..38c9d62b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs @@ -83,28 +83,24 @@ public Task GetAuthenticatedGraphClientAsync( _logger.LogInformation("Please sign in with an account that has Global Administrator or similar privileges."); _logger.LogInformation(""); - // Try browser authentication first - GraphServiceClient? graphClient = null; - bool shouldTryDeviceCode = false; - + // Use browser authentication (MsalBrowserCredential handles WAM on Windows and browser on other platforms) try { - // Use MsalBrowserCredential which handles WAM on Windows and browser on other platforms // Pass null for redirectUri to let MsalBrowserCredential decide based on platform var browserCredential = new MsalBrowserCredential( _clientAppId, tenantId, redirectUri: null, // Let MsalBrowserCredential use WAM on Windows _logger); - + _logger.LogInformation("Authenticating to Microsoft Graph..."); _logger.LogInformation("IMPORTANT: You must grant consent for all required permissions."); _logger.LogInformation("Required permissions are defined in AuthenticationConstants.RequiredClientAppPermissions."); _logger.LogInformation($"See {ConfigConstants.Agent365CliDocumentationUrl} for the complete list."); _logger.LogInformation(""); - + // Create GraphServiceClient with the credential - graphClient = new GraphServiceClient(browserCredential, RequiredScopes); + var graphClient = new GraphServiceClient(browserCredential, RequiredScopes); _logger.LogInformation("Successfully authenticated to Microsoft Graph!"); _logger.LogInformation(""); @@ -112,24 +108,26 @@ public Task GetAuthenticatedGraphClientAsync( // Cache the client for reuse _cachedClient = graphClient; _cachedTenantId = tenantId; - + return Task.FromResult(graphClient); } catch (MsalAuthenticationFailedException ex) when (ex.Message.Contains("invalid_grant")) { - // Most specific: permissions issue - don't try fallback + // Permissions issue ThrowInsufficientPermissionsException(ex); throw; // Unreachable but required for compiler } catch (MsalAuthenticationFailedException ex) when ( - ex.Message.Contains("localhost") || + ex.Message.Contains("localhost") || ex.Message.Contains("connection") || ex.Message.Contains("redirect_uri")) { - // Infrastructure issue - try device code fallback - _logger.LogWarning("Browser authentication failed due to connectivity issue, falling back to device code flow..."); - _logger.LogInformation(""); - shouldTryDeviceCode = true; + // Infrastructure/connectivity issue + _logger.LogError("Browser authentication failed due to connectivity issue: {Message}", ex.Message); + throw new GraphApiException( + "Browser authentication", + $"Authentication failed due to connectivity issue: {ex.Message}. Please ensure you have network connectivity.", + isPermissionIssue: false); } catch (Microsoft.Identity.Client.MsalServiceException ex) when (ex.ErrorCode == "access_denied") { @@ -147,75 +145,6 @@ public Task GetAuthenticatedGraphClientAsync( $"Authentication failed: {ex.Message}", isPermissionIssue: false); } - - // DeviceCodeCredential fallback safety net: - // If browser authentication fails due to infrastructure issues (localhost connectivity, - // redirect URI problems, etc.), this fallback provides an alternative authentication path. - // The device code flow displays a code that users can enter at microsoft.com/devicelogin, - // which works even in environments where browser-based OAuth redirects fail. - // This fallback is preserved even after the WAM fix (GitHub issues #146, #151). - if (shouldTryDeviceCode) - { - try - { - var deviceCodeCredential = new DeviceCodeCredential(new DeviceCodeCredentialOptions - { - TenantId = tenantId, - ClientId = _clientAppId, - AuthorityHost = AzureAuthorityHosts.AzurePublicCloud, - TokenCachePersistenceOptions = new TokenCachePersistenceOptions - { - Name = AuthenticationConstants.ApplicationName - }, - DeviceCodeCallback = (code, cancellation) => - { - _logger.LogInformation(""); - _logger.LogInformation("============================================================="); - _logger.LogInformation("DEVICE CODE AUTHENTICATION"); - _logger.LogInformation("============================================================="); - _logger.LogInformation(""); - _logger.LogInformation("To sign in, use a web browser to open the page:"); - _logger.LogInformation(" {0}", code.VerificationUri); - _logger.LogInformation(""); - _logger.LogInformation("And enter the code:"); - _logger.LogInformation(" {0}", code.UserCode); - _logger.LogInformation(""); - _logger.LogInformation("============================================================="); - _logger.LogInformation(""); - return Task.CompletedTask; - } - }); - - graphClient = new GraphServiceClient(deviceCodeCredential, RequiredScopes); - - _logger.LogInformation("Successfully authenticated to Microsoft Graph!"); - _logger.LogInformation(""); - - // Cache the client for reuse - _cachedClient = graphClient; - _cachedTenantId = tenantId; - - return Task.FromResult(graphClient); - } - catch (Azure.Identity.AuthenticationFailedException ex) when (ex.Message.Contains("invalid_grant")) - { - // Permissions issue in device code flow - ThrowInsufficientPermissionsException(ex); - throw; // Unreachable but required for compiler - } - catch (Exception ex) - { - _logger.LogError("Device code authentication failed: {Message}", ex.Message); - throw new GraphApiException( - "Device code authentication", - $"Authentication failed: {ex.Message}. Ensure you have required permissions and completed authentication flow."); - } - } - - // If browser auth succeeded, we already returned at line 83 - // If device code was attempted and succeeded, we already returned above - // This line is truly unreachable in normal flow - throw new InvalidOperationException("Authentication failed unexpectedly."); } private void ThrowInsufficientPermissionsException(Exception innerException) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs index f808925f..40af220f 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs @@ -17,6 +17,20 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Services; /// /// Implements Microsoft Graph token acquisition via PowerShell Microsoft.Graph module. +/// +/// AUTHENTICATION METHOD: +/// - Uses Connect-MgGraph (PowerShell) for Graph API authentication +/// - Default: Interactive browser authentication (useDeviceCode=false) +/// - Device Code Flow: Available but NOT used by default (DCF discouraged in production) +/// +/// TOKEN CACHING: +/// - In-memory cache per CLI process: Tokens cached by (tenant + clientId + scopes) +/// - Persistent cache: PowerShell module manages its own session cache +/// - Reduces repeated Connect-MgGraph prompts during multi-step operations +/// +/// USAGE: +/// - Called by GraphApiService when specific scopes are required +/// - Integrates with overall CLI authentication strategy (1-2 total prompts) /// public sealed class MicrosoftGraphTokenProvider : IMicrosoftGraphTokenProvider, IDisposable { @@ -63,7 +77,7 @@ public MicrosoftGraphTokenProvider( public async Task GetMgGraphAccessTokenAsync( string tenantId, IEnumerable scopes, - bool useDeviceCode = true, + bool useDeviceCode = false, string? clientAppId = null, CancellationToken ct = default) { @@ -180,7 +194,8 @@ private static string BuildPowerShellScript(string tenantId, string[] scopes, bo var escapedTenantId = CommandStringHelper.EscapePowerShellString(tenantId); var scopesArray = BuildScopesArray(scopes); - // Use -UseDeviceCode for CLI-friendly authentication (no browser popup/download) + // Use interactive browser auth by default (useDeviceCode=false) + // If useDeviceCode=true, use device code flow instead var authMethod = useDeviceCode ? "-UseDeviceCode" : ""; // Include -ClientId parameter if provided (ensures authentication uses the custom client app)