Skip to content
Merged
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 @@ -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("");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,29 @@
namespace Microsoft.Agents.A365.DevTools.Cli.Services;

/// <summary>
/// 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
/// </summary>
public class AuthenticationService
{
Expand All @@ -37,12 +59,12 @@ public AuthenticationService(ILogger<AuthenticationService> logger)
/// <param name="clientId">Optional client ID for authentication. If not provided, uses PowerShell client ID</param>
/// <param name="scopes">Optional explicit scopes to request. If not provided, uses .default scope pattern</param>
public async Task<string> GetAccessTokenAsync(
string resourceUrl,
string? tenantId = null,
bool forceRefresh = false,
string resourceUrl,
string? tenantId = null,
bool forceRefresh = false,
string? clientId = null,
IEnumerable<string>? 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,
Expand Down Expand Up @@ -338,12 +360,12 @@ private bool IsTokenExpired(TokenInfo token)
/// <param name="clientId">Optional client ID for authentication. If not provided, uses PowerShell client ID</param>
/// <returns>Access token with the requested scopes</returns>
public async Task<string> GetAccessTokenWithScopesAsync(
string resourceAppId,
IEnumerable<string> scopes,
string? tenantId = null,
string resourceAppId,
IEnumerable<string> 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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,15 @@ public async Task<bool> 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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,20 +182,47 @@ public GraphApiService(ILogger<GraphApiService> logger, CommandExecutor executor

private async Task<bool> EnsureGraphHeadersAsync(string tenantId, CancellationToken ct = default, IEnumerable<string>? 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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,53 +83,51 @@ public Task<GraphServiceClient> 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("");

// 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")
{
Expand All @@ -147,75 +145,6 @@ public Task<GraphServiceClient> 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Services;

/// <summary>
/// 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)
/// </summary>
public sealed class MicrosoftGraphTokenProvider : IMicrosoftGraphTokenProvider, IDisposable
{
Expand Down Expand Up @@ -63,7 +77,7 @@ public MicrosoftGraphTokenProvider(
public async Task<string?> GetMgGraphAccessTokenAsync(
string tenantId,
IEnumerable<string> scopes,
bool useDeviceCode = true,
bool useDeviceCode = false,
string? clientAppId = null,
CancellationToken ct = default)
{
Expand Down Expand Up @@ -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)
Expand Down
Loading