diff --git a/Services/YouTubeAuthService.cs b/Services/YouTubeAuthService.cs index bf091cd..1f589c7 100644 --- a/Services/YouTubeAuthService.cs +++ b/Services/YouTubeAuthService.cs @@ -33,7 +33,37 @@ public class YouTubeAuthService /// 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 const string Scope = + "https://www.googleapis.com/auth/youtube https://www.googleapis.com/auth/yt-analytics.readonly"; + + /// Just the Analytics half, so callers can ask "did this session grant it?" without + /// string-splitting the whole scope list. + internal const string AnalyticsScope = "https://www.googleapis.com/auth/yt-analytics.readonly"; + + /// 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. + public bool HasAnalyticsScope => GrantedScopes.Contains(AnalyticsScope, StringComparer.Ordinal); + + /// 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". + public IReadOnlyCollection GrantedScopes => _grantedScopes; + + private readonly List _grantedScopes = new(); + + /// 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. + 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; @@ -91,6 +121,7 @@ public class YouTubeAuthService ("redirect_uri", redirectUri), ("response_type", "code"), ("scope", Scope), + ("include_granted_scopes", "true"), ("access_type", "offline"), ("prompt", "consent"), }; @@ -193,6 +224,7 @@ public class YouTubeAuthService var tokenData = JsonSerializer.Deserialize(json); var accessToken = tokenData.GetProperty("access_token").GetString()!; + CaptureScopes(tokenData); var refreshToken = tokenData.GetProperty("refresh_token").GetString()!; var expiresIn = tokenData.GetProperty("expires_in").GetInt32(); @@ -218,6 +250,7 @@ public class YouTubeAuthService var json = await response.Content.ReadAsStringAsync(); var tokenData = JsonSerializer.Deserialize(json); + CaptureScopes(tokenData); CurrentChannel.AccessToken = tokenData.GetProperty("access_token").GetString()!; CurrentChannel.TokenExpiry = DateTime.UtcNow.AddSeconds(tokenData.GetProperty("expires_in").GetInt32()); _sessionChanged?.Invoke(CurrentChannel); diff --git a/ytLive.Tests/AnalyticsScopeTests.cs b/ytLive.Tests/AnalyticsScopeTests.cs new file mode 100644 index 0000000..ccc7617 --- /dev/null +++ b/ytLive.Tests/AnalyticsScopeTests.cs @@ -0,0 +1,25 @@ +using Xunit; + +namespace ytLive.Tests; + +/// Adding the Analytics scope means existing sessions lack it. The app must be able to TELL +/// — an anonymous 403 mid-stream is the failure this guards against. +public class AnalyticsScopeTests +{ + [Fact] + public void The_app_asks_for_analytics_and_can_tell_when_a_session_lacks_it() + { + var source = SourceAssert.Text("Services", "YouTubeAuthService.cs"); + + // Requested, incrementally (so an existing user sees only the new prompt). + Assert.Contains("yt-analytics.readonly", source); + Assert.Contains("include_granted_scopes", source); + + // And the grant is inspected from the token response, not assumed. + Assert.Contains("CaptureScopes", source); + Assert.Contains("HasAnalyticsScope", source); + + // A connection type that exposes the flag, so the scoreboard can offer the one-click path. + Assert.NotNull(typeof(ytLive.Services.YouTubeAuthService).GetProperty("HasAnalyticsScope")); + } +} diff --git a/ytLive.Tests/YouTubeAuthServiceTests.cs b/ytLive.Tests/YouTubeAuthServiceTests.cs index 955954f..1dc03b6 100644 --- a/ytLive.Tests/YouTubeAuthServiceTests.cs +++ b/ytLive.Tests/YouTubeAuthServiceTests.cs @@ -138,10 +138,17 @@ public class YouTubeAuthServiceTests /// documenting the guarantee gets deleted instead of obeyed. /// [Fact] - public void ExactlyOneScopeIsRequested_AndItIsTheBroadOne() + public void TwoScopesAreRequested_TheBroadYouTubeOneAndReadOnlyAnalytics() { + // Updated 2026-10-30: the scoreboard's two polled tiles need watch-time analytics, so the + // app asks for exactly one more scope. The DECISION worth locking is still "no redundant + // scopes": force-ssl stays gone, and analytics is read-only. Assert.Equal( - new[] { "https://www.googleapis.com/auth/youtube" }, + new[] + { + "https://www.googleapis.com/auth/youtube", + "https://www.googleapis.com/auth/yt-analytics.readonly", + }, SourceAssert.Scopes("Scope", "Services", "YouTubeAuthService.cs")); }