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 ]; // Graph APPLICATION permissions (app roles) for certificate (app-only) auth. private static readonly string[] GraphAppRoles = [ "User.Read.All", "Group.ReadWrite.All", "Directory.Read.All", // expand M365/AAD group membership in the user-access audit (SharePointGroupResolver) "Sites.FullControl.All", "Mail.Send", // app-only sendMail for emailed scheduled reports ]; // SharePoint APPLICATION permission (app role) for certificate (app-only) CSOM. private static readonly string[] SpAppRoles = [ "Sites.FullControl.All", ]; private readonly HttpClient _http; public AppRegistrationService(HttpClient http) { _http = http; } public async Task CreateAsync( string adminAccessToken, string tenantName, string redirectUri, CertProvisioningResult? appOnlyCert = null, CancellationToken ct = default) { _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", adminAccessToken); bool wantsAppOnly = appOnlyCert is not null; // 1. Resolve Graph + SharePoint service principals + the permission ids we need var graph = await ResolveServicePrincipalAsync(GraphAppId, GraphScopes, GraphAppRoles, ct); var sp = await ResolveServicePrincipalAsync(SharePointAppId, SpScopes, SpAppRoles, ct); // 2. Create app registration (delegated scopes always; application roles when app-only) var appBody = new Dictionary { ["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 = ResourceAccess(graph.ScopeIds, wantsAppOnly ? graph.AppRoleIds : []), }, new { resourceAppId = SharePointAppId, resourceAccess = ResourceAccess(sp.ScopeIds, wantsAppOnly ? sp.AppRoleIds : []), }, }, }; // Attach the certificate as a sign-in credential so app-only token requests succeed. if (wantsAppOnly) appBody["keyCredentials"] = new[] { BuildKeyCredential(appOnlyCert!, tenantName) }; 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 + SharePoint delegated scopes await GrantDelegatedConsentAsync(newSpId, graph.SpObjectId, GraphScopes, ct); await GrantDelegatedConsentAsync(newSpId, sp.SpObjectId, SpScopes, ct); // 5. Grant admin consent for application permissions (app roles) when app-only if (wantsAppOnly) { await GrantAppRolesAsync(newSpId, graph.SpObjectId, graph.AppRoleIds, ct); await GrantAppRolesAsync(newSpId, sp.SpObjectId, sp.AppRoleIds, ct); } return clientId; } private static object BuildKeyCredential(CertProvisioningResult cert, string tenantName) => new { type = "AsymmetricX509Cert", usage = "Verify", key = cert.PublicCertBase64, displayName = $"CN=SP Toolbox — {tenantName}", startDateTime = cert.NotBefore.UtcDateTime.ToString("o"), endDateTime = cert.NotAfter.UtcDateTime.ToString("o"), }; private static object[] ResourceAccess(string[] scopeIds, string[] appRoleIds) { var list = new List(scopeIds.Length + appRoleIds.Length); list.AddRange(scopeIds.Select(id => new { id, type = "Scope" })); list.AddRange(appRoleIds.Select(id => new { id, type = "Role" })); return list.ToArray(); } private async Task GrantDelegatedConsentAsync(string clientSpId, string resourceSpId, string[] scopes, CancellationToken ct) { await PostGraphAsync("https://graph.microsoft.com/v1.0/oauth2PermissionGrants", new { clientId = clientSpId, consentType = "AllPrincipals", resourceId = resourceSpId, scope = string.Join(" ", scopes), }, ct); } private async Task GrantAppRolesAsync(string clientSpId, string resourceSpId, string[] appRoleIds, CancellationToken ct) { foreach (var appRoleId in appRoleIds) { await PostGraphAsync( $"https://graph.microsoft.com/v1.0/servicePrincipals/{clientSpId}/appRoleAssignments", new { principalId = clientSpId, resourceId = resourceSpId, appRoleId }, ct); } } // Returns the SP object id plus the ids of the requested delegated scopes and application roles. private async Task<(string SpObjectId, string[] ScopeIds, string[] AppRoleIds)> ResolveServicePrincipalAsync( string appId, string[] scopeNames, string[] roleNames, CancellationToken ct) { var url = $"https://graph.microsoft.com/v1.0/servicePrincipals" + $"?$filter=appId eq '{appId}'&$select=id,oauth2PermissionScopes,appRoles"; var resp = await _http.GetAsync(url, ct); var json = await resp.Content.ReadAsStringAsync(ct); resp.EnsureSuccessStatusCode(); using var doc = JsonDocument.Parse(json); var sp = doc.RootElement.GetProperty("value").EnumerateArray().First(); var spId = sp.GetProperty("id").GetString()!; var scopeIds = MatchByValue(sp.GetProperty("oauth2PermissionScopes"), scopeNames); var roleIds = MatchByValue(sp.GetProperty("appRoles"), roleNames); return (spId, scopeIds, roleIds); } private static string[] MatchByValue(JsonElement entries, string[] wantedValues) { var ids = new List(); foreach (var entry in entries.EnumerateArray()) { var value = entry.GetProperty("value").GetString(); if (wantedValues.Contains(value, StringComparer.OrdinalIgnoreCase)) ids.Add(entry.GetProperty("id").GetString()!); } return 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(); } }