using console_spo_utils.Model; using Microsoft.SharePoint.Client; using System.Collections.Concurrent; using System.Text; using System.Text.Json; using System.Web; using Microsoft.Extensions.Logging; using console_spo_utils.Interfaces.Services; namespace console_spo_utils.Services { internal sealed class SharePointAuthenticationManager : IDisposable, ISharePointAuthenticationManager { private readonly ILogger logger; private readonly ISiteOptions siteOptions; public SharePointAuthenticationManager( ILogger logger, ISiteOptions siteOptions) { this.logger = logger; this.siteOptions = siteOptions; } private static readonly HttpClient HttpClient = new(); private static readonly SemaphoreSlim SemaphoreSlimTokens = new(1); private AutoResetEvent tokenResetEvent = null; private readonly ConcurrentDictionary tokenCache = new(); private bool disposedValue; #region CSOM public ClientContext GetContext(Uri web) { var context = new ClientContext(web); context.ExecutingWebRequest += (sender, e) => { var accessToken = EnsureAccessTokenAsync(new Uri($"{web.Scheme}://{web.DnsSafeHost}"), siteOptions.GetUser(), new System.Net.NetworkCredential(string.Empty, siteOptions.GetPassword()).Password) .GetAwaiter() .GetResult(); e.WebRequestExecutor.RequestHeaders["Authorization"] = "Bearer " + accessToken; }; return context; } public async Task EnsureAccessTokenAsync(Uri resourceUri, string userPrincipalName, string userPassword) { var accessTokenFromCache = TokenFromCache(resourceUri, tokenCache); if (accessTokenFromCache == null) { await SemaphoreSlimTokens.WaitAsync(); try { // No async methods are allowed in a lock section var accessToken = await AcquireTokenAsync(resourceUri, userPrincipalName, userPassword); logger.LogInformation($"Successfully requested new access token resource {resourceUri.DnsSafeHost} for user {userPrincipalName}"); AddTokenToCache(resourceUri, tokenCache, accessToken); // Register a thread to invalidate the access token once's it's expired tokenResetEvent = new AutoResetEvent(false); var wi = new TokenWaitInfo(); wi.Handle = ThreadPool.RegisterWaitForSingleObject( tokenResetEvent, (state, timedOut) => { if (!timedOut) { var internalWaitToken = (TokenWaitInfo)state; if (internalWaitToken?.Handle is not null) { internalWaitToken.Handle.Unregister(null); } } else { try { // Take a lock to ensure no other threads are updating the SharePoint Access token at this time SemaphoreSlimTokens.Wait(); RemoveTokenFromCache(resourceUri, tokenCache); logger.LogInformation($"Cached token for resource {resourceUri.DnsSafeHost} and user {userPrincipalName} expired"); } catch (Exception ex) { logger.LogInformation($"Something went wrong during cache token invalidation: {ex.Message}"); RemoveTokenFromCache(resourceUri, tokenCache); } finally { SemaphoreSlimTokens.Release(); } } }, wi, (uint)CalculateThreadSleep(accessToken).TotalMilliseconds, true ); return accessToken; } finally { SemaphoreSlimTokens.Release(); } } else { logger.LogInformation("OK - Execution Query"); return accessTokenFromCache; } } private async Task AcquireTokenAsync(Uri resourceUri, string username, string password) { var resource = $"{resourceUri.Scheme}://{resourceUri.DnsSafeHost}"; var clientId = siteOptions.DefaultAadAppId; var body = $"resource={resource}&client_id={clientId}&grant_type=password&username={HttpUtility.UrlEncode(username)}&password={HttpUtility.UrlEncode(password)}"; using var stringContent = new StringContent(body, Encoding.UTF8, "application/x-www-form-urlencoded"); var result = await HttpClient.PostAsync(siteOptions.TokenEndpoint, stringContent).ContinueWith((response) => { return response.Result.Content.ReadAsStringAsync().Result; }).ConfigureAwait(false); var tokenResult = JsonSerializer.Deserialize(result); var token = tokenResult.GetProperty("access_token").GetString(); return token; } private static string TokenFromCache(Uri web, ConcurrentDictionary tokenCache) { if (tokenCache.TryGetValue(web.DnsSafeHost, out var accessToken)) { return accessToken; } return null; } private static void AddTokenToCache(Uri web, ConcurrentDictionary tokenCache, string newAccessToken) { if (tokenCache.TryGetValue(web.DnsSafeHost, out var currentAccessToken)) { tokenCache.TryUpdate(web.DnsSafeHost, newAccessToken, currentAccessToken); } else { tokenCache.TryAdd(web.DnsSafeHost, newAccessToken); } } private static void RemoveTokenFromCache(Uri web, ConcurrentDictionary tokenCache) { tokenCache.TryRemove(web.DnsSafeHost, out _); } private static TimeSpan CalculateThreadSleep(string accessToken) { var token = new System.IdentityModel.Tokens.Jwt.JwtSecurityToken(accessToken); var lease = GetAccessTokenLease(token.ValidTo); lease = TimeSpan.FromSeconds(lease.TotalSeconds - TimeSpan.FromMinutes(5).TotalSeconds > 0 ? lease.TotalSeconds - TimeSpan.FromMinutes(5).TotalSeconds : lease.TotalSeconds); return lease; } private static TimeSpan GetAccessTokenLease(DateTime expiresOn) { var now = DateTime.UtcNow; var expires = expiresOn.Kind == DateTimeKind.Utc ? expiresOn : TimeZoneInfo.ConvertTimeToUtc(expiresOn); var lease = expires - now; return lease; } private void Dispose(bool disposing) { if (disposedValue) { return; } if (disposing) { if (tokenResetEvent is not null) { tokenResetEvent.Set(); tokenResetEvent.Dispose(); } } disposedValue = true; } public void Dispose() { // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method Dispose(disposing: true); GC.SuppressFinalize(this); } #endregion } }