6d9c79ad5a
Feature work: - Certificate (app-only) auth per profile: cert store, context/Graph client factories, automated app-registration provisioning (delegated + application permissions, admin consent), and a SessionManager seam that resolves the auth model per profile. - Scheduled reports: repositories, hosted service/runner/coordinator, report pages, and email delivery (app-only Mail.Send). - Tenant-wide user-access audit when no site is selected. Audit fixes: - Site enumeration: app-only discovery used Graph getAllSites (needs Graph Sites.Read.All the cert app lacks) and silently returned empty. Switched to the admin-host CSOM TenantSiteEnumerator, matching the scheduler; both auth models now share one enumeration path. - Group expansion: the scan records a SharePoint group as a single principal, so user-centric audits found nothing for group-granted access. Resolve group membership (shared by audit + scheduler) and attribute it to the target user. - M365 group claims: the resolver only recognized AAD security groups (c:0t.c|). Group-connected/Teams sites grant via the M365 group claim (c:0o.c|…|<guid>[_o]); now expanded too, resolving owners for the "_o" claim. - Provision Directory.Read.All as an application permission so M365/AAD group expansion works under the cert identity. Also: ignore data/appcerts/ (encrypted certificate key material). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
204 lines
8.6 KiB
C#
204 lines
8.6 KiB
C#
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<string> 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<string, object?>
|
|
{
|
|
["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<object>(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<string>();
|
|
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<JsonElement> 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();
|
|
}
|
|
}
|