feat(scoreboard): unit 9 — the Analytics scope, requested incrementally
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.
This commit is contained in:
@@ -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.</para>
|
||||
/// </summary>
|
||||
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";
|
||||
|
||||
/// <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;
|
||||
@@ -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<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();
|
||||
|
||||
@@ -218,6 +250,7 @@ public class YouTubeAuthService
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using Xunit;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -138,10 +138,17 @@ public class YouTubeAuthServiceTests
|
||||
/// documenting the guarantee gets deleted instead of obeyed.
|
||||
/// </summary>
|
||||
[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"));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user