using System.Diagnostics; using System.Net; using System.Net.Http; using System.Text; using System.Text.Json; using ytLive.Models; namespace ytLive.Services; /// /// Handles YouTube OAuth2 authentication flow. /// Uses a local HTTP listener for the redirect callback. /// public class YouTubeAuthService { private const string AuthorizationEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"; private const string TokenEndpoint = "https://oauth2.googleapis.com/token"; private const string Scope = "https://www.googleapis.com/auth/youtube https://www.googleapis.com/auth/youtube.force-ssl"; private readonly string _clientId; private readonly string _clientSecret; private readonly HttpClient _http; private readonly Action? _sessionChanged; public YouTubeChannel? CurrentChannel { get; private set; } public YouTubeAuthService(string clientId, string clientSecret, HttpClient? http = null, Action? sessionChanged = null) { _clientId = clientId; _clientSecret = clientSecret; _http = http ?? new HttpClient(); _sessionChanged = sessionChanged; } /// Replaces the current session (used when a saved token loads at startup). public void SetSession(YouTubeChannel channel) { CurrentChannel = channel; } public string GetAuthorizationUrl(string redirectUri) { var query = new[] { ("client_id", _clientId), ("redirect_uri", redirectUri), ("response_type", "code"), ("scope", Scope), ("access_type", "offline"), ("prompt", "consent"), }; var encoded = string.Join("&", query.Select(kv => $"{Uri.EscapeDataString(kv.Item1)}={Uri.EscapeDataString(kv.Item2)}")); return $"{AuthorizationEndpoint}?{encoded}"; } public async Task AuthenticateAsync() { const int port = 8765; var redirectUri = $"http://localhost:{port}/oauth2/callback"; if (string.IsNullOrWhiteSpace(_clientId) || string.IsNullOrWhiteSpace(_clientSecret)) return null; using var listener = new HttpListener(); listener.Prefixes.Add($"{redirectUri}/"); listener.Start(); Process.Start(new ProcessStartInfo(GetAuthorizationUrl(redirectUri)) { UseShellExecute = true }); HttpListenerContext context; try { using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); context = await listener.GetContextAsync().WaitAsync(cts.Token); } catch (OperationCanceledException) { return null; } var code = context.Request.QueryString["code"]; var error = context.Request.QueryString["error"]; var html = error != null ? "

Sign-in failed

You can close this window and return to ytLlive.

" : "

Sign-in successful!

You can close this window and return to ytLlive.

"; var buffer = Encoding.UTF8.GetBytes(html); context.Response.ContentType = "text/html; charset=utf-8"; context.Response.ContentLength64 = buffer.Length; await context.Response.OutputStream.WriteAsync(buffer); context.Response.Close(); if (error != null || string.IsNullOrEmpty(code)) return null; return await ExchangeCodeForToken(code, redirectUri); } public async Task ExchangeCodeForToken(string code, string redirectUri) { var body = new FormUrlEncodedContent(new Dictionary { ["code"] = code, ["client_id"] = _clientId, ["client_secret"] = _clientSecret, ["redirect_uri"] = redirectUri, ["grant_type"] = "authorization_code" }); var response = await _http.PostAsync(TokenEndpoint, body); if (!response.IsSuccessStatusCode) return null; var json = await response.Content.ReadAsStringAsync(); var tokenData = JsonSerializer.Deserialize(json); var accessToken = tokenData.GetProperty("access_token").GetString()!; var refreshToken = tokenData.GetProperty("refresh_token").GetString()!; var expiresIn = tokenData.GetProperty("expires_in").GetInt32(); // Fetch channel info return await FetchChannelInfo(accessToken, refreshToken, expiresIn); } public async Task RefreshToken() { if (CurrentChannel == null) return false; var body = new FormUrlEncodedContent(new Dictionary { ["refresh_token"] = CurrentChannel.RefreshToken, ["client_id"] = _clientId, ["client_secret"] = _clientSecret, ["grant_type"] = "refresh_token" }); var response = await _http.PostAsync(TokenEndpoint, body); if (!response.IsSuccessStatusCode) return false; var json = await response.Content.ReadAsStringAsync(); var tokenData = JsonSerializer.Deserialize(json); CurrentChannel.AccessToken = tokenData.GetProperty("access_token").GetString()!; CurrentChannel.TokenExpiry = DateTime.UtcNow.AddSeconds(tokenData.GetProperty("expires_in").GetInt32()); _sessionChanged?.Invoke(CurrentChannel); return true; } private async Task FetchChannelInfo(string accessToken, string refreshToken, int expiresIn) { _http.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); var response = await _http.GetAsync("https://www.googleapis.com/youtube/v3/channels?part=snippet&mine=true"); if (!response.IsSuccessStatusCode) return null; var json = await response.Content.ReadAsStringAsync(); var data = JsonSerializer.Deserialize(json); var items = data.GetProperty("items"); if (items.GetArrayLength() == 0) return null; var snippet = items[0].GetProperty("snippet"); CurrentChannel = new YouTubeChannel { ChannelId = items[0].GetProperty("id").GetString()!, DisplayName = snippet.GetProperty("title").GetString()!, ProfileImageUrl = snippet.GetProperty("thumbnails").GetProperty("default").GetProperty("url").GetString()!, AccessToken = accessToken, RefreshToken = refreshToken, TokenExpiry = DateTime.UtcNow.AddSeconds(expiresIn) }; _sessionChanged?.Invoke(CurrentChannel); return CurrentChannel; } }