Files
SharepointToolbox-Web/Services/Auth/AppRegistrationService.cs
T
kawa b7061867f1 Register created app as public client (fix connect AADSTS7000218)
The per-client app registered its redirect URI under the `web` platform,
so Entra treated it as a confidential client and the connect token
exchange (PKCE, no secret) failed with AADSTS7000218 (client_secret
required). Register the redirect under `publicClient` instead — matching
the desktop reference (PublicClient.RedirectUris) — so the secretless
PKCE code redemption is accepted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:04:09 +02:00

146 lines
5.9 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
];
private readonly HttpClient _http;
public AppRegistrationService(HttpClient http) { _http = http; }
public async Task<string> 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<string>();
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<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();
}
}