diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs index ce1b663..8fb7ca5 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs @@ -418,7 +418,7 @@ private static async Task EnsureMcpInheritablePermissionsAsync( var requiredPermissions = new[] { "AgentIdentityBlueprint.UpdateAuthProperties.All", "Application.ReadWrite.All" }; var (ok, alreadyExists, err) = await blueprintService.SetInheritablePermissionsAsync( - config.TenantId, config.AgentBlueprintId, resourceAppId, scopes, requiredScopes: requiredPermissions, ct); + config.TenantId, config.AgentBlueprintId, resourceAppId, scopes, requiredPermissions, ct); if (!ok && !alreadyExists) { 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 a7833a3..e2a4c72 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs @@ -275,9 +275,7 @@ public static async Task CreateBlueprintImplementationA var cleanLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(); var delegatedConsentService = new DelegatedConsentService( cleanLoggerFactory.CreateLogger(), - new GraphApiService( - cleanLoggerFactory.CreateLogger(), - executor)); + graphApiService); // Use DI-provided GraphApiService which already has MicrosoftGraphTokenProvider configured var graphService = graphApiService; @@ -655,19 +653,28 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( // ======================================================================== try { - logger.LogInformation("Creating Agent Blueprint using Microsoft Graph SDK..."); + logger.LogInformation("Creating Agent Blueprint using Microsoft Graph REST..."); - using GraphServiceClient graphClient = await GetAuthenticatedGraphClientAsync(logger, setupConfig, tenantId, ct); + // Use delegated device-code auth scopes for the full flow + var authScopes = AuthenticationConstants.PermissionGrantAuthScopes; - // Get current user for sponsors field (mimics PowerShell script behavior) + // 1) Get current user for sponsors (best-effort) string? sponsorUserId = null; try { - var me = await graphClient.Me.GetAsync(cancellationToken: ct); - if (me != null && !string.IsNullOrEmpty(me.Id)) + var meDoc = await graphApiService.GraphGetAsync( + tenantId, + "/v1.0/me?$select=id,displayName,userPrincipalName", + ct, + scopes: new[] { "User.Read" }); + + if (meDoc?.RootElement.TryGetProperty("id", out var idEl) == true) { - sponsorUserId = me.Id; - logger.LogInformation("Current user: {DisplayName} <{UPN}>", me.DisplayName, me.UserPrincipalName); + sponsorUserId = idEl.GetString(); + var displayNameEl = meDoc.RootElement.TryGetProperty("displayName", out var dn) ? dn.GetString() : null; + var upnEl = meDoc.RootElement.TryGetProperty("userPrincipalName", out var upn) ? upn.GetString() : null; + + logger.LogInformation("Current user: {DisplayName} <{UPN}>", displayNameEl ?? "(unknown)", upnEl ?? "(unknown)"); logger.LogInformation("Sponsor: https://graph.microsoft.com/v1.0/users/{UserId}", sponsorUserId); } } @@ -676,15 +683,14 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( logger.LogWarning("Could not retrieve current user for sponsors field: {Message}", ex.Message); } - // Define the application manifest with @odata.type for Agent Identity Blueprint + // 2) Create application in /beta with @odata.type var appManifest = new JsonObject { - ["@odata.type"] = "Microsoft.Graph.AgentIdentityBlueprint", // CRITICAL: Required for Agent Blueprint type + ["@odata.type"] = "Microsoft.Graph.AgentIdentityBlueprint", ["displayName"] = displayName, - ["signInAudience"] = "AzureADMultipleOrgs" // Multi-tenant + ["signInAudience"] = "AzureADMultipleOrgs" }; - // Add sponsors field if we have the current user (PowerShell script includes this) if (!string.IsNullOrEmpty(sponsorUserId)) { appManifest["sponsors@odata.bind"] = new JsonArray @@ -693,81 +699,71 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( }; } - var graphToken = await GetTokenFromGraphClient(logger, graphClient, tenantId, setupConfig.ClientAppId); - if (string.IsNullOrEmpty(graphToken)) + var extraHeaders = new Dictionary { - logger.LogError("Failed to extract access token from Graph client"); - return (false, null, null, null, alreadyExisted: false); - } - - // Create the application using Microsoft Graph SDK - using var httpClient = HttpClientFactory.CreateAuthenticatedClient(graphToken); - httpClient.DefaultRequestHeaders.Add("ConsistencyLevel", "eventual"); - httpClient.DefaultRequestHeaders.Add("OData-Version", "4.0"); // Required for @odata.type - - var createAppUrl = "https://graph.microsoft.com/beta/applications"; + ["ConsistencyLevel"] = "eventual", + ["OData-Version"] = "4.0" + }; logger.LogInformation("Creating Agent Blueprint application..."); logger.LogInformation(" - Display Name: {DisplayName}", displayName); - if (!string.IsNullOrEmpty(sponsorUserId)) + + var createResp = await graphApiService.GraphPostWithResponseAsync( + tenantId, + "/beta/applications", + payload: JsonNode.Parse(appManifest.ToJsonString())!, // preserve JSON exactly + ct: ct, + scopes: authScopes, + extraHeaders: extraHeaders); + + // If sponsor binding fails, retry without sponsors + if (!createResp.IsSuccess && !string.IsNullOrEmpty(sponsorUserId) && createResp.StatusCode == 400) { - logger.LogInformation(" - Sponsor: User ID {UserId}", sponsorUserId); - } + logger.LogWarning("Agent Blueprint creation with sponsors failed (400). Retrying without sponsors..."); + appManifest.Remove("sponsors@odata.bind"); - var appResponse = await httpClient.PostAsync( - createAppUrl, - new StringContent(appManifest.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), - ct); + createResp = await graphApiService.GraphPostWithResponseAsync( + tenantId, + "/beta/applications", + payload: JsonNode.Parse(appManifest.ToJsonString())!, + ct: ct, + scopes: authScopes, + extraHeaders: extraHeaders); + } - if (!appResponse.IsSuccessStatusCode) + if (!createResp.IsSuccess || createResp.Json == null) { - var errorContent = await appResponse.Content.ReadAsStringAsync(ct); + logger.LogError("Failed to create application: {Status} {Reason} - {Body}", createResp.StatusCode, createResp.ReasonPhrase, createResp.Body); + return (false, null, null, null, alreadyExisted: false); + } - // If sponsors field causes error (Bad Request 400), retry without it - if (appResponse.StatusCode == System.Net.HttpStatusCode.BadRequest && - !string.IsNullOrEmpty(sponsorUserId)) - { - logger.LogWarning("Agent Blueprint creation with sponsors failed (Bad Request). Retrying without sponsors..."); + var root = createResp.Json.RootElement; - // Remove sponsors field and retry - appManifest.Remove("sponsors@odata.bind"); + var appId = root.GetProperty("appId").GetString(); + var objectId = root.GetProperty("id").GetString(); - appResponse = await httpClient.PostAsync( - createAppUrl, - new StringContent(appManifest.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), - ct); - - if (!appResponse.IsSuccessStatusCode) - { - errorContent = await appResponse.Content.ReadAsStringAsync(ct); - logger.LogError("Failed to create application (fallback): {Status} - {Error}", appResponse.StatusCode, errorContent); - return (false, null, null, null, alreadyExisted: false); - } - } - else - { - logger.LogError("Failed to create application: {Status} - {Error}", appResponse.StatusCode, errorContent); - return (false, null, null, null, alreadyExisted: false); - } + if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(objectId)) + { + logger.LogError("Create application succeeded but response missing appId/id."); + return (false, null, null, null, alreadyExisted: false); } - var appJson = await appResponse.Content.ReadAsStringAsync(ct); - var app = JsonNode.Parse(appJson)!.AsObject(); - var appId = app["appId"]!.GetValue(); - var objectId = app["id"]!.GetValue(); - logger.LogInformation("Application created successfully"); logger.LogInformation(" - App ID: {AppId}", appId); logger.LogInformation(" - Object ID: {ObjectId}", objectId); - // Wait for application propagation using RetryHelper + // 3) Wait for application propagation var retryHelper = new RetryHelper(logger); logger.LogInformation("Waiting for application object to propagate in directory..."); var appAvailable = await retryHelper.ExecuteWithRetryAsync( async ct => { - var checkResp = await httpClient.GetAsync($"https://graph.microsoft.com/v1.0/applications/{objectId}", ct); - return checkResp.IsSuccessStatusCode; + var doc = await graphApiService.GraphGetAsync( + tenantId, + $"/v1.0/applications/{objectId}", + ct, + scopes: authScopes); + return doc != null; }, result => !result, maxRetries: 10, @@ -779,102 +775,53 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( logger.LogError("Application object not available after creation and retries. Aborting setup."); return (false, null, null, null, alreadyExisted: false); } - + logger.LogInformation("Application object verified in directory"); - // Update application with identifier URI + // 4) Patch identifierUris (best-effort; if propagation delay, log and continue) var identifierUri = $"api://{appId}"; - var patchAppUrl = $"https://graph.microsoft.com/v1.0/applications/{objectId}"; - var patchBody = new JsonObject - { - ["identifierUris"] = new JsonArray { identifierUri } - }; - - var patchResponse = await httpClient.PatchAsync( - patchAppUrl, - new StringContent(patchBody.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), - ct); + var patched = await graphApiService.GraphPatchAsync( + tenantId, + $"/v1.0/applications/{objectId}", + new { identifierUris = new[] { identifierUri } }, + ct, + scopes: authScopes); - if (!patchResponse.IsSuccessStatusCode) + if (patched) { - var patchError = await patchResponse.Content.ReadAsStringAsync(ct); - logger.LogInformation("Waiting for application propagation before setting identifier URI..."); - logger.LogDebug("Identifier URI update deferred (propagation delay): {Error}", patchError); + logger.LogInformation("Identifier URI set to: {Uri}", identifierUri); } else { - logger.LogInformation("Identifier URI set to: {Uri}", identifierUri); + logger.LogInformation("Identifier URI update deferred (propagation delay)."); } - // Create service principal - logger.LogInformation("Creating service principal..."); - - var spManifest = new JsonObject - { - ["appId"] = appId - }; - - var createSpUrl = "https://graph.microsoft.com/v1.0/servicePrincipals"; - var spResponse = await httpClient.PostAsync( - createSpUrl, - new StringContent(spManifest.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), - ct); - + // 5) Ensure service principal exists (this already handles create + lookup) + logger.LogInformation("Ensuring service principal exists..."); string? servicePrincipalId = null; - if (spResponse.IsSuccessStatusCode) + try { - var spJson = await spResponse.Content.ReadAsStringAsync(ct); - var sp = JsonNode.Parse(spJson)!.AsObject(); - servicePrincipalId = sp["id"]!.GetValue(); - logger.LogInformation("Service principal created: {SpId}", servicePrincipalId); + servicePrincipalId = await graphApiService.EnsureServicePrincipalForAppIdAsync( + tenantId, + appId, + ct, + scopes: authScopes); + logger.LogInformation("Service principal ensured: {SpId}", servicePrincipalId); } - else + catch (Exception ex) { - var spError = await spResponse.Content.ReadAsStringAsync(ct); - logger.LogInformation("Waiting for application propagation before creating service principal..."); - logger.LogDebug("Service principal creation deferred (propagation delay): {Error}", spError); + logger.LogWarning("Service principal creation/lookup failed (may be propagation): {Message}", ex.Message); } - // Wait for service principal propagation using RetryHelper - if (!string.IsNullOrWhiteSpace(servicePrincipalId)) - { - logger.LogInformation("Verifying service principal propagation in directory..."); - var spPropagated = await retryHelper.ExecuteWithRetryAsync( - async ct => - { - var checkSp = await httpClient.GetAsync($"https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq '{appId}'", ct); - if (checkSp.IsSuccessStatusCode) - { - var content = await checkSp.Content.ReadAsStringAsync(ct); - var spList = JsonDocument.Parse(content); - return spList.RootElement.GetProperty("value").GetArrayLength() > 0; - } - return false; - }, - result => !result, - maxRetries: 10, - baseDelaySeconds: 5, - ct); - - if (spPropagated) - { - logger.LogInformation("Service principal verified in directory"); - } - else - { - logger.LogWarning("Service principal not fully propagated after retries. This may cause issues with federated credentials."); - } - } - - // Store blueprint identifiers in config object (will be persisted after secret creation) + // 6) Persist identifiers in config object (same as before) setupConfig.AgentBlueprintObjectId = objectId; setupConfig.AgentBlueprintServicePrincipalObjectId = servicePrincipalId; setupConfig.AgentBlueprintId = appId; - - logger.LogDebug("Blueprint identifiers staged for persistence: ObjectId={ObjectId}, SPObjectId={SPObjectId}, AppId={AppId}", + + logger.LogDebug("Blueprint identifiers staged for persistence: ObjectId={ObjectId}, SPObjectId={SPObjectId}, AppId={AppId}", objectId, servicePrincipalId, appId); - // Complete configuration (FIC validation + admin consent) + // Complete configuration (FIC + admin consent) return await CompleteBlueprintConfigurationAsync( logger, executor, @@ -1222,10 +1169,10 @@ private static List GetApplicationScopes(Models.Agent365Config setupConf // Request consent via browser logger.LogInformation("Requesting admin consent for application"); logger.LogInformation(" - Application scopes: {Scopes}", string.Join(", ", applicationScopes)); - logger.LogInformation("Opening browser for Graph API admin consent..."); - TryOpenBrowser(consentUrlGraph); + logger.LogInformation("Admin consent required. Please open this URL in a browser (as an admin):"); + logger.LogInformation("{Url}", consentUrlGraph); - var consentSuccess = await AdminConsentHelper.PollAdminConsentAsync(executor, logger, appId, "Graph API Scopes", 180, 5, ct); + var consentSuccess = await AdminConsentHelper.PollAdminConsentAsync(graphApiService, tenantId, logger, appId, "Graph API Scopes", 180, 5, ct); if (consentSuccess) { @@ -1267,88 +1214,6 @@ await SetupHelpers.EnsureResourcePermissionsAsync( return (consentSuccess, consentUrlGraph); } - /// - /// Extracts the access token from a GraphServiceClient for use in direct HTTP calls. - /// This uses InteractiveBrowserCredential directly which is simpler and more reliable. - /// - private static async Task GetTokenFromGraphClient(ILogger logger, GraphServiceClient graphClient, string tenantId, string clientAppId) - { - try - { - // Use Azure.Identity to get the token directly - // This is cleaner and more reliable than trying to extract it from GraphServiceClient - var credential = new InteractiveBrowserCredential(new InteractiveBrowserCredentialOptions - { - TenantId = tenantId, - ClientId = clientAppId - }); - - var tokenRequestContext = new TokenRequestContext(new[] { "https://graph.microsoft.com/.default" }); - var token = await credential.GetTokenAsync(tokenRequestContext, CancellationToken.None); - - return token.Token; - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to get access token"); - return null; - } - } - - /// - /// Creates and authenticates a GraphServiceClient using InteractiveGraphAuthService. - /// This common method consolidates the authentication logic used across multiple methods. - /// - private async static Task GetAuthenticatedGraphClientAsync(ILogger logger, Models.Agent365Config setupConfig, string tenantId, CancellationToken ct) - { - logger.LogInformation("Authenticating to Microsoft Graph using interactive browser authentication..."); - logger.LogInformation("IMPORTANT: Agent Blueprint operations require Application.ReadWrite.All permission."); - logger.LogInformation("This will open a browser window for interactive authentication."); - logger.LogInformation("Please sign in with a Global Administrator account."); - logger.LogInformation(""); - - // Use InteractiveGraphAuthService to get proper authentication - using var cleanLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(); - var interactiveAuth = new InteractiveGraphAuthService( - cleanLoggerFactory.CreateLogger(), - setupConfig.ClientAppId); - - try - { - var graphClient = await interactiveAuth.GetAuthenticatedGraphClientAsync(tenantId, ct); - logger.LogInformation("Successfully authenticated to Microsoft Graph"); - return graphClient; - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to authenticate to Microsoft Graph: {Message}", ex.Message); - logger.LogError(""); - logger.LogError("TROUBLESHOOTING:"); - logger.LogError("1. Ensure you are a Global Administrator or have Application.ReadWrite.All permission"); - logger.LogError("2. The account must have already consented to these permissions"); - logger.LogError(""); - throw new InvalidOperationException($"Microsoft Graph authentication failed: {ex.Message}", ex); - } - } - - private static void TryOpenBrowser(string url) - { - try - { - using var p = new System.Diagnostics.Process(); - p.StartInfo = new System.Diagnostics.ProcessStartInfo - { - FileName = url, - UseShellExecute = true - }; - p.Start(); - } - catch - { - // non-fatal - } - } - /// /// Creates client secret for Agent Blueprint (Phase 2.5) /// Used by: BlueprintSubcommand and A365SetupRunner diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs index 7d27a9d..8fca679 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs @@ -332,17 +332,17 @@ public static async Task ValidateAzureCliAuthenticationAsync( var accountCheck = await executor.ExecuteAsync("az", "account show", captureOutput: true, suppressErrorLogging: true, cancellationToken: cancellationToken); if (!accountCheck.Success) { - logger.LogInformation("Azure CLI not authenticated. Initiating login with management scope..."); - logger.LogInformation("A browser window will open for authentication."); - - var loginResult = await executor.ExecuteAsync("az", $"login --tenant {tenantId}", cancellationToken: cancellationToken); - + logger.LogInformation("Azure CLI not authenticated. Initiating device code login..."); + logger.LogInformation("Please follow the device code instructions in your terminal."); + + var loginResult = await executor.ExecuteAsync("az", $"login --tenant {tenantId} --use-device-code --allow-no-subscriptions", cancellationToken: cancellationToken); + if (!loginResult.Success) { - logger.LogError("Azure CLI login failed. Please run manually: az login --scope https://management.core.windows.net//.default"); + logger.LogError("Azure CLI login failed. Please run manually: az login --tenant {TenantId} --use-device-code --scope https://management.core.windows.net//.default", tenantId); return false; } - + logger.LogInformation("Azure CLI login successful!"); await Task.Delay(2000, cancellationToken); } @@ -362,17 +362,17 @@ public static async Task ValidateAzureCliAuthenticationAsync( if (!tokenCheck.Success) { - logger.LogWarning("Unable to acquire management scope token. Attempting re-authentication..."); - logger.LogInformation("A browser window will open for authentication."); - - var loginResult = await executor.ExecuteAsync("az", $"login --tenant {tenantId}", cancellationToken: cancellationToken); - + logger.LogWarning("Unable to acquire management scope token. Attempting device code re-authentication..."); + logger.LogInformation("Please follow the device code instructions in your terminal."); + + var loginResult = await executor.ExecuteAsync("az", $"login --tenant {tenantId} --use-device-code --allow-no-subscriptions", cancellationToken: cancellationToken); + if (!loginResult.Success) { - logger.LogError("Azure CLI login with management scope failed. Please run manually: az login --scope https://management.core.windows.net//.default"); + logger.LogError("Azure CLI login with management scope failed. Please run manually: az login --tenant {TenantId} --use-device-code --scope https://management.core.windows.net//.default", tenantId); return false; } - + logger.LogInformation("Azure CLI re-authentication successful!"); await Task.Delay(2000, cancellationToken); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs index 1bbffdb..09a3c73 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs @@ -210,20 +210,20 @@ public static async Task EnsureResourcePermissionsAsync( throw new SetupValidationException("AgentBlueprintId (appId) is required."); // Use delegated token provider for *all* permission operations to avoid bouncing between Azure CLI auth and Microsoft Graph PowerShell auth. - var permissionGrantScopes = AuthenticationConstants.RequiredPermissionGrantScopes; + var permissionGrantScopes = AuthenticationConstants.PermissionGrantAuthScopes; // Pre-warm the delegated token once - var user = await graph.GraphGetAsync( + var warmup = await graph.GraphGetAsync( config.TenantId, "/v1.0/me?$select=id", ct, scopes: permissionGrantScopes); - - if (user == null) + + if (warmup == null) { throw new SetupValidationException( - "Failed to authenticate to Microsoft Graph with delegated permissions. " + - "Please sign in when prompted and ensure your account has the required roles and permission scopes."); + "Failed to authenticate to Microsoft Graph with delegated permissions required for permission grants. " + + "Please sign in when prompted and ensure your account has the required roles and the custom client app has admin-consented scopes."); } var blueprintSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, config.AgentBlueprintId, ct, permissionGrantScopes); @@ -287,7 +287,7 @@ public static async Task EnsureResourcePermissionsAsync( var requiredPermissions = new[] { "AgentIdentityBlueprint.UpdateAuthProperties.All", "Application.ReadWrite.All" }; var (ok, alreadyExists, err) = await blueprintService.SetInheritablePermissionsAsync( - config.TenantId, config.AgentBlueprintId, resourceAppId, scopes, requiredScopes: requiredPermissions, ct); + config.TenantId, config.AgentBlueprintId, resourceAppId, scopes, requiredPermissions, ct); if (!ok && !alreadyExists) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs index 4e68601..12dd330 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs @@ -84,12 +84,24 @@ public static class AuthenticationConstants /// These scopes enable the service principals to operate correctly with the necessary permissions. /// All scopes require admin consent. /// - public static readonly string[] RequiredPermissionGrantScopes = new[] + public static readonly string[] PermissionGrantAuthScopes = new[] { + "User.Read", "Application.ReadWrite.All", "DelegatedPermissionGrant.ReadWrite.All" }; + /// + /// Required scopes for agent blueprint operations using delegated authentication. + /// These scopes enable creating, updating, and deleting agent blueprints. + /// All scopes require admin consent. + /// + public static readonly string[] AgentBlueprintAuthScopes = new[] + { + "User.Read", + "AgentIdentityBlueprint.ReadWrite.All" + }; + /// /// Environment variable name for bearer token used in local development. /// This token is stored in .env files (Python/Node.js) or launchSettings.json (.NET) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/PublishHelpers.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/PublishHelpers.cs index 0400856..3c1d8d0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/PublishHelpers.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/PublishHelpers.cs @@ -25,9 +25,14 @@ private static async Task CheckMosPrerequisitesAsync( ILogger logger, CancellationToken ct) { + var authScopes = AuthenticationConstants.PermissionGrantAuthScopes; + // Check 1: Verify all required service principals exist - var firstPartyClientSpId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, - MosConstants.TpsAppServicesClientAppId, ct); + var firstPartyClientSpId = await graph.LookupServicePrincipalByAppIdAsync( + config.TenantId, + MosConstants.TpsAppServicesClientAppId, + ct, + authScopes); if (string.IsNullOrWhiteSpace(firstPartyClientSpId)) { logger.LogDebug("Service principal for {ConstantName} ({AppId}) not found - configuration needed", @@ -39,7 +44,7 @@ private static async Task CheckMosPrerequisitesAsync( foreach (var resourceAppId in MosConstants.AllResourceAppIds) { - var spId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, resourceAppId, ct); + var spId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, resourceAppId, ct, authScopes); if (string.IsNullOrWhiteSpace(spId)) { logger.LogDebug("Service principal for {ResourceAppId} not found - configuration needed", resourceAppId); @@ -109,9 +114,11 @@ private static async Task CheckMosPrerequisitesAsync( } // Check if OAuth2 permission grant exists - var grantDoc = await graph.GraphGetAsync(config.TenantId, + var grantDoc = await graph.GraphGetAsync( + config.TenantId, $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{firstPartyClientSpId}' and resourceId eq '{resourceSpId}'", - ct); + ct, + authScopes); if (grantDoc == null || !grantDoc.RootElement.TryGetProperty("value", out var grants) || grants.GetArrayLength() == 0) { @@ -155,9 +162,14 @@ private static async Task EnsureMosServicePrincipalsAsync( ILogger logger, CancellationToken ct) { + var authScopes = AuthenticationConstants.PermissionGrantAuthScopes; + // Check 1: First-party client app service principal - var firstPartySpId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, - MosConstants.TpsAppServicesClientAppId, ct); + var firstPartySpId = await graph.LookupServicePrincipalByAppIdAsync( + config.TenantId, + MosConstants.TpsAppServicesClientAppId, + ct, + authScopes); if (string.IsNullOrWhiteSpace(firstPartySpId)) { @@ -165,8 +177,11 @@ private static async Task EnsureMosServicePrincipalsAsync( try { - firstPartySpId = await graph.EnsureServicePrincipalForAppIdAsync(config.TenantId, - MosConstants.TpsAppServicesClientAppId, ct); + firstPartySpId = await graph.EnsureServicePrincipalForAppIdAsync( + config.TenantId, + MosConstants.TpsAppServicesClientAppId, + ct, + authScopes); if (string.IsNullOrWhiteSpace(firstPartySpId)) { @@ -201,7 +216,7 @@ private static async Task EnsureMosServicePrincipalsAsync( var missingResourceApps = new List(); foreach (var resourceAppId in MosConstants.AllResourceAppIds) { - var spId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, resourceAppId, ct); + var spId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, resourceAppId, ct, authScopes); if (string.IsNullOrWhiteSpace(spId)) { missingResourceApps.Add(resourceAppId); @@ -216,7 +231,7 @@ private static async Task EnsureMosServicePrincipalsAsync( { try { - var spId = await graph.EnsureServicePrincipalForAppIdAsync(config.TenantId, resourceAppId, ct); + var spId = await graph.EnsureServicePrincipalForAppIdAsync(config.TenantId, resourceAppId, ct, authScopes); if (string.IsNullOrWhiteSpace(spId)) { @@ -260,6 +275,8 @@ private static async Task EnsureMosPermissionsConfiguredAsync( ILogger logger, CancellationToken ct) { + var authScopes = AuthenticationConstants.PermissionGrantAuthScopes; + if (!app.TryGetProperty("id", out var appObjectIdElement)) { throw new SetupValidationException($"Application {config.ClientAppId} missing id property"); @@ -382,7 +399,7 @@ private static async Task EnsureMosPermissionsConfiguredAsync( logger.LogDebug("Updating application {AppObjectId} with {Count} resource access entries", appObjectId, updatedResourceAccess.Count); - var updated = await graph.GraphPatchAsync(config.TenantId, $"/v1.0/applications/{appObjectId}", patchPayload, ct); + var updated = await graph.GraphPatchAsync(config.TenantId, $"/v1.0/applications/{appObjectId}", patchPayload, ct, authScopes); if (!updated) { throw new SetupValidationException("Failed to update application with MOS API permissions."); @@ -414,6 +431,8 @@ public static async Task EnsureMosPrerequisitesAsync( ILogger logger, CancellationToken ct = default) { + var authScopes = AuthenticationConstants.PermissionGrantAuthScopes; + if (string.IsNullOrWhiteSpace(config.ClientAppId)) { logger.LogError("Custom client app ID not found in configuration. Run 'a365 config init' first."); @@ -422,8 +441,11 @@ public static async Task EnsureMosPrerequisitesAsync( // Load custom client app logger.LogDebug("Checking MOS prerequisites for custom client app {ClientAppId}", config.ClientAppId); - var appDoc = await graph.GraphGetAsync(config.TenantId, - $"/v1.0/applications?$filter=appId eq '{config.ClientAppId}'&$select=id,requiredResourceAccess", ct); + var appDoc = await graph.GraphGetAsync( + config.TenantId, + $"/v1.0/applications?$filter=appId eq '{config.ClientAppId}'&$select=id,requiredResourceAccess", + ct, + authScopes); if (appDoc == null || !appDoc.RootElement.TryGetProperty("value", out var appsArray) || appsArray.GetArrayLength() == 0) { @@ -467,9 +489,14 @@ private static async Task EnsureMosAdminConsentAsync( ILogger logger, CancellationToken ct) { + var authScopes = AuthenticationConstants.PermissionGrantAuthScopes; + // Look up the first-party client app's service principal - var clientSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, - MosConstants.TpsAppServicesClientAppId, ct); + var clientSpObjectId = await graph.LookupServicePrincipalByAppIdAsync( + config.TenantId, + MosConstants.TpsAppServicesClientAppId, + ct, + authScopes); if (string.IsNullOrWhiteSpace(clientSpObjectId)) { @@ -487,7 +514,7 @@ private static async Task EnsureMosAdminConsentAsync( // Check which resources need consent foreach (var (resourceAppId, scopeName) in mosResourceScopes) { - var resourceSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, resourceAppId, ct); + var resourceSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, resourceAppId, ct, authScopes); if (string.IsNullOrWhiteSpace(resourceSpObjectId)) { logger.LogWarning("Service principal not found for MOS resource app {ResourceAppId} - skipping consent", resourceAppId); @@ -495,9 +522,11 @@ private static async Task EnsureMosAdminConsentAsync( } // Check if consent already exists - var grantDoc = await graph.GraphGetAsync(config.TenantId, + var grantDoc = await graph.GraphGetAsync( + config.TenantId, $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{clientSpObjectId}' and resourceId eq '{resourceSpObjectId}'", - ct); + ct, + authScopes); var hasConsent = false; if (grantDoc != null && grantDoc.RootElement.TryGetProperty("value", out var grants) && grants.GetArrayLength() > 0) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AdminConsentHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AdminConsentHelper.cs deleted file mode 100644 index e69de29..0000000 diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AgentBlueprintService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AgentBlueprintService.cs index fa973f5..7d0feb5 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AgentBlueprintService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AgentBlueprintService.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Text.Json; +using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Extensions.Logging; @@ -47,7 +48,11 @@ public string? CustomClientAppId try { // Make the API call to get inheritable permissions - var doc = await _graphApiService.GraphGetAsync(tenantId, $"/beta/applications/microsoft.graph.agentIdentityBlueprint/{blueprintId}/inheritablePermissions", cancellationToken); + var doc = await _graphApiService.GraphGetAsync( + tenantId, + $"/beta/applications/microsoft.graph.agentIdentityBlueprint/{blueprintId}/inheritablePermissions", + cancellationToken, + AuthenticationConstants.AgentBlueprintAuthScopes); if (doc == null) { @@ -87,10 +92,9 @@ public async Task DeleteAgentBlueprintAsync( _logger.LogInformation("Deleting agent blueprint application: {BlueprintId}", blueprintId); // Agent Blueprint deletion requires special delegated permission scope - var requiredScopes = new[] { "AgentIdentityBlueprint.ReadWrite.All" }; + var authScopes = AuthenticationConstants.AgentBlueprintAuthScopes; - _logger.LogInformation("Acquiring access token with AgentIdentityBlueprint.ReadWrite.All scope..."); - _logger.LogInformation("A browser window will open for authentication."); + _logger.LogInformation("Acquiring access token for agent blueprint operations (device code flow may prompt once)..."); // Use the special agentIdentityBlueprint endpoint for deletion var deletePath = $"/beta/applications/{blueprintId}/microsoft.graph.agentIdentityBlueprint"; @@ -101,7 +105,7 @@ public async Task DeleteAgentBlueprintAsync( deletePath, cancellationToken, treatNotFoundAsSuccess: true, - scopes: requiredScopes); + scopes: authScopes); if (success) { @@ -139,10 +143,9 @@ public async Task DeleteAgentIdentityAsync( _logger.LogInformation("Deleting agent identity application: {applicationId}", applicationId); // Agent Identity deletion requires special delegated permission scope - var requiredScopes = new[] { "AgentIdentityBlueprint.ReadWrite.All" }; + var authScopes = AuthenticationConstants.AgentBlueprintAuthScopes; - _logger.LogInformation("Acquiring access token with AgentIdentityBlueprint.ReadWrite.All scope..."); - _logger.LogInformation("A browser window will open for authentication."); + _logger.LogInformation("Acquiring access token for agent identity operations (device code flow may prompt once)..."); // Use the special servicePrincipals endpoint for deletion var deletePath = $"/beta/servicePrincipals/{applicationId}"; @@ -153,7 +156,7 @@ public async Task DeleteAgentIdentityAsync( deletePath, cancellationToken, treatNotFoundAsSuccess: true, - scopes: requiredScopes); + scopes: authScopes); } catch (Exception ex) { @@ -171,7 +174,7 @@ public async Task DeleteAgentIdentityAsync( string blueprintId, string resourceAppId, IEnumerable scopes, - IEnumerable? requiredScopes = null, + IEnumerable? authScopes = null, CancellationToken ct = default) { var desiredSet = new HashSet(scopes ?? Enumerable.Empty(), StringComparer.OrdinalIgnoreCase); @@ -182,11 +185,11 @@ public async Task DeleteAgentIdentityAsync( try { // Resolve blueprintId to object ID if needed - var blueprintObjectId = await ResolveBlueprintObjectIdAsync(tenantId, blueprintId, ct, requiredScopes); + var blueprintObjectId = await ResolveBlueprintObjectIdAsync(tenantId, blueprintId, ct, authScopes); // Retrieve existing inheritable permissions var getPath = $"/beta/applications/microsoft.graph.agentIdentityBlueprint/{blueprintObjectId}/inheritablePermissions"; - var existingDoc = await _graphApiService.GraphGetAsync(tenantId, getPath, ct, requiredScopes); + var existingDoc = await _graphApiService.GraphGetAsync(tenantId, getPath, ct, authScopes); // Inspect existing entries JsonElement? existingEntry = null; @@ -228,7 +231,7 @@ public async Task DeleteAgentIdentityAsync( } }; - var patched = await _graphApiService.GraphPatchAsync(tenantId, patchPath, patchPayload, ct, requiredScopes); + var patched = await _graphApiService.GraphPatchAsync(tenantId, patchPath, patchPayload, ct, authScopes); if (!patched) { return (ok: false, alreadyExists: false, error: "PATCH failed"); @@ -249,7 +252,7 @@ public async Task DeleteAgentIdentityAsync( } }; - var createdResp = await _graphApiService.GraphPostWithResponseAsync(tenantId, postPath, postPayload, ct, requiredScopes); + var createdResp = await _graphApiService.GraphPostWithResponseAsync(tenantId, postPath, postPayload, ct, authScopes); if (!createdResp.IsSuccess) { var err = string.IsNullOrWhiteSpace(createdResp.Body) @@ -277,16 +280,16 @@ public async Task DeleteAgentIdentityAsync( string blueprintId, string resourceAppId, CancellationToken ct = default, - IEnumerable? requiredScopes = null) + IEnumerable? authScopes = null) { try { // Resolve blueprintId to object ID if needed - var blueprintObjectId = await ResolveBlueprintObjectIdAsync(tenantId, blueprintId, ct, requiredScopes); + var blueprintObjectId = await ResolveBlueprintObjectIdAsync(tenantId, blueprintId, ct, authScopes); // Retrieve inheritable permissions var getPath = $"/beta/applications/microsoft.graph.agentIdentityBlueprint/{blueprintObjectId}/inheritablePermissions"; - var existingDoc = await _graphApiService.GraphGetAsync(tenantId, getPath, ct, requiredScopes); + var existingDoc = await _graphApiService.GraphGetAsync(tenantId, getPath, ct, authScopes); if (existingDoc == null) { @@ -340,7 +343,8 @@ public virtual async Task ReplaceOauth2PermissionGrantAsync( var listDoc = await _graphApiService.GraphGetAsync( tenantId, $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{clientSpObjectId}' and resourceId eq '{resourceSpObjectId}'", - ct); + ct, + AuthenticationConstants.PermissionGrantAuthScopes); var existing = listDoc?.RootElement.TryGetProperty("value", out var arr) == true ? arr : default; @@ -355,7 +359,12 @@ public virtual async Task ReplaceOauth2PermissionGrantAsync( _logger.LogDebug("Deleting existing oauth2PermissionGrant {Id} for client {ClientId} and resource {ResourceId}", id, clientSpObjectId, resourceSpObjectId); - var ok = await _graphApiService.GraphDeleteAsync(tenantId, $"/v1.0/oauth2PermissionGrants/{id}", ct); + var ok = await _graphApiService.GraphDeleteAsync( + tenantId, + $"/v1.0/oauth2PermissionGrants/{id}", + ct, + true, + AuthenticationConstants.PermissionGrantAuthScopes); if (!ok) { _logger.LogError("Failed to delete existing oauth2PermissionGrant {Id} for client {ClientId} and resource {ResourceId}. " + @@ -388,7 +397,13 @@ public virtual async Task ReplaceOauth2PermissionGrantAsync( scope = desiredScopeString }; - var created = await _graphApiService.GraphPostAsync(tenantId, "/v1.0/oauth2PermissionGrants", payload, ct); + var created = await _graphApiService.GraphPostAsync( + tenantId, + "/v1.0/oauth2PermissionGrants", + payload, + ct, + AuthenticationConstants.PermissionGrantAuthScopes); + return created != null; } @@ -405,7 +420,8 @@ public virtual async Task CreateOrUpdateOauth2PermissionGrantAsync( var listDoc = await _graphApiService.GraphGetAsync( tenantId, $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{clientSpObjectId}' and resourceId eq '{resourceSpObjectId}'", - ct); + ct, + AuthenticationConstants.PermissionGrantAuthScopes); var existing = listDoc?.RootElement.TryGetProperty("value", out var arr) == true && arr.GetArrayLength() > 0 ? arr[0] @@ -421,7 +437,7 @@ public virtual async Task CreateOrUpdateOauth2PermissionGrantAsync( resourceId = resourceSpObjectId, scope = desiredScopeString }; - var created = await _graphApiService.GraphPostAsync(tenantId, "/v1.0/oauth2PermissionGrants", payload, ct); + var created = await _graphApiService.GraphPostAsync(tenantId, "/v1.0/oauth2PermissionGrants", payload, ct, AuthenticationConstants.PermissionGrantAuthScopes); return created != null; // success if response parsed } @@ -438,7 +454,7 @@ public virtual async Task CreateOrUpdateOauth2PermissionGrantAsync( var id = existing.Value.GetProperty("id").GetString(); if (string.IsNullOrWhiteSpace(id)) return false; - return await _graphApiService.GraphPatchAsync(tenantId, $"/v1.0/oauth2PermissionGrants/{id}", new { scope = merged }, ct); + return await _graphApiService.GraphPatchAsync(tenantId, $"/v1.0/oauth2PermissionGrants/{id}", new { scope = merged }, ct, AuthenticationConstants.PermissionGrantAuthScopes); } /// @@ -462,8 +478,10 @@ public virtual async Task AddRequiredResourceAccessAsync( { try { + var permissionGrantAuthScopes = AuthenticationConstants.PermissionGrantAuthScopes; + // Get the application object by appId - var appsDoc = await _graphApiService.GraphGetAsync(tenantId, $"/v1.0/applications?$filter=appId eq '{appId}'&$select=id,requiredResourceAccess", ct); + var appsDoc = await _graphApiService.GraphGetAsync(tenantId, $"/v1.0/applications?$filter=appId eq '{appId}'&$select=id,requiredResourceAccess", ct, permissionGrantAuthScopes); if (appsDoc == null) { _logger.LogError("Failed to retrieve application with appId {AppId}", appId); @@ -485,7 +503,7 @@ public virtual async Task AddRequiredResourceAccessAsync( var objectId = idProp.GetString()!; // Get the resource service principal to look up permission IDs - var resourceSp = await _graphApiService.LookupServicePrincipalByAppIdAsync(tenantId, resourceAppId, ct); + var resourceSp = await _graphApiService.LookupServicePrincipalByAppIdAsync(tenantId, resourceAppId, ct, permissionGrantAuthScopes); if (string.IsNullOrEmpty(resourceSp)) { _logger.LogError("Resource service principal not found for appId {ResourceAppId}", resourceAppId); @@ -493,7 +511,7 @@ public virtual async Task AddRequiredResourceAccessAsync( } // Get the resource SP's published permissions - var resourceSpDoc = await _graphApiService.GraphGetAsync(tenantId, $"/v1.0/servicePrincipals/{resourceSp}?$select=oauth2PermissionScopes,appRoles", ct); + var resourceSpDoc = await _graphApiService.GraphGetAsync(tenantId, $"/v1.0/servicePrincipals/{resourceSp}?$select=oauth2PermissionScopes,appRoles", ct, permissionGrantAuthScopes); if (resourceSpDoc == null) { _logger.LogError("Failed to retrieve resource service principal {ResourceSp}", resourceSp); @@ -605,7 +623,7 @@ public virtual async Task AddRequiredResourceAccessAsync( requiredResourceAccess = resourceAccessList }; - var updated = await _graphApiService.GraphPatchAsync(tenantId, $"/v1.0/applications/{objectId}", patchPayload, ct); + var updated = await _graphApiService.GraphPatchAsync(tenantId, $"/v1.0/applications/{objectId}", patchPayload, ct, permissionGrantAuthScopes); if (updated) { _logger.LogInformation("Successfully added required resource access for {ResourceAppId} to application {AppId}", resourceAppId, appId); @@ -676,11 +694,13 @@ private async Task ResolveBlueprintObjectIdAsync( string tenantId, string blueprintAppId, CancellationToken ct = default, - IEnumerable? requiredScopes = null) + IEnumerable? authScopes = null) { + authScopes ??= AuthenticationConstants.AgentBlueprintAuthScopes; + // First try direct access to inheritable permissions endpoint var getPath = $"/beta/applications/microsoft.graph.agentIdentityBlueprint/{blueprintAppId}/inheritablePermissions"; - var existingDoc = await _graphApiService.GraphGetAsync(tenantId, getPath, ct, requiredScopes); + var existingDoc = await _graphApiService.GraphGetAsync(tenantId, getPath, ct, authScopes); if (existingDoc != null) { @@ -689,7 +709,7 @@ private async Task ResolveBlueprintObjectIdAsync( } // Attempt to resolve as appId -> application object id - var apps = await _graphApiService.GraphGetAsync(tenantId, $"/v1.0/applications?$filter=appId eq '{blueprintAppId}'&$select=id", ct, requiredScopes); + var apps = await _graphApiService.GraphGetAsync(tenantId, $"/v1.0/applications?$filter=appId eq '{blueprintAppId}'&$select=id", ct, authScopes); if (apps != null && apps.RootElement.TryGetProperty("value", out var arr) && arr.GetArrayLength() > 0) { var appObj = arr[0]; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs index 3058d88..c0b556d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs @@ -189,58 +189,32 @@ private async Task AuthenticateInteractivelyAsync( ? AuthenticationConstants.PowershellClientId : clientId; - TokenCredential credential; + // ALWAYS use device code flow for CLI-friendly authentication (no browser popups) + _logger.LogInformation("Using device code authentication..."); + _logger.LogInformation("Please sign in with your Microsoft account"); - if (useInteractiveBrowser) + TokenCredential credential = new DeviceCodeCredential(new DeviceCodeCredentialOptions { - // Use InteractiveBrowserCredential with redirect URI for better public client support - _logger.LogInformation("Using interactive browser authentication..."); - _logger.LogInformation("IMPORTANT: A browser window will open for authentication."); - _logger.LogInformation("Please sign in with your Microsoft account and grant consent for the requested permissions."); - _logger.LogInformation(""); - - credential = new InteractiveBrowserCredential(new InteractiveBrowserCredentialOptions + TenantId = effectiveTenantId, + ClientId = effectiveClientId, + AuthorityHost = AzureAuthorityHosts.AzurePublicCloud, + TokenCachePersistenceOptions = new TokenCachePersistenceOptions { - TenantId = effectiveTenantId, - ClientId = effectiveClientId, - AuthorityHost = AzureAuthorityHosts.AzurePublicCloud, - RedirectUri = new Uri(AuthenticationConstants.LocalhostRedirectUri), - TokenCachePersistenceOptions = new TokenCachePersistenceOptions - { - Name = AuthenticationConstants.ApplicationName - } - }); - } - else - { - // For Power Platform API authentication, use device code flow to avoid URL length issues - // InteractiveBrowserCredential with Power Platform scopes can create URLs that exceed browser limits - _logger.LogInformation("Using device code authentication..."); - _logger.LogInformation("Please sign in with your Microsoft account"); - - credential = new DeviceCodeCredential(new DeviceCodeCredentialOptions + Name = AuthenticationConstants.ApplicationName + }, + DeviceCodeCallback = (code, cancellation) => { - TenantId = effectiveTenantId, - ClientId = effectiveClientId, - AuthorityHost = AzureAuthorityHosts.AzurePublicCloud, - TokenCachePersistenceOptions = new TokenCachePersistenceOptions - { - Name = AuthenticationConstants.ApplicationName - }, - DeviceCodeCallback = (code, cancellation) => - { - Console.WriteLine(); - Console.WriteLine("=========================================================================="); - Console.WriteLine($"To sign in, use a web browser to open the page:"); - Console.WriteLine($" {code.VerificationUri}"); - Console.WriteLine(); - Console.WriteLine($"And enter the code: {code.UserCode}"); - Console.WriteLine("=========================================================================="); - Console.WriteLine(); - return Task.CompletedTask; - } - }); - } + Console.WriteLine(); + Console.WriteLine("=========================================================================="); + Console.WriteLine($"To sign in, use a web browser to open the page:"); + Console.WriteLine($" {code.VerificationUri}"); + Console.WriteLine(); + Console.WriteLine($"And enter the code: {code.UserCode}"); + Console.WriteLine("=========================================================================="); + Console.WriteLine(); + return Task.CompletedTask; + } + }); var tokenRequestContext = new TokenRequestContext(scopes); var tokenResult = await credential.GetTokenAsync(tokenRequestContext, default); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureWebAppCreator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureWebAppCreator.cs index f9c8800..6c9b5ed 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureWebAppCreator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureWebAppCreator.cs @@ -35,18 +35,24 @@ public async Task CreateWebAppAsync( ArmClient armClient; if (!string.IsNullOrWhiteSpace(tenantId)) { + // Exclude interactive browser credential to force CLI-friendly authentication (device code via Azure CLI) var credential = new DefaultAzureCredential(new DefaultAzureCredentialOptions { VisualStudioTenantId = tenantId, SharedTokenCacheTenantId = tenantId, InteractiveBrowserTenantId = tenantId, - ExcludeInteractiveBrowserCredential = false + ExcludeInteractiveBrowserCredential = true }); armClient = new ArmClient(credential, subscriptionId); } else { - armClient = new ArmClient(new DefaultAzureCredential(), subscriptionId); + // Exclude interactive browser credential for CLI-friendly authentication + var credential = new DefaultAzureCredential(new DefaultAzureCredentialOptions + { + ExcludeInteractiveBrowserCredential = true + }); + armClient = new ArmClient(credential, subscriptionId); } var subscription = armClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{subscriptionId}")); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs index d02ad72..07390fc 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs @@ -20,6 +20,7 @@ public sealed class DelegatedConsentService private readonly GraphApiService _graphService; // Constants + private static readonly string[] PermissionGrantScopes = AuthenticationConstants.PermissionGrantAuthScopes; private const string TargetScope = "AgentIdentityBlueprint.ReadWrite.All"; private const string AllPrincipalsConsentType = "AllPrincipals"; @@ -51,7 +52,6 @@ public async Task EnsureBlueprintPermissionGrantAsync( _logger.LogInformation(" Tenant ID: {TenantId}", tenantId); _logger.LogInformation(" Required Scope: {Scope}", TargetScope); - // Validate inputs if (!Guid.TryParse(callingAppId, out _)) { _logger.LogError("Invalid Calling App ID format: {AppId}", callingAppId); @@ -64,461 +64,75 @@ public async Task EnsureBlueprintPermissionGrantAsync( return false; } - // Get Graph access token with required scopes - _logger.LogInformation("Acquiring Graph API access token..."); - var graphToken = await _graphService.GetGraphAccessTokenAsync(tenantId, cancellationToken); - if (string.IsNullOrWhiteSpace(graphToken)) - { - _logger.LogError("Failed to acquire Graph API access token"); - return false; - } + // Prewarm delegated auth (device code) once; validates we can get a delegated token. + var warmup = await _graphService.GraphGetAsync( + tenantId, + "/v1.0/me?$select=id", + cancellationToken, + scopes: new[] { "User.Read" }); - using var httpClient = HttpClientFactory.CreateAuthenticatedClient(graphToken); - - // Step 1: Get or create service principal for custom client app - _logger.LogInformation(" Looking up service principal for client app (ID: {AppId})", callingAppId); - var clientSp = await GetOrCreateServicePrincipalAsync(httpClient, callingAppId, tenantId, cancellationToken); - if (clientSp == null) + if (warmup == null) { - _logger.LogError("Failed to get or create service principal for calling app"); + _logger.LogError("Failed to authenticate to Microsoft Graph with delegated permissions (device code)."); return false; } - var clientSpId = clientSp.RootElement.GetProperty("id").GetString()!; - _logger.LogInformation(" Client Service Principal ID: {SpId}", clientSpId); + // 1) Ensure SP for the calling (custom client) app + _logger.LogInformation(" Ensuring service principal for client app (appId: {AppId})", callingAppId); + var clientSpObjectId = await _graphService.EnsureServicePrincipalForAppIdAsync( + tenantId, + callingAppId, + cancellationToken, + scopes: PermissionGrantScopes); - // Step 2: Get Microsoft Graph service principal - _logger.LogInformation(" Looking up Microsoft Graph service principal"); - var graphSp = await GetServicePrincipalAsync(httpClient, AuthenticationConstants.MicrosoftGraphResourceAppId, cancellationToken); - if (graphSp == null) + if (string.IsNullOrWhiteSpace(clientSpObjectId)) { - _logger.LogError("Failed to get Microsoft Graph service principal"); + _logger.LogError("Failed to ensure service principal for calling app"); return false; } - var graphSpId = graphSp.RootElement.GetProperty("id").GetString()!; - _logger.LogInformation(" Graph Service Principal ID: {SpId}", graphSpId); - - // Step 3: Check if grant already exists - _logger.LogInformation(" Checking for existing permission grant"); - var existingGrants = await GetExistingGrantsAsync(httpClient, clientSpId, graphSpId, cancellationToken); - - if (existingGrants != null && existingGrants.Count > 0) - { - _logger.LogInformation(" Found {Count} existing grant(s)", existingGrants.Count); - - // Update existing grant(s) to include required scope - foreach (var grant in existingGrants) - { - await EnsureScopeOnGrantAsync(httpClient, grant, TargetScope, cancellationToken); - } - } - else - { - _logger.LogInformation(" No existing grants found, creating new grant"); - - // Create new grant with required scope - var success = await CreateGrantAsync(httpClient, clientSpId, graphSpId, TargetScope, cancellationToken); - if (!success) - { - _logger.LogError("Failed to create permission grant"); - return false; - } - } - - _logger.LogInformation("Successfully ensured grant for scope: {Scope}", TargetScope); - _logger.LogInformation(" You can now create Agent Blueprints"); - - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to ensure AgentIdentityBlueprint.ReadWrite.All consent: {Message}", ex.Message); - - // Check if this looks like a CAE error - if (ex.Message.Contains("TokenIssuedBeforeRevocationTimestamp", StringComparison.OrdinalIgnoreCase) || - ex.Message.Contains("InteractionRequired", StringComparison.OrdinalIgnoreCase)) - { - _logger.LogError(""); - _logger.LogError("=== AUTHENTICATION TOKEN EXPIRED ==="); - } - - return false; - } - } - - /// - /// Gets or creates a service principal for the given app ID - /// Equivalent to Get-OrCreateServicePrincipalByAppId in PowerShell - /// - private async Task GetOrCreateServicePrincipalAsync( - HttpClient httpClient, - string appId, - string tenantId, - CancellationToken cancellationToken) - { - try - { - // Try to get existing service principal - var getSp = await GetServicePrincipalAsync(httpClient, appId, cancellationToken); - if (getSp != null) - { - _logger.LogInformation(" Service principal already exists for app {AppId}", appId); - return getSp; - } - - // Create new service principal - _logger.LogInformation("Creating service principal for app {AppId}", appId); - var createSpUrl = "https://graph.microsoft.com/v1.0/servicePrincipals"; - var createBody = new - { - appId = appId - }; + _logger.LogInformation(" Client Service Principal ID: {SpId}", clientSpObjectId); - var createResponse = await httpClient.PostAsync( - createSpUrl, - new StringContent( - JsonSerializer.Serialize(createBody), - System.Text.Encoding.UTF8, - "application/json"), - cancellationToken); + // 2) Ensure SP for Microsoft Graph resource app + _logger.LogInformation(" Ensuring Microsoft Graph service principal"); + var graphSpObjectId = await _graphService.EnsureServicePrincipalForAppIdAsync( + tenantId, + AuthenticationConstants.MicrosoftGraphResourceAppId, + cancellationToken, + scopes: PermissionGrantScopes); - if (!createResponse.IsSuccessStatusCode) - { - var error = await createResponse.Content.ReadAsStringAsync(cancellationToken); - - // Check if this is a CAE token error requiring re-authentication - if (IsCaeTokenError(error)) - { - _logger.LogWarning("Continuous Access Evaluation detected stale token. Re-authenticating automatically..."); - - // Perform automatic logout and re-login - var freshToken = await ForceReAuthenticationAsync(tenantId, cancellationToken); - if (string.IsNullOrWhiteSpace(freshToken)) - { - _logger.LogError("Automatic re-authentication failed"); - return null; - } - - // Update the HTTP client with fresh token - httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", freshToken); - - // Retry the service principal creation with fresh token - _logger.LogInformation("Retrying service principal creation with fresh token..."); - var retryResponse = await httpClient.PostAsync( - createSpUrl, - new StringContent( - JsonSerializer.Serialize(createBody), - System.Text.Encoding.UTF8, - "application/json"), - cancellationToken); - - if (!retryResponse.IsSuccessStatusCode) - { - var retryError = await retryResponse.Content.ReadAsStringAsync(cancellationToken); - _logger.LogError("Failed to create service principal after re-authentication: {Error}", retryError); - return null; - } - - var retrySpJson = await retryResponse.Content.ReadAsStringAsync(cancellationToken); - _logger.LogInformation(" Service principal created successfully after re-authentication"); - return JsonDocument.Parse(retrySpJson); - } - - _logger.LogError("Failed to create service principal: {Error}", error); - return null; - } - - var spJson = await createResponse.Content.ReadAsStringAsync(cancellationToken); - _logger.LogInformation(" Service principal created successfully"); - return JsonDocument.Parse(spJson); - } - catch (Exception ex) - { - _logger.LogError(ex, "Exception in GetOrCreateServicePrincipalAsync"); - return null; - } - } - - /// - /// Forces re-authentication by logging out and logging back in - /// Returns a fresh Graph API access token - /// - private async Task ForceReAuthenticationAsync(string tenantId, CancellationToken cancellationToken) - { - try - { - _logger.LogInformation(" Logging out of Azure CLI..."); - - // Logout using CommandExecutor - var cleanLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(); - var executor = new CommandExecutor( - cleanLoggerFactory.CreateLogger()); - - await executor.ExecuteAsync("az", "logout", suppressErrorLogging: true, cancellationToken: cancellationToken); - - _logger.LogInformation(" Initiating fresh login..."); - var loginResult = await executor.ExecuteAsync( - "az", - $"login --tenant {tenantId}", - cancellationToken: cancellationToken); - - if (!loginResult.Success) - { - _logger.LogError("Fresh login failed"); - return null; - } - - _logger.LogInformation(" Acquiring fresh Graph API token..."); - - // Get fresh token - var tokenResult = await executor.ExecuteAsync( - "az", - $"account get-access-token --resource https://graph.microsoft.com/ --tenant {tenantId} --query accessToken -o tsv", - captureOutput: true, - cancellationToken: cancellationToken); - - if (tokenResult.Success && !string.IsNullOrWhiteSpace(tokenResult.StandardOutput)) - { - var token = tokenResult.StandardOutput.Trim(); - _logger.LogInformation(" Fresh token acquired successfully"); - return token; - } - - _logger.LogError("Failed to acquire fresh token: {Error}", tokenResult.StandardError); - return null; - } - catch (Exception ex) - { - _logger.LogError(ex, "Exception during forced re-authentication"); - return null; - } - } - - /// - /// Checks if an error response indicates a Continuous Access Evaluation (CAE) token issue - /// - private bool IsCaeTokenError(string errorJson) - { - try - { - if (string.IsNullOrWhiteSpace(errorJson)) + if (string.IsNullOrWhiteSpace(graphSpObjectId)) { + _logger.LogError("Failed to ensure Microsoft Graph service principal"); return false; } - // Check for common CAE error indicators - return errorJson.Contains("TokenIssuedBeforeRevocationTimestamp", StringComparison.OrdinalIgnoreCase) || - errorJson.Contains("InteractionRequired", StringComparison.OrdinalIgnoreCase) || - errorJson.Contains("InvalidAuthenticationToken", StringComparison.OrdinalIgnoreCase) && - errorJson.Contains("Continuous access evaluation", StringComparison.OrdinalIgnoreCase); - } - catch - { - return false; - } - } - - /// - /// Gets a service principal by app ID - /// Equivalent to Get-GraphServicePrincipal in PowerShell - /// - private async Task GetServicePrincipalAsync( - HttpClient httpClient, - string appId, - CancellationToken cancellationToken) - { - try - { - var url = $"https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq '{appId}'"; - var response = await httpClient.GetAsync(url, cancellationToken); - - if (!response.IsSuccessStatusCode) - { - return null; - } - - var json = await response.Content.ReadAsStringAsync(cancellationToken); - var doc = JsonDocument.Parse(json); + _logger.LogInformation(" Graph Service Principal ID: {SpId}", graphSpObjectId); - if (doc.RootElement.TryGetProperty("value", out var value) && value.GetArrayLength() > 0) - { - // Return just the first service principal - var spElement = value[0]; - var spJson = JsonSerializer.Serialize(spElement); - return JsonDocument.Parse(spJson); - } + // 3) Create or update the oauth2PermissionGrant (AllPrincipals) to include TargetScope + _logger.LogInformation(" Creating/updating oauth2PermissionGrant (AllPrincipals) for scope: {Scope}", TargetScope); - return null; - } - catch (Exception ex) - { - _logger.LogError(ex, "Exception in GetServicePrincipalAsync"); - return null; - } - } + var ok = await _graphService.CreateOrUpdateOauth2PermissionGrantAsync( + tenantId, + clientSpObjectId, + graphSpObjectId, + new[] { TargetScope }, + cancellationToken, + permissionGrantScopes: PermissionGrantScopes); - /// - /// Gets existing AllPrincipals grants between client and resource - /// Equivalent to Get-ExistingAllPrincipalsGrant in PowerShell - /// - private async Task?> GetExistingGrantsAsync( - HttpClient httpClient, - string clientId, - string resourceId, - CancellationToken cancellationToken) - { - try - { - var filter = $"clientId eq '{clientId}' and resourceId eq '{resourceId}' and consentType eq '{AllPrincipalsConsentType}'"; - var url = $"https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter={Uri.EscapeDataString(filter)}"; - - var response = await httpClient.GetAsync(url, cancellationToken); - - if (!response.IsSuccessStatusCode) + if (!ok) { - return null; - } - - var json = await response.Content.ReadAsStringAsync(cancellationToken); - var doc = JsonDocument.Parse(json); - - if (doc.RootElement.TryGetProperty("value", out var value)) - { - var grants = new List(); - foreach (var grant in value.EnumerateArray()) - { - grants.Add(grant.Clone()); - } - return grants; - } - - return null; - } - catch (Exception ex) - { - _logger.LogError(ex, "Exception in GetExistingGrantsAsync"); - return null; - } - } - - /// - /// Ensures the specified scope is present on an existing grant - /// Equivalent to Ensure-ScopeOnGrant in PowerShell - /// - private async Task EnsureScopeOnGrantAsync( - HttpClient httpClient, - JsonElement grant, - string scopeToAdd, - CancellationToken cancellationToken) - { - try - { - var grantId = grant.GetProperty("id").GetString(); - var existingScope = grant.TryGetProperty("scope", out var scopeElement) - ? scopeElement.GetString() ?? "" - : ""; - - // Parse existing scopes - var existingScopes = existingScope - .Split(' ', StringSplitOptions.RemoveEmptyEntries) - .ToHashSet(); - - // Check if scope already exists - if (existingScopes.Contains(scopeToAdd)) - { - _logger.LogInformation(" Scope '{Scope}' already exists on grant {GrantId}", scopeToAdd, grantId); - return true; - } - - // Add new scope - existingScopes.Add(scopeToAdd); - var newScope = string.Join(' ', existingScopes.OrderBy(s => s)); - - _logger.LogInformation(" Updating grant {GrantId} to include scope: {Scope}", grantId, scopeToAdd); - - // Update the grant - var updateUrl = $"https://graph.microsoft.com/v1.0/oauth2PermissionGrants/{grantId}"; - var updateBody = new - { - scope = newScope - }; - - var updateResponse = await httpClient.PatchAsync( - updateUrl, - new StringContent( - JsonSerializer.Serialize(updateBody), - System.Text.Encoding.UTF8, - "application/json"), - cancellationToken); - - if (!updateResponse.IsSuccessStatusCode) - { - var error = await updateResponse.Content.ReadAsStringAsync(cancellationToken); - _logger.LogDebug("Grant update returned error (may be transient): {Error}", error); - // Note: We return true here because the grant update failure is often transient - // and the setup can continue. The "Successfully ensured grant" message below - // indicates the overall operation succeeded even if this specific update had issues. - return true; - } - - _logger.LogInformation(" Grant updated successfully"); - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Exception in EnsureScopeOnGrantAsync"); - return false; - } - } - - /// - /// Creates a new AllPrincipals permission grant - /// Equivalent to Create-AllPrincipalsGrant in PowerShell - /// - private async Task CreateGrantAsync( - HttpClient httpClient, - string clientId, - string resourceId, - string scope, - CancellationToken cancellationToken) - { - try - { - var createUrl = "https://graph.microsoft.com/v1.0/oauth2PermissionGrants"; - var createBody = new - { - clientId = clientId, - consentType = AllPrincipalsConsentType, - resourceId = resourceId, - scope = scope - }; - - var createResponse = await httpClient.PostAsync( - createUrl, - new StringContent( - JsonSerializer.Serialize(createBody), - System.Text.Encoding.UTF8, - "application/json"), - cancellationToken); - - if (!createResponse.IsSuccessStatusCode) - { - var error = await createResponse.Content.ReadAsStringAsync(cancellationToken); - _logger.LogError("Failed to create grant: {Error}", error); + _logger.LogError("Failed to create/update oauth2PermissionGrant for scope: {Scope}", TargetScope); return false; } - var responseJson = await createResponse.Content.ReadAsStringAsync(cancellationToken); - var response = JsonDocument.Parse(responseJson); - var grantId = response.RootElement.GetProperty("id").GetString(); - - _logger.LogInformation(" Permission grant created successfully (ID: {GrantId})", grantId); + _logger.LogInformation("Successfully ensured grant for scope: {Scope}", TargetScope); + _logger.LogInformation(" You can now create Agent Blueprints"); return true; } catch (Exception ex) { - _logger.LogError(ex, "Exception in CreateGrantAsync"); + _logger.LogError(ex, "Failed to ensure AgentIdentityBlueprint.ReadWrite.All consent: {Message}", ex.Message); return false; } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/FederatedCredentialService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/FederatedCredentialService.cs index 928072d..7ff6d3a 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/FederatedCredentialService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/FederatedCredentialService.cs @@ -3,6 +3,7 @@ using System.Text.Json; using Microsoft.Extensions.Logging; +using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Models; namespace Microsoft.Agents.A365.DevTools.Cli.Services; @@ -47,11 +48,14 @@ public async Task> GetFederatedCredentialsAsync( { _logger.LogDebug("Retrieving federated credentials for blueprint: {ObjectId}", blueprintObjectId); + var authScopes = AuthenticationConstants.PermissionGrantAuthScopes; + // Try standard endpoint first var doc = await _graphApiService.GraphGetAsync( tenantId, $"/beta/applications/{blueprintObjectId}/federatedIdentityCredentials", - cancellationToken); + cancellationToken, + authScopes); // If standard endpoint returns data with credentials, use it if (doc != null && doc.RootElement.TryGetProperty("value", out var valueCheck) && valueCheck.GetArrayLength() > 0) @@ -65,7 +69,8 @@ public async Task> GetFederatedCredentialsAsync( doc = await _graphApiService.GraphGetAsync( tenantId, $"/beta/applications/microsoft.graph.agentIdentityBlueprint/{blueprintObjectId}/federatedIdentityCredentials", - cancellationToken); + cancellationToken, + authScopes); } if (doc == null) @@ -255,11 +260,14 @@ public async Task CreateFederatedCredentialAsyn { _logger.LogDebug("Attempting federated credential creation with endpoint: {Endpoint}", endpoint); + var authScopes = AuthenticationConstants.PermissionGrantAuthScopes; + var response = await _graphApiService.GraphPostWithResponseAsync( tenantId, endpoint, payload, - cancellationToken); + cancellationToken, + authScopes); if (response.IsSuccess) { @@ -369,13 +377,15 @@ public async Task DeleteFederatedCredentialAsync( credentialId, blueprintObjectId); // Try the standard endpoint first - var endpoint = $"/beta/applications/{blueprintObjectId}/federatedIdentityCredentials/{credentialId}"; - + var endpoint = $"/beta/applications/{blueprintObjectId}/federatedIdentityCredentials/{credentialId}"; + var authScopes = AuthenticationConstants.PermissionGrantAuthScopes; + var success = await _graphApiService.GraphDeleteAsync( tenantId, endpoint, cancellationToken, - treatNotFoundAsSuccess: true); + treatNotFoundAsSuccess: true, + authScopes); if (success) { @@ -391,7 +401,8 @@ public async Task DeleteFederatedCredentialAsync( tenantId, endpoint, cancellationToken, - treatNotFoundAsSuccess: true); + treatNotFoundAsSuccess: true, + authScopes); if (success) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs index f336660..5f2da77 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs @@ -83,7 +83,7 @@ public GraphApiService(ILogger logger, CommandExecutor executor _logger.LogInformation("Azure CLI not authenticated. Initiating login..."); var loginResult = await _executor.ExecuteAsync( "az", - $"login --tenant {tenantId}", + $"login --tenant {tenantId} --use-device-code --allow-no-subscriptions", cancellationToken: ct); if (!loginResult.Success) @@ -122,7 +122,7 @@ public GraphApiService(ILogger logger, CommandExecutor executor _logger.LogInformation("Initiating fresh login..."); var freshLoginResult = await _executor.ExecuteAsync( "az", - $"login --tenant {tenantId}", + $"login --tenant {tenantId} --use-device-code --allow-no-subscriptions", cancellationToken: ct); if (!freshLoginResult.Success) @@ -179,22 +179,29 @@ 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) + // When specific scopes are required AND token provider is configured, use delegated auth + // Otherwise fall back to Azure CLI (useful for tests and when token provider is not available) + string? token; + + if (scopes != null && _tokenProvider != null) { - _logger.LogError("Token provider is not configured, but specific scopes are required: {Scopes}", string.Join(", ", scopes)); - return false; + // Use token provider with delegated scopes (device code flow) + token = await _tokenProvider.GetMgGraphAccessTokenAsync(tenantId, scopes, useDeviceCode: true, clientAppId: CustomClientAppId, ct: ct); + } + else + { + // Fall back to Azure CLI token (for tests or when token provider is not configured) + if (scopes != null && _tokenProvider == null) + { + _logger.LogWarning("Token provider is not configured, falling back to Azure CLI for scopes: {Scopes}", string.Join(", ", scopes)); + } + 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; _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + // NOTE: Do NOT add "ConsistencyLevel: eventual" header here. // This header is only required for advanced Graph query capabilities ($count, $search, certain $filter operations). // For simple queries like service principal lookups, this header is not needed and causes HTTP 400 errors. @@ -321,6 +328,69 @@ public async Task GraphDeleteAsync( return true; } + public virtual async Task GraphPostWithResponseAsync( + string tenantId, + string relativePath, + object payload, + CancellationToken ct = default, + IEnumerable? scopes = null, + IDictionary? extraHeaders = null) + { + if (!await EnsureGraphHeadersAsync(tenantId, ct, scopes)) + { + return new GraphResponse { IsSuccess = false, StatusCode = 0, ReasonPhrase = "NoAuth", Body = "Failed to acquire token" }; + } + + var url = relativePath.StartsWith("http", StringComparison.OrdinalIgnoreCase) + ? relativePath + : $"https://graph.microsoft.com{relativePath}"; + + using var req = CreateJsonRequest(HttpMethod.Post, url, payload, extraHeaders); + using var resp = await _httpClient.SendAsync(req, ct); + var body = await resp.Content.ReadAsStringAsync(ct); + + JsonDocument? json = null; + if (!string.IsNullOrWhiteSpace(body)) + { + try { json = JsonDocument.Parse(body); } catch { /* ignore */ } + } + + return new GraphResponse + { + IsSuccess = resp.IsSuccessStatusCode, + StatusCode = (int)resp.StatusCode, + ReasonPhrase = resp.ReasonPhrase ?? string.Empty, + Body = body ?? string.Empty, + Json = json + }; + } + + public virtual async Task GraphPatchAsync( + string tenantId, + string relativePath, + object payload, + CancellationToken ct = default, + IEnumerable? scopes = null, + IDictionary? extraHeaders = null) + { + if (!await EnsureGraphHeadersAsync(tenantId, ct, scopes)) return false; + + var url = relativePath.StartsWith("http", StringComparison.OrdinalIgnoreCase) + ? relativePath + : $"https://graph.microsoft.com{relativePath}"; + + using var req = CreateJsonRequest(new HttpMethod("PATCH"), url, payload, extraHeaders); + using var resp = await _httpClient.SendAsync(req, ct); + + if (!resp.IsSuccessStatusCode) + { + var body = await resp.Content.ReadAsStringAsync(ct); + _logger.LogError("Graph PATCH {Url} failed {Code} {Reason}: {Body}", url, (int)resp.StatusCode, resp.ReasonPhrase, body); + } + + return resp.IsSuccessStatusCode; + } + /// /// Looks up a service principal by its application (client) ID. /// Virtual to allow mocking in unit tests using Moq. @@ -481,4 +551,33 @@ public async Task CreateOrUpdateOauth2PermissionGrantAsync( return (false, new List()); } } + + private static HttpRequestMessage CreateJsonRequest( + HttpMethod method, + string url, + object? payload = null, + IDictionary? extraHeaders = null) + { + var req = new HttpRequestMessage(method, url); + + if (payload != null) + { + req.Content = new StringContent( + JsonSerializer.Serialize(payload), + Encoding.UTF8, + "application/json"); + } + + if (extraHeaders != null) + { + foreach (var kvp in extraHeaders) + { + // avoid duplicates if caller reuses service instance + req.Headers.Remove(kvp.Key); + req.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); + } + } + + return req; + } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/AdminConsentHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/AdminConsentHelper.cs index 7b1824c..fb7e396 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/AdminConsentHelper.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/AdminConsentHelper.cs @@ -5,22 +5,23 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Extensions.Logging; namespace Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; /// -/// Helper methods for admin consent flows that use az cli to poll Graph resources. +/// Helper methods for admin consent flows and verification. /// Kept intentionally small and focused so it can be reused across commands/runners. /// public static class AdminConsentHelper { /// - /// Polls Azure AD/Graph (via az rest) to detect an oauth2 permission grant for the provided appId. - /// Mirrors the behavior previously implemented in A365SetupRunner.PollAdminConsentAsync. + /// Polls Microsoft Graph API for admin consent by checking for existence of oauth2PermissionGrants /// public static async Task PollAdminConsentAsync( - CommandExecutor executor, + Services.GraphApiService graphApiService, + string tenantId, ILogger logger, string appId, string scopeDescriptor, @@ -31,55 +32,43 @@ public static async Task PollAdminConsentAsync( var start = DateTime.UtcNow; string? spId = null; + // Use delegated scopes so this polling doesn't rely on Azure CLI. + var scopes = AuthenticationConstants.PermissionGrantAuthScopes; + try { while ((DateTime.UtcNow - start).TotalSeconds < timeoutSeconds && !ct.IsCancellationRequested) { if (spId == null) { - var spResult = await executor.ExecuteAsync("az", - $"rest --method GET --url \"https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq '{appId}'\"", - captureOutput: true, suppressErrorLogging: true, cancellationToken: ct); - - if (spResult.Success) - { - try - { - using var doc = JsonDocument.Parse(spResult.StandardOutput); - var value = doc.RootElement.GetProperty("value"); - if (value.GetArrayLength() > 0) - { - spId = value[0].GetProperty("id").GetString(); - } - } - catch { } - } + // Find SP by appId + spId = await graphApiService.LookupServicePrincipalByAppIdAsync( + tenantId, + appId, + ct, + scopes); + + // Note: SP propagation can lag; keep polling. } - if (spId != null) + if (!string.IsNullOrWhiteSpace(spId)) { - var grants = await executor.ExecuteAsync("az", - $"rest --method GET --url \"https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spId}'\"", - captureOutput: true, suppressErrorLogging: true, cancellationToken: ct); - - if (grants.Success) + // Check if ANY oauth2PermissionGrants exist for that clientId + var grantsDoc = await graphApiService.GraphGetAsync( + tenantId, + $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spId}'", + ct, + scopes); + + if (grantsDoc != null && + grantsDoc.RootElement.TryGetProperty("value", out var arr) && + arr.GetArrayLength() > 0) { - try - { - using var gdoc = JsonDocument.Parse(grants.StandardOutput); - var arr = gdoc.RootElement.GetProperty("value"); - if (arr.GetArrayLength() > 0) - { - logger.LogInformation("Consent granted ({ScopeDescriptor}).", scopeDescriptor); - return true; - } - } - catch { } + logger.LogInformation("Consent granted ({ScopeDescriptor}).", scopeDescriptor); + return true; } } - // Delay between polls. If cancellation is requested this will throw OperationCanceledException, - // which we catch below and treat as a graceful cancellation resulting in 'false'. await Task.Delay(TimeSpan.FromSeconds(intervalSeconds), ct); } @@ -87,10 +76,14 @@ public static async Task PollAdminConsentAsync( } catch (OperationCanceledException) { - // Treat cancellation as a graceful timeout/no-consent scenario logger.LogDebug("Polling for admin consent was cancelled or timed out for app {AppId} ({Scope}).", appId, scopeDescriptor); return false; } + catch (Exception ex) + { + logger.LogDebug(ex, "Polling for admin consent failed for app {AppId} ({Scope}).", appId, scopeDescriptor); + return false; + } } /// @@ -127,7 +120,8 @@ public static async Task CheckConsentExistsAsync( var grantDoc = await graphApiService.GraphGetAsync( tenantId, $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{clientSpId}' and resourceId eq '{resourceSpId}'", - ct); + ct, + AuthenticationConstants.PermissionGrantAuthScopes); if (grantDoc == null || !grantDoc.RootElement.TryGetProperty("value", out var grants) || grants.GetArrayLength() == 0) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs index 91072dd..27f6775 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs @@ -75,39 +75,47 @@ public Task GetAuthenticatedGraphClientAsync( return Task.FromResult(_cachedClient); } - _logger.LogInformation("Attempting to authenticate to Microsoft Graph interactively..."); + _logger.LogInformation("Attempting to authenticate to Microsoft Graph using device code flow..."); _logger.LogInformation("This requires permissions defined in AuthenticationConstants.RequiredClientAppPermissions for Agent Blueprint operations."); _logger.LogInformation(""); - _logger.LogInformation("IMPORTANT: A browser window will open for authentication."); - _logger.LogInformation("Please sign in with an account that has Global Administrator or similar privileges."); + _logger.LogInformation("IMPORTANT: Please follow the device code instructions."); + _logger.LogInformation("Sign in with an account that has Global Administrator or similar privileges."); _logger.LogInformation(""); - // Try browser authentication first + // ALWAYS use device code flow for CLI-friendly authentication (no browser popups) GraphServiceClient? graphClient = null; - bool shouldTryDeviceCode = false; - + try { - var browserCredential = new InteractiveBrowserCredential(new InteractiveBrowserCredentialOptions + var deviceCodeCredential = new DeviceCodeCredential(new DeviceCodeCredentialOptions { TenantId = tenantId, ClientId = _clientAppId, AuthorityHost = AzureAuthorityHosts.AzurePublicCloud, - RedirectUri = new Uri(AuthenticationConstants.LocalhostRedirectUri), 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; } }); - - _logger.LogInformation("Opening browser for authentication..."); - _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); + + graphClient = new GraphServiceClient(deviceCodeCredential, RequiredScopes); _logger.LogInformation("Successfully authenticated to Microsoft Graph!"); _logger.LogInformation(""); @@ -115,105 +123,22 @@ public Task GetAuthenticatedGraphClientAsync( // Cache the client for reuse _cachedClient = graphClient; _cachedTenantId = tenantId; - + return Task.FromResult(graphClient); } catch (Azure.Identity.AuthenticationFailedException ex) when (ex.Message.Contains("invalid_grant")) { - // Most specific: permissions issue - don't try fallback + // Permissions issue in device code flow ThrowInsufficientPermissionsException(ex); throw; // Unreachable but required for compiler } - catch (Azure.Identity.AuthenticationFailedException ex) when ( - 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; - } - catch (Azure.Identity.CredentialUnavailableException) - { - _logger.LogError("Interactive browser authentication is not available"); - throw new GraphApiException( - "Interactive browser authentication", - "Not available in non-interactive environments or when browser is unavailable", - isPermissionIssue: false); - } catch (Exception ex) { - _logger.LogError("Failed to authenticate to Microsoft Graph: {Message}", ex.Message); + _logger.LogError("Device code authentication failed: {Message}", ex.Message); throw new GraphApiException( - "Browser authentication", - $"Authentication failed: {ex.Message}", - isPermissionIssue: false); - } - - // Fallback to Device Code Flow if browser authentication had infrastructure issues - 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."); - } + "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 f808925..971076f 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs @@ -180,13 +180,14 @@ 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) - var authMethod = useDeviceCode ? "-UseDeviceCode" : ""; - + // ALWAYS use -UseDeviceCode for CLI-friendly authentication (no browser popup/download) + // This enforces device code flow for the entire CLI workflow + var authMethod = "-UseDeviceCode"; + // Include -ClientId parameter if provided (ensures authentication uses the custom client app) // Add leading space only when parameter is present to avoid double spaces - var clientIdParam = !string.IsNullOrWhiteSpace(clientAppId) - ? $" -ClientId '{CommandStringHelper.EscapePowerShellString(clientAppId)}'" + var clientIdParam = !string.IsNullOrWhiteSpace(clientAppId) + ? $" -ClientId '{CommandStringHelper.EscapePowerShellString(clientAppId)}'" : ""; // Workaround for older Microsoft.Graph versions that don't have Get-MgAccessToken diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/MosTokenService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/MosTokenService.cs index ed26223..97475d2 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/MosTokenService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/MosTokenService.cs @@ -79,11 +79,11 @@ public MosTokenService(ILogger logger, IConfigService configSer return null; } - // Acquire new token using MSAL.NET + // Acquire new token using MSAL.NET with device code flow (no browser popup) try { _logger.LogInformation("Acquiring MOS token for environment: {Environment}", environment); - _logger.LogInformation("A browser window will open for authentication..."); + _logger.LogInformation("Please follow the device code instructions..."); var app = PublicClientApplicationBuilder .Create(config.ClientId) @@ -92,8 +92,18 @@ public MosTokenService(ILogger logger, IConfigService configSer .Build(); var result = await app - .AcquireTokenInteractive(new[] { config.Scope }) - .WithPrompt(Prompt.SelectAccount) + .AcquireTokenWithDeviceCode(new[] { config.Scope }, deviceCodeResult => + { + _logger.LogInformation(""); + _logger.LogInformation("========================================================================"); + _logger.LogInformation("To sign in, use a web browser to open the page:"); + _logger.LogInformation(" {VerificationUri}", deviceCodeResult.VerificationUrl); + _logger.LogInformation(""); + _logger.LogInformation("And enter the code: {UserCode}", deviceCodeResult.UserCode); + _logger.LogInformation("========================================================================"); + _logger.LogInformation(""); + return Task.CompletedTask; + }) .ExecuteAsync(cancellationToken); if (result?.AccessToken == null) diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AdminConsentHelperTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AdminConsentHelperTests.cs index f5685b5..0fca390 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AdminConsentHelperTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AdminConsentHelperTests.cs @@ -16,21 +16,40 @@ public class AdminConsentHelperTests [Fact] public async Task PollAdminConsentAsync_ReturnsTrue_WhenGrantExists() { - var executor = Substitute.For(Substitute.For>()); - var logger = Substitute.For(); + var graph = Substitute.For( + Substitute.For>(), + Substitute.For(Substitute.For>())); - // Mock service principal lookup - var spJson = JsonDocument.Parse("{\"value\":[{\"id\":\"sp-123\"}]}", new JsonDocumentOptions()).RootElement.GetRawText(); - executor.ExecuteAsync("az", Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(ci => Task.FromResult(new Microsoft.Agents.A365.DevTools.Cli.Services.CommandResult { ExitCode = 0, StandardOutput = spJson })); + var logger = Substitute.For(); - // On the grants call, return a grant - var grantsJson = JsonDocument.Parse("{\"value\":[{\"id\":\"grant-1\"}]}", new JsonDocumentOptions()).RootElement.GetRawText(); - executor.ExecuteAsync("az", Arg.Is(s => s.Contains("oauth2PermissionGrants")), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new Microsoft.Agents.A365.DevTools.Cli.Services.CommandResult { ExitCode = 0, StandardOutput = grantsJson })); + // 1) LookupServicePrincipalByAppIdAsync returns sp id + graph.LookupServicePrincipalByAppIdAsync( + "tenant-1", + "appId-1", + Arg.Any(), + Arg.Any?>()) + .Returns("sp-123"); + + // 2) oauth2PermissionGrants?$filter=clientId eq 'sp-123' -> returns a grant + var grantsDoc = JsonDocument.Parse("{\"value\":[{\"id\":\"grant-1\"}]}"); + graph.GraphGetAsync( + Arg.Any(), + Arg.Is(p => p.Contains("/v1.0/oauth2PermissionGrants?$filter=clientId eq 'sp-123'")), + Arg.Any(), + Arg.Any?>()) + .Returns(Task.FromResult(grantsDoc)); var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - var result = await AdminConsentHelper.PollAdminConsentAsync(executor, logger, "appId-1", "Test", 10, 1, cts.Token); + + var result = await AdminConsentHelper.PollAdminConsentAsync( + graph, + tenantId: "tenant-1", + logger, + appId: "appId-1", + scopeDescriptor: "Test", + timeoutSeconds: 10, + intervalSeconds: 1, + ct: cts.Token); result.Should().BeTrue(); } @@ -38,15 +57,31 @@ public async Task PollAdminConsentAsync_ReturnsTrue_WhenGrantExists() [Fact] public async Task PollAdminConsentAsync_ReturnsFalse_WhenNoGrant() { - var executor = Substitute.For(Substitute.For>()); + var graph = Substitute.For( + Substitute.For>(), + Substitute.For(Substitute.For>())); + var logger = Substitute.For(); - // service principal not found - executor.ExecuteAsync("az", Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new Microsoft.Agents.A365.DevTools.Cli.Services.CommandResult { ExitCode = 0, StandardOutput = "{\"value\":[]}" })); + // service principal not found - LookupServicePrincipalByAppIdAsync returns null + graph.LookupServicePrincipalByAppIdAsync( + "tenant-1", + "appId-1", + Arg.Any(), + Arg.Any?>()) + .Returns((string?)null); var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - var result = await AdminConsentHelper.PollAdminConsentAsync(executor, logger, "appId-1", "Test", 3, 1, cts.Token); + + var result = await AdminConsentHelper.PollAdminConsentAsync( + graph, + tenantId: "tenant-1", + logger, + appId: "appId-1", + scopeDescriptor: "Test", + timeoutSeconds: 3, + intervalSeconds: 1, + ct: cts.Token); result.Should().BeFalse(); } @@ -69,7 +104,7 @@ public async Task CheckConsentExistsAsync_ReturnsTrue_WhenAllScopesGranted() } """; var grantDoc = JsonDocument.Parse(grantJson); - graphApiService.GraphGetAsync("tenant-1", Arg.Any(), Arg.Any()) + graphApiService.GraphGetAsync("tenant-1", Arg.Any(), Arg.Any(), Arg.Any?>()) .Returns(Task.FromResult(grantDoc)); var requiredScopes = new[] { "User.Read", "Mail.Send" }; @@ -98,7 +133,7 @@ public async Task CheckConsentExistsAsync_ReturnsFalse_WhenScopeMissing() } """; var grantDoc = JsonDocument.Parse(grantJson); - graphApiService.GraphGetAsync("tenant-1", Arg.Any(), Arg.Any()) + graphApiService.GraphGetAsync("tenant-1", Arg.Any(), Arg.Any(), Arg.Any?>()) .Returns(Task.FromResult(grantDoc)); var requiredScopes = new[] { "User.Read", "Mail.Send" }; @@ -127,7 +162,7 @@ public async Task CheckConsentExistsAsync_IsCaseInsensitive() } """; var grantDoc = JsonDocument.Parse(grantJson); - graphApiService.GraphGetAsync("tenant-1", Arg.Any(), Arg.Any()) + graphApiService.GraphGetAsync("tenant-1", Arg.Any(), Arg.Any(), Arg.Any?>()) .Returns(Task.FromResult(grantDoc)); var requiredScopes = new[] { "User.Read", "Mail.Send" }; @@ -151,7 +186,7 @@ public async Task CheckConsentExistsAsync_ReturnsFalse_WhenNoGrantsExist() } """; var grantDoc = JsonDocument.Parse(grantJson); - graphApiService.GraphGetAsync("tenant-1", Arg.Any(), Arg.Any()) + graphApiService.GraphGetAsync("tenant-1", Arg.Any(), Arg.Any(), Arg.Any?>()) .Returns(Task.FromResult(grantDoc)); var requiredScopes = new[] { "User.Read" }; @@ -207,7 +242,7 @@ public async Task CheckConsentExistsAsync_ReturnsFalse_WhenGrantMissingScopeProp } """; var grantDoc = JsonDocument.Parse(grantJson); - graphApiService.GraphGetAsync("tenant-1", Arg.Any(), Arg.Any()) + graphApiService.GraphGetAsync("tenant-1", Arg.Any(), Arg.Any(), Arg.Any?>()) .Returns(Task.FromResult(grantDoc)); var requiredScopes = new[] { "User.Read" }; diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AgentBlueprintServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AgentBlueprintServiceTests.cs index 80fcdb7..dbfc111 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AgentBlueprintServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AgentBlueprintServiceTests.cs @@ -175,7 +175,7 @@ public async Task DeleteAgentIdentityAsync_WithValidIdentity_ReturnsTrue() _mockTokenProvider.GetMgGraphAccessTokenAsync( tenantId, Arg.Is>(scopes => scopes.Contains("AgentIdentityBlueprint.ReadWrite.All")), - false, + true, Arg.Any(), Arg.Any()) .Returns("fake-delegated-token"); @@ -191,7 +191,7 @@ public async Task DeleteAgentIdentityAsync_WithValidIdentity_ReturnsTrue() await _mockTokenProvider.Received(1).GetMgGraphAccessTokenAsync( tenantId, Arg.Is>(scopes => scopes.Contains("AgentIdentityBlueprint.ReadWrite.All")), - false, + true, Arg.Any(), Arg.Any()); } @@ -228,24 +228,46 @@ public async Task DeleteAgentIdentityAsync_WhenResourceNotFound_ReturnsTrueIdemp } [Fact] - public async Task DeleteAgentIdentityAsync_WhenTokenProviderIsNull_ReturnsFalse() + public async Task DeleteAgentIdentityAsync_WhenTokenProviderIsNull_FallsBackToAzureCli() { // Arrange var handler = new FakeHttpMessageHandler(); - var graphService = new GraphApiService(_mockGraphLogger, _mockExecutor, handler, tokenProvider: null); + + // Create a mock executor that returns Azure CLI tokens + var mockExecutor = Substitute.For(Substitute.For>()); + mockExecutor.ExecuteAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + var cmd = callInfo.ArgAt(0); + var args = callInfo.ArgAt(1); + + if (cmd == "az" && args?.StartsWith("account show", StringComparison.OrdinalIgnoreCase) == true) + return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = "{}", StandardError = string.Empty }); + + if (cmd == "az" && args?.Contains("get-access-token", StringComparison.OrdinalIgnoreCase) == true) + return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = "fake-azure-cli-token", StandardError = string.Empty }); + + return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = string.Empty, StandardError = string.Empty }); + }); + + var graphService = new GraphApiService(_mockGraphLogger, mockExecutor, handler, tokenProvider: null); var service = new AgentBlueprintService(_mockLogger, graphService); const string tenantId = "12345678-1234-1234-1234-123456789012"; const string identityId = "identity-123"; + // Queue successful response for DELETE operation + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.NoContent)); + // Act var result = await service.DeleteAgentIdentityAsync(tenantId, identityId); - // Assert - result.Should().BeFalse(); + // Assert - Should succeed by falling back to Azure CLI auth + result.Should().BeTrue(); + // Should log warning about falling back to Azure CLI _mockGraphLogger.Received().Log( - LogLevel.Error, + LogLevel.Warning, Arg.Any(), Arg.Is(o => o.ToString()!.Contains("Token provider is not configured")), Arg.Any(), diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/FederatedCredentialServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/FederatedCredentialServiceTests.cs index ea729d7..449b9ad 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/FederatedCredentialServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/FederatedCredentialServiceTests.cs @@ -62,7 +62,7 @@ public async Task GetFederatedCredentialsAsync_WhenCredentialsExist_ReturnsListO _graphApiService.GraphGetAsync( TestTenantId, $"/beta/applications/{TestBlueprintObjectId}/federatedIdentityCredentials", - Arg.Any()) + Arg.Any(), Arg.Any?>()) .Returns(jsonDoc); // Act @@ -86,7 +86,7 @@ public async Task GetFederatedCredentialsAsync_WhenNoCredentials_ReturnsEmptyLis _graphApiService.GraphGetAsync( TestTenantId, $"/beta/applications/{TestBlueprintObjectId}/federatedIdentityCredentials", - Arg.Any()) + Arg.Any(), Arg.Any?>()) .Returns(jsonDoc); // Act @@ -117,7 +117,7 @@ public async Task CheckFederatedCredentialExistsAsync_WhenMatchingCredentialExis _graphApiService.GraphGetAsync( TestTenantId, $"/beta/applications/{TestBlueprintObjectId}/federatedIdentityCredentials", - Arg.Any()) + Arg.Any(), Arg.Any?>()) .Returns(jsonDoc); // Act @@ -155,7 +155,7 @@ public async Task CheckFederatedCredentialExistsAsync_WhenNoMatchingCredential_R _graphApiService.GraphGetAsync( TestTenantId, $"/beta/applications/{TestBlueprintObjectId}/federatedIdentityCredentials", - Arg.Any()) + Arg.Any(), Arg.Any?>()) .Returns(jsonDoc); // Act @@ -191,7 +191,7 @@ public async Task CheckFederatedCredentialExistsAsync_IsCaseInsensitive() _graphApiService.GraphGetAsync( TestTenantId, $"/beta/applications/{TestBlueprintObjectId}/federatedIdentityCredentials", - Arg.Any()) + Arg.Any(), Arg.Any?>()) .Returns(jsonDoc); // Act - Pass in different casing @@ -397,7 +397,7 @@ public async Task GetFederatedCredentialsAsync_OnException_ReturnsEmptyList() _graphApiService.GraphGetAsync( TestTenantId, Arg.Any(), - Arg.Any()) + Arg.Any(), Arg.Any?>()) .Throws(new Exception("Network error")); // Act @@ -422,7 +422,7 @@ public async Task GetFederatedCredentialsAsync_WhenStandardEndpointReturnsEmpty_ _graphApiService.GraphGetAsync( TestTenantId, standardEndpoint, - Arg.Any()) + Arg.Any(), Arg.Any?>()) .Returns(emptyJsonDoc); // Fallback endpoint returns credentials @@ -442,7 +442,7 @@ public async Task GetFederatedCredentialsAsync_WhenStandardEndpointReturnsEmpty_ _graphApiService.GraphGetAsync( TestTenantId, fallbackEndpoint, - Arg.Any()) + Arg.Any(), Arg.Any?>()) .Returns(fallbackJsonDoc); // Act @@ -458,12 +458,14 @@ public async Task GetFederatedCredentialsAsync_WhenStandardEndpointReturnsEmpty_ await _graphApiService.Received(1).GraphGetAsync( TestTenantId, standardEndpoint, - Arg.Any()); + Arg.Any(), + Arg.Any?>()); await _graphApiService.Received(1).GraphGetAsync( TestTenantId, fallbackEndpoint, - Arg.Any()); + Arg.Any(), + Arg.Any?>()); } [Fact] @@ -501,7 +503,7 @@ public async Task GetFederatedCredentialsAsync_WithMalformedCredentials_ReturnsO _graphApiService.GraphGetAsync( TestTenantId, $"/beta/applications/{TestBlueprintObjectId}/federatedIdentityCredentials", - Arg.Any()) + Arg.Any(), Arg.Any?>()) .Returns(jsonDoc); // Act diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs index 5e871d0..eab9953 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs @@ -57,7 +57,12 @@ public async Task GraphPostWithResponseAsync_Returns_Success_And_ParsesJson() }); // Act - var resp = await service.GraphPostWithResponseAsync("tid", "/v1.0/some/path", new { a = 1 }); + var resp = await service.GraphPostWithResponseAsync( + "tid", + "/v1.0/some/path", + new { a = 1 }, + CancellationToken.None, + scopes: null); // Assert resp.IsSuccess.Should().BeTrue(); @@ -98,7 +103,12 @@ public async Task GraphPostWithResponseAsync_Returns_Failure_With_Body() }); // Act - var resp = await service.GraphPostWithResponseAsync("tid", "/v1.0/some/path", new { a = 1 }); + var resp = await service.GraphPostWithResponseAsync( + "tid", + "/v1.0/some/path", + new { a = 1 }, + CancellationToken.None, + scopes: null); // Assert resp.IsSuccess.Should().BeFalse(); @@ -238,3 +248,119 @@ protected override Task SendAsync(HttpRequestMessage reques } } +public class GraphApiServiceScopeTests +{ + [Fact] + public async Task GraphGetAsync_WhenScopesProvidedButTokenProviderNull_FallsBackToAzureCli() + { + // Arrange + var handler = new TestHttpMessageHandler(); + var logger = Substitute.For>(); + var executor = Substitute.For(Substitute.For>()); + + // Mock Azure CLI to return token + executor.ExecuteAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(callInfo => + { + var cmd = callInfo.ArgAt(0); + var args = callInfo.ArgAt(1); + + if (cmd == "az" && args?.StartsWith("account show", StringComparison.OrdinalIgnoreCase) == true) + return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = "{}", StandardError = string.Empty }); + + if (cmd == "az" && args?.Contains("get-access-token", StringComparison.OrdinalIgnoreCase) == true) + return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = "azure-cli-token", StandardError = string.Empty }); + + return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = string.Empty, StandardError = string.Empty }); + }); + + // Create GraphApiService WITHOUT token provider (null) + var service = new GraphApiService(logger, executor, handler, tokenProvider: null); + + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(new { value = Array.Empty() })) + }); + + // Act - Call with scopes even though token provider is null + var scopes = new[] { "User.Read", "Mail.Read" }; + var result = await service.GraphGetAsync("tenant-123", "/v1.0/users", CancellationToken.None, scopes); + + // Assert + result.Should().NotBeNull("should successfully fall back to Azure CLI authentication"); + + // Verify warning was logged about falling back to Azure CLI + logger.Received().Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("Token provider is not configured")), + Arg.Any(), + Arg.Any>()); + + // Verify Azure CLI was called to get token + await executor.Received().ExecuteAsync( + "az", + Arg.Is(args => args.Contains("get-access-token")), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task GraphGetAsync_WhenScopesAndTokenProviderProvided_UsesDeviceCodeFlow() + { + // Arrange + var handler = new TestHttpMessageHandler(); + var logger = Substitute.For>(); + var executor = Substitute.For(Substitute.For>()); + var tokenProvider = Substitute.For(); + + var scopes = new[] { "User.Read", "Mail.Read" }; + + // Mock token provider to return delegated token with device code + tokenProvider.GetMgGraphAccessTokenAsync( + "tenant-123", + scopes, + true, // Must use device code = true + Arg.Any(), + Arg.Any()) + .Returns("delegated-token-with-device-code"); + + var service = new GraphApiService(logger, executor, handler, tokenProvider); + + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(new { value = Array.Empty() })) + }); + + // Act + var result = await service.GraphGetAsync("tenant-123", "/v1.0/users", CancellationToken.None, scopes); + + // Assert + result.Should().NotBeNull(); + + // Verify device code flow was used (useDeviceCode: true, not false) + await tokenProvider.Received(1).GetMgGraphAccessTokenAsync( + "tenant-123", + scopes, + true, // CRITICAL: Must be true for device code enforcement + Arg.Any(), + Arg.Any()); + + // Verify NO warning was logged (token provider is available) + logger.DidNotReceive().Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("Token provider is not configured")), + Arg.Any(), + Arg.Any>()); + } +} + diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs index 6952a08..7430682 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs @@ -240,4 +240,42 @@ public async Task GetMgGraphAccessTokenAsync_EscapesSingleQuotesInClientAppId() // Assert token.Should().Be(expectedToken); } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task GetMgGraphAccessTokenAsync_AlwaysUsesDeviceCode_RegardlessOfParameter(bool useDeviceCodeParam) + { + // Arrange + var tenantId = "12345678-1234-1234-1234-123456789abc"; + var scopes = new[] { "User.Read" }; + var expectedToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature"; + + _executor.ExecuteWithStreamingAsync( + Arg.Any(), + Arg.Is(args => args.Contains("-UseDeviceCode")), // Must ALWAYS be present + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new CommandResult { ExitCode = 0, StandardOutput = expectedToken, StandardError = string.Empty }); + + var provider = new MicrosoftGraphTokenProvider(_executor, _logger); + + // Act + var token = await provider.GetMgGraphAccessTokenAsync(tenantId, scopes, useDeviceCodeParam); + + // Assert + token.Should().Be(expectedToken); + + // Verify -UseDeviceCode is ALWAYS in the PowerShell command, regardless of parameter value + // This ensures device code flow is enforced across the entire CLI + await _executor.Received(1).ExecuteWithStreamingAsync( + Arg.Any(), + Arg.Is(args => args.Contains("-UseDeviceCode")), // CRITICAL: Must always be present + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } }