26 KiB
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
Recommended Project Structure
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 profileUser.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
principalIdmust 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 viaProfileService). - Treating
GET /me/memberOf403 as an error: It means the user doesn't haveDirectory.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.
Pitfall 3: RequiredResourceAccess Does Not Grant Admin Consent
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);
Grant AppRole Assignment (Admin Consent)
// 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): usetransitiveMemberOfon/meinstead.
Open Questions
-
SharePoint
AllSites.FullControldelegated 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
- What we know: The permission exists and is required; the SPO app appId is
-
/me/memberOfpermission requirement- What we know: Requires
Directory.Read.AllorRoleManagement.Read.Directory - What's unclear: Whether the
.defaultscope 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
- What we know: Requires
-
TenantProfile.AppId field and re-registration
- What we know: We need to store the registered
appIdon the profile to support removal - What's unclear: Whether the user wants to re-register on the same profile after removal
- Recommendation: Null out
AppIdafter successful removal; show "Register App" button whenAppIdis null
- What we know: We need to store the registered
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-06SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelRegistrationTests.cs— covers APPREG-01, APPREG-05SharepointToolbox/Core/Models/AppRegistrationResult.cs— discriminated union modelSharepointToolbox/Services/IAppRegistrationService.cs+AppRegistrationService.cs— interface + implementation stubs
Sources
Primary (HIGH confidence)
- Microsoft Graph: Create application — POST /applications — C# SDK v5 code confirmed
- Microsoft Graph: Grant/revoke API permissions programmatically — full sequential workflow with C# examples (updated 2026-03-21)
- Microsoft Graph: Delete application —
DELETE /applications/{id}endpoint - Microsoft Graph: Delete servicePrincipal — cascade behavior confirmed
- MSAL.NET: Clear the token cache —
RemoveAsyncloop pattern
Secondary (MEDIUM confidence)
- Microsoft Q&A: Check admin status via Graph API — confirms
/me/memberOfapproach - CIAOPS: Getting Global Administrators using the Graph — confirms
roleTemplateId = 62e90394-69f5-4237-9190-012177145e10 - Microsoft Graph: List directoryRoles — role template ID source
Tertiary (LOW confidence)
- SharePoint Online
AllSites.FullControldelegated 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)