force-ssl is gone from the requested scope. Its grant ("see, edit, and
permanently delete your videos, ratings, comments and captions") is a strict
subset of auth/youtube's ("Manage your YouTube account"), and Google's own
liveBroadcasts.insert reference lists auth/youtube as an accepted ALTERNATIVE
to force-ssl, so every endpoint this app calls — channels, liveBroadcasts,
liveStreams, liveChat, playlistItems — is covered by the one we keep. Two
consent lines became one, and one fewer scope to justify. Removing a scope
cannot narrow capability when the scope being kept is the broader of the two,
and there is no forced re-consent: a refresh token keeps the scopes it was
originally granted, so existing sessions are untouched.
The playlistItems change is NOT being made, and the map entry recommending it
was wrong. It came out of the verification hunt on the theory that public data
should not ride on user credentials. Walking the code kills it: ChannelStats-
Service is built with one HttpClient, and channels?mine=true on the same
service genuinely needs the session. Splitting it means a second unauthenticated
client PLUS a Google API key, since every YouTube Data API request needs a
credential and the call 403s without one. So the fix would ship a new
extractable, quota-abusable credential inside the binary to replace a token the
app already holds, buying zero verification-surface reduction (the data is
public, no scope involved) and zero correctness. Strictly worse. Recorded as a
do-not-do so it is not "helpfully" reintroduced later.
Test: ExactlyOneScopeIsRequested_AndItIsTheBroadOne asserts on the const's
parsed VALUE rather than scanning the file — a file-wide DoesNotContain
fails on the doc comment that names force-ssl to explain its removal, which is
the same trap I hit and documented earlier today in the locator test. Suite
380/380, 0 warnings.
254 lines
10 KiB
C#
254 lines
10 KiB
C#
using System.Diagnostics;
|
|
using System.Net;
|
|
using System.Net.Http;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using ytLive.Models;
|
|
|
|
namespace ytLive.Services;
|
|
|
|
/// <summary>
|
|
/// Handles YouTube OAuth2 authentication flow.
|
|
/// Uses a local HTTP listener for the redirect callback.
|
|
/// </summary>
|
|
public class YouTubeAuthService
|
|
{
|
|
private const string AuthorizationEndpoint = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
private const string TokenEndpoint = "https://oauth2.googleapis.com/token";
|
|
/// <summary>
|
|
/// The single scope the app requests: <c>auth/youtube</c> ("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 <c>liveBroadcasts.insert</c>
|
|
/// reference lists it as an accepted alternative to <c>youtube.force-ssl</c>, so every
|
|
/// endpoint this app touches (<c>channels</c>, <c>liveBroadcasts</c>, <c>liveStreams</c>,
|
|
/// <c>liveChat</c>, <c>playlistItems</c>) is covered by it.
|
|
///
|
|
/// <para><c>auth/youtube.force-ssl</c> used to be requested alongside it and was
|
|
/// <b>dropped 2026-09-28 as redundant</b>: its grant is "see, edit, and permanently delete
|
|
/// your YouTube videos, ratings, comments and captions", a strict subset of what
|
|
/// <c>auth/youtube</c> 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.</para>
|
|
/// </summary>
|
|
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<YouTubeChannel>? _sessionChanged;
|
|
private readonly Action<string> _openBrowser;
|
|
|
|
/// <summary>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.</summary>
|
|
private readonly SemaphoreSlim _authGate = new(1, 1);
|
|
|
|
/// <summary>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 <see cref="_authGate"/>.</summary>
|
|
private Task<YouTubeChannel?>? _authInFlight;
|
|
private CancellationTokenSource? _authCts;
|
|
|
|
public YouTubeChannel? CurrentChannel { get; private set; }
|
|
|
|
public YouTubeAuthService(string clientId, string clientSecret, HttpClient? http = null,
|
|
Action<YouTubeChannel>? sessionChanged = null,
|
|
Action<string>? 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 }));
|
|
}
|
|
|
|
/// <summary>Replaces the current session (used when a saved token loads at startup).</summary>
|
|
public void SetSession(YouTubeChannel channel)
|
|
{
|
|
CurrentChannel = channel;
|
|
}
|
|
|
|
/// <summary>Signs out: drops the in-memory session (a graceful End Livestream clears the saved token via the caller).</summary>
|
|
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<YouTubeChannel?> 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<YouTubeChannel?> 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<YouTubeChannel?> 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
|
|
? "<html><body style=\"font-family:sans-serif\"><h2>Sign-in failed</h2><p>You can close this window and return to LlamaCasty.</p></body></html>"
|
|
: "<html><body style=\"font-family:sans-serif\"><h2>Sign-in successful!</h2><p>You can close this window and return to LlamaCasty.</p></body></html>";
|
|
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<YouTubeChannel?> ExchangeCodeForToken(string code, string redirectUri)
|
|
{
|
|
var body = new FormUrlEncodedContent(new Dictionary<string, string>
|
|
{
|
|
["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<JsonElement>(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<bool> RefreshToken()
|
|
{
|
|
if (CurrentChannel == null) return false;
|
|
|
|
var body = new FormUrlEncodedContent(new Dictionary<string, string>
|
|
{
|
|
["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<JsonElement>(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<YouTubeChannel?> 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<JsonElement>(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;
|
|
}
|
|
}
|