using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text.Json; using JobTrackerApi.Data; using JobTrackerApi.Models; using Microsoft.AspNetCore.DataProtection; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; namespace JobTrackerApi.Services; public interface IMicrosoftGraphOAuthService { string BuildAuthorizationUrl(string ownerUserId, string redirectUri); string? ConsumeState(string state); Task ExchangeCodeAsync(string ownerUserId, string code, string redirectUri, CancellationToken cancellationToken); Task GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken); Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken); Task> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken); Task> ListThreadMessagesAsync(string ownerUserId, string conversationId, CancellationToken cancellationToken); Task GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken); } public sealed record MicrosoftGraphOAuthExchangeResult(string MailAddress); public sealed record MicrosoftGraphMessageSummary(string Id, string ConversationId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet); public sealed record MicrosoftGraphMessageAttachment(string? FileName, string? MimeType, long? SizeBytes, string? GraphAttachmentId, bool Inline); public sealed record MicrosoftGraphMessageDetail(string Id, string ConversationId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet, string BodyText, string? BodyHtml, IReadOnlyList Labels, IReadOnlyList Attachments); internal sealed class MicrosoftGraphTokenResponse { public string? access_token { get; set; } public int expires_in { get; set; } public string? refresh_token { get; set; } public string? scope { get; set; } public string? token_type { get; set; } } /// /// Outlook / Microsoft 365 mail via Microsoft Graph. Mirrors 's shape /// (auth-code + offline refresh, encrypted token storage, per-owner connection row) so the two providers /// stay structurally interchangeable behind . /// public sealed class MicrosoftGraphOAuthService : IMicrosoftGraphOAuthService { private const string Scope = "openid email profile offline_access https://graph.microsoft.com/Mail.Read"; private readonly IConfiguration _cfg; private readonly JobTrackerContext _db; private readonly IDataProtector _protector; private readonly IHttpClientFactory _httpClientFactory; private readonly IMemoryCache _cache; public MicrosoftGraphOAuthService( IConfiguration cfg, JobTrackerContext db, IDataProtectionProvider protectionProvider, IHttpClientFactory httpClientFactory, IMemoryCache cache) { _cfg = cfg; _db = db; _protector = protectionProvider.CreateProtector("microsoft-graph-oauth-tokens-v1"); _httpClientFactory = httpClientFactory; _cache = cache; } public string BuildAuthorizationUrl(string ownerUserId, string redirectUri) { var clientId = GetRequiredClientId(); var state = Convert.ToBase64String(Guid.NewGuid().ToByteArray()) .Replace("+", "-") .Replace("/", "_") .TrimEnd('='); _cache.Set(GetStateCacheKey(state), ownerUserId, TimeSpan.FromMinutes(15)); var query = new Dictionary { ["client_id"] = clientId, ["redirect_uri"] = redirectUri, ["response_type"] = "code", ["response_mode"] = "query", ["scope"] = Scope, ["state"] = state, }; var encoded = string.Join("&", query.Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value ?? "")}")); return $"https://login.microsoftonline.com/{GetTenant()}/oauth2/v2.0/authorize?{encoded}"; } public async Task ExchangeCodeAsync(string ownerUserId, string code, string redirectUri, CancellationToken cancellationToken) { var tokens = await ExchangeCodeForTokensAsync(code, redirectUri, cancellationToken); var accessToken = tokens.access_token?.Trim(); var refreshToken = tokens.refresh_token?.Trim(); if (string.IsNullOrWhiteSpace(accessToken)) throw new InvalidOperationException("Microsoft did not return an access token."); if (string.IsNullOrWhiteSpace(refreshToken)) throw new InvalidOperationException("Microsoft did not return a refresh token. Reconnect Outlook and ensure offline_access consent is granted."); var mailAddress = await GetProfileAsync(accessToken, cancellationToken); if (string.IsNullOrWhiteSpace(mailAddress)) throw new InvalidOperationException("Microsoft did not return a mail address."); var existing = await _db.MicrosoftGraphConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); if (existing is null) { existing = new MicrosoftGraphConnection { OwnerUserId = ownerUserId, }; _db.MicrosoftGraphConnections.Add(existing); } existing.MailAddress = mailAddress.Trim(); existing.EncryptedRefreshToken = _protector.Protect(refreshToken); existing.EncryptedAccessToken = _protector.Protect(accessToken); existing.AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(tokens.expires_in - 60, 60)); existing.Scope = tokens.scope?.Trim() ?? Scope; existing.ConnectedAt = DateTimeOffset.UtcNow; existing.LastSyncStatus = "connected"; existing.LastSyncSource = "oauth-callback"; existing.LastSyncMode = "connect"; existing.LastSyncError = null; existing.LastSyncAttemptedAt = DateTimeOffset.UtcNow; existing.LastSyncSucceededAt = existing.LastSyncAttemptedAt; await _db.SaveChangesAsync(cancellationToken); return new MicrosoftGraphOAuthExchangeResult(existing.MailAddress); } public string? ConsumeState(string state) { if (!_cache.TryGetValue(GetStateCacheKey(state), out var ownerUserId)) { return null; } _cache.Remove(GetStateCacheKey(state)); return ownerUserId; } public Task GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken) { return _db.MicrosoftGraphConnections.AsNoTracking().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); } public async Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken) { var existing = await _db.MicrosoftGraphConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); if (existing is null) return; _db.MicrosoftGraphConnections.Remove(existing); await _db.SaveChangesAsync(cancellationToken); } public async Task> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken) { maxResults = Math.Clamp(maxResults, 1, 25); try { var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken); var client = _httpClientFactory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); var select = "id,conversationId,subject,from,toRecipients,receivedDateTime,bodyPreview"; var url = $"https://graph.microsoft.com/v1.0/me/messages?$top={maxResults}&$select={select}&$orderby=receivedDateTime desc"; if (!string.IsNullOrWhiteSpace(query)) { client.DefaultRequestHeaders.Add("ConsistencyLevel", "eventual"); url += $"&$search={Uri.EscapeDataString(EscapeSearchQuery(query.Trim()))}"; } using var response = await client.GetAsync(url, cancellationToken); response.EnsureSuccessStatusCode(); using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); var results = ReadMessageSummaries(doc.RootElement); await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", true, null, cancellationToken); return results; } catch (Exception ex) { await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", false, ex.Message, cancellationToken); throw; } } public async Task> ListThreadMessagesAsync(string ownerUserId, string conversationId, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(conversationId)) { return Array.Empty(); } try { var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken); var client = _httpClientFactory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); var select = "id,conversationId,subject,from,toRecipients,receivedDateTime,bodyPreview"; var filter = $"conversationId eq '{conversationId.Trim().Replace("'", "''")}'"; var url = $"https://graph.microsoft.com/v1.0/me/messages?$filter={Uri.EscapeDataString(filter)}&$select={select}&$orderby=receivedDateTime asc"; using var response = await client.GetAsync(url, cancellationToken); response.EnsureSuccessStatusCode(); using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); var results = ReadMessageSummaries(doc.RootElement); await TouchSyncStateAsync(ownerUserId, "thread-refresh", "conversation-metadata", true, null, cancellationToken); return results; } catch (Exception ex) { await TouchSyncStateAsync(ownerUserId, "thread-refresh", "conversation-metadata", false, ex.Message, cancellationToken); throw; } } public async Task GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken) { try { var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken); var client = _httpClientFactory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); var select = "id,conversationId,subject,from,toRecipients,receivedDateTime,bodyPreview,body,categories,hasAttachments"; var url = $"https://graph.microsoft.com/v1.0/me/messages/{Uri.EscapeDataString(messageId)}?$select={select}"; using var response = await client.GetAsync(url, cancellationToken); response.EnsureSuccessStatusCode(); using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); var root = doc.RootElement; var conversationId = root.TryGetProperty("conversationId", out var convEl) ? convEl.GetString() ?? "" : ""; var snippet = root.TryGetProperty("bodyPreview", out var previewEl) ? previewEl.GetString() ?? "" : ""; var categories = root.TryGetProperty("categories", out var categoriesEl) && categoriesEl.ValueKind == JsonValueKind.Array ? categoriesEl.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.String).Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast().ToList() : new List(); string bodyText = ""; string? bodyHtml = null; if (root.TryGetProperty("body", out var bodyEl) && bodyEl.ValueKind == JsonValueKind.Object) { var contentType = bodyEl.TryGetProperty("contentType", out var ctEl) ? ctEl.GetString() ?? "" : ""; var content = bodyEl.TryGetProperty("content", out var contentEl) ? contentEl.GetString() ?? "" : ""; if (string.Equals(contentType, "html", StringComparison.OrdinalIgnoreCase)) { bodyHtml = content; bodyText = StripHtml(content); } else { bodyText = content; } } var attachments = root.TryGetProperty("hasAttachments", out var hasAttEl) && hasAttEl.ValueKind == JsonValueKind.True ? await ListAttachmentsAsync(client, messageId, cancellationToken) : Array.Empty(); await TouchSyncStateAsync(ownerUserId, "message-detail", "graph-message", true, null, cancellationToken); return new MicrosoftGraphMessageDetail( messageId, conversationId, ReadSubject(root), ReadRecipientAddress(root, "from"), ReadRecipientListAddresses(root, "toRecipients"), ReadReceivedDate(root), snippet, bodyText.Trim(), bodyHtml, categories, attachments); } catch (Exception ex) { await TouchSyncStateAsync(ownerUserId, "message-detail", "graph-message", false, ex.Message, cancellationToken); throw; } } private static async Task> ListAttachmentsAsync(HttpClient client, string messageId, CancellationToken cancellationToken) { var url = $"https://graph.microsoft.com/v1.0/me/messages/{Uri.EscapeDataString(messageId)}/attachments?$select=id,name,contentType,size,isInline"; using var response = await client.GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) { return Array.Empty(); } using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); if (!doc.RootElement.TryGetProperty("value", out var valueEl) || valueEl.ValueKind != JsonValueKind.Array) { return Array.Empty(); } return valueEl.EnumerateArray().Select(item => new MicrosoftGraphMessageAttachment( item.TryGetProperty("name", out var nameEl) ? nameEl.GetString() : null, item.TryGetProperty("contentType", out var typeEl) ? typeEl.GetString() : null, item.TryGetProperty("size", out var sizeEl) && sizeEl.TryGetInt64(out var size) ? size : null, item.TryGetProperty("id", out var idEl) ? idEl.GetString() : null, item.TryGetProperty("isInline", out var inlineEl) && inlineEl.ValueKind == JsonValueKind.True )).ToList(); } private static List ReadMessageSummaries(JsonElement root) { if (!root.TryGetProperty("value", out var valueEl) || valueEl.ValueKind != JsonValueKind.Array) { return new List(); } return valueEl.EnumerateArray().Select(item => new MicrosoftGraphMessageSummary( item.TryGetProperty("id", out var idEl) ? idEl.GetString() ?? "" : "", item.TryGetProperty("conversationId", out var convEl) ? convEl.GetString() ?? "" : "", ReadSubject(item), ReadRecipientAddress(item, "from"), ReadRecipientListAddresses(item, "toRecipients"), ReadReceivedDate(item), item.TryGetProperty("bodyPreview", out var previewEl) ? previewEl.GetString() ?? "" : "" )).Where(m => !string.IsNullOrWhiteSpace(m.Id)).ToList(); } private static string ReadSubject(JsonElement item) => item.TryGetProperty("subject", out var subjectEl) ? subjectEl.GetString() ?? "" : ""; private static DateTimeOffset? ReadReceivedDate(JsonElement item) => item.TryGetProperty("receivedDateTime", out var dateEl) && dateEl.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(dateEl.GetString(), out var parsed) ? parsed : null; private static string ReadRecipientAddress(JsonElement item, string property) { if (!item.TryGetProperty(property, out var recipientEl) || recipientEl.ValueKind != JsonValueKind.Object) return ""; if (!recipientEl.TryGetProperty("emailAddress", out var addressEl) || addressEl.ValueKind != JsonValueKind.Object) return ""; return addressEl.TryGetProperty("address", out var addrEl) ? addrEl.GetString() ?? "" : ""; } private static string ReadRecipientListAddresses(JsonElement item, string property) { if (!item.TryGetProperty(property, out var listEl) || listEl.ValueKind != JsonValueKind.Array) return ""; var addresses = listEl.EnumerateArray() .Where(r => r.TryGetProperty("emailAddress", out _)) .Select(r => r.GetProperty("emailAddress").TryGetProperty("address", out var a) ? a.GetString() : null) .Where(a => !string.IsNullOrWhiteSpace(a)); return string.Join(", ", addresses); } private static string EscapeSearchQuery(string query) => "\"" + query.Replace("\"", "'") + "\""; private static string StripHtml(string html) => System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " ").Trim(); private async Task GetValidAccessTokenAsync(string ownerUserId, CancellationToken cancellationToken) { var connection = await _db.MicrosoftGraphConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); if (connection is null) throw new InvalidOperationException("Outlook is not connected for this account."); if (!string.IsNullOrWhiteSpace(connection.EncryptedAccessToken) && connection.AccessTokenExpiresAt is { } expiresAt && expiresAt > DateTimeOffset.UtcNow.AddMinutes(1)) { try { return _protector.Unprotect(connection.EncryptedAccessToken); } catch (CryptographicException) { connection.EncryptedAccessToken = null; connection.AccessTokenExpiresAt = null; } } string refreshToken; try { refreshToken = _protector.Unprotect(connection.EncryptedRefreshToken); } catch (CryptographicException) { throw new InvalidOperationException("Your stored Outlook connection can no longer be decrypted after a server key change. Disconnect Outlook and connect it again."); } var refreshed = await RefreshAccessTokenAsync(refreshToken, cancellationToken); var accessToken = refreshed.access_token?.Trim(); if (string.IsNullOrWhiteSpace(accessToken)) throw new InvalidOperationException("Failed to refresh Outlook access token."); connection.EncryptedAccessToken = _protector.Protect(accessToken); connection.AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(refreshed.expires_in - 60, 60)); if (!string.IsNullOrWhiteSpace(refreshed.scope)) { connection.Scope = refreshed.scope.Trim(); } if (!string.IsNullOrWhiteSpace(refreshed.refresh_token)) { connection.EncryptedRefreshToken = _protector.Protect(refreshed.refresh_token.Trim()); } await _db.SaveChangesAsync(cancellationToken); return accessToken; } private async Task ExchangeCodeForTokensAsync(string code, string redirectUri, CancellationToken cancellationToken) { var client = _httpClientFactory.CreateClient(); using var response = await client.PostAsync( $"https://login.microsoftonline.com/{GetTenant()}/oauth2/v2.0/token", new FormUrlEncodedContent(new Dictionary { ["code"] = code, ["client_id"] = GetRequiredClientId(), ["client_secret"] = GetRequiredClientSecret(), ["redirect_uri"] = redirectUri, ["grant_type"] = "authorization_code", ["scope"] = Scope, }), cancellationToken); var payload = await response.Content.ReadAsStringAsync(cancellationToken); if (!response.IsSuccessStatusCode) { throw new InvalidOperationException($"Microsoft token exchange failed: {payload}"); } return JsonSerializer.Deserialize(payload) ?? throw new InvalidOperationException("Unable to parse Microsoft token response."); } private async Task RefreshAccessTokenAsync(string refreshToken, CancellationToken cancellationToken) { var client = _httpClientFactory.CreateClient(); using var response = await client.PostAsync( $"https://login.microsoftonline.com/{GetTenant()}/oauth2/v2.0/token", new FormUrlEncodedContent(new Dictionary { ["refresh_token"] = refreshToken, ["client_id"] = GetRequiredClientId(), ["client_secret"] = GetRequiredClientSecret(), ["grant_type"] = "refresh_token", ["scope"] = Scope, }), cancellationToken); var payload = await response.Content.ReadAsStringAsync(cancellationToken); if (!response.IsSuccessStatusCode) { throw new InvalidOperationException($"Microsoft token refresh failed: {payload}"); } return JsonSerializer.Deserialize(payload) ?? throw new InvalidOperationException("Unable to parse Microsoft refresh response."); } private async Task GetProfileAsync(string accessToken, CancellationToken cancellationToken) { var client = _httpClientFactory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); using var response = await client.GetAsync("https://graph.microsoft.com/v1.0/me?$select=mail,userPrincipalName", cancellationToken); var payload = await response.Content.ReadAsStringAsync(cancellationToken); if (!response.IsSuccessStatusCode) { throw new InvalidOperationException($"Failed to read Microsoft profile: {payload}"); } using var doc = JsonDocument.Parse(payload); var mail = doc.RootElement.TryGetProperty("mail", out var mailEl) ? mailEl.GetString() : null; if (!string.IsNullOrWhiteSpace(mail)) return mail; return doc.RootElement.TryGetProperty("userPrincipalName", out var upnEl) ? upnEl.GetString() ?? "" : ""; } private async Task TouchSyncStateAsync(string ownerUserId, string mode, string source, bool succeeded, string? error, CancellationToken cancellationToken) { var connection = await _db.MicrosoftGraphConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); if (connection is null) return; connection.LastSyncAttemptedAt = DateTimeOffset.UtcNow; connection.LastSyncMode = mode; connection.LastSyncSource = source; connection.LastSyncStatus = succeeded ? "ok" : "error"; connection.LastSyncError = succeeded ? null : error; if (succeeded) { connection.LastSyncedAt = DateTimeOffset.UtcNow; connection.LastSyncSucceededAt = connection.LastSyncedAt; } await _db.SaveChangesAsync(cancellationToken); } private string GetTenant() => (_cfg["Microsoft:TenantId"] ?? "common").Trim(); private string GetRequiredClientId() { var value = (_cfg["Microsoft:ClientId"] ?? "").Trim(); return value.Length > 0 ? value : throw new InvalidOperationException("Microsoft:ClientId is not configured."); } private string GetRequiredClientSecret() { var value = (_cfg["Microsoft:ClientSecret"] ?? "").Trim(); return value.Length > 0 ? value : throw new InvalidOperationException("Microsoft:ClientSecret is not configured."); } private static string GetStateCacheKey(string state) => $"microsoft-oauth-state:{state}"; }