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 <cursoragent@cursor.com>
This commit is contained in:
Tommy Chow 2026-08-09 15:33:06 -07:00
parent 2d65aeec29
commit cc772a7c90
15 changed files with 690 additions and 128 deletions

View file

@ -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<List<String>?> 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<TwitchTokenInfo?> validateToken({required String token}) async {
try {
final data = await get<JsonMap>(
'$_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 <String>[];
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<String> scopes;
/// Present for user access tokens; omitted for app access tokens.
final String? userId;
const TwitchTokenInfo({required this.scopes, this.userId});
}

View file

@ -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<AuthStore>.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<AuthStore>.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');

View file

@ -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',
);
}

View file

@ -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',
);
}

View file

@ -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<AuthStore>.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,
),
),
);

View file

@ -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 <String>[];
@readonly
var _grantedScopes = ObservableList<String>();
List<String> 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<NavigationDecision> 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<void> _extractGqlToken() async {
try {
@ -247,13 +303,40 @@ abstract class AuthBase with Store {
await _storage.delete(key: _gqlTokenKey);
}
void _setGrantedScopes(List<String> scopes) {
_grantedScopes = ObservableList.of(scopes);
}
Future<void> _persistModeratorOptIn(String userId) async {
if (_moderatorOptInUserId == userId) return;
_moderatorOptInUserId = userId;
await _storage.write(key: _moderatorOptInUserIdKey, value: userId);
}
Future<void> _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<void> 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<String> 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<void> 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<bool> 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<void> 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();
}

View file

@ -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<bool>? _$hasModeratorScopesComputed;
@override
bool get hasModeratorScopes =>
(_$hasModeratorScopesComputed ??= Computed<bool>(
() => super.hasModeratorScopes,
name: 'AuthBase.hasModeratorScopes',
)).value;
Computed<Map<String, String>>? _$headersTwitchComputed;
@override
@ -75,6 +83,26 @@ mixin _$AuthStore on AuthBase, Store {
});
}
late final _$_grantedScopesAtom = Atom(
name: 'AuthBase._grantedScopes',
context: context,
);
ObservableList<String> get grantedScopes {
_$_grantedScopesAtom.reportRead();
return super._grantedScopes;
}
@override
ObservableList<String> get _grantedScopes => grantedScopes;
@override
set _grantedScopes(ObservableList<String> 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<bool> 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}
''';
}

View file

@ -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<void> 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<String>();
}
}
_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<void> 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<bool> 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;
}
}

View file

@ -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 = <String>[
'chat:read',
'chat:edit',
@ -6,15 +9,28 @@ const twitchUserScopes = <String>[
'user:read:blocked_users',
'user:manage:blocked_users',
'user:manage:chat_color',
];
/// Moderator-only scopes requested via an explicit opt-in upgrade.
const twitchModeratorScopes = <String>[
'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 = <String>[
...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 = <String, String>{
'chat:read': 'Read chat messages',
'chat:edit': 'Send chat messages',
@ -27,6 +43,13 @@ const twitchScopeLabels = <String, String>{
'moderator:manage:banned_users': 'Timeout and ban users as a moderator',
};
/// Whether [granted] includes every moderator scope Frosty needs.
bool hasModeratorScopes(Iterable<String> granted) =>
missingTwitchScopes(
granted: granted,
required: twitchModeratorScopes,
).isEmpty;
/// Scopes in [required] that are not present in [granted].
List<String> missingTwitchScopes({
required Iterable<String> granted,
@ -36,7 +59,15 @@ List<String> missingTwitchScopes({
return required.where((scope) => !grantedSet.contains(scope)).toList();
}
/// Dialog body for a Twitch Helix/OAuth 401.
String _scopeBullets(Iterable<String> 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<String> 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.';
}

View file

@ -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();

View file

@ -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', () {

View file

@ -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);
});
});
}

View file

@ -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);
});
});
}

View file

@ -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 => <UserBlockedTwitch>[],
);
when(() => api.getModeratedChannels(id: any(named: 'id'))).thenAnswer(
(_) async => <String>['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<void>.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<void>.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'),
),
);
});
});
}

View file

@ -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'));
});
});
}