using System.Net.Http.Headers; using System.Text; using System.Text.Json; namespace SharepointToolbox.Web.Services.Auth; public class AppRegistrationService : IAppRegistrationService { private const string GraphAppId = "00000003-0000-0000-c000-000000000000"; private const string SharePointAppId = "00000003-0000-0ff1-ce00-000000000000"; // Graph delegated scopes to request + consent private static readonly string[] GraphScopes = [ "User.Read", // signed-in user basic profile "User.Read.All", // look up users by email/UPN (GraphUserDirectoryService, BulkMemberService) "Group.ReadWrite.All", // read group members + add members/owners (BulkMemberService, SharePointGroupResolver) "Sites.Read.All", // resolve site groupId from siteId (BulkMemberService) ]; // SharePoint delegated scopes to request + consent private static readonly string[] SpScopes = [ "AllSites.FullControl", // CSOM — site permissions, content, admin operations ]; private readonly HttpClient _http; public AppRegistrationService(HttpClient http) { _http = http; } public async Task CreateAsync( string adminAccessToken, string tenantName, string redirectUri, CancellationToken ct = default) { _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", adminAccessToken); // 1. Resolve Graph + SharePoint service principals in the target tenant var (graphSpId, graphPermIds) = await ResolveServicePrincipalAsync(GraphAppId, GraphScopes, ct); var (spSpId, spPermIds) = await ResolveServicePrincipalAsync(SharePointAppId, SpScopes, ct); // 2. Create app registration var appBody = new { displayName = $"SP Toolbox — {tenantName}", signInAudience = "AzureADMyOrg", isFallbackPublicClient = true, // Register the redirect under the PUBLIC client platform so the connect // flow can redeem the auth code with PKCE only (no client secret). A // redirect under `web` makes Entra treat the app as confidential and the // token exchange fails with AADSTS7000218 (secret required). publicClient = new { redirectUris = new[] { redirectUri } }, requiredResourceAccess = new[] { new { resourceAppId = GraphAppId, resourceAccess = graphPermIds.Select(id => new { id, type = "Scope" }).ToArray(), }, new { resourceAppId = SharePointAppId, resourceAccess = spPermIds.Select(id => new { id, type = "Scope" }).ToArray(), }, }, }; var appJson = await PostGraphAsync("https://graph.microsoft.com/v1.0/applications", appBody, ct); var clientId = appJson.GetProperty("appId").GetString()!; // 3. Create service principal for the new app var spJson = await PostGraphAsync("https://graph.microsoft.com/v1.0/servicePrincipals", new { appId = clientId }, ct); var newSpId = spJson.GetProperty("id").GetString()!; // 4. Grant org-wide admin consent for Graph await PostGraphAsync("https://graph.microsoft.com/v1.0/oauth2PermissionGrants", new { clientId = newSpId, consentType = "AllPrincipals", resourceId = graphSpId, scope = string.Join(" ", GraphScopes), }, ct); // 5. Grant org-wide admin consent for SharePoint await PostGraphAsync("https://graph.microsoft.com/v1.0/oauth2PermissionGrants", new { clientId = newSpId, consentType = "AllPrincipals", resourceId = spSpId, scope = string.Join(" ", SpScopes), }, ct); return clientId; } // Returns (servicePrincipalObjectId, [permissionIds matching requested scopes]) private async Task<(string SpObjectId, string[] PermissionIds)> ResolveServicePrincipalAsync( string appId, string[] scopeNames, CancellationToken ct) { var url = $"https://graph.microsoft.com/v1.0/servicePrincipals" + $"?$filter=appId eq '{appId}'&$select=id,oauth2PermissionScopes"; var resp = await _http.GetAsync(url, ct); var json = await resp.Content.ReadAsStringAsync(ct); resp.EnsureSuccessStatusCode(); using var doc = JsonDocument.Parse(json); var values = doc.RootElement.GetProperty("value"); var sp = values.EnumerateArray().First(); var spId = sp.GetProperty("id").GetString()!; var allScopes = sp.GetProperty("oauth2PermissionScopes"); var ids = new List(); foreach (var scope in allScopes.EnumerateArray()) { var value = scope.GetProperty("value").GetString(); if (scopeNames.Contains(value, StringComparer.OrdinalIgnoreCase)) ids.Add(scope.GetProperty("id").GetString()!); } return (spId, ids.ToArray()); } private async Task PostGraphAsync(string url, object body, CancellationToken ct) { var content = new StringContent( JsonSerializer.Serialize(body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }), Encoding.UTF8, "application/json"); var resp = await _http.PostAsync(url, content, ct); var json = await resp.Content.ReadAsStringAsync(ct); if (!resp.IsSuccessStatusCode) throw new InvalidOperationException( $"Graph API error {resp.StatusCode} calling {url}: {json}"); return JsonDocument.Parse(json).RootElement.Clone(); } }