using Microsoft.Extensions.Logging; using Microsoft.Graph; using Microsoft.Graph.Models; using SharepointToolbox.Core.Models; using AppGraphClientFactory = SharepointToolbox.Infrastructure.Auth.GraphClientFactory; using AppMsalClientFactory = SharepointToolbox.Infrastructure.Auth.MsalClientFactory; namespace SharepointToolbox.Services; /// /// Manages Azure AD app registration and removal using the Microsoft Graph API. /// All operations use for token acquisition. /// public class AppRegistrationService : IAppRegistrationService { private const string GlobalAdminTemplateId = "62e90394-69f5-4237-9190-012177145e10"; private const string GraphAppId = "00000003-0000-0000-c000-000000000000"; private const string SharePointAppId = "00000003-0000-0ff1-ce00-000000000000"; private readonly AppGraphClientFactory _graphFactory; private readonly AppMsalClientFactory _msalFactory; private readonly ISessionManager _sessionManager; private readonly ILogger _logger; public AppRegistrationService( AppGraphClientFactory graphFactory, AppMsalClientFactory msalFactory, ISessionManager sessionManager, ILogger logger) { _graphFactory = graphFactory; _msalFactory = msalFactory; _sessionManager = sessionManager; _logger = logger; } /// public async Task IsGlobalAdminAsync(string clientId, CancellationToken ct) { try { var graphClient = await _graphFactory.CreateClientAsync(clientId, ct); var roles = await graphClient.Me.TransitiveMemberOf.GetAsync(req => { req.QueryParameters.Filter = "isof('microsoft.graph.directoryRole')"; }, ct); return roles?.Value? .OfType() .Any(r => string.Equals(r.RoleTemplateId, GlobalAdminTemplateId, StringComparison.OrdinalIgnoreCase)) ?? false; } catch (Exception ex) { _logger.LogWarning(ex, "IsGlobalAdminAsync failed — treating as non-admin. ClientId={ClientId}", clientId); return false; } } /// public async Task RegisterAsync( string clientId, string tenantDisplayName, CancellationToken ct) { Application? createdApp = null; try { var graphClient = await _graphFactory.CreateClientAsync(clientId, ct); // Step 1: Create Application object var appRequest = new Application { DisplayName = $"SharePoint Toolbox - {tenantDisplayName}", SignInAudience = "AzureADMyOrg", IsFallbackPublicClient = true, PublicClient = new PublicClientApplication { RedirectUris = new List { "https://login.microsoftonline.com/common/oauth2/nativeclient" } }, RequiredResourceAccess = BuildRequiredResourceAccess() }; createdApp = await graphClient.Applications.PostAsync(appRequest, cancellationToken: ct); _logger.LogInformation("Created Application {AppId} ({ObjectId})", createdApp!.AppId, createdApp.Id); // Step 2: Create ServicePrincipal var sp = await graphClient.ServicePrincipals.PostAsync( new ServicePrincipal { AppId = createdApp.AppId }, cancellationToken: ct); _logger.LogInformation("Created ServicePrincipal {SpId}", sp!.Id); // Step 3a: Look up Microsoft Graph resource SP var graphSpCollection = await graphClient.ServicePrincipals.GetAsync(req => { req.QueryParameters.Filter = $"appId eq '{GraphAppId}'"; req.QueryParameters.Select = new[] { "id" }; }, ct); var graphResourceId = graphSpCollection?.Value?.FirstOrDefault()?.Id ?? throw new InvalidOperationException("Microsoft Graph service principal not found."); // Step 3b: Look up SharePoint Online resource SP var spoSpCollection = await graphClient.ServicePrincipals.GetAsync(req => { req.QueryParameters.Filter = $"appId eq '{SharePointAppId}'"; req.QueryParameters.Select = new[] { "id" }; }, ct); var spoResourceId = spoSpCollection?.Value?.FirstOrDefault()?.Id ?? throw new InvalidOperationException("SharePoint Online service principal not found."); // Step 4a: Grant delegated permissions for Microsoft Graph await graphClient.Oauth2PermissionGrants.PostAsync(new OAuth2PermissionGrant { ClientId = sp.Id, ConsentType = "AllPrincipals", ResourceId = graphResourceId, Scope = "User.Read User.Read.All Group.Read.All Directory.Read.All" }, cancellationToken: ct); // Step 4b: Grant delegated permissions for SharePoint Online await graphClient.Oauth2PermissionGrants.PostAsync(new OAuth2PermissionGrant { ClientId = sp.Id, ConsentType = "AllPrincipals", ResourceId = spoResourceId, Scope = "AllSites.FullControl" }, cancellationToken: ct); _logger.LogInformation("App registration complete. AppId={AppId}", createdApp.AppId); return AppRegistrationResult.Success(createdApp.AppId!); } catch (Exception ex) { _logger.LogError(ex, "RegisterAsync failed. Attempting rollback."); if (createdApp?.Id is not null) { try { var graphClient = await _graphFactory.CreateClientAsync(clientId, ct); await graphClient.Applications[createdApp.Id].DeleteAsync(cancellationToken: ct); _logger.LogInformation("Rollback: deleted Application {ObjectId}", createdApp.Id); } catch (Exception rollbackEx) { _logger.LogWarning(rollbackEx, "Rollback failed for Application {ObjectId}", createdApp.Id); } } return AppRegistrationResult.Failure(ex.Message); } } /// public async Task RemoveAsync(string clientId, string appId, CancellationToken ct) { try { var graphClient = await _graphFactory.CreateClientAsync(clientId, ct); await graphClient.Applications[$"(appId='{appId}')"].DeleteAsync(cancellationToken: ct); _logger.LogInformation("Removed Application appId={AppId}", appId); } catch (Exception ex) { _logger.LogWarning(ex, "RemoveAsync failed for appId={AppId}", appId); } } /// public async Task ClearMsalSessionAsync(string clientId, string tenantUrl) { // 1. Clear live ClientContext from SessionManager await _sessionManager.ClearSessionAsync(tenantUrl); // 2. Clear in-memory MSAL accounts var pca = await _msalFactory.GetOrCreateAsync(clientId); var accounts = (await pca.GetAccountsAsync()).ToList(); while (accounts.Any()) { await pca.RemoveAsync(accounts.First()); accounts = (await pca.GetAccountsAsync()).ToList(); } // 3. Unregister persistent cache (releases file lock, harmless if not registered) var helper = _msalFactory.GetCacheHelper(clientId); helper.UnregisterCache(pca.UserTokenCache); _logger.LogInformation("MSAL session cleared for clientId={ClientId}", clientId); } /// /// Builds the RequiredResourceAccess list for the app registration manifest. /// Declares delegated permissions for Microsoft Graph and SharePoint Online. /// /// /// Graph permission GUIDs are HIGH confidence (stable, from official Microsoft docs). /// SharePoint AllSites.FullControl GUID (56680e0d-d2a3-4ae1-80d8-3c4a5c70c4a6) is LOW confidence /// and should be verified against a live tenant by querying: /// GET /servicePrincipals?$filter=appId eq '00000003-0000-0ff1-ce00-000000000000'&$select=oauth2PermissionScopes /// internal static List BuildRequiredResourceAccess() { var graphAccess = new RequiredResourceAccess { ResourceAppId = GraphAppId, ResourceAccess = new List { new() { Id = Guid.Parse("e1fe6dd8-ba31-4d61-89e7-88639da4683d"), Type = "Scope" }, // User.Read new() { Id = Guid.Parse("a154be20-db9c-4678-8ab7-66f6cc099a59"), Type = "Scope" }, // User.Read.All (delegated) new() { Id = Guid.Parse("5b567255-7703-4780-807c-7be8301ae99b"), Type = "Scope" }, // Group.Read.All (delegated) new() { Id = Guid.Parse("06da0dbc-49e2-44d2-8312-53f166ab848a"), Type = "Scope" }, // Directory.Read.All (delegated) } }; // LOW confidence GUID — verify against live tenant before deploying var spoAccess = new RequiredResourceAccess { ResourceAppId = SharePointAppId, ResourceAccess = new List { new() { Id = Guid.Parse("56680e0d-d2a3-4ae1-80d8-3c4a5c70c4a6"), Type = "Scope" }, // AllSites.FullControl (delegated) — LOW confidence GUID } }; return new List { graphAccess, spoAccess }; } }