diff --git a/SharepointToolbox/Core/Models/AppRegistrationResult.cs b/SharepointToolbox/Core/Models/AppRegistrationResult.cs
new file mode 100644
index 0000000..5b63b3a
--- /dev/null
+++ b/SharepointToolbox/Core/Models/AppRegistrationResult.cs
@@ -0,0 +1,33 @@
+namespace SharepointToolbox.Core.Models;
+
+///
+/// Discriminated result type for app registration operations.
+/// Use the static factory methods to construct instances.
+///
+public class AppRegistrationResult
+{
+ public bool IsSuccess { get; }
+ public bool IsFallback { get; }
+ public string? AppId { get; }
+ public string? ErrorMessage { get; }
+
+ private AppRegistrationResult(bool isSuccess, bool isFallback, string? appId, string? errorMessage)
+ {
+ IsSuccess = isSuccess;
+ IsFallback = isFallback;
+ AppId = appId;
+ ErrorMessage = errorMessage;
+ }
+
+ /// Registration succeeded; carries the newly-created appId.
+ public static AppRegistrationResult Success(string appId) =>
+ new(isSuccess: true, isFallback: false, appId: appId, errorMessage: null);
+
+ /// Registration failed; carries an error message.
+ public static AppRegistrationResult Failure(string errorMessage) =>
+ new(isSuccess: false, isFallback: false, appId: null, errorMessage: errorMessage);
+
+ /// User lacks the required permissions — caller should show fallback instructions.
+ public static AppRegistrationResult FallbackRequired() =>
+ new(isSuccess: false, isFallback: true, appId: null, errorMessage: null);
+}
diff --git a/SharepointToolbox/Core/Models/TenantProfile.cs b/SharepointToolbox/Core/Models/TenantProfile.cs
index f20663f..dc1517f 100644
--- a/SharepointToolbox/Core/Models/TenantProfile.cs
+++ b/SharepointToolbox/Core/Models/TenantProfile.cs
@@ -6,4 +6,11 @@ public class TenantProfile
public string TenantUrl { get; set; } = string.Empty;
public string ClientId { get; set; } = string.Empty;
public LogoData? ClientLogo { get; set; }
+
+ ///
+ /// The Azure AD application (client) ID registered for this tenant profile.
+ /// Null when no app registration has been performed yet.
+ /// Cleared to null after successful removal.
+ ///
+ public string? AppId { get; set; }
}
diff --git a/SharepointToolbox/Services/AppRegistrationService.cs b/SharepointToolbox/Services/AppRegistrationService.cs
new file mode 100644
index 0000000..b050331
--- /dev/null
+++ b/SharepointToolbox/Services/AppRegistrationService.cs
@@ -0,0 +1,229 @@
+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 };
+ }
+}
diff --git a/SharepointToolbox/Services/IAppRegistrationService.cs b/SharepointToolbox/Services/IAppRegistrationService.cs
new file mode 100644
index 0000000..85f6e7f
--- /dev/null
+++ b/SharepointToolbox/Services/IAppRegistrationService.cs
@@ -0,0 +1,35 @@
+using SharepointToolbox.Core.Models;
+
+namespace SharepointToolbox.Services;
+
+///
+/// Manages Azure AD app registration and removal for a target tenant.
+///
+public interface IAppRegistrationService
+{
+ ///
+ /// Returns true if the currently-authenticated user has the Global Administrator
+ /// directory role (checked via transitiveMemberOf for nested-group coverage).
+ /// Returns false on any failure, including 403, rather than throwing.
+ ///
+ Task IsGlobalAdminAsync(string clientId, CancellationToken ct);
+
+ ///
+ /// Creates an Azure AD Application + ServicePrincipal + OAuth2PermissionGrants
+ /// atomically. On any intermediate failure the Application is deleted before
+ /// returning a Failure result (best-effort rollback).
+ ///
+ Task RegisterAsync(string clientId, string tenantDisplayName, CancellationToken ct);
+
+ ///
+ /// Deletes the registered application by its appId.
+ /// Logs a warning on failure but does not throw.
+ ///
+ Task RemoveAsync(string clientId, string appId, CancellationToken ct);
+
+ ///
+ /// Clears the live SessionManager context, evicts all in-memory MSAL accounts,
+ /// and unregisters the persistent token cache for the given clientId.
+ ///
+ Task ClearMsalSessionAsync(string clientId, string tenantUrl);
+}