From cc772a7c901a74f64bbc4a5852d6745e4e9ea14e Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 9 Aug 2026 15:33:06 -0700 Subject: [PATCH] fix(auth): request moderator OAuth scopes only on opt-in Stop asking for mod scopes at normal login so existing core-only tokens never hit a missing-permissions dialog. Gate moderated-channels Helix, offer an Enable flow for mods/broadcasters, and upgrade atomically. Co-authored-by: Cursor --- lib/apis/twitch_api.dart | 33 ++- lib/apis/unauthorized_interceptor.dart | 98 +++++--- lib/models/irc.dart | 5 +- .../chat/widgets/moderation_actions.dart | 66 ++++- lib/screens/onboarding/login_webview.dart | 32 ++- lib/screens/settings/stores/auth_store.dart | 225 +++++++++++++++--- lib/screens/settings/stores/auth_store.g.dart | 41 ++++ lib/screens/settings/stores/user_store.dart | 51 ++-- lib/utils/twitch_oauth_scopes.dart | 54 ++++- scripts/get_twitch_token.dart | 2 + test/apis/twitch_api_test.dart | 11 +- .../unauthorized_interceptor_paths_test.dart | 20 ++ test/models/irc_test.dart | 13 + .../settings/stores/user_store_test.dart | 99 ++++++++ test/utils/twitch_oauth_scopes_test.dart | 68 ++++-- 15 files changed, 690 insertions(+), 128 deletions(-) create mode 100644 test/apis/unauthorized_interceptor_paths_test.dart create mode 100644 test/screens/settings/stores/user_store_test.dart diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index 86015cf4..148b7efa 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -147,19 +147,24 @@ class TwitchApi extends BaseApiClient { /// Validates [token] with Twitch. /// - /// Returns the granted OAuth scopes when the token is valid, or `null` when - /// it is invalid/expired. App-access tokens may return an empty list. - Future?> validateToken({required String token}) async { + /// Returns token info (scopes + optional `user_id`) when valid, or `null` + /// when invalid/expired. App-access tokens may omit `user_id` and return an + /// empty scope list. + Future validateToken({required String token}) async { try { final data = await get( '$_oauthBaseUrl/validate', headers: {'Authorization': 'Bearer $token'}, ); final scopes = data['scopes']; - if (scopes is List) { - return scopes.map((scope) => scope.toString()).toList(); - } - return const []; + final scopeList = scopes is List + ? scopes.map((scope) => scope.toString()).toList() + : const []; + final userId = data['user_id']?.toString(); + return TwitchTokenInfo( + scopes: scopeList, + userId: userId == null || userId.isEmpty ? null : userId, + ); } on UnauthorizedException { // 401 -> token is invalid/expired (propagated from interceptor for validate requests) return null; @@ -508,8 +513,8 @@ class TwitchApi extends BaseApiClient { return result; } on ApiException catch (e) { - // Existing users may not have re-authenticated with the new scope yet. - // Treat this as non-fatal and hide moderation actions. + // Backstop: callers should skip this without moderator scopes, but a + // stale/partial token must not take down chat. debugPrint('Failed to get moderated channels: $e'); return []; } @@ -590,3 +595,13 @@ class TwitchApi extends BaseApiClient { return true; } } + +/// Result of a successful Twitch `/oauth2/validate` call. +class TwitchTokenInfo { + final List scopes; + + /// Present for user access tokens; omitted for app access tokens. + final String? userId; + + const TwitchTokenInfo({required this.scopes, this.userId}); +} diff --git a/lib/apis/unauthorized_interceptor.dart b/lib/apis/unauthorized_interceptor.dart index 0241a1e6..318a04ce 100644 --- a/lib/apis/unauthorized_interceptor.dart +++ b/lib/apis/unauthorized_interceptor.dart @@ -24,8 +24,57 @@ class UnauthorizedInterceptor extends Interceptor { return; } - // For other requests, show login dialog and reject so the Future completes - _showLoginDialog(); + final path = err.requestOptions.uri.path; + + // Background moderated-channels fetches must never nag — especially for + // core-only tokens after an app upgrade. + if (isTwitchModeratedChannelsPath(path)) { + handler.reject(err); + return; + } + + // Moderator actions without scopes → opt-in Enable flow, not a generic + // "missing permissions" re-login for core scopes. + if (isTwitchModeratorActionPath(path) && + !_authStore.hasModeratorScopes) { + _showAuthDialog( + title: 'Enable moderator tools', + message: enableModeratorToolsDialogMessage(), + primaryLabel: 'Enable', + onPrimary: () => openEnableModeratorTools(_authStore), + ); + handler.reject(err); + return; + } + + // Core-only messaging: if core scopes are present, prefer "Session expired" + // over "Missing permissions" (401 ≠ always missing scope). + final isLoggedIn = _authStore.isLoggedIn; + final missingCore = missingTwitchScopes( + granted: _authStore.grantedScopes, + ); + _showAuthDialog( + title: isLoggedIn && missingCore.isNotEmpty + ? 'Missing permissions' + : 'Session expired', + message: unauthorizedDialogMessage( + isLoggedIn: isLoggedIn, + grantedScopes: _authStore.grantedScopes, + ), + primaryLabel: 'Log in', + onPrimary: () { + final context = navigatorKey.currentContext; + if (context == null) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => Provider.value( + value: _authStore, + child: const LoginWebView(), + ), + ), + ); + }, + ); handler.reject(err); return; } @@ -34,26 +83,20 @@ class UnauthorizedInterceptor extends Interceptor { handler.next(err); } - void _showLoginDialog() { - // Use the global navigator key to show dialog without needing BuildContext + void _showAuthDialog({ + required String title, + required String message, + required String primaryLabel, + required VoidCallback onPrimary, + }) { final context = navigatorKey.currentContext; if (context == null) return; - - // Use a flag to prevent multiple dialogs if (_isDialogShowing) return; _isDialogShowing = true; - // Determine if user is logged in but missing scopes vs completely logged out - final isLoggedIn = _authStore.isLoggedIn; - final title = isLoggedIn ? 'Missing permissions' : 'Session expired'; - final message = unauthorizedDialogMessage( - isLoggedIn: isLoggedIn, - grantedScopes: _authStore.grantedScopes, - ); - showDialog( context: context, - barrierDismissible: false, // User must choose an action + barrierDismissible: false, builder: (BuildContext dialogContext) { return FrostyDialog( title: title, @@ -62,31 +105,22 @@ class UnauthorizedInterceptor extends Interceptor { TextButton( onPressed: () { _isDialogShowing = false; - Navigator.of(dialogContext).pop(); // Close dialog + Navigator.of(dialogContext).pop(); }, child: const Text('Cancel'), ), FilledButton( onPressed: () { _isDialogShowing = false; - Navigator.of(dialogContext).pop(); // Close dialog - // Navigate to login WebView - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => Provider.value( - value: _authStore, - child: const LoginWebView(), - ), - ), - ); + Navigator.of(dialogContext).pop(); + onPrimary(); }, - child: Text('Log in'), + child: Text(primaryLabel), ), ], ); }, ).then((_) { - // Reset flag when dialog is dismissed _isDialogShowing = false; }); } @@ -99,3 +133,11 @@ class UnauthorizedInterceptor extends Interceptor { url.startsWith('https://id.twitch.tv/oauth2'); } } + +/// Helix path for listing channels the user moderates. +bool isTwitchModeratedChannelsPath(String path) => + path.contains('/moderation/channels'); + +/// Helix paths for delete / ban / timeout / unban. +bool isTwitchModeratorActionPath(String path) => + path.contains('/moderation/chat') || path.contains('/moderation/bans'); diff --git a/lib/models/irc.dart b/lib/models/irc.dart index e7f7ce78..54c9fb23 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -1304,8 +1304,9 @@ class USERSTATE { raw: ircMessage.raw, color: ircMessage.tags['color'] ?? color, displayName: ircMessage.tags['display-name'] ?? displayName, - mod: ircMessage.tags['mod'] == '0' ? false : true, - subscriber: ircMessage.tags['subscriber'] == '0' ? false : true, + // Missing tags must be false — the old `!= '0'` default treated absence as mod. + mod: ircMessage.tags['mod'] == '1', + subscriber: ircMessage.tags['subscriber'] == '1', ); } diff --git a/lib/screens/channel/chat/widgets/moderation_actions.dart b/lib/screens/channel/chat/widgets/moderation_actions.dart index 2062be4b..390100c6 100644 --- a/lib/screens/channel/chat/widgets/moderation_actions.dart +++ b/lib/screens/channel/chat/widgets/moderation_actions.dart @@ -1,14 +1,18 @@ import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; +import 'package:frosty/screens/onboarding/login_webview.dart'; +import 'package:frosty/screens/settings/stores/auth_store.dart'; /// Moderator actions (delete / timeout / ban, plus unban) for [targetUserId], /// reused by the message long-press menu and the user panel. /// -/// Renders nothing unless the viewer can moderate the channel. [messageId] -/// enables the per-message Delete action; omit it (e.g. the user panel, which -/// isn't tied to one message) to hide Delete. The unban action only appears -/// when the user is currently banned/timed-out (seen via CLEARCHAT). +/// Renders nothing unless the viewer can moderate the channel, or should see +/// an opt-in row when IRC/broadcaster hints mod capability without OAuth scopes. +/// [messageId] enables the per-message Delete action; omit it (e.g. the user +/// panel, which isn't tied to one message) to hide Delete. The unban action +/// only appears when the user is currently banned/timed-out (seen via CLEARCHAT). class ModerationActions extends StatelessWidget { final ChatStore chatStore; final String targetUserId; @@ -29,10 +33,54 @@ class ModerationActions extends StatelessWidget { @override Widget build(BuildContext context) { - if (!chatStore.auth.user.canModerate(chatStore.channelId)) { - return const SizedBox.shrink(); - } + return Observer( + builder: (context) { + final auth = chatStore.auth; + final hasModScopes = auth.hasModeratorScopes; + if (hasModScopes) { + if (auth.user.canPerformModeratorActions(chatStore.channelId)) { + return _actionsColumn(context); + } + return const SizedBox.shrink(); + } + + // IRC/broadcaster is a CTA hint only — not authority to call Helix. + // Read userState.mod only on the no-scopes path so USERSTATE acks do + // not rebuild this sheet while real actions are already available. + final isBroadcaster = auth.user.details?.id == chatStore.channelId; + final ircModHint = chatStore.userState.mod; + if (isBroadcaster || ircModHint) { + return _enableModeratorToolsRow(context, auth); + } + + return const SizedBox.shrink(); + }, + ); + } + + Widget _enableModeratorToolsRow(BuildContext context, AuthStore auth) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Divider(), + ListTile( + onTap: () { + Navigator.pop(context); + // Use the root navigator after closing the sheet. + openEnableModeratorTools(auth); + }, + leading: const Icon(Icons.shield_outlined), + title: const Text('Enable moderator tools…'), + subtitle: const Text( + 'Authorize Twitch permissions to delete, timeout, and ban.', + ), + ), + ], + ); + } + + Widget _actionsColumn(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -133,7 +181,9 @@ class ModerationActions extends StatelessWidget { duration: 600, // 10 minutes ); chatStore.updateNotification( - success ? '$targetName timed out for 10 minutes.' : 'Failed to timeout user', + success + ? '$targetName timed out for 10 minutes.' + : 'Failed to timeout user', ); } diff --git a/lib/screens/onboarding/login_webview.dart b/lib/screens/onboarding/login_webview.dart index 360346be..b9096e8c 100644 --- a/lib/screens/onboarding/login_webview.dart +++ b/lib/screens/onboarding/login_webview.dart @@ -1,16 +1,39 @@ import 'package:flutter/material.dart'; +import 'package:frosty/main.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/widgets/frosty_app_bar.dart'; import 'package:frosty/widgets/frosty_dialog.dart'; import 'package:provider/provider.dart'; import 'package:webview_flutter/webview_flutter.dart'; +/// Pushes the atomic moderator-scopes upgrade WebView. +void openEnableModeratorTools(AuthStore authStore, {BuildContext? context}) { + final navContext = context ?? navigatorKey.currentContext; + if (navContext == null) return; + Navigator.of(navContext).push( + MaterialPageRoute( + builder: (context) => Provider.value( + value: authStore, + child: const LoginWebView(upgradeModeratorScopes: true), + ), + ), + ); +} + /// Reusable WebView widget for Twitch OAuth login flow class LoginWebView extends StatelessWidget { /// Optional widget to navigate to after successful login final Widget? routeAfter; - const LoginWebView({super.key, this.routeAfter}); + /// When true, requests core + moderator scopes and runs the atomic upgrade + /// commit instead of a full [AuthStore.login]. + final bool upgradeModeratorScopes; + + const LoginWebView({ + super.key, + this.routeAfter, + this.upgradeModeratorScopes = false, + }); @override Widget build(BuildContext context) { @@ -18,7 +41,11 @@ class LoginWebView extends StatelessWidget { return Scaffold( appBar: FrostyAppBar( - title: const Text('Connect with Twitch'), + title: Text( + upgradeModeratorScopes + ? 'Enable moderator tools' + : 'Connect with Twitch', + ), actions: [ IconButton( icon: const Icon(Icons.help_rounded), @@ -44,6 +71,7 @@ class LoginWebView extends StatelessWidget { body: WebViewWidget( controller: authStore.createAuthWebViewController( routeAfter: routeAfter, + upgradeModeratorScopes: upgradeModeratorScopes, ), ), ); diff --git a/lib/screens/settings/stores/auth_store.dart b/lib/screens/settings/stores/auth_store.dart index 20dd25bc..b0c36094 100644 --- a/lib/screens/settings/stores/auth_store.dart +++ b/lib/screens/settings/stores/auth_store.dart @@ -10,7 +10,7 @@ import 'package:frosty/constants.dart'; import 'package:frosty/main.dart'; import 'package:frosty/screens/settings/stores/user_store.dart'; import 'package:frosty/services/cookie_extractor.dart'; -import 'package:frosty/utils/twitch_oauth_scopes.dart'; +import 'package:frosty/utils/twitch_oauth_scopes.dart' as twitch_scopes; import 'package:frosty/widgets/frosty_dialog.dart'; import 'package:mobx/mobx.dart'; import 'package:webview_flutter/webview_flutter.dart'; @@ -32,6 +32,12 @@ abstract class AuthBase with Store { /// The secure storage key for the GQL web cookie token. static const _gqlTokenKey = 'gql_token'; + /// Twitch user id that opted into moderator scopes on this device. + static const _moderatorOptInUserIdKey = 'moderator_scopes_opt_in_user_id'; + + /// Last logged-in Twitch user id (kept across logout for re-login scope choice). + static const _lastUserIdKey = 'last_user_id'; + /// The Twitch API service for making requests. final TwitchApi twitchApi; @@ -45,7 +51,7 @@ abstract class AuthBase with Store { static const _maxReconnectAttempts = 5; /// The MobX store containing information relevant to the current user. - final UserStore user; + late final UserStore user; /// The user token used to authenticate with the Twitch API. @readonly @@ -60,11 +66,19 @@ abstract class AuthBase with Store { var _isLoggedIn = false; /// OAuth scopes granted to the current user token. - /// - /// Used to explain missing-permission 401s without another network round-trip. - var _grantedScopes = const []; + @readonly + var _grantedScopes = ObservableList(); - List get grantedScopes => _grantedScopes; + /// Twitch user id that previously enabled moderator tools on this device. + String? _moderatorOptInUserId; + + /// Last known Twitch user id, retained after logout for scope selection. + String? _lastUserId; + + /// Whether the current token includes every moderator scope Frosty needs. + @computed + bool get hasModeratorScopes => + twitch_scopes.hasModeratorScopes(_grantedScopes); /// Authentication headers for Twitch API requests. @computed @@ -81,6 +95,7 @@ abstract class AuthBase with Store { FutureOr handleNavigation({ required NavigationRequest request, Widget? routeAfter, + bool upgradeModeratorScopes = false, }) { // Check if the URL is the redirect URI. if (request.url.startsWith('https://twitch.tv/login')) { @@ -88,8 +103,13 @@ abstract class AuthBase with Store { final uri = Uri.parse(request.url.replaceFirst('#', '?')); final token = uri.queryParameters['access_token']; - // Login with the provided token. - if (token != null) login(token: token); + if (token != null) { + if (upgradeModeratorScopes) { + unawaited(completeModeratorUpgrade(token: token)); + } else { + unawaited(login(token: token)); + } + } } // Check if the the URL has been redirected to "https://www.twitch.tv/?no-reload=true". @@ -97,6 +117,13 @@ abstract class AuthBase with Store { // Checking for this will ensure that the user has automatically logged in to Twitch on the WebView itself. if (request.url == 'https://www.twitch.tv/?no-reload=true') { _extractGqlToken(); + + // Upgrade WebViews pop themselves after an atomic commit so we never + // dismiss into a half-applied session. + if (upgradeModeratorScopes) { + return NavigationDecision.navigate; + } + if (routeAfter != null) { navigatorKey.currentState?.pop(); navigatorKey.currentState?.push( @@ -112,7 +139,10 @@ abstract class AuthBase with Store { return NavigationDecision.navigate; } - WebViewController createAuthWebViewController({Widget? routeAfter}) { + WebViewController createAuthWebViewController({ + Widget? routeAfter, + bool upgradeModeratorScopes = false, + }) { final webViewController = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) // Use platform-specific user agents to allow Google OAuth sign-in. @@ -124,11 +154,18 @@ abstract class AuthBase with Store { : 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Mobile Safari/537.36', ); + final scopeQuery = upgradeModeratorScopes || _shouldIncludeModeratorScopes + ? twitch_scopes.twitchAllUserScopeQuery + : twitch_scopes.twitchUserScopeQuery; + return webViewController ..setNavigationDelegate( NavigationDelegate( - onNavigationRequest: (request) => - handleNavigation(request: request, routeAfter: routeAfter), + onNavigationRequest: (request) => handleNavigation( + request: request, + routeAfter: routeAfter, + upgradeModeratorScopes: upgradeModeratorScopes, + ), onWebResourceError: (error) { debugPrint('Auth WebView error: ${error.description}'); }, @@ -175,7 +212,7 @@ abstract class AuthBase with Store { 'client_id': clientId, 'redirect_uri': 'https://twitch.tv/login', 'response_type': 'token', - 'scope': twitchUserScopeQuery, + 'scope': scopeQuery, 'force_verify': 'true', }, ), @@ -222,7 +259,26 @@ abstract class AuthBase with Store { ); } - AuthBase({required this.twitchApi}) : user = UserStore(twitchApi: twitchApi); + AuthBase({required this.twitchApi}) { + user = UserStore( + twitchApi: twitchApi, + hasModeratorScopes: () => hasModeratorScopes, + ); + } + + bool get _shouldIncludeModeratorScopes { + final currentId = user.details?.id; + if (currentId != null && currentId == _moderatorOptInUserId) { + return true; + } + // After logout, still request mod scopes for the same opted-in account. + if (currentId == null && + _lastUserId != null && + _lastUserId == _moderatorOptInUserId) { + return true; + } + return false; + } Future _extractGqlToken() async { try { @@ -247,13 +303,40 @@ abstract class AuthBase with Store { await _storage.delete(key: _gqlTokenKey); } + void _setGrantedScopes(List scopes) { + _grantedScopes = ObservableList.of(scopes); + } + + Future _persistModeratorOptIn(String userId) async { + if (_moderatorOptInUserId == userId) return; + _moderatorOptInUserId = userId; + await _storage.write(key: _moderatorOptInUserIdKey, value: userId); + } + + Future _persistLastUserId(String? userId) async { + if (_lastUserId == userId) return; + _lastUserId = userId; + if (userId == null) { + await _storage.delete(key: _lastUserIdKey); + } else { + await _storage.write(key: _lastUserIdKey, value: userId); + } + } + /// Initialize by retrieving a token if it does not already exist. @action Future init() async { try { - // Read and set the currently stored user token, if any. - _token = await _storage.read(key: _userTokenKey); - _gqlToken = await _storage.read(key: _gqlTokenKey); + final stored = await Future.wait([ + _storage.read(key: _moderatorOptInUserIdKey), + _storage.read(key: _lastUserIdKey), + _storage.read(key: _userTokenKey), + _storage.read(key: _gqlTokenKey), + ]); + _moderatorOptInUserId = stored[0]; + _lastUserId = stored[1]; + _token = stored[2]; + _gqlToken = stored[3]; // If the token does not exist, get the default token. // Otherwise, log in. @@ -266,28 +349,30 @@ abstract class AuthBase with Store { _token = await twitchApi.getDefaultToken(); await _storage.write(key: _defaultTokenKey, value: _token); } + _setGrantedScopes(const []); } else { // Validate the existing token. If it fails, start reconnect loop. - final List scopes; + final TwitchTokenInfo info; try { final validated = await twitchApi.validateToken(token: _token!); if (validated == null) return await logout(); - scopes = validated; + info = validated; } on ApiException catch (e) { debugPrint('Token validation failed: $e'); _isLoggedIn = false; - _grantedScopes = const []; + _setGrantedScopes(const []); _startReconnectLoop(); return; } - _grantedScopes = scopes; + _setGrantedScopes(info.scopes); // Initialize the user store. await user.init(); if (user.details != null) { _isLoggedIn = true; + await _persistLastUserId(user.details!.id); _stopReconnectLoop(); // Recover a missed web session link — the WebView cookie store may @@ -309,9 +394,9 @@ abstract class AuthBase with Store { Future login({required String token}) async { try { // Validate the custom token. - final scopes = await twitchApi.validateToken(token: token); - if (scopes == null) return; - _grantedScopes = scopes; + final info = await twitchApi.validateToken(token: token); + if (info == null) return; + _setGrantedScopes(info.scopes); // Replace the current default token with the new custom token. _token = token; @@ -325,6 +410,12 @@ abstract class AuthBase with Store { // Set the login status to logged in. if (user.details != null) { _isLoggedIn = true; + await _persistLastUserId(user.details!.id); + // A fresh login that already includes mod scopes (re-auth after opt-in) + // should keep the opt-in flag for this account. + if (hasModeratorScopes) { + await _persistModeratorOptIn(user.details!.id); + } FirebaseCrashlytics.instance.setCustomKey('is_logged_in', true); FirebaseCrashlytics.instance.setUserIdentifier(user.details!.id); _stopReconnectLoop(); @@ -334,17 +425,96 @@ abstract class AuthBase with Store { } } + /// Atomically upgrades the current session with moderator OAuth scopes. + /// + /// Keeps the existing token until validation proves the new token is for the + /// same user and includes every moderator scope. On any failure, the old + /// session is left untouched. + @action + Future completeModeratorUpgrade({required String token}) async { + final currentUserId = user.details?.id; + if (currentUserId == null || !_isLoggedIn) { + _showModeratorUpgradeFailed( + 'Log in first, then enable moderator tools from a channel you moderate.', + ); + return false; + } + + try { + final info = await twitchApi.validateToken(token: token); + if (info == null) { + _showModeratorUpgradeFailed( + 'Could not validate the new Twitch permissions. Your current session was kept.', + ); + return false; + } + + if (info.userId != currentUserId) { + _showModeratorUpgradeFailed( + 'Log in with the same Twitch account to enable moderator tools. Your current session was kept.', + ); + return false; + } + + if (!twitch_scopes.hasModeratorScopes(info.scopes)) { + _showModeratorUpgradeFailed( + 'Moderator permissions were not granted. Your current session was kept.', + ); + return false; + } + + // Commit only after all gates pass. + _token = token; + _setGrantedScopes(info.scopes); + await Future.wait([ + _storage.write(key: _userTokenKey, value: token), + _persistModeratorOptIn(currentUserId), + ]); + await user.refreshModeratedChannels(); + + navigatorKey.currentState?.pop(); + return true; + } catch (e) { + debugPrint('Moderator upgrade failed due to $e'); + _showModeratorUpgradeFailed( + 'Could not enable moderator tools. Your current session was kept.', + ); + return false; + } + } + + void _showModeratorUpgradeFailed(String message) { + final context = navigatorKey.currentContext; + if (context == null) return; + showDialog( + context: context, + builder: (dialogContext) => FrostyDialog( + title: 'Moderator tools not enabled', + message: message, + actions: [ + TextButton( + onPressed: Navigator.of(dialogContext).pop, + child: const Text('OK'), + ), + ], + ), + ); + } + /// Logs out the current user and updates fields accordingly. @action Future logout() async { try { _stopReconnectLoop(); + final previousUserId = user.details?.id ?? _lastUserId; + await _persistLastUserId(previousUserId); + // Delete the existing user token and GQL token. await _storage.delete(key: _userTokenKey); await _storage.delete(key: _gqlTokenKey); _token = null; _gqlToken = null; - _grantedScopes = const []; + _setGrantedScopes(const []); // Clear the user info. user.dispose(); @@ -390,18 +560,19 @@ abstract class AuthBase with Store { return; } - final scopes = await twitchApi.validateToken(token: stored); - if (scopes == null) { + final info = await twitchApi.validateToken(token: stored); + if (info == null) { await logout(); return; } // Token valid again — restore session. _token = stored; - _grantedScopes = scopes; + _setGrantedScopes(info.scopes); await user.init(); if (user.details != null) { _isLoggedIn = true; + await _persistLastUserId(user.details!.id); _error = null; _stopReconnectLoop(); } diff --git a/lib/screens/settings/stores/auth_store.g.dart b/lib/screens/settings/stores/auth_store.g.dart index 1b49807f..d50e1ed4 100644 --- a/lib/screens/settings/stores/auth_store.g.dart +++ b/lib/screens/settings/stores/auth_store.g.dart @@ -9,6 +9,14 @@ part of 'auth_store.dart'; // ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers mixin _$AuthStore on AuthBase, Store { + Computed? _$hasModeratorScopesComputed; + + @override + bool get hasModeratorScopes => + (_$hasModeratorScopesComputed ??= Computed( + () => super.hasModeratorScopes, + name: 'AuthBase.hasModeratorScopes', + )).value; Computed>? _$headersTwitchComputed; @override @@ -75,6 +83,26 @@ mixin _$AuthStore on AuthBase, Store { }); } + late final _$_grantedScopesAtom = Atom( + name: 'AuthBase._grantedScopes', + context: context, + ); + + ObservableList get grantedScopes { + _$_grantedScopesAtom.reportRead(); + return super._grantedScopes; + } + + @override + ObservableList get _grantedScopes => grantedScopes; + + @override + set _grantedScopes(ObservableList value) { + _$_grantedScopesAtom.reportWrite(value, super._grantedScopes, () { + super._grantedScopes = value; + }); + } + late final _$_errorAtom = Atom(name: 'AuthBase._error', context: context); String? get error { @@ -121,6 +149,18 @@ mixin _$AuthStore on AuthBase, Store { return _$loginAsyncAction.run(() => super.login(token: token)); } + late final _$completeModeratorUpgradeAsyncAction = AsyncAction( + 'AuthBase.completeModeratorUpgrade', + context: context, + ); + + @override + Future completeModeratorUpgrade({required String token}) { + return _$completeModeratorUpgradeAsyncAction.run( + () => super.completeModeratorUpgrade(token: token), + ); + } + late final _$logoutAsyncAction = AsyncAction( 'AuthBase.logout', context: context, @@ -134,6 +174,7 @@ mixin _$AuthStore on AuthBase, Store { @override String toString() { return ''' +hasModeratorScopes: ${hasModeratorScopes}, headersTwitch: ${headersTwitch} '''; } diff --git a/lib/screens/settings/stores/user_store.dart b/lib/screens/settings/stores/user_store.dart index 70de0c63..60cd7dd7 100644 --- a/lib/screens/settings/stores/user_store.dart +++ b/lib/screens/settings/stores/user_store.dart @@ -9,6 +9,9 @@ class UserStore = UserStoreBase with _$UserStore; abstract class UserStoreBase with Store { final TwitchApi twitchApi; + /// Whether the active auth token includes Frosty's moderator OAuth scopes. + final bool Function() hasModeratorScopes; + /// The current user's info. @readonly UserTwitch? _details; @@ -23,7 +26,10 @@ abstract class UserStoreBase with Store { ReactionDisposer? _disposeReaction; - UserStoreBase({required this.twitchApi}); + UserStoreBase({ + required this.twitchApi, + required this.hasModeratorScopes, + }); @action Future init() async { @@ -36,11 +42,20 @@ abstract class UserStoreBase with Store { twitchApi .getUserBlockedList(id: _details!.id) .then((blockedUsers) => _blockedUsers = blockedUsers.asObservable()); - twitchApi - .getModeratedChannels(id: _details!.id) - .then((channels) => _moderatedChannels = channels.asObservable()); + // Never hit moderated-channels Helix without moderator scopes — that 401 + // is what nags non-mod users after an app upgrade. + if (hasModeratorScopes()) { + twitchApi + .getModeratedChannels(id: _details!.id) + .then( + (channels) => _moderatedChannels = channels.asObservable(), + ); + } else { + _moderatedChannels = ObservableList(); + } } + _disposeReaction?.call(); _disposeReaction = autorun( (_) => _blockedUsers.sort((a, b) => a.userLogin.compareTo(b.userLogin)), ); @@ -73,7 +88,7 @@ abstract class UserStoreBase with Store { /// status granted or revoked since then. @action Future refreshModeratedChannels() async { - if (_details?.id == null) return; + if (_details?.id == null || !hasModeratorScopes()) return; _moderatedChannels = (await twitchApi.getModeratedChannels(id: _details!.id)).asObservable(); } @@ -85,18 +100,24 @@ abstract class UserStoreBase with Store { /// Whether the logged-in user can moderate [channelId] — either a moderator /// of that channel or its broadcaster (the broadcaster isn't in their own /// moderator list but can still use the moderation endpoints). + /// + /// Role only — does not require moderator OAuth scopes. Use + /// [canPerformModeratorActions] before calling Helix. bool canModerate(String channelId) => _details?.id == channelId || isModerator(channelId); + /// Role + moderator OAuth scopes — safe to call Helix mod endpoints. + bool canPerformModeratorActions(String channelId) => + _details?.id != null && + hasModeratorScopes() && + canModerate(channelId); + @action Future deleteMessage({ required String broadcasterId, required String messageId, }) async { - // Need the moderator ID and confirmed mod status for this channel. - if (_details?.id == null || !canModerate(broadcasterId)) { - return false; - } + if (!canPerformModeratorActions(broadcasterId)) return false; return twitchApi.deleteChatMessage( broadcasterId: broadcasterId, moderatorId: _details!.id, @@ -111,10 +132,7 @@ abstract class UserStoreBase with Store { int? duration, String? reason, }) async { - // Need the moderator ID and confirmed mod status for this channel. - if (_details?.id == null || !canModerate(broadcasterId)) { - return false; - } + if (!canPerformModeratorActions(broadcasterId)) return false; return twitchApi.banUser( broadcasterId: broadcasterId, moderatorId: _details!.id, @@ -130,9 +148,7 @@ abstract class UserStoreBase with Store { required String broadcasterId, required String userIdToUnban, }) async { - if (_details?.id == null || !canModerate(broadcasterId)) { - return false; - } + if (!canPerformModeratorActions(broadcasterId)) return false; return twitchApi.unbanUser( broadcasterId: broadcasterId, moderatorId: _details!.id, @@ -145,6 +161,7 @@ abstract class UserStoreBase with Store { _details = null; _blockedUsers.clear(); _moderatedChannels.clear(); - if (_disposeReaction != null) _disposeReaction!(); + _disposeReaction?.call(); + _disposeReaction = null; } } diff --git a/lib/utils/twitch_oauth_scopes.dart b/lib/utils/twitch_oauth_scopes.dart index 21a649b5..8e0677f9 100644 --- a/lib/utils/twitch_oauth_scopes.dart +++ b/lib/utils/twitch_oauth_scopes.dart @@ -1,4 +1,7 @@ -/// OAuth scopes Frosty requests for a logged-in Twitch user session. +/// Core OAuth scopes Frosty requests on a normal Twitch login. +/// +/// Matches the pre-moderator-tools set so existing tokens remain valid without +/// a re-consent nag for non-moderators. const twitchUserScopes = [ 'chat:read', 'chat:edit', @@ -6,15 +9,28 @@ const twitchUserScopes = [ 'user:read:blocked_users', 'user:manage:blocked_users', 'user:manage:chat_color', +]; + +/// Moderator-only scopes requested via an explicit opt-in upgrade. +const twitchModeratorScopes = [ 'user:read:moderated_channels', 'moderator:manage:chat_messages', 'moderator:manage:banned_users', ]; -/// Space-separated scope string for Twitch's authorize URL. +/// Core + moderator scopes for an opted-in moderator session. +const twitchAllUserScopes = [ + ...twitchUserScopes, + ...twitchModeratorScopes, +]; + +/// Space-separated scope string for Twitch's authorize URL (core only). String get twitchUserScopeQuery => twitchUserScopes.join(' '); -/// Human-readable labels for [twitchUserScopes]. +/// Space-separated scope string for Twitch's authorize URL (core + mod). +String get twitchAllUserScopeQuery => twitchAllUserScopes.join(' '); + +/// Human-readable labels for Frosty-requested scopes. const twitchScopeLabels = { 'chat:read': 'Read chat messages', 'chat:edit': 'Send chat messages', @@ -27,6 +43,13 @@ const twitchScopeLabels = { 'moderator:manage:banned_users': 'Timeout and ban users as a moderator', }; +/// Whether [granted] includes every moderator scope Frosty needs. +bool hasModeratorScopes(Iterable granted) => + missingTwitchScopes( + granted: granted, + required: twitchModeratorScopes, + ).isEmpty; + /// Scopes in [required] that are not present in [granted]. List missingTwitchScopes({ required Iterable granted, @@ -36,7 +59,15 @@ List missingTwitchScopes({ return required.where((scope) => !grantedSet.contains(scope)).toList(); } -/// Dialog body for a Twitch Helix/OAuth 401. +String _scopeBullets(Iterable scopes) => scopes + .map((scope) => '• ${twitchScopeLabels[scope] ?? scope}') + .join('\n'); + +/// Dialog body for a Twitch Helix/OAuth 401 on a core (non-mod) request. +/// +/// Missing-permissions copy only lists **core** scopes. A token that already +/// has core scopes is treated as expired/invalid rather than "missing mod +/// permissions" so non-mods are never nagged about moderator consent. String unauthorizedDialogMessage({ required bool isLoggedIn, required Iterable grantedScopes, @@ -47,14 +78,17 @@ String unauthorizedDialogMessage({ final missing = missingTwitchScopes(granted: grantedScopes); if (missing.isEmpty) { - return 'Your session is missing permissions. Please log in again to continue.'; + return 'Your session has expired. Please log in again to continue.'; } - final bullets = missing - .map((scope) => '• ${twitchScopeLabels[scope] ?? scope}') - .join('\n'); - return 'Frosty needs additional Twitch permissions:\n\n' - '$bullets\n\n' + '${_scopeBullets(missing)}\n\n' 'Log in again to grant them.'; } + +/// Copy for the explicit moderator-tools opt-in dialog. +String enableModeratorToolsDialogMessage() { + return 'Enable moderator tools to manage chat in channels you moderate:\n\n' + '${_scopeBullets(twitchModeratorScopes)}\n\n' + 'You will be asked to authorize these permissions with Twitch.'; +} diff --git a/scripts/get_twitch_token.dart b/scripts/get_twitch_token.dart index 5605f378..22630424 100644 --- a/scripts/get_twitch_token.dart +++ b/scripts/get_twitch_token.dart @@ -31,6 +31,8 @@ void main() async { 'client_id': clientId, 'redirect_uri': redirectUri, 'response_type': 'token', + // Core scopes only — matches normal app login. Use the in-app + // "Enable moderator tools" flow when moderator scopes are needed. 'scope': twitchUserScopeQuery, }, ).toString(); diff --git a/test/apis/twitch_api_test.dart b/test/apis/twitch_api_test.dart index e1a18f52..7727e23f 100644 --- a/test/apis/twitch_api_test.dart +++ b/test/apis/twitch_api_test.dart @@ -215,15 +215,16 @@ void main() { }); group('validateToken', () { - test('returns granted scopes for valid token', () async { + test('returns scopes and user id for valid token', () async { dioAdapter.onGet( 'https://id.twitch.tv/oauth2/validate', (server) => server.reply(200, twitchValidateTokenResponse), ); - final scopes = await api.validateToken(token: 'valid_token'); + final info = await api.validateToken(token: 'valid_token'); - expect(scopes, ['chat:read', 'chat:edit']); + expect(info?.scopes, ['chat:read', 'chat:edit']); + expect(info?.userId, '12345'); }); test('returns null for expired token (401)', () async { @@ -232,9 +233,9 @@ void main() { (server) => server.reply(401, {'message': 'invalid access token'}), ); - final scopes = await api.validateToken(token: 'expired_token'); + final info = await api.validateToken(token: 'expired_token'); - expect(scopes, isNull); + expect(info, isNull); }); test('rethrows on server error', () { diff --git a/test/apis/unauthorized_interceptor_paths_test.dart b/test/apis/unauthorized_interceptor_paths_test.dart new file mode 100644 index 00000000..a4855c54 --- /dev/null +++ b/test/apis/unauthorized_interceptor_paths_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/unauthorized_interceptor.dart'; + +void main() { + group('moderation path helpers', () { + test('matches helix moderation paths', () { + expect( + isTwitchModeratedChannelsPath('/helix/moderation/channels'), + isTrue, + ); + expect(isTwitchModeratorActionPath('/helix/moderation/chat'), isTrue); + expect(isTwitchModeratorActionPath('/helix/moderation/bans'), isTrue); + expect( + isTwitchModeratorActionPath('/helix/moderation/channels'), + isFalse, + ); + expect(isTwitchModeratedChannelsPath('/helix/users'), isFalse); + }); + }); +} diff --git a/test/models/irc_test.dart b/test/models/irc_test.dart index 56b1cf99..2155d611 100644 --- a/test/models/irc_test.dart +++ b/test/models/irc_test.dart @@ -684,5 +684,18 @@ void main() { final updated = original.fromIRCMessage(ircMsg); expect(updated.mod, isFalse); }); + + test('missing mod and subscriber tags are false', () { + const original = USERSTATE(); + final ircMsg = IRCMessage( + raw: '', + command: Command.userState, + tags: {'display-name': 'TestUser'}, + ); + + final updated = original.fromIRCMessage(ircMsg); + expect(updated.mod, isFalse); + expect(updated.subscriber, isFalse); + }); }); } diff --git a/test/screens/settings/stores/user_store_test.dart b/test/screens/settings/stores/user_store_test.dart new file mode 100644 index 00000000..4affd3d7 --- /dev/null +++ b/test/screens/settings/stores/user_store_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/models/user.dart'; +import 'package:frosty/screens/settings/stores/user_store.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockTwitchApi extends Mock implements TwitchApi {} + +void main() { + late MockTwitchApi api; + + setUp(() { + api = MockTwitchApi(); + when(() => api.getUserInfo()).thenAnswer( + (_) async => const UserTwitch( + '12345', + 'testuser', + 'TestUser', + 'https://cdn/profile.png', + ), + ); + when(() => api.getUserBlockedList(id: any(named: 'id'))).thenAnswer( + (_) async => [], + ); + when(() => api.getModeratedChannels(id: any(named: 'id'))).thenAnswer( + (_) async => ['999'], + ); + }); + + group('UserStore moderated channels gating', () { + test('init skips moderated-channels without moderator scopes', () async { + final store = UserStore( + twitchApi: api, + hasModeratorScopes: () => false, + ); + + await store.init(); + await Future.delayed(Duration.zero); + + verifyNever(() => api.getModeratedChannels(id: any(named: 'id'))); + expect(store.moderatedChannels, isEmpty); + }); + + test('init fetches moderated-channels when moderator scopes exist', () async { + final store = UserStore( + twitchApi: api, + hasModeratorScopes: () => true, + ); + + await store.init(); + await Future.delayed(Duration.zero); + + verify(() => api.getModeratedChannels(id: '12345')).called(1); + expect(store.moderatedChannels, ['999']); + }); + + test('refreshModeratedChannels no-ops without moderator scopes', () async { + final store = UserStore( + twitchApi: api, + hasModeratorScopes: () => false, + ); + await store.init(); + clearInteractions(api); + + await store.refreshModeratedChannels(); + verifyNever(() => api.getModeratedChannels(id: any(named: 'id'))); + }); + + test('deleteMessage requires moderator scopes', () async { + when( + () => api.deleteChatMessage( + broadcasterId: any(named: 'broadcasterId'), + moderatorId: any(named: 'moderatorId'), + messageId: any(named: 'messageId'), + ), + ).thenAnswer((_) async => true); + + final store = UserStore( + twitchApi: api, + hasModeratorScopes: () => false, + ); + await store.init(); + + final result = await store.deleteMessage( + broadcasterId: '12345', + messageId: 'msg', + ); + + expect(result, isFalse); + verifyNever( + () => api.deleteChatMessage( + broadcasterId: any(named: 'broadcasterId'), + moderatorId: any(named: 'moderatorId'), + messageId: any(named: 'messageId'), + ), + ); + }); + }); +} diff --git a/test/utils/twitch_oauth_scopes_test.dart b/test/utils/twitch_oauth_scopes_test.dart index 797c5d2e..94723f66 100644 --- a/test/utils/twitch_oauth_scopes_test.dart +++ b/test/utils/twitch_oauth_scopes_test.dart @@ -2,21 +2,49 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:frosty/utils/twitch_oauth_scopes.dart'; void main() { + group('scope sets', () { + test('core scopes exclude moderator scopes', () { + for (final scope in twitchModeratorScopes) { + expect(twitchUserScopes, isNot(contains(scope))); + } + expect(twitchAllUserScopes, containsAll(twitchUserScopes)); + expect(twitchAllUserScopes, containsAll(twitchModeratorScopes)); + }); + }); + + group('hasModeratorScopes', () { + test('requires all three moderator scopes', () { + expect(hasModeratorScopes(twitchAllUserScopes), isTrue); + expect( + hasModeratorScopes([ + ...twitchUserScopes, + 'user:read:moderated_channels', + 'moderator:manage:chat_messages', + ]), + isFalse, + ); + expect(hasModeratorScopes(twitchUserScopes), isFalse); + }); + }); + group('missingTwitchScopes', () { - test('returns scopes not present on the token', () { + test('defaults to core scopes only', () { final missing = missingTwitchScopes( granted: const ['chat:read', 'chat:edit'], ); - expect(missing, contains('user:read:moderated_channels')); - expect(missing, contains('moderator:manage:chat_messages')); - expect(missing, contains('moderator:manage:banned_users')); + expect(missing, contains('user:read:follows')); + expect(missing, isNot(contains('user:read:moderated_channels'))); expect(missing, isNot(contains('chat:read'))); }); test('returns empty when all required scopes are granted', () { + expect(missingTwitchScopes(granted: twitchUserScopes), isEmpty); expect( - missingTwitchScopes(granted: twitchUserScopes), + missingTwitchScopes( + granted: twitchAllUserScopes, + required: twitchModeratorScopes, + ), isEmpty, ); }); @@ -33,34 +61,34 @@ void main() { ); }); - test('lists human-readable missing permissions when logged in', () { + test('lists missing core permissions when logged in', () { final message = unauthorizedDialogMessage( isLoggedIn: true, - grantedScopes: const [ - 'chat:read', - 'chat:edit', - 'user:read:follows', - 'user:read:blocked_users', - 'user:manage:blocked_users', - 'user:manage:chat_color', - ], + grantedScopes: const ['chat:read', 'chat:edit'], ); expect(message, contains('additional Twitch permissions')); - expect(message, contains('View channels you moderate')); - expect(message, contains('Delete chat messages as a moderator')); - expect(message, contains('Timeout and ban users as a moderator')); - expect(message, isNot(contains('Send chat messages'))); + expect(message, contains('View followed channels')); + expect(message, isNot(contains('View channels you moderate'))); }); - test('falls back when logged in but no missing scopes are known', () { + test('prefers session expired when core scopes are complete', () { expect( unauthorizedDialogMessage( isLoggedIn: true, grantedScopes: twitchUserScopes, ), - contains('missing permissions'), + contains('expired'), ); }); }); + + group('enableModeratorToolsDialogMessage', () { + test('lists moderator permission labels', () { + final message = enableModeratorToolsDialogMessage(); + expect(message, contains('View channels you moderate')); + expect(message, contains('Delete chat messages as a moderator')); + expect(message, contains('Timeout and ban users as a moderator')); + }); + }); }