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 };
}
}