Add OAth flow for Gmail and update tables and UI
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
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 IGmailOAuthService
|
||||
{
|
||||
string BuildAuthorizationUrl(string ownerUserId, string redirectUri);
|
||||
string? ConsumeState(string state);
|
||||
Task<GmailOAuthExchangeResult> ExchangeCodeAsync(string ownerUserId, string code, string redirectUri, CancellationToken cancellationToken);
|
||||
Task<GmailConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken);
|
||||
Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<GmailMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken);
|
||||
Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record GmailOAuthExchangeResult(string GmailAddress);
|
||||
public sealed record GmailMessageSummary(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet);
|
||||
public sealed record GmailMessageDetail(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet, string BodyText, string? BodyHtml);
|
||||
|
||||
internal sealed class GmailTokenResponse
|
||||
{
|
||||
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; }
|
||||
}
|
||||
|
||||
public sealed class GmailOAuthService : IGmailOAuthService
|
||||
{
|
||||
private const string Scope = "openid email profile https://www.googleapis.com/auth/gmail.readonly";
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly IDataProtector _protector;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IMemoryCache _cache;
|
||||
|
||||
public GmailOAuthService(
|
||||
IConfiguration cfg,
|
||||
JobTrackerContext db,
|
||||
IDataProtectionProvider protectionProvider,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IMemoryCache cache)
|
||||
{
|
||||
_cfg = cfg;
|
||||
_db = db;
|
||||
_protector = protectionProvider.CreateProtector("gmail-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<string, string?>
|
||||
{
|
||||
["client_id"] = clientId,
|
||||
["redirect_uri"] = redirectUri,
|
||||
["response_type"] = "code",
|
||||
["access_type"] = "offline",
|
||||
["prompt"] = "consent",
|
||||
["include_granted_scopes"] = "true",
|
||||
["scope"] = Scope,
|
||||
["state"] = state,
|
||||
};
|
||||
|
||||
var encoded = string.Join("&", query.Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value ?? "")}"));
|
||||
return $"https://accounts.google.com/o/oauth2/v2/auth?{encoded}";
|
||||
}
|
||||
|
||||
public async Task<GmailOAuthExchangeResult> 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("Google did not return an access token.");
|
||||
if (string.IsNullOrWhiteSpace(refreshToken))
|
||||
throw new InvalidOperationException("Google did not return a refresh token. Reconnect Gmail and ensure consent is granted.");
|
||||
|
||||
var gmailAddress = await GetProfileAsync(accessToken, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(gmailAddress))
|
||||
throw new InvalidOperationException("Google did not return a Gmail address.");
|
||||
|
||||
var existing = await _db.GmailConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new GmailConnection
|
||||
{
|
||||
OwnerUserId = ownerUserId,
|
||||
};
|
||||
_db.GmailConnections.Add(existing);
|
||||
}
|
||||
|
||||
existing.GmailAddress = gmailAddress.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;
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return new GmailOAuthExchangeResult(existing.GmailAddress);
|
||||
}
|
||||
|
||||
public string? ConsumeState(string state)
|
||||
{
|
||||
if (!_cache.TryGetValue<string>(GetStateCacheKey(state), out var ownerUserId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
_cache.Remove(GetStateCacheKey(state));
|
||||
return ownerUserId;
|
||||
}
|
||||
|
||||
public Task<GmailConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
return _db.GmailConnections.AsNoTracking().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _db.GmailConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (existing is null) return;
|
||||
_db.GmailConnections.Remove(existing);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<GmailMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
|
||||
{
|
||||
maxResults = Math.Clamp(maxResults, 1, 25);
|
||||
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults={maxResults}";
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
url += $"&q={Uri.EscapeDataString(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);
|
||||
if (!doc.RootElement.TryGetProperty("messages", out var messagesElement) || messagesElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<GmailMessageSummary>();
|
||||
}
|
||||
|
||||
var ids = messagesElement.EnumerateArray()
|
||||
.Select(x => x.TryGetProperty("id", out var id) ? id.GetString() : null)
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Cast<string>()
|
||||
.ToList();
|
||||
|
||||
var results = new List<GmailMessageSummary>(ids.Count);
|
||||
foreach (var id in ids)
|
||||
{
|
||||
var detail = await GetMessageAsync(ownerUserId, id, cancellationToken);
|
||||
results.Add(new GmailMessageSummary(detail.Id, detail.ThreadId, detail.Subject, detail.From, detail.To, detail.Date, detail.Snippet));
|
||||
}
|
||||
|
||||
await TouchSyncTimeAsync(ownerUserId, cancellationToken);
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
|
||||
{
|
||||
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/messages/{Uri.EscapeDataString(messageId)}?format=full";
|
||||
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 threadId = root.TryGetProperty("threadId", out var threadEl) ? threadEl.GetString() ?? "" : "";
|
||||
var snippet = root.TryGetProperty("snippet", out var snippetEl) ? snippetEl.GetString() ?? "" : "";
|
||||
|
||||
var payload = root.GetProperty("payload");
|
||||
var headers = ReadHeaders(payload);
|
||||
var bodyText = ExtractBody(payload, "text/plain");
|
||||
var bodyHtml = ExtractBody(payload, "text/html");
|
||||
if (string.IsNullOrWhiteSpace(bodyText) && !string.IsNullOrWhiteSpace(bodyHtml))
|
||||
{
|
||||
bodyText = StripHtml(bodyHtml);
|
||||
}
|
||||
|
||||
return new GmailMessageDetail(
|
||||
messageId,
|
||||
threadId,
|
||||
headers.TryGetValue("subject", out var subject) ? subject : "",
|
||||
headers.TryGetValue("from", out var from) ? from : "",
|
||||
headers.TryGetValue("to", out var to) ? to : "",
|
||||
headers.TryGetValue("date", out var dateRaw) && DateTimeOffset.TryParse(dateRaw, out var parsedDate) ? parsedDate : null,
|
||||
snippet,
|
||||
bodyText.Trim(),
|
||||
string.IsNullOrWhiteSpace(bodyHtml) ? null : bodyHtml
|
||||
);
|
||||
}
|
||||
|
||||
private async Task<string> GetValidAccessTokenAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _db.GmailConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (connection is null)
|
||||
throw new InvalidOperationException("Gmail is not connected for this account.");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(connection.EncryptedAccessToken) &&
|
||||
connection.AccessTokenExpiresAt is { } expiresAt &&
|
||||
expiresAt > DateTimeOffset.UtcNow.AddMinutes(1))
|
||||
{
|
||||
return _protector.Unprotect(connection.EncryptedAccessToken);
|
||||
}
|
||||
|
||||
var refreshToken = _protector.Unprotect(connection.EncryptedRefreshToken);
|
||||
var refreshed = await RefreshAccessTokenAsync(refreshToken, cancellationToken);
|
||||
var accessToken = refreshed.access_token?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(accessToken))
|
||||
throw new InvalidOperationException("Failed to refresh Gmail 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();
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
private async Task<GmailTokenResponse> ExchangeCodeForTokensAsync(string code, string redirectUri, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
using var response = await client.PostAsync(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["code"] = code,
|
||||
["client_id"] = GetRequiredClientId(),
|
||||
["client_secret"] = GetRequiredClientSecret(),
|
||||
["redirect_uri"] = redirectUri,
|
||||
["grant_type"] = "authorization_code",
|
||||
}),
|
||||
cancellationToken);
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException($"Google token exchange failed: {payload}");
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<GmailTokenResponse>(payload)
|
||||
?? throw new InvalidOperationException("Unable to parse Google token response.");
|
||||
}
|
||||
|
||||
private async Task<GmailTokenResponse> RefreshAccessTokenAsync(string refreshToken, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
using var response = await client.PostAsync(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["refresh_token"] = refreshToken,
|
||||
["client_id"] = GetRequiredClientId(),
|
||||
["client_secret"] = GetRequiredClientSecret(),
|
||||
["grant_type"] = "refresh_token",
|
||||
}),
|
||||
cancellationToken);
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException($"Google token refresh failed: {payload}");
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<GmailTokenResponse>(payload)
|
||||
?? throw new InvalidOperationException("Unable to parse Google refresh response.");
|
||||
}
|
||||
|
||||
private async Task<string> GetProfileAsync(string accessToken, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
using var response = await client.GetAsync("https://gmail.googleapis.com/gmail/v1/users/me/profile", cancellationToken);
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to read Gmail profile: {payload}");
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(payload);
|
||||
return doc.RootElement.TryGetProperty("emailAddress", out var emailEl)
|
||||
? emailEl.GetString() ?? ""
|
||||
: "";
|
||||
}
|
||||
|
||||
private async Task TouchSyncTimeAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _db.GmailConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (connection is null) return;
|
||||
connection.LastSyncedAt = DateTimeOffset.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private string GetRequiredClientId()
|
||||
{
|
||||
return (_cfg["Google:ClientId"] ?? _cfg["Auth:GoogleClientId"] ?? "").Trim() switch
|
||||
{
|
||||
"" => throw new InvalidOperationException("Google Gmail client ID is not configured."),
|
||||
var value => value,
|
||||
};
|
||||
}
|
||||
|
||||
private string GetRequiredClientSecret()
|
||||
{
|
||||
return (_cfg["Google:GmailClientSecret"] ?? _cfg["Google:ClientSecret"] ?? "").Trim() switch
|
||||
{
|
||||
"" => throw new InvalidOperationException("Google Gmail client secret is not configured."),
|
||||
var value => value,
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetStateCacheKey(string state) => $"gmail-oauth-state:{state}";
|
||||
|
||||
private static Dictionary<string, string> ReadHeaders(JsonElement payload)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (!payload.TryGetProperty("headers", out var headers) || headers.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var item in headers.EnumerateArray())
|
||||
{
|
||||
var name = item.TryGetProperty("name", out var nameEl) ? nameEl.GetString() : null;
|
||||
var value = item.TryGetProperty("value", out var valueEl) ? valueEl.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(name) || value is null) continue;
|
||||
result[name.Trim()] = value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string ExtractBody(JsonElement payload, string mimeType)
|
||||
{
|
||||
if (payload.TryGetProperty("mimeType", out var mimeTypeEl) &&
|
||||
string.Equals(mimeTypeEl.GetString(), mimeType, StringComparison.OrdinalIgnoreCase) &&
|
||||
payload.TryGetProperty("body", out var bodyEl) &&
|
||||
bodyEl.TryGetProperty("data", out var dataEl))
|
||||
{
|
||||
return DecodeBody(dataEl.GetString());
|
||||
}
|
||||
|
||||
if (payload.TryGetProperty("parts", out var partsEl) && partsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var part in partsEl.EnumerateArray())
|
||||
{
|
||||
var nested = ExtractBody(part, mimeType);
|
||||
if (!string.IsNullOrWhiteSpace(nested))
|
||||
{
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private static string DecodeBody(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return "";
|
||||
var normalized = raw.Replace('-', '+').Replace('_', '/');
|
||||
var padding = normalized.Length % 4;
|
||||
if (padding > 0)
|
||||
{
|
||||
normalized = normalized.PadRight(normalized.Length + (4 - padding), '=');
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(normalized);
|
||||
return Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static string StripHtml(string html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html)) return "";
|
||||
return System.Text.RegularExpressions.Regex.Replace(html, "<.*?>", string.Empty)
|
||||
.Replace(" ", " ")
|
||||
.Replace("&", "&")
|
||||
.Trim();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user