Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,18 @@ private static Command CreateInitSubcommand(ILogger logger, string configDir, IC
{
new Option<string?>(new[] { "-c", "--configfile" }, "Path to an existing config file to import"),
new Option<bool>(new[] { "--global", "-g" }, "Create config in global directory (AppData) instead of current directory"),
new Option<bool>(new[] { "--yes", "-y" }, "Skip confirmation prompts and apply any required app registration changes automatically"),
};

cmd.SetHandler(async (System.CommandLine.Invocation.InvocationContext context) =>
{
var configFileOption = cmd.Options.OfType<Option<string?>>().First(opt => opt.HasAlias("-c"));
var globalOption = cmd.Options.OfType<Option<bool>>().First(opt => opt.HasAlias("--global"));
var yesOption = cmd.Options.OfType<Option<bool>>().First(opt => opt.HasAlias("--yes"));

string? configFile = context.ParseResult.GetValueForOption(configFileOption);
bool useGlobal = context.ParseResult.GetValueForOption(globalOption);
bool yes = context.ParseResult.GetValueForOption(yesOption);

// Determine config path
string configPath = useGlobal
Expand Down Expand Up @@ -95,7 +98,8 @@ private static Command CreateInitSubcommand(ILogger logger, string configDir, IC
await clientAppValidator.EnsureValidClientAppAsync(
importedConfig.ClientAppId,
importedConfig.TenantId,
context.GetCancellationToken());
skipConfirmation: yes,
ct: context.GetCancellationToken());
}
catch (ClientAppValidationException ex)
{
Expand All @@ -108,9 +112,10 @@ await clientAppValidator.EnsureValidClientAppAsync(
}
if (ex.MitigationSteps.Count > 0)
{
logger.LogInformation("");
foreach (var step in ex.MitigationSteps)
{
logger.LogError(step);
logger.LogInformation(" {Step}", step.TrimEnd());
}
}
logger.LogError("");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ public static Command CreateCommand(
await RequirementsSubcommand.RunChecksOrExitAsync(
checks, setupConfig, logger, ct);
}
catch (Exception reqEx) when (reqEx is not OperationCanceledException)
catch (Exception reqEx) when (reqEx is not OperationCanceledException && reqEx is not CleanExitException)
{
logger.LogError(reqEx, "Requirements check failed: {Message}", reqEx.Message);
logger.LogError("Rerun with --skip-requirements to bypass.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ public static Command CreateCommand(
await RequirementsSubcommand.RunChecksOrExitAsync(
checks, setupConfig, logger, ct);
}
catch (Exception reqEx) when (reqEx is not OperationCanceledException)
catch (Exception reqEx) when (reqEx is not OperationCanceledException && reqEx is not CleanExitException)
{
logger.LogError(reqEx, "Requirements check failed with an unexpected error: {Message}", reqEx.Message);
logger.LogError("If you want to bypass requirement validation, rerun this command with the --skip-requirements flag.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ public static Command CreateCommand(
await RequirementsSubcommand.RunChecksOrExitAsync(
checks, setupConfig, logger, ct);
}
catch (Exception reqEx) when (reqEx is not OperationCanceledException)
catch (Exception reqEx) when (reqEx is not OperationCanceledException && reqEx is not CleanExitException)
{
logger.LogError(reqEx, "Requirements check failed with an unexpected error: {Message}", reqEx.Message);
logger.LogError("If you want to bypass requirement validation, rerun this command with the --skip-requirements flag.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,16 @@ public static class AuthenticationConstants
public const string LocalhostRedirectUri = "http://localhost:8400/";

/// <summary>
/// Required redirect URIs for Microsoft Graph PowerShell SDK authentication.
/// The SDK requires both http://localhost and http://localhost:8400/ for different auth flows.
/// Required redirect URIs for authentication.
/// <list type="bullet">
/// <item><term>http://localhost</term><description>Required by the Microsoft Graph PowerShell SDK
/// (<c>Connect-MgGraph -ClientId</c>). Without this URI, PowerShell-based operations (OAuth2 grants,
/// service principal lookups) fall back to the Azure CLI token, which lacks required delegated
/// permissions and causes 403 errors on inheritable permissions operations.</description></item>
/// <item><term>http://localhost:8400/</term><description>Required by MSAL for interactive browser
/// authentication. Uses a fixed port to ensure consistent OAuth callbacks.</description></item>
/// </list>
/// See also <see cref="WamBrokerRedirectUriFormat"/> for the Windows WAM broker URI.
/// </summary>
public static readonly string[] RequiredRedirectUris = new[]
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@ public sealed class ClientAppValidator : IClientAppValidator
{
private readonly ILogger<ClientAppValidator> _logger;
private readonly GraphApiService _graphApiService;
private readonly IConfirmationProvider? _confirmationProvider;

public ClientAppValidator(ILogger<ClientAppValidator> logger, GraphApiService graphApiService)
public ClientAppValidator(ILogger<ClientAppValidator> logger, GraphApiService graphApiService, IConfirmationProvider? confirmationProvider = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_graphApiService = graphApiService ?? throw new ArgumentNullException(nameof(graphApiService));
_confirmationProvider = confirmationProvider;
}

/// <summary>
Expand All @@ -33,11 +35,14 @@ public ClientAppValidator(ILogger<ClientAppValidator> logger, GraphApiService gr
/// </summary>
/// <param name="clientAppId">The client app ID to validate</param>
/// <param name="tenantId">The tenant ID where the app should exist</param>
/// <param name="skipConfirmation">When true, applies any required app registration fixes without prompting the user.
/// Use for non-interactive or CI scenarios. Defaults to false (prompt before modifying the app registration).</param>
/// <param name="ct">Cancellation token</param>
/// <exception cref="ClientAppValidationException">Thrown when validation fails</exception>
public async Task EnsureValidClientAppAsync(
string clientAppId,
string tenantId,
bool skipConfirmation = false,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(clientAppId);
Expand Down Expand Up @@ -71,7 +76,7 @@ public async Task EnsureValidClientAppAsync(

_logger.LogDebug("Found client app: {DisplayName} ({AppId})", appInfo.DisplayName, clientAppId);

// Step 3: Validate permissions in manifest
// Step 3: Validate permissions in manifest (read-only)
var missingPermissions = await ValidatePermissionsConfiguredAsync(appInfo, tenantId, ct);

// Step 3.5: For any unresolvable permissions (beta APIs), check oauth2PermissionGrants as fallback
Expand All @@ -87,8 +92,48 @@ public async Task EnsureValidClientAppAsync(
}
}

// Read-only pre-flight: collect what redirect URIs and public client settings need fixing
var missingRedirectUris = await CollectMissingRedirectUrisAsync(clientAppId, tenantId, ct);
var publicClientNeedsEnabling = await IsPublicClientFlowsDisabledAsync(clientAppId, tenantId, ct);

// Determine what mutations are needed
bool hasMissingPermissions = missingPermissions.Count > 0;
bool hasMissingRedirectUris = missingRedirectUris.Count > 0;
bool needsPublicClientEnabled = publicClientNeedsEnabling;
bool hasPendingMutations = hasMissingPermissions || hasMissingRedirectUris || needsPublicClientEnabled;

// Prompt the user before making any changes (unless skipConfirmation or no confirmation provider)
bool applyFixes = true;
if (hasPendingMutations && _confirmationProvider != null && !skipConfirmation)
{
Comment thread
sellakumaran marked this conversation as resolved.
_logger.LogInformation("The following changes will be applied to app registration ({AppId}):", clientAppId);
_logger.LogInformation("");
if (hasMissingPermissions)
{
_logger.LogInformation(" - Add permissions and grant admin consent:");
foreach (var perm in missingPermissions)
_logger.LogInformation(" {Permission}", perm);
}
if (hasMissingRedirectUris)
{
_logger.LogInformation(" - Add redirect URIs:");
foreach (var uri in missingRedirectUris)
_logger.LogInformation(" {Uri}", uri);
}
if (needsPublicClientEnabled)
_logger.LogInformation(" - Enable 'Allow public client flows' (required for device code fallback)");
_logger.LogInformation("For more information: https://learn.microsoft.com/en-us/microsoft-agent-365/developer/custom-client-app-registration");
_logger.LogInformation("");

applyFixes = await _confirmationProvider.ConfirmAsync("Do you want to proceed? (y/N): ");
if (!applyFixes)
{
_logger.LogInformation("App registration was not modified. Re-run and accept the prompt, or configure manually.");
Comment thread
sellakumaran marked this conversation as resolved.
}
}

// Step 3.6: Auto-provision any remaining missing permissions (self-healing)
if (missingPermissions.Count > 0)
if (applyFixes && missingPermissions.Count > 0)
{
_logger.LogInformation("Auto-provisioning {Count} missing permission(s): {Permissions}",
missingPermissions.Count, string.Join(", ", missingPermissions));
Expand Down Expand Up @@ -125,10 +170,12 @@ public async Task EnsureValidClientAppAsync(
}

// Step 5: Verify and fix redirect URIs
await EnsureRedirectUrisAsync(clientAppId, tenantId, ct);
if (applyFixes)
await EnsureRedirectUrisAsync(clientAppId, tenantId, ct);

// Step 6: Verify and fix public client flows (required for device code fallback on non-Windows)
await EnsurePublicClientFlowsEnabledAsync(clientAppId, tenantId, ct);
// Step 6: Verify and fix public client flows (required for device code fallback)
if (applyFixes)
await EnsurePublicClientFlowsEnabledAsync(clientAppId, tenantId, ct);
Comment thread
sellakumaran marked this conversation as resolved.

_logger.LogDebug("Client app validation successful for {ClientAppId}", clientAppId);
}
Expand All @@ -137,6 +184,11 @@ public async Task EnsureValidClientAppAsync(
// Re-throw validation exceptions as-is
throw;
}
catch (OperationCanceledException)
{
// Ctrl+C / cancellation — propagate immediately without wrapping
throw;
}
catch (JsonException ex)
{
_logger.LogDebug(ex, "JSON parsing error during validation");
Expand Down Expand Up @@ -298,7 +350,10 @@ private async Task EnsurePublicClientFlowsEnabledAsync(
return;
}

_logger.LogInformation("Enabling 'Allow public client flows' on app registration (required for device code authentication fallback).");
_logger.LogInformation(
"Enabling 'Allow public client flows' on app registration " +
"(required for device code authentication fallback on macOS, Linux, WSL, " +
"headless environments, and as a Conditional Access Policy fallback on Windows).");
_logger.LogInformation("Run 'a365 setup requirements' at any time to re-verify and auto-fix this setting.");

var patchSuccess = await _graphApiService.GraphPatchAsync(tenantId,
Expand Down Expand Up @@ -524,7 +579,11 @@ private async Task TryExtendConsentGrantScopesAsync(

if (patchSuccess)
{
_logger.LogInformation("Extended consent grant with scope(s): {Scopes}", string.Join(", ", scopesToAdd));
_logger.LogInformation("Extending admin consent grant with {Count} new permission(s): {Scopes}.",
scopesToAdd.Count, string.Join(", ", scopesToAdd));
// Invalidate the process-level az CLI token cache so the next Graph call
// re-acquires a token that includes the newly consented scope(s).
Services.Helpers.AzCliHelper.InvalidateAzCliTokenCache();
Comment thread
sellakumaran marked this conversation as resolved.
Outdated
}
else
{
Expand All @@ -540,6 +599,101 @@ private async Task TryExtendConsentGrantScopesAsync(
}
}

/// <summary>
/// Returns the subset of <see cref="AuthenticationConstants.RequiredClientAppPermissions"/>
/// that are not yet present in the client app's oauth2PermissionGrant (i.e. not consented).
/// </summary>
public async Task<List<string>> GetUnconsentedRequiredPermissionsAsync(
string clientAppId,
string tenantId,
CancellationToken ct = default)
{
var consented = await GetConsentedPermissionsAsync(clientAppId, tenantId, ct);
return AuthenticationConstants.RequiredClientAppPermissions
.Where(p => !consented.Contains(p, StringComparer.OrdinalIgnoreCase))
.ToList();
}

/// <summary>
/// Extends the client app's oauth2PermissionGrant to include the specified permissions.
/// Call after the user has confirmed they want to grant admin consent.
/// </summary>
public Task GrantConsentForPermissionsAsync(
string clientAppId,
List<string> permissions,
string tenantId,
CancellationToken ct = default)
=> TryExtendConsentGrantScopesAsync(clientAppId, permissions, tenantId, ct);


/// <summary>
/// Read-only check: returns the redirect URIs that are missing from the app registration
/// without making any changes. Used to build the pre-flight mutation summary.
/// </summary>
private async Task<List<string>> CollectMissingRedirectUrisAsync(
string clientAppId,
string tenantId,
CancellationToken ct)
{
try
{
using var appDoc = await _graphApiService.GraphGetAsync(tenantId,
$"/v1.0/applications?$filter=appId eq '{clientAppId}'&$select=id,publicClient", ct);

if (appDoc == null) return new List<string>();

var response = JsonNode.Parse(appDoc.RootElement.GetRawText());
var apps = response?["value"]?.AsArray();
if (apps == null || apps.Count == 0) return new List<string>();

var publicClient = apps[0]!.AsObject()["publicClient"]?.AsObject();
var currentRedirectUris = publicClient?["redirectUris"]?.AsArray()
?.Select(uri => uri?.GetValue<string>())
.Where(uri => !string.IsNullOrWhiteSpace(uri))
.Select(uri => uri!)
.ToHashSet(StringComparer.OrdinalIgnoreCase) ?? new HashSet<string>(StringComparer.OrdinalIgnoreCase);

return AuthenticationConstants.GetRequiredRedirectUris(clientAppId)
.Where(uri => !currentRedirectUris.Contains(uri))
.ToList();
}
catch (Exception ex)
{
_logger.LogDebug("CollectMissingRedirectUrisAsync failed (non-fatal): {Message}", ex.Message);
return new List<string>();
}
}

/// <summary>
/// Read-only check: returns true if 'Allow public client flows' (isFallbackPublicClient)
/// is currently disabled on the app registration, without making any changes.
/// </summary>
private async Task<bool> IsPublicClientFlowsDisabledAsync(
string clientAppId,
string tenantId,
CancellationToken ct)
{
try
{
using var appDoc = await _graphApiService.GraphGetAsync(tenantId,
$"/v1.0/applications?$filter=appId eq '{clientAppId}'&$select=id,isFallbackPublicClient", ct);

if (appDoc == null) return false;

var response = JsonNode.Parse(appDoc.RootElement.GetRawText());
var apps = response?["value"]?.AsArray();
if (apps == null || apps.Count == 0) return false;

var isFallbackPublicClient = apps[0]!.AsObject()["isFallbackPublicClient"]?.GetValue<bool>() ?? false;
return !isFallbackPublicClient;
}
catch (Exception ex)
{
_logger.LogDebug("IsPublicClientFlowsDisabledAsync failed (non-fatal): {Message}", ex.Message);
return false;
}
}

#region Private Helper Methods

private async Task<ClientAppInfo?> GetClientAppInfoAsync(string clientAppId, string tenantId, CancellationToken ct)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -988,7 +988,7 @@ private string GetUsageLocationFromAccount(AzureAccountInfo accountInfo)

try
{
await validator.EnsureValidClientAppAsync(clientAppId, tenantId, CancellationToken.None);
await validator.EnsureValidClientAppAsync(clientAppId, tenantId, ct: CancellationToken.None);
Console.WriteLine("Client app validation successful!");
Console.WriteLine();
return clientAppId;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,18 @@ public class ConsoleConfirmationProvider : IConfirmationProvider
public Task<bool> ConfirmAsync(string prompt)
{
Console.Write(prompt);
var response = Console.ReadLine()?.Trim().ToLowerInvariant();
return Task.FromResult(response == "y" || response == "yes");
var response = Console.ReadLine();

// null means stdin was closed (Ctrl+C / EOF) — exit immediately without further output,
// matching az CLI behavior where Ctrl+C terminates cleanly with no trailing messages.
if (response == null)
{
Console.WriteLine();
throw new OperationCanceledException();
Comment thread
sellakumaran marked this conversation as resolved.
}

return Task.FromResult(response.Trim().Equals("y", StringComparison.OrdinalIgnoreCase)
|| response.Trim().Equals("yes", StringComparison.OrdinalIgnoreCase));
}

/// <summary>
Expand Down
Loading
Loading