Files
Sharepoint-Toolbox/SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs
T

121 lines
4.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.Graph;
using Microsoft.Identity.Client;
using Microsoft.Kiota.Abstractions.Authentication;
namespace SharepointToolbox.Infrastructure.Auth;
public class GraphClientFactory
{
private readonly MsalClientFactory _msalFactory;
public GraphClientFactory(MsalClientFactory msalFactory)
{
_msalFactory = msalFactory;
}
/// <summary>
/// Creates a GraphServiceClient that acquires tokens via the same MSAL PCA
/// used for SharePoint auth, but with Graph scopes. Uses the /common authority
/// and the <c>.default</c> scope (whatever the client is pre-consented for).
/// </summary>
public Task<GraphServiceClient> CreateClientAsync(string clientId, CancellationToken ct)
=> CreateClientAsync(clientId, tenantId: null, scopes: null, ct);
/// <summary>
/// Creates a GraphServiceClient pinned to a specific tenant authority.
/// Pass the tenant domain (e.g. "contoso.onmicrosoft.com") or tenant GUID.
/// Null <paramref name="tenantId"/> falls back to /common.
/// </summary>
public Task<GraphServiceClient> CreateClientAsync(string clientId, string? tenantId, CancellationToken ct)
=> CreateClientAsync(clientId, tenantId, scopes: null, ct);
/// <summary>
/// Creates a GraphServiceClient with explicit Graph delegated scopes.
/// Use when <c>.default</c> is insufficient — typically for admin actions that
/// need scopes not pre-consented on the bootstrap client (e.g. app registration
/// requires <c>Application.ReadWrite.All</c> and
/// <c>DelegatedPermissionGrant.ReadWrite.All</c>). Triggers an admin-consent
/// prompt on first use if the tenant has not yet consented.
/// </summary>
public async Task<GraphServiceClient> CreateClientAsync(
string clientId,
string? tenantId,
string[]? scopes,
CancellationToken ct)
{
var pca = await _msalFactory.GetOrCreateAsync(clientId);
// Always reuse a cached account when one exists — `WithTenantId` on the
// silent/interactive call redirects the authority, and MSAL stores
// refresh tokens per tenant. Skipping the cached account forces an
// interactive prompt on every Graph call (the bug that produced 45
// sign-in windows during app registration).
var accounts = await pca.GetAccountsAsync();
var account = accounts.FirstOrDefault();
var graphScopes = scopes ?? new[] { "https://graph.microsoft.com/.default" };
var tokenProvider = new MsalTokenProvider(pca, account, graphScopes, tenantId);
var authProvider = new BaseBearerTokenAuthenticationProvider(tokenProvider);
return new GraphServiceClient(authProvider);
}
}
/// <summary>
/// Bridges MSAL PCA token acquisition with Graph SDK's IAccessTokenProvider interface.
/// </summary>
internal class MsalTokenProvider : IAccessTokenProvider
{
private readonly IPublicClientApplication _pca;
private IAccount? _account;
private readonly string[] _scopes;
private readonly string? _tenantId;
public MsalTokenProvider(IPublicClientApplication pca, IAccount? account, string[] scopes, string? tenantId = null)
{
_pca = pca;
_account = account;
_scopes = scopes;
_tenantId = tenantId;
}
public AllowedHostsValidator AllowedHostsValidator { get; } = new();
public async Task<string> GetAuthorizationTokenAsync(
Uri uri,
Dictionary<string, object>? additionalAuthenticationContext = null,
CancellationToken cancellationToken = default)
{
// Refresh _account from PCA cache each call — interactive flows on a
// sibling token provider populate the cache, and we want the next
// request on this provider to use that account silently.
if (_account is null)
{
var accounts = await _pca.GetAccountsAsync();
_account = accounts.FirstOrDefault();
}
if (_account is not null)
{
try
{
var silent = _pca.AcquireTokenSilent(_scopes, _account);
if (_tenantId is not null) silent = silent.WithTenantId(_tenantId);
var result = await silent.ExecuteAsync(cancellationToken);
return result.AccessToken;
}
catch (MsalUiRequiredException)
{
// fall through to interactive
}
}
var interactive = _pca.AcquireTokenInteractive(_scopes);
if (_tenantId is not null) interactive = interactive.WithTenantId(_tenantId);
var interactiveResult = await interactive.ExecuteAsync(cancellationToken);
// Cache the account so subsequent calls on this provider go silent.
_account = interactiveResult.Account;
return interactiveResult.AccessToken;
}
}