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. /// /// /// GraphServiceClient lifecycle: a fresh client is created per public call /// (, , /// , ). This is intentional — /// each call may use different scopes (RegistrationScopes vs. default) and target /// a different tenant, so a cached per-service instance would bind the wrong /// authority. The factory itself caches the underlying MSAL PCA and token cache, /// so client construction is cheap (no network hit when tokens are valid). /// Do not cache a GraphServiceClient at call sites — always go through /// so tenant pinning and scope selection stay /// correct. /// public class AppRegistrationService : IAppRegistrationService { // Entra built-in directory role template IDs are global constants shared across all tenants. // GlobalAdminTemplateId: "Global Administrator" directoryRoleTemplate. // See https://learn.microsoft.com/entra/identity/role-based-access-control/permissions-reference#global-administrator private const string GlobalAdminTemplateId = "62e90394-69f5-4237-9190-012177145e10"; // First-party Microsoft service appIds (constant across tenants). private const string GraphAppId = "00000003-0000-0000-c000-000000000000"; private const string SharePointAppId = "00000003-0000-0ff1-ce00-000000000000"; // Explicit scopes for the registration flow. The bootstrap client // (Microsoft Graph Command Line Tools) does not pre-consent these, so // requesting `.default` returns a token without them → POST /applications // fails with 403 even for a Global Admin. Requesting them explicitly // triggers the admin-consent prompt on first use. private static readonly string[] RegistrationScopes = new[] { "https://graph.microsoft.com/Application.ReadWrite.All", "https://graph.microsoft.com/DelegatedPermissionGrant.ReadWrite.All", "https://graph.microsoft.com/Directory.Read.All", }; 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, string tenantUrl, CancellationToken ct) { // No $filter: isof() on directoryObject requires advanced query params // (ConsistencyLevel: eventual + $count=true) and fails with 400 otherwise. // The user's membership list is small; filtering client-side is fine. var tenantId = ResolveTenantId(tenantUrl); var graphClient = await _graphFactory.CreateClientAsync(clientId, tenantId, ct); var memberships = await graphClient.Me.TransitiveMemberOf.GetAsync(cancellationToken: ct); return memberships?.Value? .OfType() .Any(r => string.Equals(r.RoleTemplateId, GlobalAdminTemplateId, StringComparison.OrdinalIgnoreCase)) ?? false; } /// public async Task RegisterAsync( string clientId, string tenantUrl, string tenantDisplayName, CancellationToken ct) { var tenantId = ResolveTenantId(tenantUrl); Application? createdApp = null; try { var graphClient = await _graphFactory.CreateClientAsync(clientId, tenantId, RegistrationScopes, ct); // Step 1: Create Application object var appRequest = new Application { DisplayName = $"SharePoint Toolbox - {tenantDisplayName}", SignInAudience = "AzureADMyOrg", IsFallbackPublicClient = true, PublicClient = new PublicClientApplication { RedirectUris = new List { // Loopback URI for MSAL desktop default (any port accepted by Entra). "http://localhost", // Legacy native-client URI for embedded WebView fallback. "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 (Microsoft.Graph.Models.ODataErrors.ODataError odataEx) when (odataEx.ResponseStatusCode == 401 || odataEx.ResponseStatusCode == 403) { _logger.LogWarning(odataEx, "RegisterAsync refused by Graph (status {Status}) — user lacks role/consent. Surfacing fallback.", odataEx.ResponseStatusCode); await RollbackAsync(createdApp, clientId, tenantId, ct); return AppRegistrationResult.FallbackRequired(); } catch (Exception ex) { _logger.LogError(ex, "RegisterAsync failed. Attempting rollback."); await RollbackAsync(createdApp, clientId, tenantId, ct); return AppRegistrationResult.Failure(ex.Message); } } private async Task RollbackAsync(Application? createdApp, string clientId, string tenantId, CancellationToken ct) { if (createdApp?.Id is null) return; try { var graphClient = await _graphFactory.CreateClientAsync(clientId, tenantId, RegistrationScopes, 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); } } /// public async Task RemoveAsync(string clientId, string tenantUrl, string appId, CancellationToken ct) { try { var tenantId = ResolveTenantId(tenantUrl); var graphClient = await _graphFactory.CreateClientAsync(clientId, tenantId, RegistrationScopes, 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); } } /// /// Derives a tenant identifier (domain) from a SharePoint tenant URL so MSAL /// can pin the authority to the correct tenant. Examples: /// https://contoso.sharepoint.com → contoso.onmicrosoft.com /// https://contoso-admin.sharepoint.com → contoso.onmicrosoft.com /// Throws when the URL is not a recognisable /// SharePoint URL — falling back to /common would silently route registration /// to the signed-in user's home tenant, which is the bug this guards against. /// internal static string ResolveTenantId(string tenantUrl) { if (!Uri.TryCreate(tenantUrl, UriKind.Absolute, out var uri)) throw new ArgumentException($"Invalid tenant URL: '{tenantUrl}'", nameof(tenantUrl)); var host = uri.Host; var firstDot = host.IndexOf('.'); if (firstDot <= 0) throw new ArgumentException($"Cannot derive tenant from host '{host}'", nameof(tenantUrl)); var prefix = host.Substring(0, firstDot); if (prefix.EndsWith("-admin", StringComparison.OrdinalIgnoreCase)) prefix = prefix.Substring(0, prefix.Length - "-admin".Length); return $"{prefix}.onmicrosoft.com"; } /// 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 }; } }