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"; /// /// The single scope the app requests: auth/youtube ("Manage your YouTube /// account"). It is the **broadest** YouTube scope and is unavoidable — no narrower one /// covers live broadcast create/transition, and Google's own liveBroadcasts.insert /// reference lists it as an accepted alternative to youtube.force-ssl, so every /// endpoint this app touches (channels, liveBroadcasts, liveStreams, /// liveChat, playlistItems) is covered by it. /// /// auth/youtube.force-ssl used to be requested alongside it and was /// dropped 2026-09-28 as redundant: its grant is "see, edit, and permanently delete /// your YouTube videos, ratings, comments and captions", a strict subset of what /// auth/youtube already grants. Requesting both meant two consent lines and two /// scopes to justify for one capability. Removing a scope can never narrow capability, /// because the scope being kept is the broader of the two. /// /// No forced re-consent: a refresh token keeps the scopes it was originally granted, so an /// existing session is unaffected; the reduced set applies from the next fresh sign-in. /// private const string Scope = "https://www.googleapis.com/auth/youtube"; private readonly string _clientId; private readonly string _clientSecret; private readonly HttpClient _http; private readonly Action? _sessionChanged; private readonly Action _openBrowser; /// Serialises "take over from the previous attempt". Without this, two /// overlapping calls both see a free port and race to bind it, which is the same /// failure the cancel-and-await is meant to prevent. private readonly SemaphoreSlim _authGate = new(1, 1); /// The attempt currently holding the loopback callback port, if any. /// A creator who abandons the Google account chooser gives the app no signal, so /// the attempt simply waits out its 5-minute timeout while still holding port /// 8765 — and a retry in that window cannot bind. A new attempt cancels and /// awaits the abandoned one first, which releases the port (creator report /// 2026-09-27: "cannot sign-in because the callback url was not released"). /// Both fields below are only touched while holding . private Task? _authInFlight; private CancellationTokenSource? _authCts; public YouTubeChannel? CurrentChannel { get; private set; } public YouTubeAuthService(string clientId, string clientSecret, HttpClient? http = null, Action? sessionChanged = null, Action? openBrowser = null) { _clientId = clientId; _clientSecret = clientSecret; _http = http ?? new HttpClient(); _sessionChanged = sessionChanged; // Seam: the real flow shells out to the default browser, which a test must not do. _openBrowser = openBrowser ?? (url => Process.Start(new ProcessStartInfo(url) { UseShellExecute = true })); } /// Replaces the current session (used when a saved token loads at startup). public void SetSession(YouTubeChannel channel) { CurrentChannel = channel; } /// Signs out: drops the in-memory session (a graceful End Livestream clears the saved token via the caller). public void ClearSession() { CurrentChannel = null; } 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() { if (string.IsNullOrWhiteSpace(_clientId) || string.IsNullOrWhiteSpace(_clientSecret)) return null; // Taking over from an abandoned attempt has to be atomic: cancel it, wait for // it to actually let go of the port, then bind. Letting the gate go before the // bind reopens the race the takeover exists to close. Task attempt; await _authGate.WaitAsync().ConfigureAwait(false); try { var previousCts = _authCts; _authCts = null; previousCts?.Cancel(); if (_authInFlight is { } previous) { try { await previous.ConfigureAwait(false); } catch { /* its result is irrelevant */ } _authInFlight = null; } var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); _authCts = cts; _authInFlight = attempt = RunAuthFlowAsync(cts.Token); } finally { _authGate.Release(); } return await attempt.ConfigureAwait(false); } private async Task RunAuthFlowAsync(CancellationToken cancellationToken) { const int port = 8765; var redirectUri = $"http://localhost:{port}/oauth2/callback"; using var listener = new HttpListener(); listener.Prefixes.Add($"{redirectUri}/"); listener.Start(); _openBrowser(GetAuthorizationUrl(redirectUri)); HttpListenerContext context; try { context = await listener.GetContextAsync().WaitAsync(cancellationToken); } 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 LlamaCasty.

" : "

Sign-in successful!

You can close this window and return to LlamaCasty.

"; 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; } }