chore: release v2.4
- Add theme system (Dark/Light palettes, ModernTheme, ThemeManager) - Add InputDialog, Spinner common view - Add DuplicatesCsvExportService - Refresh views, dialogs, and view models across tabs - Update localization strings (en/fr) - Tweak services (transfer, permissions, search, user access, ownership elevation, bulk operations) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,12 +11,41 @@ namespace SharepointToolbox.Services;
|
||||
/// Manages Azure AD app registration and removal using the Microsoft Graph API.
|
||||
/// All operations use <see cref="GraphClientFactory"/> for token acquisition.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>GraphServiceClient lifecycle: a fresh client is created per public call
|
||||
/// (<see cref="IsGlobalAdminAsync"/>, <see cref="RegisterAsync"/>,
|
||||
/// <see cref="RollbackAsync"/>, <see cref="RemoveAsync"/>). 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).</para>
|
||||
/// <para>Do not cache a GraphServiceClient at call sites — always go through
|
||||
/// <see cref="GraphClientFactory"/> so tenant pinning and scope selection stay
|
||||
/// correct.</para>
|
||||
/// </remarks>
|
||||
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;
|
||||
@@ -35,38 +64,33 @@ public class AppRegistrationService : IAppRegistrationService
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<bool> IsGlobalAdminAsync(string clientId, CancellationToken ct)
|
||||
public async Task<bool> IsGlobalAdminAsync(string clientId, string tenantUrl, 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);
|
||||
// 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 roles?.Value?
|
||||
.OfType<DirectoryRole>()
|
||||
.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;
|
||||
}
|
||||
return memberships?.Value?
|
||||
.OfType<DirectoryRole>()
|
||||
.Any(r => string.Equals(r.RoleTemplateId, GlobalAdminTemplateId,
|
||||
StringComparison.OrdinalIgnoreCase)) ?? false;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<AppRegistrationResult> RegisterAsync(
|
||||
string clientId,
|
||||
string tenantUrl,
|
||||
string tenantDisplayName,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tenantId = ResolveTenantId(tenantUrl);
|
||||
Application? createdApp = null;
|
||||
try
|
||||
{
|
||||
var graphClient = await _graphFactory.CreateClientAsync(clientId, ct);
|
||||
var graphClient = await _graphFactory.CreateClientAsync(clientId, tenantId, RegistrationScopes, ct);
|
||||
|
||||
// Step 1: Create Application object
|
||||
var appRequest = new Application
|
||||
@@ -78,7 +102,10 @@ public class AppRegistrationService : IAppRegistrationService
|
||||
{
|
||||
RedirectUris = new List<string>
|
||||
{
|
||||
"https://login.microsoftonline.com/common/oauth2/nativeclient"
|
||||
// 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()
|
||||
@@ -131,34 +158,45 @@ public class AppRegistrationService : IAppRegistrationService
|
||||
_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.");
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task RemoveAsync(string clientId, string appId, CancellationToken ct)
|
||||
public async Task RemoveAsync(string clientId, string tenantUrl, string appId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var graphClient = await _graphFactory.CreateClientAsync(clientId, ct);
|
||||
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);
|
||||
}
|
||||
@@ -168,6 +206,32 @@ public class AppRegistrationService : IAppRegistrationService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="ArgumentException"/> 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.
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task ClearMsalSessionAsync(string clientId, string tenantUrl)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user