diff --git a/CHANGELOG.md b/CHANGELOG.md index f2b2eb14..0304b05c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g - `a365 develop get-token --device-code` — forces device code auth for Microsoft Graph scopes the Windows WAM broker rejects (e.g. Exchange `MailboxSettings.ReadWrite`, `ExchangeMessageTrace.Read.All`). ### Fixed +- Cloud-specific Graph, authority, and Agent 365 Tools endpoint overrides now apply consistently across setup, consent, authentication, query, and create-instance flows for sovereign and custom clouds. (#478) - `setup all --authmode s2s` no longer prints spurious "Action Required" PowerShell steps when the agent identity already inherits its app roles from the blueprint, and now retries the grant automatically before falling back to manual steps (#460). - `a365 develop get-token` now falls back to device code when the Windows WAM broker rejects Exchange Graph scopes with `ApiContractViolation`, instead of failing with an opaque MSAL error. - `setup blueprint` now configures the blueprint's inheritable Microsoft Graph permissions even when the signed-in user is not a Global Administrator, no longer aborts with a misleading "Failed to configure inheritable permissions" error when the tenant-wide consent grant cannot be made programmatically, and ends with a setup summary whose Action Required block surfaces the admin-consent URL for non-admins to hand off (#452). diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopCommand.cs index ea02c7dc..ec07e440 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopCommand.cs @@ -146,7 +146,10 @@ private static async Task CallDiscoverToolServersAsync(bool skipAuth, ILog // Resolve az CLI login hint so WAM targets the correct account instead of // defaulting to the first cached MSAL account (which may be stale). var loginHint = await Services.Helpers.AzCliHelper.ResolveLoginHintAsync(); - authToken = await authService.GetAccessTokenAsync(audience, userId: loginHint); + authToken = await authService.GetAccessTokenAsync( + audience, + userId: loginHint, + authorityHost: ConfigConstants.GetAuthorityHost(environment)); if (string.IsNullOrWhiteSpace(authToken)) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs index 39dd414b..187fe254 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs @@ -3,6 +3,7 @@ using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Helpers; +using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Extensions.Logging; using System.CommandLine; @@ -74,6 +75,10 @@ public static Command CreateCommand( var setupConfig = File.Exists(configFile.FullName) ? await configService.LoadAsync(configFile.FullName) : null; + graphApiService.ConfigureCloudEndpoints(setupConfig ?? new Agent365Config + { + Environment = Environment.GetEnvironmentVariable("A365_ENVIRONMENT") ?? "prod" + }); if (setupConfig == null && string.IsNullOrWhiteSpace(appId)) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs index 8b9d21e3..fe6ad674 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs @@ -139,7 +139,7 @@ public static Command CreateCommand( } // Determine environment - var environment = setupConfig?.Environment ?? "prod"; + var environment = ResolveEnvironment(setupConfig); // Resolve resource app ID string resourceAppId; @@ -283,7 +283,10 @@ private static async Task AcquireTokenAsync( forceRefresh, clientAppId, useInteractiveBrowser: !useDeviceCode, - userId: loginHint); + userId: loginHint, + authorityHost: ConfigConstants.GetAuthorityHost( + ResolveEnvironment(setupConfig), + setupConfig?.AuthorityHost)); if (string.IsNullOrWhiteSpace(token)) { @@ -394,7 +397,7 @@ private static async Task AcquireAndDisplayManifestTokensAsync( logger.LogInformation(""); - var tokenAtgAppId = ConfigConstants.GetAgent365ToolsResourceAppId(setupConfig?.Environment ?? "prod"); + var tokenAtgAppId = ConfigConstants.GetAgent365ToolsResourceAppId(ResolveEnvironment(setupConfig)); var scopesByAudience = await ManifestHelper.GetScopesByAudienceAsync(manifestPath, resolvedAtgAppId: tokenAtgAppId); var serverNamesByAudience = await ManifestHelper.GetServerNamesByAudienceAsync(manifestPath, resolvedAtgAppId: tokenAtgAppId); @@ -455,6 +458,11 @@ private static string ResolveClientAppId(string? appId, Agent365Config? setupCon throw new InvalidOperationException("No client application ID specified. Use --app-id or ensure ClientAppId is set in config."); } + private static string ResolveEnvironment(Agent365Config? setupConfig) => + setupConfig?.Environment + ?? Environment.GetEnvironmentVariable("A365_ENVIRONMENT") + ?? "prod"; + private static async Task SaveAndReportTokenAsync( string token, Agent365Config? setupConfig, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs index 46ad66c9..282d9971 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs @@ -27,7 +27,7 @@ public static Command CreateCommand( // Add subcommands for different query types command.AddCommand(CreateBlueprintScopesSubcommand(logger, configService, executor, graphApiService, blueprintService, resolver)); - command.AddCommand(CreateInstanceScopesSubcommand(logger, configService, executor, resolver)); + command.AddCommand(CreateInstanceScopesSubcommand(logger, configService, executor, graphApiService, resolver)); command.AddCommand(CreateInheritanceSubcommand(logger, configService, graphApiService, blueprintService, resolver)); return command; @@ -82,6 +82,7 @@ private static Command CreateInheritanceSubcommand( context.ExitCode = 1; return; } + graphApiService.ConfigureCloudEndpoints(setupConfig); if (string.IsNullOrEmpty(setupConfig.AgentBlueprintId)) { @@ -258,6 +259,7 @@ private static Command CreateBlueprintScopesSubcommand( context.ExitCode = 1; return; } + graphApiService.ConfigureCloudEndpoints(setupConfig); if (string.IsNullOrEmpty(setupConfig.AgentBlueprintId)) { @@ -365,6 +367,7 @@ private static Command CreateInstanceScopesSubcommand( ILogger logger, IConfigService configService, CommandExecutor executor, + GraphApiService graphApiService, IBootstrapConfigResolver? resolver = null) { var command = new Command("instance-scopes", "List configured scopes and consent status for the agent instance"); @@ -407,6 +410,7 @@ private static Command CreateInstanceScopesSubcommand( context.ExitCode = 1; return; } + graphApiService.ConfigureCloudEndpoints(instanceConfig); // Check for agent identity (could be AgentBlueprintId or specific instance identity) string? agenticAppId = null; @@ -476,7 +480,7 @@ private static Command CreateInstanceScopesSubcommand( // Use Microsoft Graph API through Azure CLI to get OAuth2 permission grants var grantsResult = await executor.ExecuteAsync("az", - $"rest --method GET --url \"https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter=clientId eq '{agenticAppId}'\" --output json"); + $"rest --method GET --url \"{graphApiService.GraphBaseUrl}/v1.0/oauth2PermissionGrants?$filter=clientId eq '{agenticAppId}'\" --output json"); // Distinguish "API call failed" (can't read) from "API succeeded but returned no grants". // Non-admin developers lack DelegatedPermissionGrant.Read.All and always get a failure here — @@ -501,7 +505,7 @@ private static Command CreateInstanceScopesSubcommand( // Get the resource display name using Graph API var resourceResult = await executor.ExecuteAsync("az", - $"rest --method GET --url \"https://graph.microsoft.com/v1.0/servicePrincipals/{resourceId}?$select=displayName,appId\" --output json"); + $"rest --method GET --url \"{graphApiService.GraphBaseUrl}/v1.0/servicePrincipals/{resourceId}?$select=displayName,appId\" --output json"); string resourceName = "Unknown Resource"; string resourceAppId = "Unknown"; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs index 0e94cc98..551b15db 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs @@ -407,8 +407,7 @@ effectiveAuthModeForValidation is not ("obo" or "s2s" or "both")) } // Build SetupContext for non-DW blueprint and delegate to orchestrator. - if (!string.IsNullOrWhiteSpace(nonDwConfig.ClientAppId)) - graphApiService.CustomClientAppId = nonDwConfig.ClientAppId; + graphApiService.ConfigureCloudEndpoints(nonDwConfig); var nonDwGeneratedConfigPath = Path.Combine( config.DirectoryName ?? Environment.CurrentDirectory, @@ -526,12 +525,7 @@ effectiveAuthModeForValidation is not ("obo" or "s2s" or "both")) } } - // Configure GraphApiService with custom client app ID if available - // This ensures inheritable permissions operations use the validated custom app - if (!string.IsNullOrWhiteSpace(setupConfig.ClientAppId)) - { - graphApiService.CustomClientAppId = setupConfig.ClientAppId; - } + graphApiService.ConfigureCloudEndpoints(setupConfig); setupResults.PrerequisitesSkipped = skipRequirements; setupResults.InfrastructureSkipped = true; @@ -627,7 +621,7 @@ await ExecuteBatchPermissionsStepAsync( // Display verification URLs and setup summary await SetupHelpers.DisplayVerificationInfoAsync(config, logger); logger.LogInformation(""); - SetupHelpers.DisplaySetupSummary(setupResults, logger); + SetupHelpers.DisplaySetupSummary(setupResults, logger, graphApiService.GraphBaseUrl); } catch (Agent365Exception ex) { @@ -635,7 +629,7 @@ await ExecuteBatchPermissionsStepAsync( ExceptionHandler.HandleAgent365Exception(ex, logFilePath: logFilePath); setupResults.Errors.Add(ex.Message); logger.LogInformation(""); - SetupHelpers.DisplaySetupSummary(setupResults, logger); + SetupHelpers.DisplaySetupSummary(setupResults, logger, graphApiService.GraphBaseUrl); ExceptionHandler.ExitWithCleanup(1); } catch (FileNotFoundException fnfEx) @@ -643,7 +637,7 @@ await ExecuteBatchPermissionsStepAsync( logger.LogError("Setup failed: {Message}", fnfEx.Message); setupResults.Errors.Add(fnfEx.Message); logger.LogInformation(""); - SetupHelpers.DisplaySetupSummary(setupResults, logger); + SetupHelpers.DisplaySetupSummary(setupResults, logger, graphApiService.GraphBaseUrl); ExceptionHandler.ExitWithCleanup(1); } catch (OperationCanceledException) @@ -657,7 +651,7 @@ await ExecuteBatchPermissionsStepAsync( logger.LogError(ex, "Setup failed: {Message}", ex.Message); setupResults.Errors.Add(ex.Message); logger.LogInformation(""); - SetupHelpers.DisplaySetupSummary(setupResults, logger); + SetupHelpers.DisplaySetupSummary(setupResults, logger, graphApiService.GraphBaseUrl); throw; } }); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AzRestConsentRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AzRestConsentRunner.cs index 5baa5e09..88c1d66b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AzRestConsentRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AzRestConsentRunner.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Extensions.Logging; using System.Text.Json; @@ -65,7 +66,8 @@ internal static partial class AzRestConsentRunner string blueprintSpObjectId, IReadOnlyList specs, ILogger logger, - CancellationToken ct) + CancellationToken ct, + string graphBaseUrl = GraphApiConstants.BaseUrl) { if (!GuidPattern().IsMatch(blueprintSpObjectId)) { @@ -99,6 +101,10 @@ internal static partial class AzRestConsentRunner } } + // Resolve the Graph base URL once so every az rest call targets the configured + // (sovereign / commercial) cloud endpoint rather than a hardcoded commercial host. + var baseUrl = ConfigConstants.NormalizeGraphBaseUrl(graphBaseUrl); + logger.LogInformation("Granting delegated admin consent..."); var allOk = true; @@ -107,7 +113,7 @@ internal static partial class AzRestConsentRunner ct.ThrowIfCancellationRequested(); try { - var ok = await GrantOneAsync(executor, blueprintSpObjectId, spec, logger, ct); + var ok = await GrantOneAsync(executor, blueprintSpObjectId, spec, baseUrl, logger, ct); if (!ok) allOk = false; } catch (OperationCanceledException) @@ -132,13 +138,14 @@ private static async Task GrantOneAsync( CommandExecutor executor, string blueprintSpObjectId, ResourcePermissionSpec spec, + string graphBaseUrl, ILogger logger, CancellationToken ct) { // 1. Resolve the resource SP object id. var resourceSpResult = await executor.ExecuteAsync( "az", - $"rest --method GET --url \"https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq '{spec.ResourceAppId}'&$select=id\"", + $"rest --method GET --url \"{graphBaseUrl}/v1.0/servicePrincipals?$filter=appId eq '{spec.ResourceAppId}'&$select=id\"", captureOutput: true, suppressErrorLogging: true, cancellationToken: ct); @@ -166,7 +173,7 @@ private static async Task GrantOneAsync( // un-created. Filter on consentType to be precise. var grantQueryResult = await executor.ExecuteAsync( "az", - $"rest --method GET --url \"https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter=clientId eq '{blueprintSpObjectId}' and resourceId eq '{resourceSpId}' and consentType eq 'AllPrincipals'\"", + $"rest --method GET --url \"{graphBaseUrl}/v1.0/oauth2PermissionGrants?$filter=clientId eq '{blueprintSpObjectId}' and resourceId eq '{resourceSpId}' and consentType eq 'AllPrincipals'\"", captureOutput: true, suppressErrorLogging: true, cancellationToken: ct); @@ -200,7 +207,7 @@ private static async Task GrantOneAsync( var patched = await ExecuteAzRestWithBodyAsync( executor, method: "PATCH", - url: $"https://graph.microsoft.com/v1.0/oauth2PermissionGrants/{existingGrantId}", + url: $"{graphBaseUrl}/v1.0/oauth2PermissionGrants/{existingGrantId}", bodyJson: patchBody, logger: logger, ct: ct); @@ -224,7 +231,7 @@ private static async Task GrantOneAsync( var created = await ExecuteAzRestWithBodyAsync( executor, method: "POST", - url: "https://graph.microsoft.com/v1.0/oauth2PermissionGrants", + url: $"{graphBaseUrl}/v1.0/oauth2PermissionGrants", bodyJson: createBody, logger: logger, ct: ct); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AzRestS2SRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AzRestS2SRunner.cs index fe5134e9..2bc5be21 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AzRestS2SRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AzRestS2SRunner.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Extensions.Logging; using System.Text.Json; @@ -52,7 +53,8 @@ internal static partial class AzRestS2SRunner string blueprintSpObjectId, IReadOnlyList specs, ILogger logger, - CancellationToken ct) + CancellationToken ct, + string graphBaseUrl = GraphApiConstants.BaseUrl) { if (!GuidPattern().IsMatch(blueprintSpObjectId)) { @@ -85,13 +87,17 @@ internal static partial class AzRestS2SRunner } } + // Resolve the Graph base URL once so every az rest call targets the configured + // (sovereign / commercial) cloud endpoint rather than a hardcoded commercial host. + var baseUrl = ConfigConstants.NormalizeGraphBaseUrl(graphBaseUrl); + logger.LogInformation("Assigning S2S app roles..."); var allOk = true; // Fetch the existing assignment list once at the top — every per-role idempotency // check then compares against this in-memory set, avoiding N+1 Graph round-trips. - var existingAssignments = await GetExistingAssignmentsAsync(executor, blueprintSpObjectId, logger, ct); + var existingAssignments = await GetExistingAssignmentsAsync(executor, blueprintSpObjectId, baseUrl, logger, ct); if (existingAssignments is null) { // The GET itself failed; that's a hard stop because we can't reason about @@ -104,7 +110,7 @@ internal static partial class AzRestS2SRunner ct.ThrowIfCancellationRequested(); try { - var ok = await AssignOneAsync(executor, blueprintSpObjectId, spec, existingAssignments, logger, ct); + var ok = await AssignOneAsync(executor, blueprintSpObjectId, spec, existingAssignments, baseUrl, logger, ct); if (!ok) allOk = false; } catch (OperationCanceledException) @@ -132,12 +138,13 @@ private static async Task AssignOneAsync( string blueprintSpObjectId, ResourcePermissionSpec spec, HashSet<(string ResourceId, string AppRoleId)> existingAssignments, + string graphBaseUrl, ILogger logger, CancellationToken ct) { var spResult = await executor.ExecuteAsync( "az", - $"rest --method GET --url \"https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq '{spec.ResourceAppId}'&$select=id,appRoles\"", + $"rest --method GET --url \"{graphBaseUrl}/v1.0/servicePrincipals?$filter=appId eq '{spec.ResourceAppId}'&$select=id,appRoles\"", captureOutput: true, suppressErrorLogging: true, cancellationToken: ct); @@ -182,7 +189,7 @@ private static async Task AssignOneAsync( var created = await ExecuteAzRestWithBodyAsync( executor, method: "POST", - url: $"https://graph.microsoft.com/v1.0/servicePrincipals/{blueprintSpObjectId}/appRoleAssignments", + url: $"{graphBaseUrl}/v1.0/servicePrincipals/{blueprintSpObjectId}/appRoleAssignments", bodyJson: createBody, logger: logger, ct: ct); @@ -212,12 +219,13 @@ private static async Task AssignOneAsync( private static async Task?> GetExistingAssignmentsAsync( CommandExecutor executor, string blueprintSpObjectId, + string graphBaseUrl, ILogger logger, CancellationToken ct) { var result = await executor.ExecuteAsync( "az", - $"rest --method GET --url \"https://graph.microsoft.com/v1.0/servicePrincipals/{blueprintSpObjectId}/appRoleAssignments\"", + $"rest --method GET --url \"{graphBaseUrl}/v1.0/servicePrincipals/{blueprintSpObjectId}/appRoleAssignments\"", captureOutput: true, suppressErrorLogging: true, cancellationToken: ct); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs index 1d6124fb..04a1ce08 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs @@ -238,7 +238,7 @@ internal static class BatchPermissionsOrchestrator { logger.LogDebug("S2S app role assignments could not be completed via the Graph API; falling back to az rest."); var (attempted, succeeded) = await AzRestS2SRunner.TryRunAsync( - commandExecutor, phase1Result.BlueprintSpObjectId, specs, logger, ct); + commandExecutor, phase1Result.BlueprintSpObjectId, specs, logger, ct, graph.GraphBaseUrl); if (attempted && succeeded) { logger.LogInformation("Application permissions granted."); @@ -472,8 +472,12 @@ private static async Task UpdateBlueprintPermissions /// emit identical scope identifiers (e.g. https://agent365.svc.cloud.microsoft/Tools.Execute, /// not api://{appId}/Tools.Execute). /// - private static string BuildFullyQualifiedScope(string resourceAppId, string scope, bool isMcpAudience = false) - => SetupHelpers.BuildFullyQualifiedScope(resourceAppId, scope, isMcpAudience); + private static string BuildFullyQualifiedScope( + string resourceAppId, string scope, bool isMcpAudience = false, + string graphResourceUri = AuthenticationConstants.MicrosoftGraphResourceUri, + string? sharedMcpResourceAppId = null) + => SetupHelpers.BuildFullyQualifiedScope( + resourceAppId, scope, isMcpAudience, graphResourceUri, sharedMcpResourceAppId); /// /// Grants S2S app role assignments for all specs that carry . @@ -656,16 +660,19 @@ await EnsureMissingResourceSpsAsync( ? specs.Where(s => resolvedSpAppIds.Contains(s.ResourceAppId)).ToList() : specs.ToList(); + var sharedMcpResourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); var allScopes = specsForUrl .Where(s => s.Scopes is { Length: > 0 }) .SelectMany(s => s.Scopes.Select(scope => BuildFullyQualifiedScope( s.ResourceAppId, scope, - isMcpAudience: knownMcpAudienceAppIds?.Contains(s.ResourceAppId) ?? false))) + isMcpAudience: knownMcpAudienceAppIds?.Contains(s.ResourceAppId) ?? false, + graphResourceUri: graph.GraphBaseUrl, + sharedMcpResourceAppId: sharedMcpResourceAppId))) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); string? consentUrl = allScopes.Count > 0 - ? SetupHelpers.BuildAdminConsentUrl(tenantId, blueprintAppId, allScopes) + ? SetupHelpers.BuildAdminConsentUrl(tenantId, blueprintAppId, allScopes, graph.AuthorityHost) : null; // No delegated scopes to consent at all — nothing to do. The caller still surfaces @@ -728,7 +735,8 @@ await EnsureMissingResourceSpsAsync( ct, consentType: "AllPrincipals", blueprintSpObjectId: phase1Result.BlueprintSpObjectId, - resourceSpObjectId: resourceSpId); + resourceSpObjectId: resourceSpId, + graphBaseUrl: graph.GraphBaseUrl); } else { @@ -802,7 +810,8 @@ await EnsureMissingResourceSpsAsync( // longer holds since PR #409 removed that scope from the CLI client app registration. var found = await AdminConsentHelper.PollAdminConsentAsync( commandExecutor, logger, blueprintAppId, - "All permissions", timeoutSeconds: 180, intervalSeconds: 5, ct); + "All permissions", timeoutSeconds: 180, intervalSeconds: 5, ct, + graphBaseUrl: graph.GraphBaseUrl); consentVerified = found; // Browser was opened regardless — either the grant was directly observed (Verified) // or the timeout elapsed without observing it (AssumedComplete). Either way, setup @@ -877,7 +886,7 @@ await EnsureMissingResourceSpsAsync( else { var (attempted, succeeded) = await AzRestConsentRunner.TryRunAsync( - commandExecutor, p.BlueprintSpObjectId, originalSpecs, logger, ct); + commandExecutor, p.BlueprintSpObjectId, originalSpecs, logger, ct, graph.GraphBaseUrl); if (attempted && succeeded) { logger.LogInformation("Delegated admin consent granted."); @@ -1054,7 +1063,7 @@ internal static async Task EnsureMissingResourceSpsAsync( "{Count} resource(s) require service principal provisioning. Auto-provisioning is disabled; steps will be listed in the setup summary.", stillMissing.Count); foreach (var spec in stillMissing) - RecordMissingSpAction(spec, tenantId, blueprintAppId, logger, setupResults, knownMcpAudienceAppIds); + RecordMissingSpAction(spec, tenantId, blueprintAppId, logger, setupResults, knownMcpAudienceAppIds, graph.AuthorityHost); return; } @@ -1102,7 +1111,7 @@ internal static async Task EnsureMissingResourceSpsAsync( logger.LogWarning( "{Idx}. {Name} ({AppId}): skipping — resource app id is not a valid GUID.", i + 1, spec.ResourceName, spec.ResourceAppId); - RecordMissingSpAction(spec, tenantId, blueprintAppId, logger, setupResults, knownMcpAudienceAppIds); + RecordMissingSpAction(spec, tenantId, blueprintAppId, logger, setupResults, knownMcpAudienceAppIds, graph.AuthorityHost); continue; } @@ -1120,7 +1129,7 @@ internal static async Task EnsureMissingResourceSpsAsync( if (!shouldProvision) { logger.LogInformation("Skipped."); - RecordMissingSpAction(spec, tenantId, blueprintAppId, logger, setupResults, knownMcpAudienceAppIds); + RecordMissingSpAction(spec, tenantId, blueprintAppId, logger, setupResults, knownMcpAudienceAppIds, graph.AuthorityHost); continue; } @@ -1136,7 +1145,7 @@ internal static async Task EnsureMissingResourceSpsAsync( { var stderr = string.IsNullOrWhiteSpace(azResult.StandardError) ? azResult.StandardOutput : azResult.StandardError; logger.LogWarning("Failed: {Error}", (stderr ?? string.Empty).Trim()); - RecordMissingSpAction(spec, tenantId, blueprintAppId, logger, setupResults, knownMcpAudienceAppIds); + RecordMissingSpAction(spec, tenantId, blueprintAppId, logger, setupResults, knownMcpAudienceAppIds, graph.AuthorityHost); continue; } @@ -1159,7 +1168,7 @@ internal static async Task EnsureMissingResourceSpsAsync( logger.LogWarning( "az exited 0 but the output did not contain a service principal id. Output: {Output}", (azResult.StandardOutput ?? string.Empty).Trim()); - RecordMissingSpAction(spec, tenantId, blueprintAppId, logger, setupResults, knownMcpAudienceAppIds); + RecordMissingSpAction(spec, tenantId, blueprintAppId, logger, setupResults, knownMcpAudienceAppIds, graph.AuthorityHost); } } } @@ -1238,7 +1247,8 @@ private static void RecordMissingSpAction( string blueprintAppId, ILogger logger, SetupResults? setupResults, - IReadOnlyCollection? knownMcpAudienceAppIds = null) + IReadOnlyCollection? knownMcpAudienceAppIds = null, + string? authorityHost = null) { _ = logger; // intentionally unused — caller already emits a one-line inline marker // ("Skipped." / "Failed: " / "...invalid GUID...") immediately @@ -1248,7 +1258,7 @@ private static void RecordMissingSpAction( var azCommand = BuildAzAdSpCreateCommand(spec.ResourceAppId); var isMcpAudience = knownMcpAudienceAppIds?.Contains(spec.ResourceAppId) ?? false; - var perSpConsentUrl = BuildPerSpBlueprintConsentUrl(tenantId, blueprintAppId, spec, isMcpAudience); + var perSpConsentUrl = BuildPerSpBlueprintConsentUrl(tenantId, blueprintAppId, spec, isMcpAudience, authorityHost); setupResults?.MissingSpActions.Add(new MissingSpAction( ResourceName: spec.ResourceName, @@ -1270,14 +1280,16 @@ internal static string BuildPerSpBlueprintConsentUrl( string tenantId, string blueprintAppId, ResourcePermissionSpec spec, - bool isMcpAudience = false) + bool isMcpAudience = false, + string? authorityHost = null) { var scopes = spec.Scopes ?? Array.Empty(); var fullyQualified = scopes .Select(s => $"{GetResourceUriForBlueprintConsent(spec.ResourceAppId, isMcpAudience)}/{s}"); var scopeParam = string.Join("%20", fullyQualified.Select(Uri.EscapeDataString)); var redirectEncoded = Uri.EscapeDataString(AuthenticationConstants.BlueprintConsentRedirectUri); - return $"https://login.microsoftonline.com/{tenantId}/v2.0/adminconsent" + + var consentBaseUrl = ConfigConstants.BuildAdminConsentEndpointUrl(authorityHost, tenantId); + return consentBaseUrl + $"?client_id={blueprintAppId}" + $"&scope={scopeParam}" + $"&redirect_uri={redirectEncoded}" + 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 0a9c6a2f..e84dcadf 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs @@ -111,7 +111,6 @@ internal static class BlueprintSubcommand } private const int ClientSecretValidationRetryDelayMs = 1000; private const int ClientSecretValidationTimeoutSeconds = 10; - private const string MicrosoftLoginOAuthTokenEndpoint = "https://login.microsoftonline.com/{0}/oauth2/v2.0/token"; public static Command CreateCommand( ILogger logger, @@ -354,14 +353,7 @@ public static Command CreateCommand( // Configure GraphApiService with custom client app ID if available // This ensures inheritable permissions operations use the validated custom app - if (!string.IsNullOrWhiteSpace(setupConfig.ClientAppId)) - { - graphApiService.CustomClientAppId = setupConfig.ClientAppId; - } - - // Wire the sovereign/government cloud base URL from config so all Graph calls - // target the correct national cloud endpoint (commercial by default). - graphApiService.GraphBaseUrl = setupConfig.GraphBaseUrl; + graphApiService.ConfigureCloudEndpoints(setupConfig); // Handle --update-endpoint flag (--m365 is inferred for endpoint operations). if (!string.IsNullOrWhiteSpace(updateEndpoint)) @@ -560,13 +552,12 @@ public static async Task CreateBlueprintImplementationA // Create required services. // Pass the caller's logger so consent messages appear in the correct indent scope. var cleanLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(); + var delegatedGraphService = new GraphApiService( + cleanLoggerFactory.CreateLogger(), executor, + new AuthenticationService(cleanLoggerFactory.CreateLogger())); + delegatedGraphService.ConfigureCloudEndpoints(setupConfig); var delegatedConsentService = new DelegatedConsentService( - logger, - new GraphApiService( - cleanLoggerFactory.CreateLogger(), - executor, - new AuthenticationService(cleanLoggerFactory.CreateLogger()), - graphBaseUrl: setupConfig.GraphBaseUrl)); + logger, delegatedGraphService); // Use DI-provided GraphApiService which already has MicrosoftGraphTokenProvider configured var graphService = graphApiService; @@ -681,6 +672,7 @@ public static async Task CreateBlueprintImplementationA setupConfig.AgentBlueprintClientSecret, setupConfig.AgentBlueprintClientSecretProtected, setupConfig.TenantId!, + graphService, logger, cancellationToken); @@ -776,7 +768,7 @@ await PermissionsSubcommand.ConfigureCustomPermissionsAsync( GraphInheritablePermissionsError = blueprintResult.graphInheritablePermissionsError, FederatedCredentialError = blueprintResult.ficError, }; - SetupHelpers.DisplaySetupSummary(summary, logger); + SetupHelpers.DisplaySetupSummary(summary, logger, graphApiService.GraphBaseUrl); } return new BlueprintCreationResult @@ -953,7 +945,8 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( { using var spHttpClient = Services.Internal.HttpClientFactory.CreateAuthenticatedClient(spToken); var spRetryHelper = new Services.Helpers.RetryHelper(logger); - existingServicePrincipalId = await CreateServicePrincipalAsync(existingAppId, spHttpClient, spRetryHelper, logger, ct); + existingServicePrincipalId = await CreateServicePrincipalAsync( + existingAppId, spHttpClient, spRetryHelper, logger, graphApiService.GraphBaseUrl, ct); if (!string.IsNullOrWhiteSpace(existingServicePrincipalId)) { requiresPersistence = true; @@ -976,7 +969,7 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( { if (spAuthDenied) return true; using var checkResp = await spHttpClient.GetAsync( - $"{Constants.GraphApiConstants.BaseUrl}/v1.0/oauth2PermissionGrants?$filter=clientId eq '{existingServicePrincipalId}'", token); + $"{graphApiService.GraphBaseUrl}/v1.0/oauth2PermissionGrants?$filter=clientId eq '{existingServicePrincipalId}'", token); if (checkResp.StatusCode == System.Net.HttpStatusCode.Forbidden) { spAuthDenied = true; @@ -1090,7 +1083,7 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( { sponsorUserId = me.Id; logger.LogInformation("Current user: {DisplayName} <{UPN}>", me.DisplayName, me.UserPrincipalName); - logger.LogDebug("Sponsor: {BaseUrl}/v1.0/users/{UserId}", Constants.GraphApiConstants.BaseUrl, sponsorUserId); + logger.LogDebug("Sponsor: {BaseUrl}/v1.0/users/{UserId}", graphApiService.GraphBaseUrl, sponsorUserId); } } catch (Exception ex) @@ -1114,11 +1107,11 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( { appManifest["sponsors@odata.bind"] = new JsonArray { - $"{Constants.GraphApiConstants.BaseUrl}/v1.0/users/{sponsorUserId}" + $"{graphApiService.GraphBaseUrl}/v1.0/users/{sponsorUserId}" }; appManifest["owners@odata.bind"] = new JsonArray { - $"{Constants.GraphApiConstants.BaseUrl}/v1.0/users/{sponsorUserId}" + $"{graphApiService.GraphBaseUrl}/v1.0/users/{sponsorUserId}" }; } @@ -1134,7 +1127,9 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( logger.LogDebug("Acquiring blueprint httpClient token — scope: AgentIdentityBlueprintPrincipal.Create, loginHint: {LoginHint}", blueprintLoginHint ?? "(none)"); var graphToken = await AcquireMsalGraphTokenAsync(tenantId, setupConfig.ClientAppId, logger, ct, scope: AuthenticationConstants.AgentIdentityBlueprintPrincipalCreateScope, - loginHint: blueprintLoginHint); + loginHint: blueprintLoginHint, + graphBaseUrl: graphApiService.GraphBaseUrl, + authorityHost: graphApiService.AuthorityHost); if (string.IsNullOrEmpty(graphToken)) { logger.LogError("Failed to extract access token from Graph client"); @@ -1146,7 +1141,7 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( httpClient.DefaultRequestHeaders.Add("ConsistencyLevel", "eventual"); httpClient.DefaultRequestHeaders.Add("OData-Version", "4.0"); // Required for @odata.type - var createAppUrl = $"{Constants.GraphApiConstants.BaseUrl}/beta/applications"; + var createAppUrl = $"{graphApiService.GraphBaseUrl}/beta/applications"; logger.LogInformation("Display Name: {DisplayName}", displayName); if (!string.IsNullOrEmpty(sponsorUserId)) @@ -1239,7 +1234,7 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( var appAvailable = await retryHelper.ExecuteWithRetryAsync( async ct => { - var checkResp = await httpClient.GetAsync($"{Constants.GraphApiConstants.BaseUrl}/v1.0/applications/{objectId}", ct); + var checkResp = await httpClient.GetAsync($"{graphApiService.GraphBaseUrl}/v1.0/applications/{objectId}", ct); return checkResp.IsSuccessStatusCode; }, result => !result, @@ -1258,7 +1253,7 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( // Update application with identifier URI and expose the access_agent_as_user scope // so callers can acquire tokens scoped to this blueprint via the OBO flow. var identifierUri = $"api://{appId}"; - var patchAppUrl = $"{Constants.GraphApiConstants.BaseUrl}/v1.0/applications/{objectId}"; + var patchAppUrl = $"{graphApiService.GraphBaseUrl}/v1.0/applications/{objectId}"; var patchBody = new JsonObject { ["identifierUris"] = new JsonArray { identifierUri }, @@ -1354,7 +1349,8 @@ await retryHelper.ExecuteWithRetryAsync( // objectId. Retry with backoff until the appId index is replicated. logger.LogInformation(""); logger.LogInformation("Creating blueprint service principal..."); - string? servicePrincipalId = await CreateServicePrincipalAsync(appId, httpClient, retryHelper, logger, ct); + string? servicePrincipalId = await CreateServicePrincipalAsync( + appId, httpClient, retryHelper, logger, graphApiService.GraphBaseUrl, ct); if (string.IsNullOrWhiteSpace(servicePrincipalId)) { logger.LogError("Service principal creation failed after retries"); @@ -1465,9 +1461,10 @@ await retryHelper.ExecuteWithRetryAsync( HttpClient httpClient, Services.Helpers.RetryHelper retryHelper, ILogger logger, + string graphBaseUrl, CancellationToken ct) { - var createSpUrl = $"{Constants.GraphApiConstants.BaseUrl}/v1.0/serviceprincipals/graph.agentIdentityBlueprintPrincipal"; + var createSpUrl = $"{graphBaseUrl}/v1.0/serviceprincipals/graph.agentIdentityBlueprintPrincipal"; var spManifestJson = new JsonObject { ["appId"] = appId }.ToJsonString(); int forbiddenRetries = 0; const int maxForbiddenRetries = 3; @@ -1598,7 +1595,7 @@ await retryHelper.ExecuteWithRetryAsync( { var ownerPayload = new Dictionary { - ["@odata.id"] = $"{Constants.GraphApiConstants.BaseUrl}/v1.0/users/{currentUserObjectId}" + ["@odata.id"] = $"{graphApiService.GraphBaseUrl}/v1.0/users/{currentUserObjectId}" }; var ownerResponse = await graphApiService.GraphPostWithResponseAsync( @@ -1665,7 +1662,7 @@ await retryHelper.ExecuteWithRetryAsync( tenantId, objectId, credentialName, - $"https://login.microsoftonline.com/{tenantId}/v2.0", + $"{graphApiService.AuthorityHost}/{tenantId}/v2.0", managedIdentityPrincipalId, new List { "api://AzureADTokenExchange" }, ct); @@ -1907,7 +1904,8 @@ private static List GetApplicationScopes(Models.Agent365Config setupConf // Build the reference/handoff URL up front so it is available even if the orchestrator throws. var consentUrlGraph = SetupHelpers.BuildAdminConsentUrl( tenantId, appId, - applicationScopes.Select(s => $"{AuthenticationConstants.MicrosoftGraphResourceUri}/{s}")); + applicationScopes.Select(s => $"{graphApiService.GraphBaseUrl}/{s}"), + graphApiService.AuthorityHost); bool consentSuccess; bool inheritedConfigured; @@ -1964,7 +1962,10 @@ await BatchPermissionsOrchestrator.ConfigureAllPermissionsAsync( /// rejected by the Agent Blueprint API. Defaults to .default (all consented permissions). /// Pass loginHint so WAM targets the az-logged-in user rather than the OS default account. /// - private static async Task AcquireMsalGraphTokenAsync(string tenantId, string clientAppId, ILogger logger, CancellationToken ct = default, string? scope = null, string? loginHint = null, string[]? additionalScopes = null) + private static async Task AcquireMsalGraphTokenAsync( + string tenantId, string clientAppId, ILogger logger, CancellationToken ct = default, + string? scope = null, string? loginHint = null, string[]? additionalScopes = null, + string? graphBaseUrl = null, string? authorityHost = null) { // Guard: MSAL will fail (and block for ~30s on WAM) with empty credentials. if (string.IsNullOrWhiteSpace(clientAppId) || string.IsNullOrWhiteSpace(tenantId)) @@ -1975,19 +1976,22 @@ await BatchPermissionsOrchestrator.ConfigureAllPermissionsAsync( try { + var resolvedGraphBaseUrl = ConfigConstants.NormalizeGraphBaseUrl(graphBaseUrl); + var resolvedAuthorityHost = ConfigConstants.NormalizeAuthorityHost(authorityHost); var credential = new MsalBrowserCredential( clientAppId, tenantId, redirectUri: null, // Let MsalBrowserCredential use WAM on Windows logger, + authority: $"{resolvedAuthorityHost}/{tenantId}", loginHint: loginHint); var primaryScope = string.IsNullOrWhiteSpace(scope) - ? $"{Constants.GraphApiConstants.BaseUrl}/.default" - : $"{Constants.GraphApiConstants.BaseUrl}/{scope}"; + ? $"{resolvedGraphBaseUrl}/.default" + : $"{resolvedGraphBaseUrl}/{scope}"; var allScopes = additionalScopes?.Length > 0 - ? new[] { primaryScope }.Concat(additionalScopes.Select(s => $"{Constants.GraphApiConstants.BaseUrl}/{s}")).ToArray() + ? new[] { primaryScope }.Concat(additionalScopes.Select(s => $"{resolvedGraphBaseUrl}/{s}")).ToArray() : new[] { primaryScope }; var tokenRequestContext = new TokenRequestContext(allScopes); @@ -2039,7 +2043,9 @@ private async static Task GetAuthenticatedGraphClientAsync(I // Pass the caller's logger so messages appear in the correct indent scope. var interactiveAuth = new InteractiveGraphAuthService( logger, - setupConfig.ClientAppId); + setupConfig.ClientAppId, + graphBaseUrl: ConfigConstants.GetGraphBaseUrl(setupConfig.Environment, setupConfig.GraphBaseUrl), + authorityHost: ConfigConstants.GetAuthorityHost(setupConfig.Environment, setupConfig.AuthorityHost)); try { @@ -2103,7 +2109,9 @@ public static async Task CreateBlueprintClientSecretAsync( setupConfig.ClientAppId ?? string.Empty, logger, ct, scope: AuthenticationConstants.AgentIdentityBlueprintReadWriteAllScope, - loginHint: loginHint); + loginHint: loginHint, + graphBaseUrl: graphService.GraphBaseUrl, + authorityHost: graphService.AuthorityHost); if (string.IsNullOrWhiteSpace(graphToken)) { @@ -2122,7 +2130,7 @@ public static async Task CreateBlueprintClientSecretAsync( } }; - var addPasswordUrl = $"{Constants.GraphApiConstants.BaseUrl}/v1.0/applications/{blueprintObjectId}/addPassword"; + var addPasswordUrl = $"{graphService.GraphBaseUrl}/v1.0/applications/{blueprintObjectId}/addPassword"; var secretBodyJson = secretBody.ToJsonString(); // Retry on 404 (blueprint not yet visible on all replicas) and transient 403 (owner @@ -2232,6 +2240,7 @@ private static async Task ValidateClientSecretAsync( string clientSecret, bool isProtected, string tenantId, + GraphApiService graphService, ILogger logger, CancellationToken ct = default) { @@ -2245,7 +2254,7 @@ private static async Task ValidateClientSecretAsync( using var httpClient = new HttpClient(); httpClient.Timeout = TimeSpan.FromSeconds(ClientSecretValidationTimeoutSeconds); - var tokenUrl = string.Format(MicrosoftLoginOAuthTokenEndpoint, tenantId); + var tokenUrl = ConfigConstants.BuildTokenEndpointUrl(graphService.AuthorityHost, tenantId); for (int attempt = 1; attempt <= ClientSecretValidationMaxRetries; attempt++) { @@ -2255,7 +2264,7 @@ private static async Task ValidateClientSecretAsync( { ["client_id"] = clientId, ["client_secret"] = plaintextSecret, - ["scope"] = $"{Constants.GraphApiConstants.BaseUrl}/.default", + ["scope"] = $"{graphService.GraphBaseUrl}/.default", ["grant_type"] = "client_credentials" }); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/CopilotStudioSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/CopilotStudioSubcommand.cs index 6e9a96ed..714ad3d1 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/CopilotStudioSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/CopilotStudioSubcommand.cs @@ -99,10 +99,7 @@ public static Command CreateCommand( } // Configure GraphApiService with custom client app ID if available - if (!string.IsNullOrWhiteSpace(setupConfig.ClientAppId)) - { - graphApiService.CustomClientAppId = setupConfig.ClientAppId; - } + graphApiService.ConfigureCloudEndpoints(setupConfig); // Verify system requirements (PowerShell modules are required for Graph operations). // Skipped in dry-run: PowerShellModulesRequirementCheck can auto-install modules, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs index 9a6150cc..078ad139 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs @@ -217,7 +217,7 @@ private static async Task EnsureConsentWithPromptAsync(SetupContext ctx) if (roleCheck == Models.RoleCheckResult.DoesNotHaveRole) { ctx.Logger.LogWarning("Granting tenant-wide consent requires a tenant administrator. Setup will continue and may fail if these permissions are required at runtime."); - var url = Exceptions.ClientAppValidationException.BuildAdminConsentUrl(clientAppId, tenantId); + var url = Exceptions.ClientAppValidationException.BuildAdminConsentUrl(clientAppId, tenantId, ctx.GraphApiService.AuthorityHost); if (!string.IsNullOrWhiteSpace(url)) { ctx.Logger.LogInformation("Share the following URL with a tenant administrator so they can grant consent:"); @@ -416,7 +416,7 @@ await AllSubcommand.ExecuteBatchPermissionsStepAsync( // IsNonDwBlueprintFlow=true was set at the top of this method; DisplaySetupSummary reads that // flag directly to pick the non-DW step layout and action-required content. ctx.Logger.LogInformation(""); - SetupHelpers.DisplaySetupSummary(ctx.Results, ctx.Logger); + SetupHelpers.DisplaySetupSummary(ctx.Results, ctx.Logger, ctx.GraphApiService.GraphBaseUrl); return ctx.Results.HasErrors ? 1 : 0; } @@ -739,7 +739,7 @@ internal static async Task GrantOrInstructAgentIdentityAppPermissionsAsync( // Issue #460: Graph token lacks AppRoleAssignment.ReadWrite.All; retry via az rest (a GA's az token carries it) before PowerShell. ctx.Logger.LogDebug("S2S app role assignments on the agent identity could not be completed via the Graph API; falling back to az rest."); var (attempted, succeeded) = await AzRestS2SRunner.TryRunAsync( - ctx.Executor, agentIdentitySpObjectId, failedSpecs, ctx.Logger, ctx.CancellationToken); + ctx.Executor, agentIdentitySpObjectId, failedSpecs, ctx.Logger, ctx.CancellationToken, ctx.GraphApiService.GraphBaseUrl); if (attempted && succeeded) { using (ctx.Logger.Indent()) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs index c1b4aaea..ff257c3b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs @@ -196,11 +196,7 @@ private static Command CreateMcpSubcommand( return; } - // Configure GraphApiService with custom client app ID if available - if (!string.IsNullOrWhiteSpace(setupConfig.ClientAppId)) - { - graphApiService.CustomClientAppId = setupConfig.ClientAppId; - } + graphApiService.ConfigureCloudEndpoints(setupConfig); // Verify system requirements (PowerShell modules are required for Graph operations). // Skipped in dry-run: PowerShellModulesRequirementCheck can auto-install modules, @@ -329,11 +325,7 @@ private static Command CreateBotSubcommand( return; } - // Configure GraphApiService with custom client app ID if available - if (!string.IsNullOrWhiteSpace(setupConfig.ClientAppId)) - { - graphApiService.CustomClientAppId = setupConfig.ClientAppId; - } + graphApiService.ConfigureCloudEndpoints(setupConfig); // Verify system requirements (PowerShell modules are required for Graph operations). // Skipped in dry-run: PowerShellModulesRequirementCheck can auto-install modules, @@ -513,11 +505,7 @@ private static Command CreateCustomSubcommand( return; } - // Configure GraphApiService with custom client app ID if available - if (!string.IsNullOrWhiteSpace(setupConfig.ClientAppId)) - { - graphApiService.CustomClientAppId = setupConfig.ClientAppId; - } + graphApiService.ConfigureCloudEndpoints(setupConfig); // Verify system requirements (PowerShell modules are required for Graph operations). // Skipped in dry-run: PowerShellModulesRequirementCheck can auto-install modules, @@ -587,9 +575,12 @@ await SetupHelpers.EnsureResourcePermissionsAsync( StringComparison.OrdinalIgnoreCase); if (isGraph) { - var fullyQualified = scopes.Select(s => $"{AuthenticationConstants.MicrosoftGraphResourceUri}/{s}"); + var graphBaseUrl = ConfigConstants.GetGraphBaseUrl(setupConfig.Environment, setupConfig.GraphBaseUrl); + var graphResourceUri = GraphApiConstants.GetResource(graphBaseUrl).TrimEnd('/'); + var authorityHost = ConfigConstants.GetAuthorityHost(setupConfig.Environment, setupConfig.AuthorityHost); + var fullyQualified = scopes.Select(s => $"{graphResourceUri}/{s}"); var url = SetupHelpers.BuildAdminConsentUrl( - setupConfig.TenantId, setupConfig.AgentBlueprintId!, fullyQualified); + setupConfig.TenantId, setupConfig.AgentBlueprintId!, fullyQualified, authorityHost); LogAdminConsentNextSteps(logger, url); } else diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/RequirementsSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/RequirementsSubcommand.cs index 9f88f255..9358ae02 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/RequirementsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/RequirementsSubcommand.cs @@ -105,6 +105,7 @@ public static Command CreateCommand( return; } + graphApiService.ConfigureCloudEndpoints(configForChecks); var configPassed = await RunRequirementChecksAsync(configChecks, configForChecks, logger, ct: ct); allPassed = allPassed && configPassed; } 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 53e85506..e8dd97fa 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs @@ -459,8 +459,9 @@ public static async Task DisplayVerificationInfoAsync(FileInfo setupConfigFile, /// The DW vs non-DW branch is determined solely by , /// which both orchestrators set explicitly — there is no separate caller-supplied flag. /// - public static void DisplaySetupSummary(SetupResults results, ILogger logger) + public static void DisplaySetupSummary(SetupResults results, ILogger logger, string? graphBaseUrl = null) { + var resolvedGraphBaseUrl = ConfigConstants.NormalizeGraphBaseUrl(graphBaseUrl); var isNonDw = results.IsNonDwBlueprintFlow; var isBlueprintOnly = results.IsBlueprintOnlyFlow; // Which row groups this run actually performs. Blueprint-only ('setup blueprint') stops after @@ -881,12 +882,12 @@ public static void DisplaySetupSummary(SetupResults results, ILogger logger) logger.LogInformation(" # Observability API"); logger.LogInformation(" $obsSp = Get-MgServicePrincipal -Filter \"appId eq '{ObsAppId}'\"", ConfigConstants.ObservabilityApiAppId); logger.LogInformation(" $body = @{{ clientId = $agentSpId; consentType = 'AllPrincipals'; resourceId = $obsSp.Id; scope = '{ObsScope}' }} | ConvertTo-Json", ConfigConstants.ObservabilityApiOtelWriteScope); - logger.LogInformation(" Invoke-MgGraphRequest -Method POST -Uri 'https://graph.microsoft.com/v1.0/oauth2PermissionGrants' -Body $body -ContentType 'application/json'"); + logger.LogInformation(" Invoke-MgGraphRequest -Method POST -Uri '{GraphBaseUrl}/v1.0/oauth2PermissionGrants' -Body $body -ContentType 'application/json'", resolvedGraphBaseUrl); logger.LogInformation(""); logger.LogInformation(" # Power Platform API"); logger.LogInformation(" $ppSp = Get-MgServicePrincipal -Filter \"appId eq '{PpAppId}'\"", PowerPlatformConstants.PowerPlatformApiResourceAppId); logger.LogInformation(" $body = @{{ clientId = $agentSpId; consentType = 'AllPrincipals'; resourceId = $ppSp.Id; scope = '{PpScope}' }} | ConvertTo-Json", PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead); - logger.LogInformation(" Invoke-MgGraphRequest -Method POST -Uri 'https://graph.microsoft.com/v1.0/oauth2PermissionGrants' -Body $body -ContentType 'application/json'"); + logger.LogInformation(" Invoke-MgGraphRequest -Method POST -Uri '{GraphBaseUrl}/v1.0/oauth2PermissionGrants' -Body $body -ContentType 'application/json'", resolvedGraphBaseUrl); } if (messagingEndpointManualRequired) { @@ -1062,7 +1063,14 @@ internal static List PopulateAdminConsentUrls( IReadOnlyDictionary? mcpScopesByAudience = null, IReadOnlyDictionary>? mcpAudienceDisplayNames = null) { - var urls = BuildAdminConsentUrls(config.TenantId, config.AgentBlueprintId!, config.AgentApplicationScopes, mcpScopes, isM365, mcpScopesByAudience, mcpAudienceDisplayNames); + var graphBaseUrl = ConfigConstants.GetGraphBaseUrl(config.Environment, config.GraphBaseUrl); + var graphResourceUri = graphBaseUrl; + var authorityHost = ConfigConstants.GetAuthorityHost(config.Environment, config.AuthorityHost); + + var urls = BuildAdminConsentUrls( + config.TenantId, config.AgentBlueprintId!, config.AgentApplicationScopes, mcpScopes, + isM365, mcpScopesByAudience, mcpAudienceDisplayNames, graphResourceUri, authorityHost, + mcpResourceAppId); // Map resource names to App IDs for upsert into ResourceConsents. The fixed-name // entries cover Graph + Bot + Obs + PP + the WorkIQ shared MCP audience. V2 @@ -1145,11 +1153,13 @@ private static bool TryExtractAudienceAppIdFromResourceName(string resourceName, /// Each scope is individually Uri.EscapeDataString-encoded and joined with %20. /// A random GUID state parameter is generated for CSRF protection. /// - internal static string BuildAdminConsentUrl(string tenantId, string clientId, IEnumerable fullyQualifiedScopes) + internal static string BuildAdminConsentUrl( + string tenantId, string clientId, IEnumerable fullyQualifiedScopes, string? authorityHost = null) { var scopeParam = string.Join("%20", fullyQualifiedScopes.Select(Uri.EscapeDataString)); var redirectEncoded = Uri.EscapeDataString(AuthenticationConstants.BlueprintConsentRedirectUri); - return $"https://login.microsoftonline.com/{tenantId}/v2.0/adminconsent?client_id={clientId}&scope={scopeParam}&redirect_uri={redirectEncoded}&state={Guid.NewGuid():N}"; + var normalizedAuthorityHost = ConfigConstants.NormalizeAuthorityHost(authorityHost); + return $"{normalizedAuthorityHost}/{tenantId}/v2.0/adminconsent?client_id={clientId}&scope={scopeParam}&redirect_uri={redirectEncoded}&state={Guid.NewGuid():N}"; } /// @@ -1169,10 +1179,14 @@ internal static string BuildAdminConsentUrl(string tenantId, string clientId, IE /// is a V2 MCP per-server audience (e.g. it sits in the ToolingManifest audience set /// or the call site is iterating mcpScopesByAudience). Default false preserves /// the safe api://{appId} fallback for any caller that has not been updated. - internal static string GetResourceIdentifierUri(string resourceAppId, bool isMcpAudience = false) + internal static string GetResourceIdentifierUri( + string resourceAppId, + bool isMcpAudience = false, + string graphResourceUri = AuthenticationConstants.MicrosoftGraphResourceUri, + string? sharedMcpResourceAppId = null) { if (string.Equals(resourceAppId, AuthenticationConstants.MicrosoftGraphResourceAppId, StringComparison.OrdinalIgnoreCase)) - return AuthenticationConstants.MicrosoftGraphResourceUri; + return graphResourceUri; if (string.Equals(resourceAppId, ConfigConstants.MessagingBotApiAppId, StringComparison.OrdinalIgnoreCase)) return ConfigConstants.MessagingBotApiIdentifierUri; if (string.Equals(resourceAppId, ConfigConstants.ObservabilityApiAppId, StringComparison.OrdinalIgnoreCase)) @@ -1182,7 +1196,7 @@ internal static string GetResourceIdentifierUri(string resourceAppId, bool isMcp // WorkIQ Tools shared (issue #429): match by appId, not display name. V2 per-server // audiences are also named "Agent 365 Tools" so the old name-based check collapsed // them onto WorkIQ's URI and produced AADSTS650053. - if (IsAgent365ToolsResourceAppId(resourceAppId)) + if (IsAgent365ToolsResourceAppId(resourceAppId, sharedMcpResourceAppId)) return McpConstants.Agent365ToolsIdentifierUri; // V2 MCP per-server audiences (identifierUris=null, only bare appId in @@ -1197,29 +1211,21 @@ internal static string GetResourceIdentifierUri(string resourceAppId, bool isMcp /// /// Returns true when the supplied resource appId is the WorkIQ Tools (Agent 365 Tools) - /// shared resource — either the hard-coded prod appId or an env-overridden value - /// pinned via A365_MCP_APP_ID_<env>. Used by + /// shared resource — either the hard-coded production appId or the explicitly resolved + /// cloud-specific appId. Used by /// to distinguish the WorkIQ shared audience /// (returns canonical https URI) from V2 MCP per-server audiences (returns bare appId /// GUID because per-server SPs have identifierUris = null and Entra rejects /// api://{appId} for them with AADSTS500011). /// - private static bool IsAgent365ToolsResourceAppId(string resourceAppId) + private static bool IsAgent365ToolsResourceAppId(string resourceAppId, string? sharedMcpResourceAppId = null) { if (string.IsNullOrWhiteSpace(resourceAppId)) return false; if (string.Equals(resourceAppId, McpConstants.WorkIQToolsProdAppId, StringComparison.OrdinalIgnoreCase)) return true; - // Also accept any value the environment-aware resolver returns for known env keys. - // Cheaper than walking every possible env: only check the env on the running config - // when explicitly passed via env var. ConfigConstants.GetAgent365ToolsResourceAppId - // already short-circuits to the prod appId when no override is set. - foreach (var envKey in new[] { "prod", "preprod", "test", "dev" }) - { - var resolved = ConfigConstants.GetAgent365ToolsResourceAppId(envKey); - if (string.Equals(resourceAppId, resolved, StringComparison.OrdinalIgnoreCase)) - return true; - } - return false; + + return !string.IsNullOrWhiteSpace(sharedMcpResourceAppId) + && string.Equals(resourceAppId, sharedMcpResourceAppId, StringComparison.OrdinalIgnoreCase); } /// @@ -1230,8 +1236,13 @@ private static bool IsAgent365ToolsResourceAppId(string resourceAppId) /// Forwarded to ; pass /// true when the caller knows is a V2 MCP per-server /// audience (e.g. found in the loaded ToolingManifest audience set). Default false. - internal static string BuildFullyQualifiedScope(string resourceAppId, string scope, bool isMcpAudience = false) - => $"{GetResourceIdentifierUri(resourceAppId, isMcpAudience)}/{scope}"; + internal static string BuildFullyQualifiedScope( + string resourceAppId, + string scope, + bool isMcpAudience = false, + string graphResourceUri = AuthenticationConstants.MicrosoftGraphResourceUri, + string? sharedMcpResourceAppId = null) + => $"{GetResourceIdentifierUri(resourceAppId, isMcpAudience, graphResourceUri, sharedMcpResourceAppId)}/{scope}"; /// /// Builds per-resource admin consent URLs covering every resource stamped on the blueprint @@ -1253,16 +1264,19 @@ internal static string BuildFullyQualifiedScope(string resourceAppId, string sco IEnumerable mcpScopes, bool isM365 = true, IReadOnlyDictionary? mcpScopesByAudience = null, - IReadOnlyDictionary>? mcpAudienceDisplayNames = null) + IReadOnlyDictionary>? mcpAudienceDisplayNames = null, + string graphResourceUri = AuthenticationConstants.MicrosoftGraphResourceUri, + string? authorityHost = null, + string? sharedMcpResourceAppId = null) { var urls = new List<(string, string)>(); - static string Build(string tenant, string client, string resourceUri, IEnumerable scopes) - => BuildAdminConsentUrl(tenant, client, scopes.Select(s => $"{resourceUri}/{s}")); + string Build(string tenant, string client, string resourceUri, IEnumerable scopes) + => BuildAdminConsentUrl(tenant, client, scopes.Select(s => $"{resourceUri}/{s}"), authorityHost); var graphScopeList = graphScopes.ToList(); if (graphScopeList.Count > 0) - urls.Add(("Microsoft Graph", Build(tenantId, blueprintClientId, AuthenticationConstants.MicrosoftGraphResourceUri, graphScopeList))); + urls.Add(("Microsoft Graph", Build(tenantId, blueprintClientId, graphResourceUri, graphScopeList))); // V2 per-server audiences (issue #429): when the caller passes a by-audience map, // emit one URL fragment per audience whose resource identifier is resolved by @@ -1278,7 +1292,8 @@ static string Build(string tenant, string client, string resourceUri, IEnumerabl if (scopes is null || scopes.Length == 0) continue; // The loop iterates over manifest-derived MCP audiences; every key here is // by definition an MCP per-server audience appId. - var resourceUri = GetResourceIdentifierUri(audienceAppId, isMcpAudience: true); + var resourceUri = GetResourceIdentifierUri( + audienceAppId, isMcpAudience: true, sharedMcpResourceAppId: sharedMcpResourceAppId); // Display name: WorkIQ shared audience keeps the legacy "Agent 365 Tools" // label. Per-server audiences use the manifest McpServerName when supplied // (e.g. "mcp_MailTools (16b1878d-...)") so the consent URL block matches the @@ -1288,7 +1303,7 @@ static string Build(string tenant, string client, string resourceUri, IEnumerabl // TryExtractAudienceAppIdFromResourceName for the PopulateAdminConsentUrls // upsert path. string resourceName; - if (IsAgent365ToolsResourceAppId(audienceAppId)) + if (IsAgent365ToolsResourceAppId(audienceAppId, sharedMcpResourceAppId)) { resourceName = "Agent 365 Tools"; } @@ -1338,11 +1353,14 @@ internal static string BuildCombinedConsentUrl( IEnumerable graphScopes, IEnumerable mcpScopes, bool isM365 = true, - IReadOnlyDictionary? mcpScopesByAudience = null) + IReadOnlyDictionary? mcpScopesByAudience = null, + string graphResourceUri = AuthenticationConstants.MicrosoftGraphResourceUri, + string? authorityHost = null, + string? sharedMcpResourceAppId = null) { var allScopes = new List(); foreach (var s in graphScopes) - allScopes.Add($"{AuthenticationConstants.MicrosoftGraphResourceUri}/{s}"); + allScopes.Add($"{graphResourceUri}/{s}"); // V2 per-server audiences (issue #429): when the caller passes a by-audience map, // emit per-audience scope URIs using GetResourceIdentifierUri so the WorkIQ @@ -1357,7 +1375,8 @@ internal static string BuildCombinedConsentUrl( if (scopes is null) continue; // The loop iterates over manifest-derived MCP audiences; every key here is // by definition an MCP per-server audience appId. - var resourceUri = GetResourceIdentifierUri(audienceAppId, isMcpAudience: true); + var resourceUri = GetResourceIdentifierUri( + audienceAppId, isMcpAudience: true, sharedMcpResourceAppId: sharedMcpResourceAppId); foreach (var s in scopes) allScopes.Add($"{resourceUri}/{s}"); } @@ -1372,7 +1391,7 @@ internal static string BuildCombinedConsentUrl( allScopes.Add($"{ConfigConstants.MessagingBotApiIdentifierUri}/{ConfigConstants.MessagingBotApiAdminConsentScope}"); allScopes.Add($"{ConfigConstants.ObservabilityApiIdentifierUri}/{ConfigConstants.ObservabilityApiOtelWriteScope}"); allScopes.Add($"{PowerPlatformConstants.PowerPlatformApiIdentifierUri}/{PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead}"); - return BuildAdminConsentUrl(tenantId, blueprintClientId, allScopes); + return BuildAdminConsentUrl(tenantId, blueprintClientId, allScopes, authorityHost); } /// @@ -1401,9 +1420,13 @@ internal static void ApplyConsentUrlsIfNeeded( var consentResourceNames = PopulateAdminConsentUrls(ctx.Config, mcpResourceAppId, mcpScopes, isM365, mcpScopesByAudience, mcpAudienceDisplayNames); ctx.Results.ConsentUrlsSavedToPath = ctx.GeneratedConfigPath; ctx.Results.ConsentResourceNames.AddRange(consentResourceNames); + var graphBaseUrl = ConfigConstants.GetGraphBaseUrl(ctx.Config.Environment, ctx.Config.GraphBaseUrl); + var graphResourceUri = graphBaseUrl; + var authorityHost = ConfigConstants.GetAuthorityHost(ctx.Config.Environment, ctx.Config.AuthorityHost); ctx.Results.CombinedConsentUrl = BuildCombinedConsentUrl( ctx.Config.TenantId!, ctx.Config.AgentBlueprintId!, - graphScopes, mcpScopes, isM365, mcpScopesByAudience); + graphScopes, mcpScopes, isM365, mcpScopesByAudience, graphResourceUri, authorityHost, + mcpResourceAppId); } /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs index bf2fe665..41f1ed9f 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System; +using System.Text.RegularExpressions; namespace Microsoft.Agents.A365.DevTools.Cli.Constants; @@ -10,6 +11,14 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Constants; /// public static class ConfigConstants { + /// + /// Commercial-cloud OAuth authority host. Used as the fallback when no cloud-specific + /// override is configured. + /// + public const string DefaultAuthorityHost = "https://login.microsoftonline.com"; + private const string AuthorityHostEnvVar = "A365_AUTHORITY_HOST"; + private const string GraphBaseUrlEnvVar = "A365_GRAPH_BASE_URL"; + /// /// Default static configuration file name (user-managed, version-controlled) /// @@ -153,7 +162,7 @@ public static class ConfigConstants public static string GetDiscoverEndpointUrl(string environment) { // Check for custom endpoint in environment variable first - var customEndpoint = Environment.GetEnvironmentVariable($"A365_DISCOVER_ENDPOINT_{environment?.ToUpper()}"); + var customEndpoint = GetEnvironmentScopedSetting("A365_DISCOVER_ENDPOINT", environment); if (!string.IsNullOrEmpty(customEndpoint)) return customEndpoint; @@ -167,13 +176,78 @@ public static string GetDiscoverEndpointUrl(string environment) /// /// environment-aware Agent 365 Tools resource Application ID /// -public static string GetAgent365ToolsResourceAppId(string environment) -{ - // Check for custom app ID in environment variable first - var customAppId = Environment.GetEnvironmentVariable($"A365_MCP_APP_ID_{environment?.ToUpperInvariant()}"); - if (!string.IsNullOrEmpty(customAppId)) - return customAppId; + public static string GetAgent365ToolsResourceAppId(string environment) + => GetEnvironmentScopedSetting("A365_MCP_APP_ID", environment) + ?? McpConstants.WorkIQToolsProdAppId; + + /// + /// Returns the authority host for the selected cloud environment. + /// + public static string GetAuthorityHost(string environment, string? configAuthorityHost = null) + => NormalizeAuthorityHost(GetEnvironmentScopedSetting(AuthorityHostEnvVar, environment) ?? configAuthorityHost); + + /// + /// Returns the Graph base URL for the selected cloud environment. + /// + public static string GetGraphBaseUrl(string environment, string? configGraphBaseUrl = null) + => NormalizeGraphBaseUrl(GetEnvironmentScopedSetting(GraphBaseUrlEnvVar, environment) ?? configGraphBaseUrl); + + /// + /// Composes an OAuth2 admin-consent endpoint from an already-resolved authority host. + /// + public static string BuildAdminConsentEndpointUrl(string? authorityHost, string tenantId) + => $"{NormalizeAuthorityHost(authorityHost)}/{tenantId}/v2.0/adminconsent"; + + /// + /// Returns the OAuth2 token endpoint URL for the given tenant and environment. + /// + public static string GetTokenEndpointUrl(string tenantId, string environment, string? configAuthorityHost = null) + => BuildTokenEndpointUrl(GetAuthorityHost(environment, configAuthorityHost), tenantId); - return McpConstants.WorkIQToolsProdAppId; -} + /// + /// Composes an OAuth2 token endpoint from an already-resolved authority host. + /// + public static string BuildTokenEndpointUrl(string? authorityHost, string tenantId) + => $"{NormalizeAuthorityHost(authorityHost)}/{tenantId}/oauth2/v2.0/token"; + + internal static string NormalizeAuthorityHost(string? authorityHost) + => NormalizeHttpsOrigin(authorityHost, DefaultAuthorityHost, "Authority host"); + + internal static string NormalizeGraphBaseUrl(string? graphBaseUrl) + => NormalizeHttpsOrigin(graphBaseUrl, GraphApiConstants.BaseUrl, "Graph base URL"); + + /// + /// Normalizes an environment key so arbitrary cloud names can map to env vars. + /// + public static string NormalizeEnvironmentKey(string? environment) + { + if (string.IsNullOrWhiteSpace(environment)) + return "PROD"; + + var normalized = Regex.Replace(environment.Trim(), "[^A-Za-z0-9]", "_").ToUpperInvariant(); + return string.IsNullOrWhiteSpace(normalized) ? "PROD" : normalized; + } + + private static string? GetEnvironmentScopedSetting(string prefix, string? environment) + => Environment.GetEnvironmentVariable($"{prefix}_{NormalizeEnvironmentKey(environment)}") is { } value + && !string.IsNullOrWhiteSpace(value) + ? value.Trim() + : null; + + private static string NormalizeHttpsOrigin(string? value, string fallback, string settingName) + { + var candidate = string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); + if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri) || + !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) || + !string.IsNullOrEmpty(uri.UserInfo) || + !string.IsNullOrEmpty(uri.Query) || + !string.IsNullOrEmpty(uri.Fragment) || + uri.AbsolutePath != "/") + { + throw new ArgumentException( + $"{settingName} must be an HTTPS origin without a path, query, fragment, or user info."); + } + + return uri.GetLeftPart(UriPartial.Authority); + } } \ No newline at end of file diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs index 7dfbfed0..fb7290d0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs @@ -83,9 +83,9 @@ public static ClientAppValidationException MissingPermissions( /// Creates exception for missing admin consent. /// Includes a direct admin consent URL that a Global Administrator can open to grant consent. /// - public static ClientAppValidationException MissingAdminConsent(string clientAppId, string? tenantId = null) + public static ClientAppValidationException MissingAdminConsent(string clientAppId, string? tenantId = null, string? authorityHost = null) { - var consentUrl = BuildAdminConsentUrl(clientAppId, tenantId); + var consentUrl = BuildAdminConsentUrl(clientAppId, tenantId, authorityHost); var consentInstruction = consentUrl != null ? $"Share this URL with a Global Administrator to grant consent:\n {consentUrl}" : "Grant admin consent at: Azure Portal > App registrations > Your app > API permissions."; @@ -116,16 +116,19 @@ public static ClientAppValidationException MissingAdminConsent(string clientAppI /// Builds the admin consent URL for the given client app and tenant. /// A Global Administrator can open this URL to grant tenant-wide (AllPrincipals) consent. /// - public static string? BuildAdminConsentUrl(string clientAppId, string? tenantId) + public static string? BuildAdminConsentUrl(string clientAppId, string? tenantId, string? authorityHost = null) { if (string.IsNullOrWhiteSpace(clientAppId) || string.IsNullOrWhiteSpace(tenantId)) return null; - // Standard native-app redirect URI accepted by Entra ID for admin consent flows - const string redirectUri = "https://login.microsoftonline.com/common/oauth2/nativeclient"; + // Standard native-app redirect URI accepted by Entra ID for admin consent flows. + // Authority host defaults to commercial cloud; callers pass a cloud-resolved host + // (e.g. GraphApiService.AuthorityHost) so sovereign tenants get a matching consent URL. + var host = ConfigConstants.NormalizeAuthorityHost(authorityHost); + var redirectUri = $"{host}/common/oauth2/nativeclient"; var clientIdEncoded = Uri.EscapeDataString(clientAppId); var redirectUriEncoded = Uri.EscapeDataString(redirectUri); - return $"https://login.microsoftonline.com/{tenantId}/adminconsent?client_id={clientIdEncoded}&redirect_uri={redirectUriEncoded}"; + return $"{host}/{tenantId}/adminconsent?client_id={clientIdEncoded}&redirect_uri={redirectUriEncoded}"; } /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ProjectSettingsSyncHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ProjectSettingsSyncHelper.cs index c9b69167..2cc1848f 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ProjectSettingsSyncHelper.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ProjectSettingsSyncHelper.cs @@ -19,8 +19,6 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Helpers; /// public static class ProjectSettingsSyncHelper { - private const string DEFAULT_AUTHORITY_ENDPOINT = "https://login.microsoftonline.com"; - private const string DEFAULT_USER_AUTHORIZATION_SCOPE = "https://graph.microsoft.com/.default"; // Messaging Bot API Application GUID private const string DEFAULT_SERVICE_CONNECTION_SCOPE = $"{ConfigConstants.MessagingBotApiAppId}/.default"; @@ -459,7 +457,8 @@ static JsonObject RequireObj(JsonObject parent, string prop) var agenticSettings = RequireObj(agentic, "Settings"); agenticSettings["AlternateBlueprintConnectionName"] = "ServiceConnection"; - var uaScopes = new JsonArray(DEFAULT_USER_AUTHORIZATION_SCOPE); + var userAuthorizationScope = GetUserAuthorizationScope(pkgConfig); + var uaScopes = new JsonArray(userAuthorizationScope); agenticSettings["Scopes"] = uaScopes; // -- Connections -- @@ -470,7 +469,7 @@ static JsonObject RequireObj(JsonObject parent, string prop) if (!string.IsNullOrWhiteSpace(pkgConfig.TenantId)) { - var authority = $"{DEFAULT_AUTHORITY_ENDPOINT}/{pkgConfig.TenantId}"; + var authority = $"{ConfigConstants.GetAuthorityHost(pkgConfig.Environment, pkgConfig.AuthorityHost)}/{pkgConfig.TenantId}"; svcSettings["AuthorityEndpoint"] = authority; } @@ -579,7 +578,7 @@ void Set(string key, string? value) Set("AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__SETTINGS__ALT_BLUEPRINT_NAME", "SERVICE_CONNECTION"); Set("AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__SETTINGS__SCOPES", - DEFAULT_USER_AUTHORIZATION_SCOPE); + GetUserAuthorizationScope(pkgConfig)); // --- ConnectionsMap[0] --- Set("CONNECTIONSMAP__0__SERVICEURL", "*"); @@ -651,7 +650,7 @@ void Set(string key, string? value) // --- AgenticAuthentication Options --- Set("agentic_altBlueprintConnectionName", "service_connection"); - Set("agentic_scopes", DEFAULT_USER_AUTHORIZATION_SCOPE); + Set("agentic_scopes", GetUserAuthorizationScope(pkgConfig)); Set("agentic_connectionName", "AgenticAuthConnection"); // --- Agent365 Observability --- @@ -694,4 +693,10 @@ private static string EscapeEnv(string value) } return value; } + + private static string GetUserAuthorizationScope(Agent365Config pkgConfig) + { + var graphBaseUrl = ConfigConstants.GetGraphBaseUrl(pkgConfig.Environment, pkgConfig.GraphBaseUrl); + return $"{graphBaseUrl}/.default"; + } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs index b9323f71..ab36a996 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs @@ -140,15 +140,18 @@ private static void ValidateAuthMode(string? value, List errors) public string MessagingEndpoint { get; init; } = string.Empty; /// - /// Base URL for Microsoft Graph API. - /// Override this to target sovereign / government clouds: - /// GCC High / DoD : "https://graph.microsoft.us" - /// China (21Vianet): "https://microsoftgraph.chinacloudapi.cn" - /// Defaults to "https://graph.microsoft.com" when omitted. + /// Base URL for Microsoft Graph API. Defaults to the commercial cloud endpoint. /// [JsonPropertyName("graphBaseUrl")] public string GraphBaseUrl { get; init; } = Constants.GraphApiConstants.BaseUrl; + /// + /// OAuth authority host for the selected cloud. Pair this with + /// so authentication and Graph data-plane calls target the same environment. + /// + [JsonPropertyName("authorityHost")] + public string? AuthorityHost { get; init; } + #endregion #region Authentication Configuration diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index 6661d462..4bce9db0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -334,6 +334,7 @@ private static void ConfigureServices(IServiceCollection services, LogLevel mini // Default to "prod". Override with A365_ENVIRONMENT env var or a365.config.json. string environment = Environment.GetEnvironmentVariable("A365_ENVIRONMENT") ?? "prod"; + string? authorityHost = null; var configFilePath = ConfigService.GetConfigFilePath(); if (configFilePath != null) @@ -350,6 +351,8 @@ private static void ConfigureServices(IServiceCollection services, LogLevel mini environment = envValue; } } + if (doc.RootElement.TryGetProperty("authorityHost", out var authorityProp)) + authorityHost = authorityProp.GetString(); logger.LogDebug("Resolved environment from config: {Environment}", environment); } @@ -359,7 +362,7 @@ private static void ConfigureServices(IServiceCollection services, LogLevel mini } } - return new Agent365ToolingService(configService, authService, logger, environment); + return new Agent365ToolingService(configService, authService, logger, environment, authorityHost); }); // Add Azure validators (individual validators for composition) @@ -442,4 +445,3 @@ private static string DetectCommandName(string[] args) .Replace("_", "-"); } } - diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs index 960f445d..4dc70369 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs @@ -148,6 +148,21 @@ string GetConfig(string name) => _logger.LogInformation("Using environment from config: {Env}", environment); } + // Wire the sovereign/government cloud endpoints so all Graph calls and client-credential + // token acquisition target the correct national cloud (commercial by default). + var configuredGraphBaseUrl = GetConfig("graphBaseUrl"); + _graphService.GraphBaseUrl = ConfigConstants.GetGraphBaseUrl( + environment, + string.IsNullOrWhiteSpace(configuredGraphBaseUrl) ? null : configuredGraphBaseUrl); + var configuredAuthorityHost = GetConfig("authorityHost"); + _graphService.AuthorityHost = ConfigConstants.GetAuthorityHost( + environment, + string.IsNullOrWhiteSpace(configuredAuthorityHost) ? null : configuredAuthorityHost); + var configuredClientAppId = GetConfig("clientAppId"); + if (!string.IsNullOrWhiteSpace(configuredClientAppId)) + _graphService.CustomClientAppId = configuredClientAppId; + var mcpResourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(environment); + var usageLocation = GetConfig("agentUserUsageLocation"); await SaveInstanceAsync(generatedConfigPath, instance, cancellationToken); @@ -320,7 +335,7 @@ string GetConfig(string name) => [AuthenticationConstants.MicrosoftGraphResourceAppId] = ( "Microsoft Graph", new HashSet(ConfigConstants.DefaultAgentIdentityScopes, StringComparer.OrdinalIgnoreCase)), - [McpConstants.WorkIQToolsProdAppId] = ( + [mcpResourceAppId] = ( "Work IQ Tools", new HashSet(StringComparer.OrdinalIgnoreCase) { @@ -657,7 +672,7 @@ string GetConfig(string name) => : correlationId; using var httpClient = HttpClientFactory.CreateAuthenticatedClient(correlationId: effectiveCorrelationId); - var tokenEndpoint = $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token"; + var tokenEndpoint = ConfigConstants.BuildTokenEndpointUrl(_graphService.AuthorityHost, tenantId); var requestBody = new FormUrlEncodedContent(new[] { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs index 578b0839..0d3e65ef 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs @@ -20,6 +20,7 @@ public class Agent365ToolingService : IAgent365ToolingService private readonly AuthenticationService _authService; private readonly ILogger _logger; private readonly string _environment; + private readonly string _authorityHost; /// public string Environment => _environment; @@ -28,12 +29,14 @@ public Agent365ToolingService( IConfigService configService, AuthenticationService authService, ILogger logger, - string environment = "prod") + string environment = "prod", + string? authorityHost = null) { _configService = configService ?? throw new ArgumentNullException(nameof(configService)); _authService = authService ?? throw new ArgumentNullException(nameof(authService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _environment = environment ?? "prod"; + _authorityHost = ConfigConstants.GetAuthorityHost(_environment, authorityHost); } /// @@ -343,7 +346,8 @@ private string BuildProvisionIdentityUrl(string environment, string serverName) _logger.LogDebug("Acquiring access token for audience: {Audience}", audience); var loginHint = await AzCliHelper.ResolveLoginHintAsync(); - var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint, ct: cancellationToken); + var authToken = await _authService.GetAccessTokenAsync( + audience, userId: loginHint, ct: cancellationToken, authorityHost: _authorityHost); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogError("Failed to acquire authentication token"); @@ -422,7 +426,8 @@ private string BuildProvisionIdentityUrl(string environment, string serverName) _logger.LogDebug("Acquiring access token for audience: {Audience}", audience); var loginHint = await AzCliHelper.ResolveLoginHintAsync(); - var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint, ct: cancellationToken); + var authToken = await _authService.GetAccessTokenAsync( + audience, userId: loginHint, ct: cancellationToken, authorityHost: _authorityHost); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogError("Failed to acquire authentication token"); @@ -496,7 +501,8 @@ private string BuildProvisionIdentityUrl(string environment, string serverName) _logger.LogDebug("Acquiring access token for audience: {Audience}", audience); var loginHint = await AzCliHelper.ResolveLoginHintAsync(); - var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint, ct: cancellationToken); + var authToken = await _authService.GetAccessTokenAsync( + audience, userId: loginHint, ct: cancellationToken, authorityHost: _authorityHost); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogError("Failed to acquire authentication token"); @@ -583,7 +589,8 @@ public async Task UnpublishServerAsync( _logger.LogDebug("Acquiring access token for audience: {Audience}", audience); var loginHint = await AzCliHelper.ResolveLoginHintAsync(); - var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint, ct: cancellationToken); + var authToken = await _authService.GetAccessTokenAsync( + audience, userId: loginHint, ct: cancellationToken, authorityHost: _authorityHost); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogError("Failed to acquire authentication token"); @@ -628,7 +635,8 @@ public async Task LogRegisterUsageAsync( var endpointUrl = BuildLogRegisterUrl(_environment); var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_environment); var loginHint = await AzCliHelper.ResolveLoginHintAsync(); - var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint); + var authToken = await _authService.GetAccessTokenAsync( + audience, userId: loginHint, authorityHost: _authorityHost); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogDebug("Skipping telemetry: failed to acquire token"); @@ -666,7 +674,8 @@ public async Task LogEvaluateUsageAsync(CancellationToken cancellationToken = de var endpointUrl = BuildLogEvaluateUrl(_environment); var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_environment); var loginHint = await AzCliHelper.ResolveLoginHintAsync(); - var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint); + var authToken = await _authService.GetAccessTokenAsync( + audience, userId: loginHint, authorityHost: _authorityHost); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogDebug("Skipping telemetry: failed to acquire token"); @@ -721,7 +730,8 @@ public async Task LogEvaluateUsageAsync(CancellationToken cancellationToken = de _logger.LogDebug("Acquiring access token for audience: {Audience}", audience); var loginHint = await AzCliHelper.ResolveLoginHintAsync(); - var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint); + var authToken = await _authService.GetAccessTokenAsync( + audience, userId: loginHint, authorityHost: _authorityHost); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogError("Failed to acquire authentication token"); @@ -801,7 +811,8 @@ public async Task LogEvaluateUsageAsync(CancellationToken cancellationToken = de _logger.LogDebug("Acquiring access token for audience: {Audience}", audience); var loginHint = await AzCliHelper.ResolveLoginHintAsync(); - var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint); + var authToken = await _authService.GetAccessTokenAsync( + audience, userId: loginHint, authorityHost: _authorityHost); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogError("Failed to acquire authentication token"); @@ -873,7 +884,8 @@ public async Task LogEvaluateUsageAsync(CancellationToken cancellationToken = de _logger.LogDebug("Acquiring access token for audience: {Audience}", audience); var loginHint = await AzCliHelper.ResolveLoginHintAsync(); - var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint); + var authToken = await _authService.GetAccessTokenAsync( + audience, userId: loginHint, authorityHost: _authorityHost); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogError("Failed to acquire authentication token"); @@ -905,4 +917,3 @@ public async Task LogEvaluateUsageAsync(CancellationToken cancellationToken = de } } } - diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs index 878d50ec..3b15a2b3 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs @@ -27,7 +27,8 @@ Task GetAccessTokenAsync( IEnumerable? scopes = null, bool useInteractiveBrowser = true, string? userId = null, - CancellationToken ct = default); + CancellationToken ct = default, + string? authorityHost = null); Task ResolveLoginHintFromCacheAsync(); @@ -118,14 +119,18 @@ public async Task GetAccessTokenAsync( IEnumerable? scopes = null, bool useInteractiveBrowser = true, string? userId = null, - CancellationToken ct = default) + CancellationToken ct = default, + string? authorityHost = null) { // Access tokens are no longer cached to disk by this service. Token persistence and // silent re-acquisition are delegated entirely to the OS-protected MSAL persistent cache // (managed by MsalBrowserCredential). When forceRefresh is requested, the underlying // credential is configured to bypass MSAL's silent cache and acquire a fresh token. _logger.LogDebug("Authentication required for Agent 365 Tools"); - var token = await AuthenticateInteractivelyAsync(resourceUrl, tenantId, clientId, scopes, useInteractiveBrowser, loginHint: userId, forceRefresh: forceRefresh, ct: ct); + var token = await AuthenticateInteractivelyAsync( + resourceUrl, tenantId, clientId, scopes, useInteractiveBrowser, + loginHint: userId, forceRefresh: forceRefresh, ct: ct, + authorityHost: authorityHost); // Self-heal: validate the tid claim in the returned JWT against the requested tenant. // WAM may silently select a cached work account from a different tenant when multiple @@ -147,7 +152,10 @@ public async Task GetAccessTokenAsync( await ClearMsalCacheAsync(); // Retry once with the same parameters — MSAL disk cache is now empty so WAM // gets a clean slate and will either pick the correct account or prompt. - token = await AuthenticateInteractivelyAsync(resourceUrl, tenantId, clientId, scopes, useInteractiveBrowser, loginHint: userId, forceRefresh: forceRefresh, ct: ct); + token = await AuthenticateInteractivelyAsync( + resourceUrl, tenantId, clientId, scopes, useInteractiveBrowser, + loginHint: userId, forceRefresh: forceRefresh, ct: ct, + authorityHost: authorityHost); var retryTid = JwtHelper.TryDecodeClaim(token.AccessToken, "tid"); if (!string.IsNullOrWhiteSpace(retryTid) && !string.Equals(retryTid, tenantId, StringComparison.OrdinalIgnoreCase)) @@ -195,7 +203,8 @@ private async Task AuthenticateInteractivelyAsync( bool useInteractiveBrowser = false, string? loginHint = null, bool forceRefresh = false, - CancellationToken ct = default) + CancellationToken ct = default, + string? authorityHost = null) { // Declare variables outside try block so they're available in catch for logging string effectiveTenantId = tenantId ?? "unknown"; @@ -274,14 +283,16 @@ private async Task AuthenticateInteractivelyAsync( // Use MsalBrowserCredential which handles WAM on Windows and browser on other platforms _logger.LogDebug("Using interactive authentication (browser/WAM)..."); - credential = CreateBrowserCredential(effectiveClientId, effectiveTenantId, loginHint: loginHint, forceRefresh: forceRefresh); + credential = CreateBrowserCredentialForAuthority( + effectiveClientId, effectiveTenantId, authorityHost, loginHint, forceRefresh); } else { // Device code flow - works in all environments including SSH/remote sessions _logger.LogDebug("Using device code authentication..."); _logger.LogDebug("Please sign in with your Microsoft account"); - credential = CreateDeviceCodeCredential(effectiveClientId, effectiveTenantId); + credential = CreateDeviceCodeCredentialForAuthority( + effectiveClientId, effectiveTenantId, authorityHost); } var tokenRequestContext = new TokenRequestContext(scopes); @@ -295,7 +306,8 @@ private async Task AuthenticateInteractivelyAsync( _logger.LogWarning("Browser authentication is not supported on this platform, falling back to device code flow..."); _logger.LogDebug("Using device code authentication..."); _logger.LogDebug("Please sign in with your Microsoft account"); - var deviceCodeCredential = CreateDeviceCodeCredential(effectiveClientId, effectiveTenantId); + var deviceCodeCredential = CreateDeviceCodeCredentialForAuthority( + effectiveClientId, effectiveTenantId, authorityHost); tokenResult = await deviceCodeCredential.GetTokenAsync(tokenRequestContext, ct); } _logger.LogDebug("Authentication successful!"); @@ -368,6 +380,7 @@ private async Task AuthenticateInteractivelyAsync( /// Optional client ID for authentication. If not provided, uses PowerShell client ID /// Optional UPN/email to pre-select the account for WAM and silent acquisition. /// When provided, WAM will target this identity instead of the first cached account. + /// Optional OAuth authority host for sovereign cloud authentication. /// Access token with the requested scopes public async Task GetAccessTokenWithScopesAsync( string resourceAppId, @@ -376,7 +389,8 @@ public async Task GetAccessTokenWithScopesAsync( bool forceRefresh = false, string? clientId = null, bool useInteractiveBrowser = true, - string? userId = null) + string? userId = null, + string? authorityHost = null) { if (string.IsNullOrWhiteSpace(resourceAppId)) throw new ArgumentException("Resource App ID cannot be empty", nameof(resourceAppId)); @@ -388,7 +402,15 @@ public async Task GetAccessTokenWithScopesAsync( resourceAppId, string.Join(", ", scopes)); // Delegate to the consolidated GetAccessTokenAsync method - return await GetAccessTokenAsync(resourceAppId, tenantId, forceRefresh, clientId, scopes, useInteractiveBrowser, userId); + return await GetAccessTokenAsync( + resourceAppId, + tenantId, + forceRefresh, + clientId, + scopes, + useInteractiveBrowser, + userId, + authorityHost: authorityHost); } /// @@ -545,6 +567,18 @@ public bool ValidateScopesForResource(string resourceUrl, string? manifestPath = protected virtual TokenCredential CreateBrowserCredential(string clientId, string tenantId, string? loginHint = null, bool forceRefresh = false) => new MsalBrowserCredential(clientId, tenantId, redirectUri: null, _logger, loginHint: loginHint, forceRefresh: forceRefresh); + private TokenCredential CreateBrowserCredentialForAuthority( + string clientId, string tenantId, string? authorityHost, string? loginHint, bool forceRefresh) + { + var host = ConfigConstants.NormalizeAuthorityHost(authorityHost); + if (string.Equals(host, ConfigConstants.DefaultAuthorityHost, StringComparison.OrdinalIgnoreCase)) + return CreateBrowserCredential(clientId, tenantId, loginHint, forceRefresh); + + return new MsalBrowserCredential( + clientId, tenantId, redirectUri: null, _logger, authority: $"{host}/{tenantId}", + loginHint: loginHint, forceRefresh: forceRefresh); + } + /// /// Creates a DeviceCodeCredential configured for interactive device code authentication. /// This flow works in all environments including SSH, remote sessions, and platforms where @@ -552,12 +586,26 @@ protected virtual TokenCredential CreateBrowserCredential(string clientId, strin /// Protected virtual to allow substitution in tests. /// protected virtual TokenCredential CreateDeviceCodeCredential(string clientId, string tenantId) + => CreateDeviceCodeCredentialCore(clientId, tenantId, AzureAuthorityHosts.AzurePublicCloud); + + private TokenCredential CreateDeviceCodeCredentialForAuthority( + string clientId, string tenantId, string? authorityHost) + { + var host = ConfigConstants.NormalizeAuthorityHost(authorityHost); + if (string.Equals(host, ConfigConstants.DefaultAuthorityHost, StringComparison.OrdinalIgnoreCase)) + return CreateDeviceCodeCredential(clientId, tenantId); + + return CreateDeviceCodeCredentialCore(clientId, tenantId, new Uri(host)); + } + + private DeviceCodeCredential CreateDeviceCodeCredentialCore( + string clientId, string tenantId, Uri authorityHost) { return new DeviceCodeCredential(new DeviceCodeCredentialOptions { TenantId = tenantId, ClientId = clientId, - AuthorityHost = AzureAuthorityHosts.AzurePublicCloud, + AuthorityHost = authorityHost, TokenCachePersistenceOptions = new TokenCachePersistenceOptions { Name = AuthenticationConstants.ApplicationName diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/BootstrapConfigResolver.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/BootstrapConfigResolver.cs index 9899fbf0..86e6741c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/BootstrapConfigResolver.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/BootstrapConfigResolver.cs @@ -146,6 +146,9 @@ public async Task WriteBootstrapConfigAsync(Agent365Config config, string path) { ["tenantId"] = config.TenantId, ["clientAppId"] = config.ClientAppId, + ["environment"] = config.Environment, + ["graphBaseUrl"] = config.GraphBaseUrl, + ["authorityHost"] = config.AuthorityHost, ["agentIdentityDisplayName"] = config.AgentIdentityDisplayName, ["agentBlueprintDisplayName"] = config.AgentBlueprintDisplayName, ["agentDescription"] = config.AgentDescription, @@ -236,6 +239,27 @@ public async Task CheckAndBackupStaleConfigAsync(string configPath, Cancel } } + private async Task GetBootstrapEnvironmentAsync(CancellationToken ct) + { + var configuredEnvironment = Environment.GetEnvironmentVariable("A365_ENVIRONMENT"); + if (!string.IsNullOrWhiteSpace(configuredEnvironment)) + return configuredEnvironment; + + try + { + var result = await _executor.ExecuteAsync( + "az", "cloud show --query name -o tsv", + captureOutput: true, suppressErrorLogging: true, cancellationToken: ct); + var cloudName = result.StandardOutput?.Trim(); + return string.IsNullOrWhiteSpace(cloudName) ? "prod" : cloudName; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to resolve current Azure CLI cloud; using the default environment."); + return "prod"; + } + } + // ── Private helpers ──────────────────────────────────────────────────────── private async Task BuildBootstrapConfigAsync( @@ -247,6 +271,9 @@ public async Task CheckAndBackupStaleConfigAsync(string configPath, Cancel if (tenantId is null) return null; + var environment = await GetBootstrapEnvironmentAsync(ct); + _graphApiService?.ConfigureCloudEndpoints(new Agent365Config { Environment = environment }); + var clientAppId = await SetupHelpers.ResolveBootstrapClientAppIdAsync( tenantId, _graphApiService, _logger, ct); if (string.IsNullOrWhiteSpace(clientAppId)) @@ -259,6 +286,9 @@ public async Task CheckAndBackupStaleConfigAsync(string configPath, Cancel { TenantId = tenantId, ClientAppId = clientAppId, + Environment = environment, + GraphBaseUrl = _graphApiService?.GraphBaseUrl ?? ConfigConstants.GetGraphBaseUrl(environment), + AuthorityHost = _graphApiService?.AuthorityHost ?? ConfigConstants.GetAuthorityHost(environment), AgentIdentityDisplayName = $"{agentName} Identity", AgentBlueprintDisplayName = $"{agentName} Blueprint", AgentDescription = agentName, @@ -290,6 +320,9 @@ public async Task CheckAndBackupStaleConfigAsync(string configPath, Cancel return null; } + var environment = await GetBootstrapEnvironmentAsync(ct); + _graphApiService?.ConfigureCloudEndpoints(new Agent365Config { Environment = environment }); + // Step 2: Resolve client app ID — prefer local a365.config.json when tenant matches. var resolvedClientAppId = await SetupHelpers.ResolveBootstrapClientAppIdAsync( tenantId, _graphApiService, _logger, ct, preferLocalConfig: true); @@ -379,6 +412,9 @@ public async Task CheckAndBackupStaleConfigAsync(string configPath, Cancel { TenantId = tenantId, ClientAppId = resolvedClientAppId, + Environment = environment, + GraphBaseUrl = _graphApiService?.GraphBaseUrl ?? ConfigConstants.GetGraphBaseUrl(environment), + AuthorityHost = _graphApiService?.AuthorityHost ?? ConfigConstants.GetAuthorityHost(environment), AgentIdentityDisplayName = $"{agentName} Identity", AgentBlueprintDisplayName = blueprintDisplayName, AgentDescription = agentName, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs index 4ff9603c..9a91095b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs @@ -165,7 +165,7 @@ public async Task EnsureValidClientAppAsync( missingDetails.Add("OAuth2 consent grant must be upgraded from per-user (Principal) to tenant-wide (AllPrincipals)"); if (needsWidsClaim) missingDetails.Add("'wids' optional claim missing on access tokens — without it, role detection always returns Unknown and the AllPrincipals grant phase silently skips, leaving the agent blueprint with no permissions granted on its service principal"); - var consentUrl = ClientAppValidationException.BuildAdminConsentUrl(clientAppId, tenantId); + var consentUrl = ClientAppValidationException.BuildAdminConsentUrl(clientAppId, tenantId, _graphApiService.AuthorityHost); var steps = new List { "Next Steps — Global Administrator action required:", @@ -289,7 +289,7 @@ public async Task EnsureValidClientAppAsync( // Step 4: Verify admin consent (requires AllPrincipals grant) if (!await ValidateAdminConsentAsync(clientAppId, tenantId, ct)) { - throw ClientAppValidationException.MissingAdminConsent(clientAppId, tenantId); + throw ClientAppValidationException.MissingAdminConsent(clientAppId, tenantId, _graphApiService.AuthorityHost); } // Step 5: Verify and fix redirect URIs @@ -1570,7 +1570,7 @@ private async Task ValidateAdminConsentAsync(string clientAppId, string te } // Print the admin consent URL so the user (or their admin) can fix this immediately - var consentUrl = ClientAppValidationException.BuildAdminConsentUrl(clientAppId, tenantId); + var consentUrl = ClientAppValidationException.BuildAdminConsentUrl(clientAppId, tenantId, _graphApiService.AuthorityHost); if (consentUrl != null) { _logger.LogInformation("To grant tenant-wide admin consent, share this URL with a Global Administrator:"); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs index 8a20758a..3c0ef8c1 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs @@ -120,8 +120,10 @@ public async Task EnsureBlueprintPermissionGrantAsync( var result = await EnsureScopeOnGrantAsync(httpClient, grant, TargetScope, cancellationToken); if (result == ScopeGrantResult.NeedsAdminConsent) { - var scopeUri = Uri.EscapeDataString($"{AuthenticationConstants.MicrosoftGraphResourceUri}/{TargetScope}"); - var consentUrl = $"https://login.microsoftonline.com/{tenantId}/v2.0/adminconsent?client_id={callingAppId}&scope={scopeUri}"; + var graphResourceUri = GraphApiConstants.GetResource(_graphService.GraphBaseUrl).TrimEnd('/'); + var scopeUri = Uri.EscapeDataString($"{graphResourceUri}/{TargetScope}"); + var consentBaseUrl = ConfigConstants.BuildAdminConsentEndpointUrl(_graphService.AuthorityHost, tenantId); + var consentUrl = $"{consentBaseUrl}?client_id={callingAppId}&scope={scopeUri}"; _logger.LogError( "The existing permission grant could not be updated to include '{Scope}'. " + "An administrator ({Roles}) must grant admin consent. " + @@ -192,7 +194,7 @@ public async Task EnsureBlueprintPermissionGrantAsync( // Create new service principal _logger.LogDebug("Creating service principal for app {AppId}", appId); - var createSpUrl = $"{GraphApiConstants.BaseUrl}/v1.0/servicePrincipals"; + var createSpUrl = $"{_graphService.GraphBaseUrl}/v1.0/servicePrincipals"; var createBody = new { appId = appId @@ -350,7 +352,7 @@ private bool IsCaeTokenError(string errorJson) { try { - var url = $"{GraphApiConstants.BaseUrl}/v1.0/servicePrincipals?$filter=appId eq '{appId}'"; + var url = $"{_graphService.GraphBaseUrl}/v1.0/servicePrincipals?$filter=appId eq '{appId}'"; using var response = await httpClient.GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) @@ -391,7 +393,7 @@ private bool IsCaeTokenError(string errorJson) try { var filter = $"clientId eq '{clientId}' and resourceId eq '{resourceId}' and consentType eq '{AllPrincipalsConsentType}'"; - var url = $"{GraphApiConstants.BaseUrl}/v1.0/oauth2PermissionGrants?$filter={Uri.EscapeDataString(filter)}"; + var url = $"{_graphService.GraphBaseUrl}/v1.0/oauth2PermissionGrants?$filter={Uri.EscapeDataString(filter)}"; using var response = await httpClient.GetAsync(url, cancellationToken); @@ -458,7 +460,7 @@ private async Task EnsureScopeOnGrantAsync( _logger.LogDebug(" Updating grant {GrantId} to include scope: {Scope}", grantId, scopeToAdd); // Update the grant - var updateUrl = $"{GraphApiConstants.BaseUrl}/v1.0/oauth2PermissionGrants/{grantId}"; + var updateUrl = $"{_graphService.GraphBaseUrl}/v1.0/oauth2PermissionGrants/{grantId}"; var updateBody = new { scope = newScope @@ -509,7 +511,7 @@ private async Task CreateGrantAsync( { try { - var createUrl = $"{GraphApiConstants.BaseUrl}/v1.0/oauth2PermissionGrants"; + var createUrl = $"{_graphService.GraphBaseUrl}/v1.0/oauth2PermissionGrants"; var createBody = new { clientId = clientId, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs index 8ed1388f..c9ce44eb 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs @@ -27,6 +27,11 @@ public class GraphApiService private readonly IAuthenticationService _authService; private readonly RetryHelper _retryHelper; private string _graphBaseUrl; + private string _authorityHost = ConfigConstants.DefaultAuthorityHost; + private string? GraphBaseUrlOverride => + string.Equals(_graphBaseUrl, GraphApiConstants.BaseUrl, StringComparison.OrdinalIgnoreCase) ? null : _graphBaseUrl; + private string? AuthorityHostOverride => + string.Equals(_authorityHost, ConfigConstants.DefaultAuthorityHost, StringComparison.OrdinalIgnoreCase) ? null : _authorityHost; // Login hint resolved once per GraphApiService instance. // Used to direct MSAL/WAM to the correct identity, preventing the Windows default @@ -62,7 +67,29 @@ public class GraphApiService public string GraphBaseUrl { get => _graphBaseUrl; - set => _graphBaseUrl = string.IsNullOrWhiteSpace(value) ? GraphApiConstants.BaseUrl : value; + set => _graphBaseUrl = ConfigConstants.NormalizeGraphBaseUrl(value); + } + + /// + /// OAuth authority host used for token acquisition. + /// + public string AuthorityHost + { + get => _authorityHost; + set => _authorityHost = ConfigConstants.NormalizeAuthorityHost(value); + } + + /// + /// Applies cloud endpoints and the custom client app from a loaded project config. + /// + public void ConfigureCloudEndpoints(Agent365Config config) + { + ArgumentNullException.ThrowIfNull(config); + + GraphBaseUrl = ConfigConstants.GetGraphBaseUrl(config.Environment, config.GraphBaseUrl); + AuthorityHost = ConfigConstants.GetAuthorityHost(config.Environment, config.AuthorityHost); + if (!string.IsNullOrWhiteSpace(config.ClientAppId)) + CustomClientAppId = config.ClientAppId; } // Lightweight wrapper to surface HTTP status, reason and body to callers @@ -88,7 +115,7 @@ public GraphApiService(ILogger logger, CommandExecutor executor _retryHelper = retryHelper ?? new RetryHelper(_logger); // Default: try az CLI first (if present), fall back to JWT cache in AuthenticationService. _loginHintResolver = loginHintResolver ?? (() => ResolveLoginHintWithFallbackAsync(authService)); - _graphBaseUrl = string.IsNullOrWhiteSpace(graphBaseUrl) ? GraphApiConstants.BaseUrl : graphBaseUrl; + _graphBaseUrl = ConfigConstants.NormalizeGraphBaseUrl(graphBaseUrl); _agentRegistryRetryDelay = agentRegistryRetryDelay ?? TimeSpan.FromSeconds(30); } @@ -146,7 +173,9 @@ public GraphApiService(ILogger logger, CommandExecutor executor { var resource = GraphApiConstants.GetResource(_graphBaseUrl); var loginHint = await _loginHintResolver(); - var token = await _authService.GetAccessTokenAsync(resource, tenantId, forceRefresh: forceRefresh, userId: loginHint, ct: ct); + var token = await _authService.GetAccessTokenAsync( + resource, tenantId, forceRefresh: forceRefresh, userId: loginHint, ct: ct, + authorityHost: AuthorityHostOverride); if (!string.IsNullOrWhiteSpace(token)) { _logger.LogDebug("Graph API access token acquired successfully"); @@ -210,7 +239,9 @@ private async Task EnsureGraphHeadersAsync(string tenantId, bool forceRefr "Acquiring Graph token via token provider (clientId: {AppId}, scopes: {Scopes})", CustomClientAppId, string.Join(", ", effectiveScopes)); var loginHint = await ResolveLoginHintAsync(); - token = await _tokenProvider.GetMgGraphAccessTokenAsync(tenantId, effectiveScopes, false, CustomClientAppId, ct, loginHint, forceRefresh); + token = await _tokenProvider.GetMgGraphAccessTokenAsync( + tenantId, effectiveScopes, false, CustomClientAppId, ct, loginHint, forceRefresh, + GraphBaseUrlOverride, AuthorityHostOverride); if (string.IsNullOrWhiteSpace(token)) { @@ -1177,7 +1208,7 @@ public async Task CreatePrincipalOauth2PermissionGrantAsync( clientAppId: CustomClientAppId, ct: ct, loginHint: loginHint, - forceRefresh: false); + forceRefresh: false, graphBaseUrl: GraphBaseUrlOverride, authorityHost: AuthorityHostOverride); if (string.IsNullOrWhiteSpace(token)) return Models.RoleCheckResult.Unknown; @@ -1377,7 +1408,7 @@ public async Task CreatePrincipalOauth2PermissionGrantAsync( // Use .default so the token includes all permissions consented on the "Agent 365 CLI" app, // including AgentRegistration.ReadWrite.All, without enumerating scopes explicitly. IEnumerable? registrationScopes = _tokenProvider != null - ? [$"{Constants.AuthenticationConstants.MicrosoftGraphResourceUri}/.default"] + ? [$"{_graphBaseUrl}/.default"] : null; var now = DateTimeOffset.UtcNow.ToString("o"); @@ -1493,10 +1524,10 @@ public virtual async Task DeleteAgentRegistrationAsync( // Use .default so the token includes all permissions consented on the "Agent 365 CLI" app, // including AgentRegistration.ReadWrite.All, without enumerating scopes explicitly. IEnumerable? scopes = _tokenProvider != null - ? [$"{Constants.AuthenticationConstants.MicrosoftGraphResourceUri}/.default"] + ? [$"{_graphBaseUrl}/.default"] : null; - _logger.LogInformation("DELETE https://graph.microsoft.com{Path}/{RegistrationId}", AgentRegistrationsPath, registrationId); + _logger.LogInformation("DELETE {GraphBaseUrl}{Path}/{RegistrationId}", _graphBaseUrl, AgentRegistrationsPath, registrationId); return await GraphDeleteAsync( tenantId, @@ -1519,11 +1550,11 @@ public virtual async Task DeleteAgentRegistrationAsync( CancellationToken ct = default) { IEnumerable? scopes = _tokenProvider != null - ? [$"{Constants.AuthenticationConstants.MicrosoftGraphResourceUri}/.default"] + ? [$"{_graphBaseUrl}/.default"] : null; var path = $"{AgentRegistrationsPath}/{Uri.EscapeDataString(registrationId)}"; - _logger.LogDebug("GET https://graph.microsoft.com{Path}", path); + _logger.LogDebug("GET {GraphBaseUrl}{Path}", _graphBaseUrl, path); try { @@ -1558,7 +1589,7 @@ public virtual async Task DeleteAgentInstanceAsync( ? [Constants.AuthenticationConstants.AgentInstanceReadWriteAllScope] : null; - _logger.LogInformation("DELETE https://graph.microsoft.com/beta/agentRegistry/agentInstances/{InstanceId}", instanceId); + _logger.LogInformation("DELETE {GraphBaseUrl}/beta/agentRegistry/agentInstances/{InstanceId}", _graphBaseUrl, instanceId); return await GraphDeleteAsync( tenantId, @@ -1588,7 +1619,7 @@ public virtual async Task DeleteAgentInstanceAsync( _logger.LogDebug("Acquiring blueprint access token via client credentials (CorrelationId: {Id})", effectiveCorrelationId); using var httpClient = HttpClientFactory.CreateAuthenticatedClient(correlationId: effectiveCorrelationId); - var tokenEndpoint = $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token"; + var tokenEndpoint = ConfigConstants.BuildTokenEndpointUrl(_authorityHost, tenantId); const int maxRetries = 12; const int baseDelaySeconds = 5; @@ -1600,7 +1631,7 @@ public virtual async Task DeleteAgentInstanceAsync( { new KeyValuePair("client_id", clientId), new KeyValuePair("client_secret", clientSecret), - new KeyValuePair("scope", "https://graph.microsoft.com/.default"), + new KeyValuePair("scope", $"{_graphBaseUrl}/.default"), new KeyValuePair("grant_type", "client_credentials"), }); @@ -1690,7 +1721,8 @@ public virtual async Task DeleteAgentInstanceAsync( { var loginHint = await ResolveLoginHintAsync(); var previewToken = await _tokenProvider.GetMgGraphAccessTokenAsync( - tenantId, scopes, false, CustomClientAppId, ct, loginHint); + tenantId, scopes, false, CustomClientAppId, ct, loginHint, + graphBaseUrl: GraphBaseUrlOverride, authorityHost: AuthorityHostOverride); if (!string.IsNullOrWhiteSpace(previewToken)) { var scp = TryDecodeTokenClaim(previewToken, "scp"); @@ -1717,15 +1749,15 @@ public virtual async Task DeleteAgentInstanceAsync( { body["sponsors@odata.bind"] = new JsonArray { - $"https://graph.microsoft.com/v1.0/users/{currentUserId}" + $"{_graphBaseUrl}/v1.0/users/{currentUserId}" }; body["owners@odata.bind"] = new JsonArray { - $"https://graph.microsoft.com/v1.0/users/{currentUserId}" + $"{_graphBaseUrl}/v1.0/users/{currentUserId}" }; } - _logger.LogDebug("POST https://graph.microsoft.com/beta/servicePrincipals/Microsoft.Graph.AgentIdentity (delegated)"); + _logger.LogDebug("POST {GraphBaseUrl}/beta/servicePrincipals/Microsoft.Graph.AgentIdentity (delegated)", _graphBaseUrl); _logger.LogDebug("Body: {Body}", body.ToJsonString()); // Use GraphPostWithResponseAsync so we can log the full error body on failure. @@ -1827,13 +1859,13 @@ public virtual async Task DeleteAgentInstanceAsync( { body["sponsors@odata.bind"] = new JsonArray { - $"https://graph.microsoft.com/v1.0/users/{currentUserId}" + $"{_graphBaseUrl}/v1.0/users/{currentUserId}" }; } const int maxAttempts = 5; const int baseDelaySeconds = 5; - const string agentIdentityUrl = "https://graph.microsoft.com/beta/serviceprincipals/Microsoft.Graph.AgentIdentity"; + var agentIdentityUrl = $"{_graphBaseUrl}/beta/serviceprincipals/Microsoft.Graph.AgentIdentity"; for (int attempt = 0; attempt < maxAttempts; attempt++) { 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 5746982d..d827c5cc 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/AdminConsentHelper.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/AdminConsentHelper.cs @@ -78,12 +78,14 @@ public static async Task PollAdminConsentAsync( string scopeDescriptor, int timeoutSeconds, int intervalSeconds, - CancellationToken ct) + CancellationToken ct, + string? graphBaseUrl = null) { if (BypassConsentChecksForTests) return true; var start = DateTime.UtcNow; + var baseUrl = ConfigConstants.NormalizeGraphBaseUrl(graphBaseUrl); string? spId = null; int lastProgressReportSeconds = 0; @@ -107,7 +109,7 @@ public static async Task PollAdminConsentAsync( if (spId == null) { var spResult = await executor.ExecuteAsync("az", - $"rest --method GET --url \"https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq '{appId}'\"", + $"rest --method GET --url \"{baseUrl}/v1.0/servicePrincipals?$filter=appId eq '{appId}'\"", captureOutput: true, suppressErrorLogging: true, cancellationToken: ct); if (spResult.Success) @@ -128,7 +130,7 @@ public static async Task PollAdminConsentAsync( if (spId != null) { var grants = await executor.ExecuteAsync("az", - $"rest --method GET --url \"https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spId}'\"", + $"rest --method GET --url \"{baseUrl}/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spId}'\"", captureOutput: true, suppressErrorLogging: true, cancellationToken: ct); if (grants.Success) @@ -387,7 +389,8 @@ public static async Task CheckConsentExistsAsync( CancellationToken ct, string? consentType = null, string? blueprintSpObjectId = null, - string? resourceSpObjectId = null) + string? resourceSpObjectId = null, + string? graphBaseUrl = null) { if (BypassConsentChecksForTests) return true; @@ -406,11 +409,12 @@ public static async Task CheckConsentExistsAsync( try { + var baseUrl = ConfigConstants.NormalizeGraphBaseUrl(graphBaseUrl); // Skip SP lookups when the caller already resolved them in Phase 1 — each az rest // call costs ~1.7s due to az's Python startup. The orchestrator passes pre-resolved // IDs to cut 4-resource setup pre-check from ~21s to ~7s. var blueprintSpId = blueprintSpObjectId - ?? await LookupSpObjectIdByAppIdAsync(executor, blueprintAppId, ct); + ?? await LookupSpObjectIdByAppIdAsync(executor, blueprintAppId, baseUrl, ct); if (blueprintSpId == null) { logger.LogDebug("Blueprint SP not found for appId {BlueprintAppId} via az rest", blueprintAppId); @@ -418,7 +422,7 @@ public static async Task CheckConsentExistsAsync( } var resourceSpId = resourceSpObjectId - ?? await LookupSpObjectIdByAppIdAsync(executor, resourceAppId, ct); + ?? await LookupSpObjectIdByAppIdAsync(executor, resourceAppId, baseUrl, ct); if (resourceSpId == null) { logger.LogDebug("Resource SP not found for appId {ResourceAppId} via az rest", resourceAppId); @@ -430,7 +434,7 @@ public static async Task CheckConsentExistsAsync( filter += $" and consentType eq '{consentType}'"; var grantsResult = await executor.ExecuteAsync("az", - $"rest --method GET --url \"https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter={Uri.EscapeDataString(filter)}\"", + $"rest --method GET --url \"{baseUrl}/v1.0/oauth2PermissionGrants?$filter={Uri.EscapeDataString(filter)}\"", captureOutput: true, suppressErrorLogging: true, cancellationToken: ct); if (!grantsResult.Success) @@ -480,10 +484,10 @@ public static async Task CheckConsentExistsAsync( } private static async Task LookupSpObjectIdByAppIdAsync( - CommandExecutor executor, string appId, CancellationToken ct) + CommandExecutor executor, string appId, string graphBaseUrl, CancellationToken ct) { var spResult = await executor.ExecuteAsync("az", - $"rest --method GET --url \"https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq '{appId}'&$select=id\"", + $"rest --method GET --url \"{graphBaseUrl}/v1.0/servicePrincipals?$filter=appId eq '{appId}'&$select=id\"", captureOutput: true, suppressErrorLogging: true, cancellationToken: ct); if (!spResult.Success) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/EndpointHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/EndpointHelper.cs index 3dc521c0..c33ee3e9 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/EndpointHelper.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/EndpointHelper.cs @@ -110,7 +110,8 @@ private static string ExtractBlueprintIdSuffix(string blueprintId) public static string GetCreateEndpointUrl(string environment) { // Check for custom endpoint in environment variable first - var customEndpoint = Environment.GetEnvironmentVariable($"A365_CREATE_ENDPOINT_{environment?.ToUpper()}"); + var customEndpoint = Environment.GetEnvironmentVariable( + $"A365_CREATE_ENDPOINT_{ConfigConstants.NormalizeEnvironmentKey(environment)}"); if (!string.IsNullOrEmpty(customEndpoint)) return customEndpoint; @@ -128,7 +129,8 @@ public static string GetCreateEndpointUrl(string environment) public static string GetDeleteEndpointUrl(string environment) { // Check for custom endpoint in environment variable first - var customEndpoint = Environment.GetEnvironmentVariable($"A365_DELETE_ENDPOINT_{environment?.ToUpper()}"); + var customEndpoint = Environment.GetEnvironmentVariable( + $"A365_DELETE_ENDPOINT_{ConfigConstants.NormalizeEnvironmentKey(environment)}"); if (!string.IsNullOrEmpty(customEndpoint)) return customEndpoint; @@ -146,7 +148,8 @@ public static string GetDeleteEndpointUrl(string environment) public static string GetDeploymentEnvironment(string environment) { // Check for custom deployment environment in environment variable first - var customDeploymentEnvironment = Environment.GetEnvironmentVariable($"A365_DEPLOYMENT_ENVIRONMENT_{environment?.ToUpper()}"); + var customDeploymentEnvironment = Environment.GetEnvironmentVariable( + $"A365_DEPLOYMENT_ENVIRONMENT_{ConfigConstants.NormalizeEnvironmentKey(environment)}"); if (!string.IsNullOrEmpty(customDeploymentEnvironment)) return customDeploymentEnvironment; @@ -164,7 +167,8 @@ public static string GetDeploymentEnvironment(string environment) public static string GetClusterCategory(string environment) { // Check for custom cluster category in environment variable first - var customClusterCategory = Environment.GetEnvironmentVariable($"A365_CLUSTER_CATEGORY_{environment?.ToUpper()}"); + var customClusterCategory = Environment.GetEnvironmentVariable( + $"A365_CLUSTER_CATEGORY_{ConfigConstants.NormalizeEnvironmentKey(environment)}"); if (!string.IsNullOrEmpty(customClusterCategory)) return customClusterCategory; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs index 533cd55e..8dd3a270 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs @@ -28,6 +28,9 @@ public sealed class InteractiveGraphAuthService private readonly string _clientAppId; private readonly Func? _credentialFactory; private readonly Func> _loginHintResolver; + private readonly string _graphBaseUrl; + private readonly string _authorityHost; + private readonly string[] _requiredScopes; private GraphServiceClient? _cachedClient; private string? _cachedTenantId; @@ -37,7 +40,9 @@ public InteractiveGraphAuthService( ILogger logger, string clientAppId, Func? credentialFactory = null, - Func>? loginHintResolver = null) + Func>? loginHintResolver = null, + string? graphBaseUrl = null, + string? authorityHost = null) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -58,6 +63,14 @@ public InteractiveGraphAuthService( _clientAppId = clientAppId; _credentialFactory = credentialFactory; _loginHintResolver = loginHintResolver ?? ResolveAzLoginHintAsync; + _graphBaseUrl = ConfigConstants.NormalizeGraphBaseUrl(graphBaseUrl); + _authorityHost = ConfigConstants.NormalizeAuthorityHost(authorityHost); + _requiredScopes = RequiredScopes + .Select(scope => scope.Replace( + AuthenticationConstants.MicrosoftGraphResourceUri, + _graphBaseUrl, + StringComparison.OrdinalIgnoreCase)) + .ToArray(); } /// @@ -84,7 +97,7 @@ public async Task GetAuthenticatedGraphClientAsync( // Eagerly acquire a token so authentication failures are detected here rather than // surfacing later from inside GraphServiceClient's lazy token acquisition. // Resolve credential inside try/catch so factory exceptions are wrapped consistently. - var tokenContext = new TokenRequestContext(RequiredScopes); + var tokenContext = new TokenRequestContext(_requiredScopes); TokenCredential? credential = null; try { @@ -93,7 +106,13 @@ public async Task GetAuthenticatedGraphClientAsync( // Resolve credential: use injected factory (for tests) or default MsalBrowserCredential credential = _credentialFactory?.Invoke(_clientAppId, tenantId) - ?? new MsalBrowserCredential(_clientAppId, tenantId, redirectUri: null, _logger, loginHint: loginHint); + ?? new MsalBrowserCredential( + _clientAppId, + tenantId, + redirectUri: null, + _logger, + authority: $"{_authorityHost}/{tenantId}", + loginHint: loginHint); await credential.GetTokenAsync(tokenContext, cancellationToken); } @@ -137,7 +156,8 @@ public async Task GetAuthenticatedGraphClientAsync( // from GraphServiceClient will hit the silent cache without re-prompting. _logger.LogInformation("Successfully authenticated to Microsoft Graph!"); - var graphClient = new GraphServiceClient(credential!, RequiredScopes); + var graphClient = new GraphServiceClient(credential!, _requiredScopes); + graphClient.RequestAdapter.BaseUrl = $"{_graphBaseUrl}/{GraphApiConstants.Versions.V1}"; _cachedClient = graphClient; _cachedTenantId = tenantId; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/IMicrosoftGraphTokenProvider.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/IMicrosoftGraphTokenProvider.cs index 1974f184..006432b2 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/IMicrosoftGraphTokenProvider.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/IMicrosoftGraphTokenProvider.cs @@ -27,5 +27,7 @@ public interface IMicrosoftGraphTokenProvider string? clientAppId = null, CancellationToken ct = default, string? loginHint = null, - bool forceRefresh = false); + bool forceRefresh = false, + string? graphBaseUrl = null, + string? authorityHost = null); } 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 ce490f65..e9fed441 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs @@ -93,9 +93,13 @@ public MicrosoftGraphTokenProvider( string? clientAppId = null, CancellationToken ct = default, string? loginHint = null, - bool forceRefresh = false) + bool forceRefresh = false, + string? graphBaseUrl = null, + string? authorityHost = null) { - var validatedScopes = ValidateAndPrepareScopes(scopes); + var resolvedGraphBaseUrl = ConfigConstants.NormalizeGraphBaseUrl(graphBaseUrl); + var resolvedAuthorityHost = ConfigConstants.NormalizeAuthorityHost(authorityHost); + var validatedScopes = ValidateAndPrepareScopes(scopes, resolvedGraphBaseUrl); ValidateTenantId(tenantId); if (!string.IsNullOrWhiteSpace(clientAppId)) @@ -149,12 +153,23 @@ public MicrosoftGraphTokenProvider( // and WAM on Windows authenticates via the OS broker (no browser, CAP-compliant). var token = MsalTokenAcquirerOverride != null ? await MsalTokenAcquirerOverride(tenantId, validatedScopes, clientAppId, ct) - : await AcquireGraphTokenViaMsalAsync(tenantId, validatedScopes, clientAppId, ct, loginHint, forceRefresh); + : await AcquireGraphTokenViaMsalAsync( + tenantId, validatedScopes, clientAppId, resolvedAuthorityHost, ct, loginHint, + forceRefresh); // Fall back to PowerShell Connect-MgGraph if MSAL is unavailable (e.g. no clientAppId) // or fails for any reason. if (string.IsNullOrWhiteSpace(token)) { + if (!string.Equals(resolvedAuthorityHost, ConfigConstants.DefaultAuthorityHost, StringComparison.OrdinalIgnoreCase) + || !string.Equals(resolvedGraphBaseUrl, GraphApiConstants.BaseUrl, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogError( + "MSAL Graph authentication failed for the configured cloud. " + + "PowerShell fallback is available only for commercial Graph and authority endpoints."); + return null; + } + _logger.LogDebug("MSAL token acquisition failed, falling back to PowerShell Connect-MgGraph..."); var script = BuildPowerShellScript(tenantId, validatedScopes, useDeviceCode, clientAppId); @@ -171,7 +186,8 @@ public MicrosoftGraphTokenProvider( _logger.LogWarning( "PowerShell interactive browser authentication failed (Conditional Access Policy or embedded terminal). " + "Retrying with device code authentication..."); - var deviceCodeScript = BuildPowerShellScript(tenantId, validatedScopes, useDeviceCode: true, clientAppId); + var deviceCodeScript = BuildPowerShellScript( + tenantId, validatedScopes, useDeviceCode: true, clientAppId); var deviceCodeResult = await ExecuteWithFallbackAsync(deviceCodeScript, ct); token = ProcessResult(deviceCodeResult); } @@ -231,7 +247,9 @@ public MicrosoftGraphTokenProvider( // Retry once — do not recurse; use the underlying acquirer directly. var retryToken = MsalTokenAcquirerOverride != null ? await MsalTokenAcquirerOverride(tenantId, validatedScopes, clientAppId, ct) - : await AcquireGraphTokenViaMsalAsync(tenantId, validatedScopes, clientAppId, ct, loginHint, forceRefresh: true); + : await AcquireGraphTokenViaMsalAsync( + tenantId, validatedScopes, clientAppId, resolvedAuthorityHost, ct, loginHint, + forceRefresh: true); if (!string.IsNullOrWhiteSpace(retryToken)) token = retryToken; @@ -264,13 +282,17 @@ public MicrosoftGraphTokenProvider( } } - private string[] ValidateAndPrepareScopes(IEnumerable scopes) + private string[] ValidateAndPrepareScopes(IEnumerable scopes, string graphBaseUrl) { if (scopes == null) throw new ArgumentNullException(nameof(scopes)); var validScopes = scopes .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(s => s.Trim()) + .Select(s => s.Contains("://", StringComparison.Ordinal) + ? s + : $"{graphBaseUrl}/{s.TrimStart('/')}") .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -307,7 +329,8 @@ private static void ValidateClientAppId(string clientAppId) nameof(clientAppId)); } - private static string BuildPowerShellScript(string tenantId, string[] scopes, bool useDeviceCode, string? clientAppId = null) + private static string BuildPowerShellScript( + string tenantId, string[] scopes, bool useDeviceCode, string? clientAppId = null) { var escapedTenantId = CommandStringHelper.EscapePowerShellString(tenantId); var scopesArray = BuildScopesArray(scopes); @@ -387,6 +410,7 @@ private async Task ExecuteWithFallbackAsync( string tenantId, string[] scopes, string? clientAppId, + string authorityHost, CancellationToken ct, string? loginHint = null, bool forceRefresh = false) @@ -399,15 +423,16 @@ private async Task ExecuteWithFallbackAsync( try { - // MSAL requires fully-qualified scope URIs; PS Connect-MgGraph handles this internally. - var fullScopes = scopes - .Select(s => s.Contains("://", StringComparison.Ordinal) ? s : $"https://graph.microsoft.com/{s}") - .ToArray(); - - _logger.LogDebug("Acquiring Graph token via MSAL for scopes: {Scopes}", string.Join(", ", fullScopes)); - - var msalCredential = new MsalBrowserCredential(clientAppId, tenantId, logger: _logger, loginHint: loginHint, forceRefresh: forceRefresh); - var tokenResult = await msalCredential.GetTokenAsync(new TokenRequestContext(fullScopes), ct); + _logger.LogDebug("Acquiring Graph token via MSAL for scopes: {Scopes}", string.Join(", ", scopes)); + + var msalCredential = new MsalBrowserCredential( + clientAppId, + tenantId, + logger: _logger, + authority: $"{authorityHost}/{tenantId}", + loginHint: loginHint, + forceRefresh: forceRefresh); + var tokenResult = await msalCredential.GetTokenAsync(new TokenRequestContext(scopes), ct); if (string.IsNullOrWhiteSpace(tokenResult.Token)) return null; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs index 40baede1..1280e0c0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs @@ -45,6 +45,7 @@ public sealed class MsalBrowserCredential : TokenCredential private readonly IntPtr _windowHandle; private readonly string? _loginHint; private readonly bool _forceRefresh; + private readonly string _authorityHost; // Shared persistent cache helper - initialized once and reused across all instances. // This is the key to reducing multiple WAM prompts during setup operations. @@ -89,8 +90,7 @@ public sealed class MsalBrowserCredential : TokenCredential /// The redirect URI for authentication callbacks. /// Optional logger for diagnostic output. /// Whether to use WAM on Windows. Default is true. - /// Optional authority URL. When provided, overrides the default AzurePublic authority. - /// Use this for government clouds (e.g., "https://login.microsoftonline.us/{tenantId}"). + /// Optional authority URL. When provided, overrides the default public-cloud authority. /// Optional UPN/email to pre-select the account for silent acquisition and interactive auth. /// When provided, WAM and silent auth will target this identity instead of the first cached account. public MsalBrowserCredential( @@ -119,6 +119,11 @@ public MsalBrowserCredential( _loginHint = loginHint; _forceRefresh = forceRefresh; + // Pin consent URLs (BuildAdminConsentUrl) to the cloud we authenticate against; default commercial. + _authorityHost = Uri.TryCreate(authority, UriKind.Absolute, out var authorityUri) + ? authorityUri.GetLeftPart(UriPartial.Authority) + : ConfigConstants.DefaultAuthorityHost; + // Get window handle for WAM on Windows // Try multiple sources: console window, foreground window, or desktop window _windowHandle = IntPtr.Zero; @@ -576,7 +581,7 @@ internal static bool IsWamDeclinedScopesError(MsalException ex) /// private void LogConsentRequiredAndThrow(Exception inner) { - var consentUrl = ClientAppValidationException.BuildAdminConsentUrl(_clientAppId, _tenantId); + var consentUrl = ClientAppValidationException.BuildAdminConsentUrl(_clientAppId, _tenantId, _authorityHost); _logger?.LogWarning("Admin consent has not been granted for this application."); _logger?.LogWarning("An administrator must grant tenant-wide consent to proceed."); if (consentUrl != null) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/WidsOptionalClaimRequirementCheck.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/WidsOptionalClaimRequirementCheck.cs index b2dec229..8f711b3e 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/WidsOptionalClaimRequirementCheck.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/WidsOptionalClaimRequirementCheck.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Extensions.Logging; @@ -86,7 +87,8 @@ private async Task CheckImplementationAsync(Agent365Conf return RequirementCheckResult.Success(details: $"'wids' is present on accessToken optionalClaims for {config.ClientAppId}"); } - var manualPatch = BuildManualPatchInstructions(config.ClientAppId, config.TenantId); + var graphBaseUrl = ConfigConstants.GetGraphBaseUrl(config.Environment, config.GraphBaseUrl); + var manualPatch = BuildManualPatchInstructions(config.ClientAppId, config.TenantId, graphBaseUrl); return RequirementCheckResult.Failure( errorMessage: $"Client app {config.ClientAppId} is missing the 'wids' optional claim on accessToken. " + @@ -99,7 +101,7 @@ private async Task CheckImplementationAsync(Agent365Conf "the orchestrator collapses Unknown to 'not GA' and skips Phase 2b."); } - private static string BuildManualPatchInstructions(string clientAppId, string tenantId) + private static string BuildManualPatchInstructions(string clientAppId, string tenantId, string graphBaseUrl) { // Two-line remediation: portal path for humans, raw `az rest` for scriptable runs. // Both add { name: "wids", essential: false } to optionalClaims.accessToken. @@ -107,7 +109,7 @@ private static string BuildManualPatchInstructions(string clientAppId, string te "Add the 'wids' optional claim on the client app's access tokens. Options:\n" + $" 1. Portal: https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/TokenConfiguration/appId/{clientAppId} → 'Add optional claim' → Token type 'Access' → check 'wids' → Add.\n" + " 2. Or run as an Application Administrator / Global Administrator:\n" + - $" az rest --method PATCH --url \"https://graph.microsoft.com/v1.0/applications(appId='{clientAppId}')\" --headers \"Content-Type=application/json\" --body \"{{\\\"optionalClaims\\\":{{\\\"accessToken\\\":[{{\\\"name\\\":\\\"wids\\\",\\\"essential\\\":false,\\\"additionalProperties\\\":[]}}]}}}}\"\n" + + $" az rest --method PATCH --url \"{graphBaseUrl}/v1.0/applications(appId='{clientAppId}')\" --headers \"Content-Type=application/json\" --body \"{{\\\"optionalClaims\\\":{{\\\"accessToken\\\":[{{\\\"name\\\":\\\"wids\\\",\\\"essential\\\":false,\\\"additionalProperties\\\":[]}}]}}}}\"\n" + "After updating, sign out and back in (az logout && az login) so the next token carries the new claim, then re-run 'a365 setup requirements'."; } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/TeamsGraphBackendConfigurator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/TeamsGraphBackendConfigurator.cs index 6b2b2838..3ce8cb2e 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/TeamsGraphBackendConfigurator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/TeamsGraphBackendConfigurator.cs @@ -65,6 +65,7 @@ public TeamsGraphBackendConfigurator( var createEndpointUrl = EndpointHelper.GetCreateEndpointUrl(config.Environment); var audience = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); + var authorityHost = ConfigConstants.GetAuthorityHost(config.Environment, config.AuthorityHost); _logger.LogDebug("Create endpoint URL: {Url}", createEndpointUrl); @@ -79,7 +80,13 @@ public TeamsGraphBackendConfigurator( { bool forceRefresh = attempt > 0; - var authToken = await _authService.GetAccessTokenAsync(audience, tenantId, forceRefresh: forceRefresh, userId: currentUser, ct: ct); + var authToken = await _authService.GetAccessTokenAsync( + audience, + tenantId, + forceRefresh: forceRefresh, + userId: currentUser, + ct: ct, + authorityHost: authorityHost); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogError("Failed to acquire authentication token"); @@ -188,6 +195,7 @@ public async Task ClearBackendConfigurationAsync( var deleteEndpointUrl = EndpointHelper.GetDeleteEndpointUrl(config.Environment); var audience = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); + var authorityHost = ConfigConstants.GetAuthorityHost(config.Environment, config.AuthorityHost); _logger.LogDebug("Delete endpoint URL: {Url}", deleteEndpointUrl); @@ -201,7 +209,13 @@ public async Task ClearBackendConfigurationAsync( { bool forceRefresh = attempt > 0; - var authToken = await _authService.GetAccessTokenAsync(audience, tenantId, forceRefresh: forceRefresh, userId: currentUser, ct: ct); + var authToken = await _authService.GetAccessTokenAsync( + audience, + tenantId, + forceRefresh: forceRefresh, + userId: currentUser, + ct: ct, + authorityHost: authorityHost); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogError("Failed to acquire authentication token"); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AzRestConsentRunnerTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AzRestConsentRunnerTests.cs index 51d7ba68..26098e5f 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AzRestConsentRunnerTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AzRestConsentRunnerTests.cs @@ -156,12 +156,14 @@ public async Task NoExistingGrant_PostedWithNewGrantBody() .Returns(Task.FromResult(new CommandResult { ExitCode = 0 })); var (attempted, succeeded) = await AzRestConsentRunner.TryRunAsync( - _executor, BlueprintSpId, new[] { ObsSpec() }, _logger, ct: default); + _executor, BlueprintSpId, new[] { ObsSpec() }, _logger, + ct: default, graphBaseUrl: "https://graph.example"); attempted.Should().BeTrue(); succeeded.Should().BeTrue(); await _executor.Received().ExecuteAsync( - "az", Arg.Is(s => s.Contains("--method POST") && s.Contains("oauth2PermissionGrants") && !s.Contains($"/{ExistingGrantId}")), + "az", Arg.Is(s => s.Contains("https://graph.example/v1.0/oauth2PermissionGrants") + && s.Contains("--method POST") && !s.Contains($"/{ExistingGrantId}")), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); await _executor.DidNotReceive().ExecuteAsync( "az", Arg.Is(s => s.Contains("--method PATCH")), diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AzRestS2SRunnerTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AzRestS2SRunnerTests.cs index 468a1f93..3afcbec9 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AzRestS2SRunnerTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AzRestS2SRunnerTests.cs @@ -174,12 +174,14 @@ public async Task NoExistingAssignment_PostedWithRoleBody() .Returns(Task.FromResult(new CommandResult { ExitCode = 0 })); var (attempted, succeeded) = await AzRestS2SRunner.TryRunAsync( - _executor, BlueprintSpId, new[] { ObsSpec() }, _logger, ct: default); + _executor, BlueprintSpId, new[] { ObsSpec() }, _logger, + ct: default, graphBaseUrl: "https://graph.example"); attempted.Should().BeTrue(); succeeded.Should().BeTrue(); await _executor.Received().ExecuteAsync( - "az", Arg.Is(s => s.Contains("--method POST") && s.Contains($"/servicePrincipals/{BlueprintSpId}/appRoleAssignments")), + "az", Arg.Is(s => s.Contains("https://graph.example/v1.0") + && s.Contains("--method POST") && s.Contains($"/servicePrincipals/{BlueprintSpId}/appRoleAssignments")), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BatchPermissionsOrchestratorMissingSpTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BatchPermissionsOrchestratorMissingSpTests.cs index 62531bba..84e4f13f 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BatchPermissionsOrchestratorMissingSpTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BatchPermissionsOrchestratorMissingSpTests.cs @@ -376,9 +376,10 @@ public void BuildPerSpBlueprintConsentUrl_KeysClientIdOnBlueprintAndScopeOnResou // (first party token-to-self). This URL has the blueprint as the CLIENT and the // resource as the SCOPE target — a normal cross-app consent that Entra accepts. var spec = new ResourcePermissionSpec(TeamsMcpAppId, "Work IQ Teams MCP", new[] { "Tools.ListInvoke.All" }, SetInheritable: true); - var url = BatchPermissionsOrchestrator.BuildPerSpBlueprintConsentUrl(TenantId, BlueprintAppId, spec); + var url = BatchPermissionsOrchestrator.BuildPerSpBlueprintConsentUrl( + TenantId, BlueprintAppId, spec, authorityHost: "https://login.example"); - url.Should().StartWith($"https://login.microsoftonline.com/{TenantId}/v2.0/adminconsent", + url.Should().StartWith($"https://login.example/{TenantId}/v2.0/adminconsent", because: "the per-SP recovery URL targets the v2 admin-consent endpoint scoped to the operator's tenant"); url.Should().Contain($"client_id={BlueprintAppId}", because: "the BLUEPRINT must be the client so this is a normal cross-app consent — using the resource as client would hit AADSTS65003 token-to-self"); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Constants/ConfigConstantsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Constants/ConfigConstantsTests.cs new file mode 100644 index 00000000..60e1efbd --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Constants/ConfigConstantsTests.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Constants; + +[Collection("ConfigTests")] +public class ConfigConstantsTests +{ + [Theory] + [InlineData("gcc-high", "GCC_HIGH")] + [InlineData("Gcc High", "GCC_HIGH")] + [InlineData("gcch", "GCCH")] + [InlineData("", "PROD")] + public void NormalizeEnvironmentKey_ProducesEnvironmentVariableSuffix( + string environment, + string expected) + { + ConfigConstants.NormalizeEnvironmentKey(environment).Should().Be(expected); + } + + [Fact] + public void EnvironmentScopedOverrides_UseNormalizedCloudName() + { + const string appId = "11111111-2222-3333-4444-555555555555"; + const string discoverEndpoint = "https://tools.example/discover"; + + WithEnvironmentVariable("A365_MCP_APP_ID_GCC_HIGH", appId, () => + ConfigConstants.GetAgent365ToolsResourceAppId("gcc-high").Should().Be(appId)); + WithEnvironmentVariable("A365_DISCOVER_ENDPOINT_GCC_HIGH", discoverEndpoint, () => + ConfigConstants.GetDiscoverEndpointUrl("gcc-high").Should().Be(discoverEndpoint)); + } + + [Fact] + public void GraphBaseUrl_UsesScopedOverrideThenConfigThenDefault() + { + const string key = "A365_GRAPH_BASE_URL_GCC_HIGH"; + + WithEnvironmentVariable(key, "https://scoped.example/", () => + ConfigConstants.GetGraphBaseUrl("gcc-high", "https://config.example") + .Should().Be("https://scoped.example")); + + WithEnvironmentVariable(key, null, () => + ConfigConstants.GetGraphBaseUrl("gcc-high", "https://config.example/") + .Should().Be("https://config.example")); + + } + + [Fact] + public void AuthorityHost_UsesScopedOverrideThenConfigThenDefault() + { + const string key = "A365_AUTHORITY_HOST_GCC_HIGH"; + + WithEnvironmentVariable(key, "https://login.scoped.example/", () => + ConfigConstants.GetAuthorityHost("gcc-high", "https://login.config.example") + .Should().Be("https://login.scoped.example")); + + WithEnvironmentVariable(key, null, () => + ConfigConstants.GetAuthorityHost("gcc-high", "https://login.config.example/") + .Should().Be("https://login.config.example")); + + } + + [Theory] + [InlineData("http://graph.example")] + [InlineData("https://user@graph.example")] + [InlineData("https://graph.example/path")] + [InlineData("https://graph.example?query=value")] + [InlineData("https://graph.example#fragment")] + public void GraphBaseUrl_RejectsValuesThatAreNotHttpsOrigins(string value) + { + WithEnvironmentVariable("A365_GRAPH_BASE_URL_GCC_HIGH", null, () => + FluentActions.Invoking(() => ConfigConstants.GetGraphBaseUrl("gcc-high", value)) + .Should().Throw()); + } + + private static void WithEnvironmentVariable(string name, string? value, Action assertion) + { + var previous = Environment.GetEnvironmentVariable(name); + try + { + Environment.SetEnvironmentVariable(name, value); + assertion(); + } + finally + { + Environment.SetEnvironmentVariable(name, previous); + } + } +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Exceptions/ClientAppValidationExceptionTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Exceptions/ClientAppValidationExceptionTests.cs index 3f038d63..a63e496c 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Exceptions/ClientAppValidationExceptionTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Exceptions/ClientAppValidationExceptionTests.cs @@ -154,16 +154,19 @@ public void MissingAdminConsent_IncludesConsentGrantInstructions() public void BuildAdminConsentUrl_EncodesRedirectUri() { // Act - var consentUrl = ClientAppValidationException.BuildAdminConsentUrl(TestClientAppId, TestTenantId); + var consentUrl = ClientAppValidationException.BuildAdminConsentUrl( + TestClientAppId, TestTenantId, "https://login.example"); // Assert consentUrl.Should().NotBeNull(); + consentUrl.Should().StartWith($"https://login.example/{TestTenantId}/adminconsent", + because: "the admin consent URL must be rooted at the cloud-specific authority host with the tenant ID in the path — using the wrong authority host produces an AADSTS error for sovereign/government clouds"); consentUrl.Should().Contain($"client_id={TestClientAppId}", because: "the client ID must be preserved in the admin consent URL query string"); consentUrl.Should().Contain( - $"redirect_uri={Uri.EscapeDataString("https://login.microsoftonline.com/common/oauth2/nativeclient")}", + $"redirect_uri={Uri.EscapeDataString("https://login.example/common/oauth2/nativeclient")}", because: "redirect_uri is a URL-valued query parameter and must be encoded so the consent link remains valid when copied through shells, logs, and browsers"); consentUrl.Should().NotContain( - "&redirect_uri=https://login.microsoftonline.com/common/oauth2/nativeclient", + "&redirect_uri=https://login.example/common/oauth2/nativeclient", because: "an unescaped redirect URI contains reserved characters that can corrupt the admin consent query string"); } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs index 9607bbb4..fe42ea5e 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs @@ -177,10 +177,14 @@ public void BuildCombinedConsentUrl_ReturnsCorrectBaseUrlStructure() { var url = SetupHelpers.BuildCombinedConsentUrl( TenantId, BlueprintClientId, - new[] { "Mail.Send" }, new[] { "McpServers.Mail.All" }); + new[] { "Mail.Send" }, new[] { "McpServers.Mail.All" }, + graphResourceUri: "https://graph.example", + authorityHost: "https://login.example"); - url.Should().StartWith($"https://login.microsoftonline.com/{TenantId}/v2.0/adminconsent"); + url.Should().StartWith($"https://login.example/{TenantId}/v2.0/adminconsent"); url.Should().Contain($"client_id={BlueprintClientId}"); + url.Should().Contain(Uri.EscapeDataString("https://graph.example/Mail.Send"), + because: "Graph scopes in the consent URL must be fully-qualified resource URIs and URI-encoded — AAD rejects bare scope names or unencoded URIs in the adminconsent query string"); url.Should().Contain($"redirect_uri={Uri.EscapeDataString(AuthenticationConstants.BlueprintConsentRedirectUri)}", because: "redirect_uri must be registered on the blueprint app — AADSTS500113 is returned if absent or unregistered"); } 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 cd8a3393..c4eff2dc 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 @@ -30,9 +30,15 @@ public async Task PollAdminConsentAsync_ReturnsTrue_WhenGrantExists() .Returns(Task.FromResult(new Microsoft.Agents.A365.DevTools.Cli.Services.CommandResult { ExitCode = 0, StandardOutput = grantsJson })); 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( + executor, logger, "appId-1", "Test", 10, 1, cts.Token, + graphBaseUrl: "https://graph.example"); result.Should().BeTrue(); + await executor.Received(2).ExecuteAsync( + "az", + Arg.Is(args => args.Contains("https://graph.example/v1.0", StringComparison.Ordinal)), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] @@ -464,4 +470,3 @@ public async Task CheckConsentExistsAsync_AzCli_AggregatesScopesAcrossMultipleGr } } } - diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/InteractiveGraphAuthServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/InteractiveGraphAuthServiceTests.cs index 7551106f..e129ed31 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/InteractiveGraphAuthServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/InteractiveGraphAuthServiceTests.cs @@ -264,6 +264,31 @@ public async Task GetAuthenticatedGraphClientAsync_WhenCredentialSucceeds_Return client.Should().NotBeNull(); } + /// + /// Verifies that the returned GraphServiceClient targets the configured (sovereign) Graph + /// base URL with the API version segment appended. Overriding RequestAdapter.BaseUrl with the + /// origin alone drops the SDK default "/v1.0" segment and sends every request to a 404. + /// + [Fact] + public async Task GetAuthenticatedGraphClientAsync_UsesConfiguredGraphBaseUrlWithVersionSegment() + { + // Arrange + var workingCredential = new StubTokenCredential("token-value", DateTimeOffset.UtcNow.AddHours(1)); + var logger = Substitute.For>(); + var sut = new InteractiveGraphAuthService(logger, ValidGuid, + credentialFactory: (_, _) => workingCredential, + loginHintResolver: NoOpLoginHint, + graphBaseUrl: "https://graph.microsoft.us"); + + // Act + var client = await sut.GetAuthenticatedGraphClientAsync(ValidTenantId); + + // Assert + client.RequestAdapter.BaseUrl.Should().Be( + "https://graph.microsoft.us/v1.0", + because: "the Graph SDK routes requests relative to BaseUrl, so the cloud-specific host must retain the /v1.0 API version segment or all requests 404"); + } + /// /// Verifies that the service returns the same cached GraphServiceClient for the same tenant /// on repeated calls, avoiding redundant authentication prompts. 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 33a834d9..6af002e5 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 @@ -224,16 +224,26 @@ public async Task GetMgGraphAccessTokenAsync_WhenMsalSucceeds_ReturnsMsalTokenWi var clientAppId = "87654321-4321-4321-4321-cba987654321"; var msalToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzZWxsYWsifQ.signature"; + string[]? requestedScopes = null; var provider = new MicrosoftGraphTokenProvider(_executor, _logger) { - MsalTokenAcquirerOverride = (_, _, _, _) => Task.FromResult(msalToken) + MsalTokenAcquirerOverride = (_, resolvedScopes, _, _) => + { + requestedScopes = resolvedScopes; + return Task.FromResult(msalToken); + } }; // Act - var token = await provider.GetMgGraphAccessTokenAsync(tenantId, scopes, false, clientAppId); + var token = await provider.GetMgGraphAccessTokenAsync( + tenantId, scopes, false, clientAppId, graphBaseUrl: "https://graph.example", + authorityHost: "https://login.example"); // Assert token.Should().Be(msalToken); + requestedScopes.Should().Equal( + new[] { "https://graph.example/AgentIdentityBlueprint.DeleteRestore.All" }, + because: "short scope names must be normalized to fully-qualified URIs by prepending the configured Graph base URL before being passed to MSAL"); await _executor.DidNotReceive().ExecuteWithStreamingAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>(),