b7061867f1
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>
146 lines
5.9 KiB
C#
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();
|
|
}
|
|
}
|