@@ -19,11 +19,13 @@ public sealed class ClientAppValidator : IClientAppValidator
1919{
2020 private readonly ILogger < ClientAppValidator > _logger ;
2121 private readonly GraphApiService _graphApiService ;
22+ private readonly IConfirmationProvider ? _confirmationProvider ;
2223
23- public ClientAppValidator ( ILogger < ClientAppValidator > logger , GraphApiService graphApiService )
24+ public ClientAppValidator ( ILogger < ClientAppValidator > logger , GraphApiService graphApiService , IConfirmationProvider ? confirmationProvider = null )
2425 {
2526 _logger = logger ?? throw new ArgumentNullException ( nameof ( logger ) ) ;
2627 _graphApiService = graphApiService ?? throw new ArgumentNullException ( nameof ( graphApiService ) ) ;
28+ _confirmationProvider = confirmationProvider ;
2729 }
2830
2931 /// <summary>
@@ -33,11 +35,14 @@ public ClientAppValidator(ILogger<ClientAppValidator> logger, GraphApiService gr
3335 /// </summary>
3436 /// <param name="clientAppId">The client app ID to validate</param>
3537 /// <param name="tenantId">The tenant ID where the app should exist</param>
38+ /// <param name="skipConfirmation">When true, applies any required app registration fixes without prompting the user.
39+ /// Use for non-interactive or CI scenarios. Defaults to false (prompt before modifying the app registration).</param>
3640 /// <param name="ct">Cancellation token</param>
3741 /// <exception cref="ClientAppValidationException">Thrown when validation fails</exception>
3842 public async Task EnsureValidClientAppAsync (
3943 string clientAppId ,
4044 string tenantId ,
45+ bool skipConfirmation = false ,
4146 CancellationToken ct = default )
4247 {
4348 ArgumentException . ThrowIfNullOrWhiteSpace ( clientAppId ) ;
@@ -71,7 +76,7 @@ public async Task EnsureValidClientAppAsync(
7176
7277 _logger . LogDebug ( "Found client app: {DisplayName} ({AppId})" , appInfo . DisplayName , clientAppId ) ;
7378
74- // Step 3: Validate permissions in manifest
79+ // Step 3: Validate permissions in manifest (read-only)
7580 var missingPermissions = await ValidatePermissionsConfiguredAsync ( appInfo , tenantId , ct ) ;
7681
7782 // Step 3.5: For any unresolvable permissions (beta APIs), check oauth2PermissionGrants as fallback
@@ -87,8 +92,48 @@ public async Task EnsureValidClientAppAsync(
8792 }
8893 }
8994
95+ // Read-only pre-flight: collect what redirect URIs and public client settings need fixing
96+ var missingRedirectUris = await CollectMissingRedirectUrisAsync ( clientAppId , tenantId , ct ) ;
97+ var publicClientNeedsEnabling = await IsPublicClientFlowsDisabledAsync ( clientAppId , tenantId , ct ) ;
98+
99+ // Determine what mutations are needed
100+ bool hasMissingPermissions = missingPermissions . Count > 0 ;
101+ bool hasMissingRedirectUris = missingRedirectUris . Count > 0 ;
102+ bool needsPublicClientEnabled = publicClientNeedsEnabling ;
103+ bool hasPendingMutations = hasMissingPermissions || hasMissingRedirectUris || needsPublicClientEnabled ;
104+
105+ // Prompt the user before making any changes (unless skipConfirmation or no confirmation provider)
106+ bool applyFixes = true ;
107+ if ( hasPendingMutations && _confirmationProvider != null && ! skipConfirmation )
108+ {
109+ _logger . LogInformation ( "The following changes will be applied to app registration ({AppId}):" , clientAppId ) ;
110+ _logger . LogInformation ( "" ) ;
111+ if ( hasMissingPermissions )
112+ {
113+ _logger . LogInformation ( " - Add permissions and grant admin consent:" ) ;
114+ foreach ( var perm in missingPermissions )
115+ _logger . LogInformation ( " {Permission}" , perm ) ;
116+ }
117+ if ( hasMissingRedirectUris )
118+ {
119+ _logger . LogInformation ( " - Add redirect URIs:" ) ;
120+ foreach ( var uri in missingRedirectUris )
121+ _logger . LogInformation ( " {Uri}" , uri ) ;
122+ }
123+ if ( needsPublicClientEnabled )
124+ _logger . LogInformation ( " - Enable 'Allow public client flows' (required for device code fallback)" ) ;
125+ _logger . LogInformation ( "For more information: https://learn.microsoft.com/en-us/microsoft-agent-365/developer/custom-client-app-registration" ) ;
126+ _logger . LogInformation ( "" ) ;
127+
128+ applyFixes = await _confirmationProvider . ConfirmAsync ( "Do you want to proceed? (y/N): " ) ;
129+ if ( ! applyFixes )
130+ {
131+ _logger . LogInformation ( "App registration was not modified. Re-run and accept the prompt, or configure manually." ) ;
132+ }
133+ }
134+
90135 // Step 3.6: Auto-provision any remaining missing permissions (self-healing)
91- if ( missingPermissions . Count > 0 )
136+ if ( applyFixes && missingPermissions . Count > 0 )
92137 {
93138 _logger . LogInformation ( "Auto-provisioning {Count} missing permission(s): {Permissions}" ,
94139 missingPermissions . Count , string . Join ( ", " , missingPermissions ) ) ;
@@ -125,10 +170,12 @@ public async Task EnsureValidClientAppAsync(
125170 }
126171
127172 // Step 5: Verify and fix redirect URIs
128- await EnsureRedirectUrisAsync ( clientAppId , tenantId , ct ) ;
173+ if ( applyFixes )
174+ await EnsureRedirectUrisAsync ( clientAppId , tenantId , ct ) ;
129175
130- // Step 6: Verify and fix public client flows (required for device code fallback on non-Windows)
131- await EnsurePublicClientFlowsEnabledAsync ( clientAppId , tenantId , ct ) ;
176+ // Step 6: Verify and fix public client flows (required for device code fallback)
177+ if ( applyFixes )
178+ await EnsurePublicClientFlowsEnabledAsync ( clientAppId , tenantId , ct ) ;
132179
133180 _logger . LogDebug ( "Client app validation successful for {ClientAppId}" , clientAppId ) ;
134181 }
@@ -137,6 +184,11 @@ public async Task EnsureValidClientAppAsync(
137184 // Re-throw validation exceptions as-is
138185 throw ;
139186 }
187+ catch ( OperationCanceledException )
188+ {
189+ // Ctrl+C / cancellation — propagate immediately without wrapping
190+ throw ;
191+ }
140192 catch ( JsonException ex )
141193 {
142194 _logger . LogDebug ( ex , "JSON parsing error during validation" ) ;
@@ -298,7 +350,10 @@ private async Task EnsurePublicClientFlowsEnabledAsync(
298350 return ;
299351 }
300352
301- _logger . LogInformation ( "Enabling 'Allow public client flows' on app registration (required for device code authentication fallback)." ) ;
353+ _logger . LogInformation (
354+ "Enabling 'Allow public client flows' on app registration " +
355+ "(required for device code authentication fallback on macOS, Linux, WSL, " +
356+ "headless environments, and as a Conditional Access Policy fallback on Windows)." ) ;
302357 _logger . LogInformation ( "Run 'a365 setup requirements' at any time to re-verify and auto-fix this setting." ) ;
303358
304359 var patchSuccess = await _graphApiService . GraphPatchAsync ( tenantId ,
@@ -524,7 +579,11 @@ private async Task TryExtendConsentGrantScopesAsync(
524579
525580 if ( patchSuccess )
526581 {
527- _logger . LogInformation ( "Extended consent grant with scope(s): {Scopes}" , string . Join ( ", " , scopesToAdd ) ) ;
582+ _logger . LogInformation ( "Extending admin consent grant with {Count} new permission(s): {Scopes}." ,
583+ scopesToAdd . Count , string . Join ( ", " , scopesToAdd ) ) ;
584+ // Invalidate the process-level az CLI token cache so the next Graph call
585+ // re-acquires a token that includes the newly consented scope(s).
586+ Services . Helpers . AzCliHelper . InvalidateAzCliTokenCache ( ) ;
528587 }
529588 else
530589 {
@@ -540,6 +599,101 @@ private async Task TryExtendConsentGrantScopesAsync(
540599 }
541600 }
542601
602+ /// <summary>
603+ /// Returns the subset of <see cref="AuthenticationConstants.RequiredClientAppPermissions"/>
604+ /// that are not yet present in the client app's oauth2PermissionGrant (i.e. not consented).
605+ /// </summary>
606+ public async Task < List < string > > GetUnconsentedRequiredPermissionsAsync (
607+ string clientAppId ,
608+ string tenantId ,
609+ CancellationToken ct = default )
610+ {
611+ var consented = await GetConsentedPermissionsAsync ( clientAppId , tenantId , ct ) ;
612+ return AuthenticationConstants . RequiredClientAppPermissions
613+ . Where ( p => ! consented . Contains ( p , StringComparer . OrdinalIgnoreCase ) )
614+ . ToList ( ) ;
615+ }
616+
617+ /// <summary>
618+ /// Extends the client app's oauth2PermissionGrant to include the specified permissions.
619+ /// Call after the user has confirmed they want to grant admin consent.
620+ /// </summary>
621+ public Task GrantConsentForPermissionsAsync (
622+ string clientAppId ,
623+ List < string > permissions ,
624+ string tenantId ,
625+ CancellationToken ct = default )
626+ => TryExtendConsentGrantScopesAsync ( clientAppId , permissions , tenantId , ct ) ;
627+
628+
629+ /// <summary>
630+ /// Read-only check: returns the redirect URIs that are missing from the app registration
631+ /// without making any changes. Used to build the pre-flight mutation summary.
632+ /// </summary>
633+ private async Task < List < string > > CollectMissingRedirectUrisAsync (
634+ string clientAppId ,
635+ string tenantId ,
636+ CancellationToken ct )
637+ {
638+ try
639+ {
640+ using var appDoc = await _graphApiService . GraphGetAsync ( tenantId ,
641+ $ "/v1.0/applications?$filter=appId eq '{ clientAppId } '&$select=id,publicClient", ct ) ;
642+
643+ if ( appDoc == null ) return new List < string > ( ) ;
644+
645+ var response = JsonNode . Parse ( appDoc . RootElement . GetRawText ( ) ) ;
646+ var apps = response ? [ "value" ] ? . AsArray ( ) ;
647+ if ( apps == null || apps . Count == 0 ) return new List < string > ( ) ;
648+
649+ var publicClient = apps [ 0 ] ! . AsObject ( ) [ "publicClient" ] ? . AsObject ( ) ;
650+ var currentRedirectUris = publicClient ? [ "redirectUris" ] ? . AsArray ( )
651+ ? . Select ( uri => uri ? . GetValue < string > ( ) )
652+ . Where ( uri => ! string . IsNullOrWhiteSpace ( uri ) )
653+ . Select ( uri => uri ! )
654+ . ToHashSet ( StringComparer . OrdinalIgnoreCase ) ?? new HashSet < string > ( StringComparer . OrdinalIgnoreCase ) ;
655+
656+ return AuthenticationConstants . GetRequiredRedirectUris ( clientAppId )
657+ . Where ( uri => ! currentRedirectUris . Contains ( uri ) )
658+ . ToList ( ) ;
659+ }
660+ catch ( Exception ex )
661+ {
662+ _logger . LogDebug ( "CollectMissingRedirectUrisAsync failed (non-fatal): {Message}" , ex . Message ) ;
663+ return new List < string > ( ) ;
664+ }
665+ }
666+
667+ /// <summary>
668+ /// Read-only check: returns true if 'Allow public client flows' (isFallbackPublicClient)
669+ /// is currently disabled on the app registration, without making any changes.
670+ /// </summary>
671+ private async Task < bool > IsPublicClientFlowsDisabledAsync (
672+ string clientAppId ,
673+ string tenantId ,
674+ CancellationToken ct )
675+ {
676+ try
677+ {
678+ using var appDoc = await _graphApiService . GraphGetAsync ( tenantId ,
679+ $ "/v1.0/applications?$filter=appId eq '{ clientAppId } '&$select=id,isFallbackPublicClient", ct ) ;
680+
681+ if ( appDoc == null ) return false ;
682+
683+ var response = JsonNode . Parse ( appDoc . RootElement . GetRawText ( ) ) ;
684+ var apps = response ? [ "value" ] ? . AsArray ( ) ;
685+ if ( apps == null || apps . Count == 0 ) return false ;
686+
687+ var isFallbackPublicClient = apps [ 0 ] ! . AsObject ( ) [ "isFallbackPublicClient" ] ? . GetValue < bool > ( ) ?? false ;
688+ return ! isFallbackPublicClient ;
689+ }
690+ catch ( Exception ex )
691+ {
692+ _logger . LogDebug ( "IsPublicClientFlowsDisabledAsync failed (non-fatal): {Message}" , ex . Message ) ;
693+ return false ;
694+ }
695+ }
696+
543697 #region Private Helper Methods
544698
545699 private async Task < ClientAppInfo ? > GetClientAppInfoAsync ( string clientAppId , string tenantId , CancellationToken ct )
0 commit comments