Files
Sharepoint-Toolbox/.planning/phases/19-app-registration-removal/19-RESEARCH.md
2026-04-09 14:43:00 +02:00

26 KiB
Raw Blame History

Phase 19: App Registration & Removal - Research

Researched: 2026-04-09 Domain: Microsoft Graph API / Azure AD app registration / MSAL token cache / WPF dialog extension Confidence: HIGH


Summary

Phase 19 lets the Toolbox register itself as an Azure AD application on a target tenant directly from the profile dialog, with a guided manual fallback when the signed-in user lacks sufficient permissions. It also provides removal of that registration with full MSAL session eviction.

The entire operation runs through the existing GraphServiceClient (Microsoft.Graph 5.74.0 already in the project). Registration is a four-step sequential Graph API workflow: create Application object → create ServicePrincipal → look up the resource service principal (Microsoft Graph + SharePoint Online) → post AppRoleAssignment for each required permission. Deletion reverses it: delete Application object (which soft-deletes and cascades to the service principal); then clear MSAL in-memory accounts and the persistent cache file.

Global Admin detection uses GET /me/memberOf/microsoft.graph.directoryRole filtered on roleTemplateId 62e90394-69f5-4237-9190-012177145e10. If that returns no results the fallback instruction panel is shown instead of attempting registration.

Primary recommendation: Implement IAppRegistrationService using GraphServiceClient with a try/catch rollback pattern; store the newly-created appId on TenantProfile (new nullable field) so the Remove action can look it up.


<phase_requirements>

Phase Requirements

ID Description Research Support
APPREG-01 User can register the app on a target tenant from the profile create/edit dialog New RegisterAppCommand on ProfileManagementViewModel; opens result/fallback panel in-dialog
APPREG-02 App auto-detects if user has Global Admin permissions before attempting registration GET /me/memberOf/microsoft.graph.directoryRole filtered on Global Admin template ID
APPREG-03 App creates Azure AD application + service principal + grants required permissions atomically (with rollback on failure) Sequential Graph API calls; rollback via DELETE /applications/{id} on any intermediate failure
APPREG-04 User sees guided fallback instructions when auto-registration is not possible Separate XAML panel with step-by-step manual instructions, shown when APPREG-02 returns false
APPREG-05 User can remove the app registration from a target tenant RemoveAppCommand; DELETE /applications(appId='{appId}') → clears MSAL session
APPREG-06 App clears cached tokens and sessions when app registration is removed MSAL RemoveAsync on all accounts + MsalCacheHelper unregister; SessionManager.ClearSessionAsync
</phase_requirements>

Standard Stack

Core

Library Version Purpose Why Standard
Microsoft.Graph 5.74.0 (already in project) Create/delete Application, ServicePrincipal, AppRoleAssignment Project's existing Graph SDK — no new dependency
Microsoft.Identity.Client 4.83.3 (already in project) Clear MSAL token cache per-clientId Same PCA used throughout the project
Microsoft.Identity.Client.Extensions.Msal 4.83.3 (already in project) Unregister/delete persistent cache file MsalCacheHelper already wired in MsalClientFactory

Supporting

Library Version Purpose When to Use
CommunityToolkit.Mvvm 8.4.2 (already in project) IAsyncRelayCommand for Register/Remove buttons Matches all other VM commands in the project
Serilog 4.3.1 (already in project) Log registration steps and failures Same structured logging used everywhere

Alternatives Considered

Instead of Could Use Tradeoff
Graph SDK (typed) Raw HttpClient Graph SDK handles auth, retries, model deserialization — no reason to use raw HTTP
Sequential with rollback Parallel creation ServicePrincipal must exist before AppRoleAssignment — ordering is mandatory

Installation: No new packages required. All dependencies already in SharepointToolbox.csproj.


Architecture Patterns

SharepointToolbox/
├── Services/
│   ├── IAppRegistrationService.cs   # interface
│   └── AppRegistrationService.cs    # implementation
├── Core/Models/
│   └── TenantProfile.cs             # add AppId (nullable string)
├── Core/Models/
│   └── AppRegistrationResult.cs     # success/failure/fallback discriminated result
└── ViewModels/
    └── ProfileManagementViewModel.cs # add RegisterAppCommand, RemoveAppCommand, status properties

XAML changes live in Views/Dialogs/ProfileManagementDialog.xaml — new rows in the existing grid for status/fallback panel.

Pattern 1: Sequential Registration with Rollback

What: Each Graph API call is wrapped in a try/catch. If any step fails, previously created objects are deleted before rethrowing.

When to use: Any multi-step Entra creation where partial state (Application without ServicePrincipal, or SP without role assignments) is worse than nothing.

// Source: https://learn.microsoft.com/en-us/graph/api/application-post-applications?view=graph-rest-1.0
// Source: https://learn.microsoft.com/en-us/graph/permissions-grant-via-msgraph

public async Task<AppRegistrationResult> RegisterAsync(
    string clientId,        // the Toolbox's own app (used to get an authed GraphClient)
    string displayName,
    CancellationToken ct)
{
    Application? createdApp = null;
    try
    {
        // Step 1: create Application object
        var appRequest = new Application
        {
            DisplayName = displayName,
            SignInAudience = "AzureADMyOrg",
            IsFallbackPublicClient = true,
            PublicClient = new PublicClientApplication
            {
                RedirectUris = new List<string>
                {
                    "https://login.microsoftonline.com/common/oauth2/nativeclient"
                }
            },
            RequiredResourceAccess = BuildRequiredResourceAccess()
        };
        createdApp = await _graphClient.Applications.PostAsync(appRequest, ct);

        // Step 2: create ServicePrincipal
        var sp = await _graphClient.ServicePrincipals.PostAsync(
            new ServicePrincipal { AppId = createdApp!.AppId }, ct);

        // Step 3: grant admin consent (AppRoleAssignments) for each required scope
        await GrantAppRolesAsync(sp!.Id!, ct);

        return AppRegistrationResult.Success(createdApp.AppId!);
    }
    catch (Exception ex)
    {
        // Rollback: delete the Application (cascades soft-delete of SP)
        if (createdApp?.Id is not null)
        {
            try { await _graphClient.Applications[createdApp.Id].DeleteAsync(ct); }
            catch { /* best-effort rollback */ }
        }
        return AppRegistrationResult.Failure(ex.Message);
    }
}

Pattern 2: Global Admin Detection

What: Query the signed-in user's directory role memberships; filter on the well-known Global Admin template ID.

When to use: Before attempting registration — surfaces the fallback path immediately.

// Source: https://learn.microsoft.com/en-us/graph/api/directoryrole-list?view=graph-rest-1.0
// Global Admin roleTemplateId is stable across all tenants
private const string GlobalAdminTemplateId = "62e90394-69f5-4237-9190-012177145e10";

public async Task<bool> IsGlobalAdminAsync(CancellationToken ct)
{
    var roles = await _graphClient.Me
        .MemberOf
        .GetAsync(r => r.QueryParameters.Filter =
            $"isof('microsoft.graph.directoryRole')", ct);

    return roles?.Value?
        .OfType<DirectoryRole>()
        .Any(r => string.Equals(r.RoleTemplateId, GlobalAdminTemplateId,
            StringComparison.OrdinalIgnoreCase)) ?? false;
}

Note: GET /me/memberOf requires Directory.Read.All or RoleManagement.Read.Directory delegated permission. With https://graph.microsoft.com/.default, the app will get whatever the user has consented to — if the user is Global Admin the existing consent scope typically covers this. If the call fails with 403, treat as "not admin" and show fallback.

Pattern 3: MSAL Full Session Eviction (APPREG-06)

What: Clear in-memory MSAL accounts AND unregister the persistent cache file.

// Source: https://learn.microsoft.com/en-us/entra/msal/dotnet/acquiring-tokens/clear-token-cache
public async Task ClearMsalSessionAsync(string clientId)
{
    // 1. Clear SessionManager's live ClientContext
    await _sessionManager.ClearSessionAsync(_profile.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 so stale tokens don't survive app restart
    var helper = _msalFactory.GetCacheHelper(clientId);
    helper.UnregisterCache(pca.UserTokenCache);
    // Optionally delete the .cache file:
    // File.Delete(Path.Combine(_msalFactory.CacheDirectory, $"msal_{clientId}.cache"));
}

Pattern 4: RequiredResourceAccess (app manifest scopes)

The Toolbox needs the following delegated permissions on the new app registration. These map to requiredResourceAccess in the Application object:

Microsoft Graph (appId 00000003-0000-0000-c000-000000000000):

  • User.Read — sign-in and read profile
  • User.Read.All — read directory users (user directory feature)
  • Group.Read.All — read AAD groups (group resolver)
  • Directory.Read.All — list sites, read tenant info

SharePoint Online (appId 00000003-0000-0ff1-ce00-000000000000):

  • AllSites.FullControl (delegated) — required for Tenant admin operations via PnP

Note: requiredResourceAccess configures what's shown in the consent dialog. It does NOT auto-grant — the user still has to consent interactively on first login. For admin consent grant (AppRoleAssignment), only an admin can call servicePrincipals/{id}/appRoleAssignedTo.

Important scope ID discovery: The planner should look up the exact permission GUIDs at registration time by querying GET /servicePrincipals?$filter=appId eq '{resourceAppId}'&$select=appRoles,oauth2PermissionScopes. Hard-coding GUIDs is acceptable for well-known APIs (Graph appId is stable), but verify before hardcoding SharePoint's permission GUIDs.

Anti-Patterns to Avoid

  • Creating AppRoleAssignment before ServicePrincipal exists: SP must exist first — the assignment's principalId must resolve.
  • Swallowing rollback errors silently: Log them as Warning — don't let rollback exceptions hide the original failure.
  • Storing appId in a separate config file: Store on TenantProfile (already persisted to JSON via ProfileService).
  • Treating GET /me/memberOf 403 as an error: It means the user doesn't have Directory.Read.All — treat as "not admin, show fallback."

Don't Hand-Roll

Problem Don't Build Use Instead Why
Token acquisition for Graph calls Custom HTTP auth GraphClientFactory.CreateClientAsync (already exists) Handles silent→interactive fallback, shares MSAL cache
Persistent cache deletion Manual file path manipulation MsalCacheHelper.UnregisterCache + delete msal_{clientId}.cache Cache path already known from MsalClientFactory.CacheDirectory
Dialog for manual fallback instructions Custom dialog window Inline panel in ProfileManagementDialog Consistent with existing dialog-extension pattern (logo panel was added the same way)

Key insight: The project already owns GraphClientFactory which handles the MSAL→Graph SDK bridge. Registration just calls Graph endpoints through that same factory. Zero new auth infrastructure needed.


Common Pitfalls

Pitfall 1: /me/memberOf Does Not Enumerate Transitive Role Memberships

What goes wrong: A user who is Global Admin through a nested group (rare but possible) won't appear in a direct memberOf query.

Why it happens: The memberOf endpoint returns direct memberships only by default.

How to avoid: Use GET /me/transitiveMemberOf/microsoft.graph.directoryRole for completeness. The roleTemplateId filter is the same.

Warning signs: Admin user reports "fallback instructions shown" even though they are confirmed Global Admin.

Pitfall 2: Soft-Delete on Application Means the Name Is Reserved for 30 Days

What goes wrong: If the user registers, then removes, then tries to re-register with the same display name, the second POST /applications may succeed (display names are not unique) but the soft-deleted object still exists in the recycle bin.

Why it happens: Entra soft-deletes applications into a "deleted items" container for 30 days before permanent deletion.

How to avoid: Use a unique display name per registration (e.g., "SharePoint Toolbox {tenantDisplayName}"). Document this behavior in the fallback instructions.

Warning signs: DELETE /applications/{id} succeeds but a second POST /applications still shows "409 conflict" in the portal.

What goes wrong: The app is registered with the right scopes listed in the manifest, but the user is still prompted to consent on first use — or worse, consent is blocked for non-admins.

Why it happens: RequiredResourceAccess is the declared intent; actual grant requires the user to consent interactively OR an admin to post AppRoleAssignment for each app role.

How to avoid: After creating the app registration (APPREG-03), explicitly call POST /servicePrincipals/{resourceId}/appRoleAssignedTo for each delegated scope to pre-grant admin consent. This is what makes registration "atomic" — the user never sees a consent prompt.

Warning signs: First launch after registration opens a browser consent screen.

Pitfall 4: ServicePrincipal Creation is Eventually Consistent

What goes wrong: Immediately querying the newly-created SP by filter right after creation returns 404 or empty.

Why it happens: Entra directory replication can take a few seconds.

How to avoid: Use the id returned from POST /servicePrincipals directly (no need to re-query). Never search by appId immediately after creation.

Pitfall 5: MSAL Cache File Locked During Eviction

What goes wrong: helper.UnregisterCache(...) leaves the cache file on disk; deleting the file while the PCA still holds a handle causes IOException.

Why it happens: MsalCacheHelper keeps a file lock for cross-process safety.

How to avoid: Call UnregisterCache first (releases the lock), then delete the file. Or simply leave the file — after RemoveAsync on all accounts the file is written with an empty account list and is effectively harmless.


Code Examples

Create Application with Required Resource Access

// Source: https://learn.microsoft.com/en-us/graph/api/application-post-applications?view=graph-rest-1.0
private static List<RequiredResourceAccess> BuildRequiredResourceAccess()
{
    // Microsoft Graph resource
    var graphAccess = new RequiredResourceAccess
    {
        ResourceAppId = "00000003-0000-0000-c000-000000000000", // Microsoft Graph
        ResourceAccess = new List<ResourceAccess>
        {
            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)
        }
    };

    // SharePoint Online resource  
    var spoAccess = new RequiredResourceAccess
    {
        ResourceAppId = "00000003-0000-0ff1-ce00-000000000000", // SharePoint Online
        ResourceAccess = new List<ResourceAccess>
        {
            new() { Id = Guid.Parse("56680e0d-d2a3-4ae1-80d8-3c4a5c70c4a6"), Type = "Scope" }, // AllSites.FullControl (delegated)
        }
    };
    return new List<RequiredResourceAccess> { graphAccess, spoAccess };
}

NOTE (LOW confidence on GUIDs): The Graph delegated permission GUIDs above are well-known and stable. The SharePoint AllSites.FullControl GUID should be verified at plan time by querying GET /servicePrincipals?$filter=appId eq '00000003-0000-0ff1-ce00-000000000000'&$select=oauth2PermissionScopes. The Microsoft Graph GUIDs are HIGH confidence from official docs.

Create ServicePrincipal

// Source: https://learn.microsoft.com/en-us/graph/api/serviceprincipal-post-serviceprincipals?view=graph-rest-1.0
var sp = await graphClient.ServicePrincipals.PostAsync(
    new ServicePrincipal { AppId = createdApp.AppId }, ct);
// Source: https://learn.microsoft.com/en-us/graph/permissions-grant-via-msgraph
// Get resource SP id for Microsoft Graph
var graphSp = await graphClient.ServicePrincipals.GetAsync(r => {
    r.QueryParameters.Filter = "appId eq '00000003-0000-0000-c000-000000000000'";
    r.QueryParameters.Select = new[] { "id", "oauth2PermissionScopes" };
}, ct);
var graphResourceId = Guid.Parse(graphSp!.Value!.First().Id!);

// Grant delegated permission (oauth2PermissionGrant, not appRoleAssignment for delegated scopes)
await graphClient.Oauth2PermissionGrants.PostAsync(new OAuth2PermissionGrant
{
    ClientId = sp.Id,      // service principal Object ID of the new app
    ConsentType = "AllPrincipals",
    ResourceId = graphResourceId.ToString(),
    Scope = "User.Read User.Read.All Group.Read.All Directory.Read.All"
}, ct);

Important distinction: For delegated permissions (Type = "Scope"), use POST /oauth2PermissionGrants (admin consent for all users). For application permissions (Type = "Role"), use POST /servicePrincipals/{resourceId}/appRoleAssignedTo. The Toolbox uses interactive login (delegated flow), so use oauth2PermissionGrants.

Delete Application

// Source: https://learn.microsoft.com/en-us/graph/api/application-delete?view=graph-rest-1.0
// Can address by object ID or by appId
await graphClient.Applications[$"(appId='{profile.AppId}')"].DeleteAsync(ct);

Clear MSAL Session

// Source: https://learn.microsoft.com/en-us/entra/msal/dotnet/acquiring-tokens/clear-token-cache
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();
}

State of the Art

Old Approach Current Approach When Changed Impact
Azure AD Graph API (graph.windows.net) Microsoft Graph v1.0 (graph.microsoft.com) Deprecated 2023 Use Graph SDK v5 exclusively
oauth2PermissionGrants for app roles appRoleAssignedTo for application permissions, oauth2PermissionGrants for delegated Graph v1.0 Both still valid; use correct one per permission type
Interactive consent dialog for each scope Pre-grant via oauth2PermissionGrants with ConsentType=AllPrincipals Current Eliminates user consent prompt on first run

Deprecated/outdated:

  • Azure AD Graph (graph.windows.net) endpoints: replaced entirely by Microsoft Graph.
  • Listing roles via GET /directoryRoles (requires activating role first): use transitiveMemberOf on /me instead.

Open Questions

  1. SharePoint AllSites.FullControl delegated permission GUID

    • What we know: The permission exists and is required; the SPO app appId is 00000003-0000-0ff1-ce00-000000000000
    • What's unclear: The exact GUID may vary; it must be confirmed by querying the SPO service principal at plan/implementation time
    • Recommendation: Planner should include a Wave 0 step to look up and hard-code the GUID from the target tenant, or query it dynamically at registration time
  2. /me/memberOf permission requirement

    • What we know: Requires Directory.Read.All or RoleManagement.Read.Directory
    • What's unclear: Whether the .default scope grants this for a freshly registered app vs. an existing one
    • Recommendation: Treat 403 on the role check as "not admin" and show fallback — same user experience, no crash
  3. TenantProfile.AppId field and re-registration

    • What we know: We need to store the registered appId on the profile to support removal
    • What's unclear: Whether the user wants to re-register on the same profile after removal
    • Recommendation: Null out AppId after successful removal; show "Register App" button when AppId is null

Validation Architecture

Test Framework

Property Value
Framework xUnit 2.9.3
Config file SharepointToolbox.Tests/SharepointToolbox.Tests.csproj
Quick run command dotnet test --filter "Category=Unit" --no-build
Full suite command dotnet test --no-build

Phase Requirements → Test Map

Req ID Behavior Test Type Automated Command File Exists?
APPREG-01 RegisterAppCommand exists on ProfileManagementViewModel unit dotnet test --filter "FullyQualifiedName~AppRegistrationServiceTests" --no-build Wave 0
APPREG-02 IsGlobalAdminAsync returns false when Graph returns no matching role unit dotnet test --filter "FullyQualifiedName~AppRegistrationServiceTests" --no-build Wave 0
APPREG-02 IsGlobalAdminAsync returns true when Global Admin role present unit dotnet test --filter "FullyQualifiedName~AppRegistrationServiceTests" --no-build Wave 0
APPREG-03 Registration rollback calls delete when SP creation fails unit (mock) dotnet test --filter "FullyQualifiedName~AppRegistrationServiceTests" --no-build Wave 0
APPREG-03 AppRegistrationResult.Success carries appId unit dotnet test --filter "FullyQualifiedName~AppRegistrationServiceTests" --no-build Wave 0
APPREG-04 Fallback path returned when IsGlobalAdmin = false unit dotnet test --filter "FullyQualifiedName~AppRegistrationServiceTests" --no-build Wave 0
APPREG-05 RemoveAppCommand calls delete on Graph and clears AppId unit (mock) dotnet test --filter "FullyQualifiedName~ProfileManagementViewModelRegistrationTests" --no-build Wave 0
APPREG-06 Session and MSAL cleared after removal unit (mock MsalFactory) dotnet test --filter "FullyQualifiedName~AppRegistrationServiceTests" --no-build Wave 0
APPREG-06 TenantProfile.AppId is null-able and round-trips via JSON unit dotnet test --filter "FullyQualifiedName~AppRegistrationServiceTests" --no-build Wave 0

Note: Live Graph API calls (actual Entra tenant) are integration tests — skip them the same way other live SharePoint tests are skipped ([Trait("Category","Integration")]). All unit tests mock the GraphServiceClient or test pure model/logic in isolation.

Sampling Rate

  • Per task commit: dotnet test --filter "Category=Unit" --no-build
  • Per wave merge: dotnet test --no-build
  • Phase gate: Full suite green before /gsd:verify-work

Wave 0 Gaps

  • SharepointToolbox.Tests/Services/AppRegistrationServiceTests.cs — covers APPREG-02, APPREG-03, APPREG-04, APPREG-06
  • SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelRegistrationTests.cs — covers APPREG-01, APPREG-05
  • SharepointToolbox/Core/Models/AppRegistrationResult.cs — discriminated union model
  • SharepointToolbox/Services/IAppRegistrationService.cs + AppRegistrationService.cs — interface + implementation stubs

Sources

Primary (HIGH confidence)

Secondary (MEDIUM confidence)

Tertiary (LOW confidence)

  • SharePoint Online AllSites.FullControl delegated permission GUID — not independently verified; must be confirmed by querying the SPO service principal at plan time.

Metadata

Confidence breakdown:

  • Standard stack: HIGH — all libraries already in project, no new dependencies
  • Architecture: HIGH — Graph SDK v5 code patterns confirmed from official docs (updated 2026)
  • Global Admin detection: HIGH — roleTemplateId confirmed from multiple sources
  • Registration flow: HIGH — sequential 4-step pattern confirmed from official MS Learn
  • Permission GUIDs (Graph): MEDIUM — well-known stable IDs, but verify at implementation time
  • Permission GUIDs (SharePoint): LOW — must be queried dynamically or confirmed at plan time
  • Token cache eviction: HIGH — official MSAL docs

Research date: 2026-04-09 Valid until: 2026-05-09 (Graph API and MSAL patterns are stable)