using Azure.Core; using Azure.Identity; using Microsoft.Graph; using Microsoft.SharePoint.Client; using SharepointToolbox.Web.Core.Models; namespace SharepointToolbox.Web.Infrastructure.Auth; /// /// Certificate-based app-only client factory. Acquires tokens with /// and injects the SharePoint bearer token /// through CSOM's ExecutingWebRequest hook — the same mechanism the delegated /// SessionManager uses, so report services see an ordinary authenticated /// regardless of which auth model produced it. /// public class AppOnlyContextFactory : IAppOnlyContextFactory { private static readonly string[] GraphScopes = ["https://graph.microsoft.com/.default"]; private readonly IAppOnlyCertStore _certStore; public AppOnlyContextFactory(IAppOnlyCertStore certStore) { _certStore = certStore; } public bool IsConfigured(TenantProfile profile) => profile.AppOnlyEnabled && !string.IsNullOrWhiteSpace(profile.AppOnlyClientId) && !string.IsNullOrWhiteSpace(profile.TenantId) && _certStore.Exists(profile.Id); public async Task GetTokenAsync(TenantProfile profile, string scope, CancellationToken ct = default) { var credential = await BuildCredentialAsync(profile, ct); return await credential.GetTokenAsync(new TokenRequestContext([scope]), ct); } public async Task CreateContextAsync(TenantProfile profile, string siteUrl, CancellationToken ct = default) { var credential = await BuildCredentialAsync(profile, ct); var spScope = SharePointScope(siteUrl); var ctx = new ClientContext(siteUrl); ctx.ExecutingWebRequest += (_, e) => { // CSOM raises this synchronously immediately before sending; acquire the // token synchronously so the header is guaranteed set. ClientCertificateCredential // caches access tokens internally, so this is cheap after the first call. var token = credential.GetToken(new TokenRequestContext([spScope]), CancellationToken.None); e.WebRequestExecutor.RequestHeaders["Authorization"] = "Bearer " + token.Token; }; return ctx; } public async Task CreateGraphClientAsync(TenantProfile profile, CancellationToken ct = default) { var credential = await BuildCredentialAsync(profile, ct); return new GraphServiceClient(credential, GraphScopes); } public async Task TestConnectionAsync(TenantProfile profile, CancellationToken ct = default) { try { using var ctx = await CreateContextAsync(profile, profile.TenantUrl, ct); ctx.Load(ctx.Web, w => w.Title); await ctx.ExecuteQueryAsync(); return null; } catch (Exception ex) { return ex.Message; } } public async Task WaitUntilReadyAsync(TenantProfile profile, TimeSpan timeout, CancellationToken ct = default) { var deadline = DateTimeOffset.UtcNow + timeout; var delay = TimeSpan.FromSeconds(5); string? lastError; do { // Each attempt builds a fresh credential, so a cached 401 never sticks across retries. lastError = await TestConnectionAsync(profile, ct); if (lastError is null) return null; if (DateTimeOffset.UtcNow + delay >= deadline) break; await Task.Delay(delay, ct); } while (DateTimeOffset.UtcNow < deadline); return lastError; } private async Task BuildCredentialAsync(TenantProfile profile, CancellationToken ct) { if (!profile.AppOnlyEnabled) throw new InvalidOperationException($"App-only reports are not enabled for client '{profile.Name}'."); if (string.IsNullOrWhiteSpace(profile.AppOnlyClientId)) throw new InvalidOperationException($"No app-only client ID configured for client '{profile.Name}'."); if (string.IsNullOrWhiteSpace(profile.TenantId)) throw new InvalidOperationException($"No tenant ID configured for client '{profile.Name}'."); var cert = await _certStore.LoadAsync(profile.Id, ct) ?? throw new InvalidOperationException($"No app-only certificate stored for client '{profile.Name}'."); var options = new ClientCertificateCredentialOptions { // SharePoint app-only requires the v1 resource audience; SendCertificateChain // improves compatibility with subject-name/issuer-configured app registrations. SendCertificateChain = true }; return new ClientCertificateCredential(profile.TenantId, profile.AppOnlyClientId, cert, options); } // https://contoso.sharepoint.com/sites/Foo → https://contoso.sharepoint.com/.default private static string SharePointScope(string siteUrl) { if (Uri.TryCreate(siteUrl, UriKind.Absolute, out var uri)) return $"{uri.Scheme}://{uri.Host}/.default"; return siteUrl.TrimEnd('/') + "/.default"; } }