using Microsoft.SharePoint.Client; using SharepointToolbox.Web.Core.Models; using SharepointToolbox.Web.Services; using SharepointToolbox.Web.Services.Auth; using SharepointToolbox.Web.Services.Session; namespace SharepointToolbox.Web.Infrastructure.Auth; /// /// Delegated session manager using OAuth2 refresh tokens. /// Tokens come from ISessionCredentialStore (ProtectedSessionStorage — browser-side only). /// Caches access tokens in-memory per scope for the duration of the Blazor circuit. /// Scoped per Blazor circuit. /// public class SessionManager : ISessionManager { private readonly ISessionCredentialStore _credentialStore; private readonly ITokenRefreshService _tokenRefresh; private readonly Dictionary _contexts = new(); private readonly Dictionary _accessTokenCache = new(); private readonly SemaphoreSlim _lock = new(1, 1); public SessionManager(ISessionCredentialStore credentialStore, ITokenRefreshService tokenRefresh) { _credentialStore = credentialStore; _tokenRefresh = tokenRefresh; } public bool IsAuthenticated(string tenantUrl) => _contexts.ContainsKey(NormalizeUrl(tenantUrl)); public async Task<(string Token, DateTimeOffset ExpiresAt)> GetAccessTokenWithExpiryAsync( string scope, CancellationToken ct = default) { await _lock.WaitAsync(ct); try { if (_accessTokenCache.TryGetValue(scope, out var cached) && cached.ExpiresAt > DateTimeOffset.UtcNow) return cached; var tokens = await _credentialStore.GetAsync() ?? throw new InvalidOperationException( "No session tokens found. Please authenticate via Microsoft first."); var result = await _tokenRefresh.RefreshAsync(tokens.RefreshToken, tokens.TenantId, tokens.ClientId, scope); // Persist rotated refresh token back to browser storage if (result.RefreshToken != tokens.RefreshToken) await _credentialStore.UpdateRefreshTokenAsync(result.RefreshToken); var entry = (result.AccessToken, result.ExpiresAt); _accessTokenCache[scope] = entry; return entry; } finally { _lock.Release(); } } public async Task GetOrCreateContextAsync(TenantProfile profile, CancellationToken ct = default) { ArgumentException.ThrowIfNullOrEmpty(profile.TenantUrl); ArgumentException.ThrowIfNullOrEmpty(profile.TenantId); var key = NormalizeUrl(profile.TenantUrl); var spScope = NormalizeScopeUrl(profile.TenantUrl) + "/.default"; await _lock.WaitAsync(ct); try { if (_contexts.TryGetValue(key, out var existing)) return existing; // Validate tokens are present before creating context var tokens = await _credentialStore.GetAsync() ?? throw new InvalidOperationException( "No session tokens found. Please authenticate via Microsoft first."); _ = tokens; // validated; actual token acquired per-request below var ctx = new ClientContext(profile.TenantUrl); ctx.ExecutingWebRequest += async (_, e) => { var (token, _) = await GetAccessTokenWithExpiryAsyncInternal(spScope, tokens.TenantId); e.WebRequestExecutor.RequestHeaders["Authorization"] = "Bearer " + token; }; _contexts[key] = ctx; return ctx; } finally { _lock.Release(); } } public async Task GetOrCreateContextAsync(string siteUrl, TenantProfile profile, CancellationToken ct = default) { var profileForSite = new TenantProfile { Id = profile.Id, Name = profile.Name, TenantUrl = siteUrl, TenantId = profile.TenantId, ClientId = profile.ClientId, ClientLogo = profile.ClientLogo, }; return await GetOrCreateContextAsync(profileForSite, ct); } public async Task ClearSessionAsync(string tenantUrl) { var key = NormalizeUrl(tenantUrl); await _lock.WaitAsync(); try { if (_contexts.TryGetValue(key, out var ctx)) { ctx.Dispose(); _contexts.Remove(key); } } finally { _lock.Release(); } } public async Task ClearAllAsync() { await _lock.WaitAsync(); try { foreach (var ctx in _contexts.Values) ctx.Dispose(); _contexts.Clear(); _accessTokenCache.Clear(); } finally { _lock.Release(); } } // Internal version that bypasses the outer lock (called from ExecutingWebRequest which may run concurrently) private async Task<(string Token, DateTimeOffset ExpiresAt)> GetAccessTokenWithExpiryAsyncInternal( string scope, string tenantId) { if (_accessTokenCache.TryGetValue(scope, out var cached) && cached.ExpiresAt > DateTimeOffset.UtcNow) return cached; var tokens = await _credentialStore.GetAsync() ?? throw new InvalidOperationException("No session tokens in store."); var result = await _tokenRefresh.RefreshAsync(tokens.RefreshToken, tenantId, tokens.ClientId, scope); if (result.RefreshToken != tokens.RefreshToken) await _credentialStore.UpdateRefreshTokenAsync(result.RefreshToken); var entry = (result.AccessToken, result.ExpiresAt); _accessTokenCache[scope] = entry; return entry; } private static string NormalizeUrl(string url) => url.TrimEnd('/').ToLowerInvariant(); private static string NormalizeScopeUrl(string siteUrl) { if (Uri.TryCreate(siteUrl, UriKind.Absolute, out var uri)) return $"{uri.Scheme}://{uri.Host}"; return siteUrl.TrimEnd('/'); } }