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; /// /// Session manager that resolves the auth model per profile. When a profile is configured /// for certificate auth (), contexts are /// built app-only via the stored certificate and no interactive sign-in is required. /// Otherwise it falls back to the delegated OAuth2 refresh-token flow, whose access tokens /// come from ISessionCredentialStore (ProtectedSessionStorage — browser-side only) and are /// cached 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 IAppOnlyContextFactory _appOnly; private readonly Dictionary _contexts = new(); private readonly Dictionary _accessTokenCache = new(); private readonly SemaphoreSlim _lock = new(1, 1); public SessionManager( ISessionCredentialStore credentialStore, ITokenRefreshService tokenRefresh, IAppOnlyContextFactory appOnly) { _credentialStore = credentialStore; _tokenRefresh = tokenRefresh; _appOnly = appOnly; } 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"; // Certificate-configured profiles authenticate app-only: no interactive sign-in, // no session tokens. Build the context through the cert factory and cache it under // the same key so report services see an ordinary authenticated ClientContext. if (_appOnly.IsConfigured(profile)) { await _lock.WaitAsync(ct); try { if (_contexts.TryGetValue(key, out var existingCert)) return existingCert; var certCtx = await _appOnly.CreateContextAsync(profile, profile.TenantUrl, ct); _contexts[key] = certCtx; return certCtx; } finally { _lock.Release(); } } 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) { return await GetOrCreateContextAsync(profile.CloneForSite(siteUrl), 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('/'); } }