Scope becomes youtube + yt-analytics.readonly, with include_granted_scopes=true so an existing session is asked for the NEW permission only. GrantedScopes records what Google says on the token response (both the code exchange and the refresh), and HasAnalyticsScope is the flag the scoreboard uses to offer a one-click re-consent instead of failing anonymously mid-stream. TokenStore is unchanged on purpose: a launch-time refresh carries the scope list, so old sessions report 'not granted' until the creator re-consents — the honest state. The old auth test asserted 'exactly one scope'; it now asserts the new DECISION (two scopes, no force-ssl, analytics read-only) rather than loosening.
287 lines
12 KiB
C#
287 lines
12 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 https://www.googleapis.com/auth/yt-analytics.readonly";
|
|
|
|
/// <summary>Just the Analytics half, so callers can ask "did this session grant it?" without
|
|
/// string-splitting the whole scope list.</summary>
|
|
internal const string AnalyticsScope = "https://www.googleapis.com/auth/yt-analytics.readonly";
|
|
|
|
/// <summary>True when the stored session carries the Analytics grant. Tokens saved before the
|
|
/// scope was added report false, which is what drives the one-click re-consent rather than a
|
|
/// silent 403 in the middle of a stream.</summary>
|
|
public bool HasAnalyticsScope => GrantedScopes.Contains(AnalyticsScope, StringComparer.Ordinal);
|
|
|
|
/// <summary>Scopes granted by the CURRENT session, as reported by Google on the token response.
|
|
/// Empty for a session saved before scopes were recorded — treated as "unknown, assume missing".</summary>
|
|
public IReadOnlyCollection<string> GrantedScopes => _grantedScopes;
|
|
|
|
private readonly List<string> _grantedScopes = new();
|
|
|
|
/// <summary>Records what Google says this token may do. A refresh response carries the granted
|
|
/// scopes, so a session saved before Analytics was requested reports "not granted" until the
|
|
/// user re-consents — which is precisely the signal the scoreboard needs.</summary>
|
|
private void CaptureScopes(System.Text.Json.JsonElement tokenData)
|
|
{
|
|
_grantedScopes.Clear();
|
|
if (!tokenData.TryGetProperty("scope", out var scope) || scope.ValueKind != System.Text.Json.JsonValueKind.String)
|
|
return;
|
|
var raw = scope.GetString();
|
|
if (string.IsNullOrWhiteSpace(raw)) return;
|
|
foreach (var part in raw.Split(' ', System.StringSplitOptions.RemoveEmptyEntries))
|
|
_grantedScopes.Add(part);
|
|
}
|
|
|
|
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),
|
|
("include_granted_scopes", "true"),
|
|
("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()!;
|
|
CaptureScopes(tokenData);
|
|
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);
|
|
|
|
CaptureScopes(tokenData);
|
|
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;
|
|
}
|
|
}
|