using System.Net.Http.Headers; using System.Text.Json; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Identity.Client; using UniVerse.Application.Interfaces; using UniVerse.Domain.Exceptions; namespace UniVerse.Infrastructure.ExternalServices; public class MicrosoftAuthClient : IMicrosoftAuthClient { private readonly IConfiguration _config; private readonly EntraTokenCacheStore _cache; private readonly HttpClient _http; private readonly ILogger _logger; public MicrosoftAuthClient(IConfiguration config, EntraTokenCacheStore cache, HttpClient http, ILogger logger) { _config = config; _cache = cache; _http = http; _logger = logger; } public async Task ExchangeAuthorizationCodeAsync( string authorizationCode, string redirectUri, CancellationToken cancellationToken = default) { var app = CreateClient(redirectUri); byte[]? serializedCache = null; app.UserTokenCache.SetAfterAccess(args => { if (args.HasStateChanged) serializedCache = args.TokenCache.SerializeMsalV3(); }); try { var result = await app.AcquireTokenByAuthorizationCode(["User.Read"], authorizationCode) .ExecuteAsync(cancellationToken); return new MicrosoftTokenResult( result.IdToken, serializedCache, result.Account.HomeAccountId.Identifier); } catch (MsalException ex) { throw new UnauthorizedException($"Ошибка аутентификации Microsoft: {ex.Message}"); } } public Task SaveTokenCacheAsync(int userId, MicrosoftTokenResult result, CancellationToken cancellationToken = default) { if (!_cache.IsConfigured || result.TokenCache is null || string.IsNullOrWhiteSpace(result.AccountId)) return Task.CompletedTask; return _cache.LockedAsync(userId, async () => { await _cache.SaveAsync(userId, result.AccountId, result.TokenCache, cancellationToken); return true; }); } public async Task GetPhotoAsync(int userId, CancellationToken cancellationToken = default) { if (!_cache.IsConfigured) return null; string? accessToken; try { accessToken = await _cache.LockedAsync(userId, async () => { var saved = await _cache.ReadAsync(userId, cancellationToken); if (saved is null) return null; var app = CreateClient(); byte[]? updatedCache = null; app.UserTokenCache.SetBeforeAccess(args => args.TokenCache.DeserializeMsalV3(saved.Cache)); app.UserTokenCache.SetAfterAccess(args => { if (args.HasStateChanged) updatedCache = args.TokenCache.SerializeMsalV3(); }); var account = await app.GetAccountAsync(saved.AccountId); if (account is null) return null; var result = await app.AcquireTokenSilent(["User.Read"], account) .ExecuteAsync(cancellationToken); if (updatedCache is not null) await _cache.SaveAsync(userId, saved.AccountId, updatedCache, cancellationToken); return result.AccessToken; }); } catch (MsalUiRequiredException) { _logger.LogInformation("Microsoft requires a new sign-in before the profile photo can be loaded for user {UserId}.", userId); return null; } catch (Exception ex) when (ex is MsalException or IOException or UnauthorizedAccessException or System.Security.Cryptography.CryptographicException or JsonException) { _logger.LogWarning(ex, "Cannot read Microsoft token cache for user {UserId}.", userId); return null; } if (accessToken is null) return null; try { using var request = new HttpRequestMessage(HttpMethod.Get, "https://graph.microsoft.com/v1.0/me/photo/$value"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); using var response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); if (!response.IsSuccessStatusCode) return null; var contentType = response.Content.Headers.ContentType?.MediaType; if (contentType is not ("image/jpeg" or "image/png" or "image/gif" or "image/webp")) return null; await response.Content.LoadIntoBufferAsync(5 * 1024 * 1024, cancellationToken); return new MicrosoftPhotoResult(await response.Content.ReadAsByteArrayAsync(cancellationToken), contentType); } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) { _logger.LogWarning(ex, "Cannot load Microsoft profile photo for user {UserId}.", userId); return null; } } public Task RemoveTokenCacheAsync(int userId, CancellationToken cancellationToken = default) => _cache.LockedAsync(userId, async () => { await _cache.RemoveAsync(userId); return true; }); private IConfidentialClientApplication CreateClient(string? redirectUri = null) { var tenantId = _config["AzureAd:TenantId"]; var clientId = _config["AzureAd:ClientId"]; var clientSecret = _config["AzureAd:ClientSecret"]; var instance = _config["AzureAd:Instance"] ?? "https://login.microsoftonline.com/"; if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret)) throw new UnauthorizedException("Аутентификация Microsoft не настроена (AzureAd:TenantId/ClientId/ClientSecret)."); var builder = ConfidentialClientApplicationBuilder.Create(clientId) .WithClientSecret(clientSecret) .WithAuthority(new Uri($"{instance.TrimEnd('/')}/{tenantId}")); if (redirectUri is not null) builder = builder.WithRedirectUri(redirectUri); return builder.Build(); } }