diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index 60401ebe..86015cf4 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -145,17 +145,24 @@ class TwitchApi extends BaseApiClient { return data['access_token'] as String; } - /// Returns a bool indicating the validity of the given token. - Future validateToken({required String token}) async { + /// 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 { try { - await get( + final data = await get( '$_oauthBaseUrl/validate', headers: {'Authorization': 'Bearer $token'}, ); - return true; + final scopes = data['scopes']; + if (scopes is List) { + return scopes.map((scope) => scope.toString()).toList(); + } + return const []; } on UnauthorizedException { // 401 -> token is invalid/expired (propagated from interceptor for validate requests) - return false; + return null; } on ApiException catch (e) { // Network/timeout/server errors -> treat as indeterminate, not invalid debugPrint('Token validation indeterminate: $e'); diff --git a/lib/apis/unauthorized_interceptor.dart b/lib/apis/unauthorized_interceptor.dart index bd983fc4..0241a1e6 100644 --- a/lib/apis/unauthorized_interceptor.dart +++ b/lib/apis/unauthorized_interceptor.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:frosty/main.dart'; import 'package:frosty/screens/onboarding/login_webview.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; +import 'package:frosty/utils/twitch_oauth_scopes.dart'; import 'package:frosty/widgets/frosty_dialog.dart'; import 'package:provider/provider.dart'; @@ -45,9 +46,10 @@ class UnauthorizedInterceptor extends Interceptor { // 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 = isLoggedIn - ? 'Your session is missing permissions. Please log in again to continue.' - : 'Your session has expired. Please log in again to continue.'; + final message = unauthorizedDialogMessage( + isLoggedIn: isLoggedIn, + grantedScopes: _authStore.grantedScopes, + ); showDialog( context: context, diff --git a/lib/screens/settings/stores/auth_store.dart b/lib/screens/settings/stores/auth_store.dart index 88ec669a..20dd25bc 100644 --- a/lib/screens/settings/stores/auth_store.dart +++ b/lib/screens/settings/stores/auth_store.dart @@ -10,6 +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/widgets/frosty_dialog.dart'; import 'package:mobx/mobx.dart'; import 'package:webview_flutter/webview_flutter.dart'; @@ -34,9 +35,6 @@ abstract class AuthBase with Store { /// The Twitch API service for making requests. final TwitchApi twitchApi; - /// Whether the token is valid or not. - var _tokenIsValid = false; - /// Timer used to retry authentication when offline or on transient failures. Timer? _reconnectTimer; @@ -61,6 +59,13 @@ abstract class AuthBase with Store { @readonly 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 []; + + List get grantedScopes => _grantedScopes; + /// Authentication headers for Twitch API requests. @computed Map get headersTwitch => { @@ -170,8 +175,7 @@ abstract class AuthBase with Store { 'client_id': clientId, 'redirect_uri': 'https://twitch.tv/login', 'response_type': 'token', - 'scope': - 'chat:read chat:edit user:read:follows user:read:blocked_users user:manage:blocked_users user:manage:chat_color user:read:moderated_channels moderator:manage:chat_messages moderator:manage:banned_users', + 'scope': twitchUserScopeQuery, 'force_verify': 'true', }, ), @@ -257,23 +261,27 @@ abstract class AuthBase with Store { // Retrieve the currently stored default token if it exists. _token = await _storage.read(key: _defaultTokenKey); // If the token does not exist or is invalid, get a new token and store it. - if (_token == null || !await twitchApi.validateToken(token: _token!)) { + if (_token == null || + await twitchApi.validateToken(token: _token!) == null) { _token = await twitchApi.getDefaultToken(); await _storage.write(key: _defaultTokenKey, value: _token); } } else { // Validate the existing token. If it fails, start reconnect loop. + final List scopes; try { - _tokenIsValid = await twitchApi.validateToken(token: _token!); + final validated = await twitchApi.validateToken(token: _token!); + if (validated == null) return await logout(); + scopes = validated; } on ApiException catch (e) { debugPrint('Token validation failed: $e'); _isLoggedIn = false; + _grantedScopes = const []; _startReconnectLoop(); return; } - // If the token is invalid, logout. - if (!_tokenIsValid) return await logout(); + _grantedScopes = scopes; // Initialize the user store. await user.init(); @@ -301,8 +309,9 @@ abstract class AuthBase with Store { Future login({required String token}) async { try { // Validate the custom token. - _tokenIsValid = await twitchApi.validateToken(token: token); - if (!_tokenIsValid) return; + final scopes = await twitchApi.validateToken(token: token); + if (scopes == null) return; + _grantedScopes = scopes; // Replace the current default token with the new custom token. _token = token; @@ -335,6 +344,7 @@ abstract class AuthBase with Store { await _storage.delete(key: _gqlTokenKey); _token = null; _gqlToken = null; + _grantedScopes = const []; // Clear the user info. user.dispose(); @@ -343,7 +353,8 @@ abstract class AuthBase with Store { _token = await _storage.read(key: _defaultTokenKey); // If the default token does not already exist or it's invalid, get the new default token and store it. - if (_token == null || !await twitchApi.validateToken(token: _token!)) { + if (_token == null || + await twitchApi.validateToken(token: _token!) == null) { _token = await twitchApi.getDefaultToken(); await _storage.write(key: _defaultTokenKey, value: _token); } @@ -379,14 +390,15 @@ abstract class AuthBase with Store { return; } - final isValid = await twitchApi.validateToken(token: stored); - if (!isValid) { + final scopes = await twitchApi.validateToken(token: stored); + if (scopes == null) { await logout(); return; } // Token valid again — restore session. _token = stored; + _grantedScopes = scopes; await user.init(); if (user.details != null) { _isLoggedIn = true; diff --git a/lib/utils/twitch_oauth_scopes.dart b/lib/utils/twitch_oauth_scopes.dart new file mode 100644 index 00000000..21a649b5 --- /dev/null +++ b/lib/utils/twitch_oauth_scopes.dart @@ -0,0 +1,60 @@ +/// OAuth scopes Frosty requests for a logged-in Twitch user session. +const twitchUserScopes = [ + 'chat:read', + 'chat:edit', + 'user:read:follows', + 'user:read:blocked_users', + 'user:manage:blocked_users', + 'user:manage:chat_color', + 'user:read:moderated_channels', + 'moderator:manage:chat_messages', + 'moderator:manage:banned_users', +]; + +/// Space-separated scope string for Twitch's authorize URL. +String get twitchUserScopeQuery => twitchUserScopes.join(' '); + +/// Human-readable labels for [twitchUserScopes]. +const twitchScopeLabels = { + 'chat:read': 'Read chat messages', + 'chat:edit': 'Send chat messages', + 'user:read:follows': 'View followed channels', + 'user:read:blocked_users': 'View blocked users', + 'user:manage:blocked_users': 'Block and unblock users', + 'user:manage:chat_color': 'Change chat name color', + 'user:read:moderated_channels': 'View channels you moderate', + 'moderator:manage:chat_messages': 'Delete chat messages as a moderator', + 'moderator:manage:banned_users': 'Timeout and ban users as a moderator', +}; + +/// Scopes in [required] that are not present in [granted]. +List missingTwitchScopes({ + required Iterable granted, + Iterable required = twitchUserScopes, +}) { + final grantedSet = granted.toSet(); + return required.where((scope) => !grantedSet.contains(scope)).toList(); +} + +/// Dialog body for a Twitch Helix/OAuth 401. +String unauthorizedDialogMessage({ + required bool isLoggedIn, + required Iterable grantedScopes, +}) { + if (!isLoggedIn) { + return 'Your session has expired. Please log in again to continue.'; + } + + final missing = missingTwitchScopes(granted: grantedScopes); + if (missing.isEmpty) { + return 'Your session is missing permissions. 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' + 'Log in again to grant them.'; +} diff --git a/scripts/get_twitch_token.dart b/scripts/get_twitch_token.dart index 4a960b79..5605f378 100644 --- a/scripts/get_twitch_token.dart +++ b/scripts/get_twitch_token.dart @@ -8,9 +8,8 @@ // Then long-press the Anonymous account tile in the app to log in. import 'dart:io'; -const scopes = - 'chat:read+chat:edit+user:read:follows+user:read:blocked_users+' - 'user:manage:blocked_users+user:manage:chat_color'; +import 'package:frosty/utils/twitch_oauth_scopes.dart'; + const redirectUri = 'https://twitch.tv/login'; void main() async { @@ -24,11 +23,17 @@ void main() async { exit(1); } - final authorizeUrl = 'https://id.twitch.tv/oauth2/authorize' - '?client_id=$clientId' - '&redirect_uri=$redirectUri' - '&response_type=token' - '&scope=$scopes'; + final authorizeUrl = Uri( + scheme: 'https', + host: 'id.twitch.tv', + path: '/oauth2/authorize', + queryParameters: { + 'client_id': clientId, + 'redirect_uri': redirectUri, + 'response_type': 'token', + 'scope': twitchUserScopeQuery, + }, + ).toString(); stdout.writeln('Opening Twitch authorization in your browser...'); await _openUrl(authorizeUrl); diff --git a/test/apis/twitch_api_test.dart b/test/apis/twitch_api_test.dart index c6b2d413..e1a18f52 100644 --- a/test/apis/twitch_api_test.dart +++ b/test/apis/twitch_api_test.dart @@ -215,26 +215,26 @@ void main() { }); group('validateToken', () { - test('returns true for valid token', () async { + test('returns granted scopes for valid token', () async { dioAdapter.onGet( 'https://id.twitch.tv/oauth2/validate', (server) => server.reply(200, twitchValidateTokenResponse), ); - final isValid = await api.validateToken(token: 'valid_token'); + final scopes = await api.validateToken(token: 'valid_token'); - expect(isValid, isTrue); + expect(scopes, ['chat:read', 'chat:edit']); }); - test('returns false for expired token (401)', () async { + test('returns null for expired token (401)', () async { dioAdapter.onGet( 'https://id.twitch.tv/oauth2/validate', (server) => server.reply(401, {'message': 'invalid access token'}), ); - final isValid = await api.validateToken(token: 'expired_token'); + final scopes = await api.validateToken(token: 'expired_token'); - expect(isValid, isFalse); + expect(scopes, isNull); }); test('rethrows on server error', () { diff --git a/test/utils/twitch_oauth_scopes_test.dart b/test/utils/twitch_oauth_scopes_test.dart new file mode 100644 index 00000000..797c5d2e --- /dev/null +++ b/test/utils/twitch_oauth_scopes_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/utils/twitch_oauth_scopes.dart'; + +void main() { + group('missingTwitchScopes', () { + test('returns scopes not present on the token', () { + 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, isNot(contains('chat:read'))); + }); + + test('returns empty when all required scopes are granted', () { + expect( + missingTwitchScopes(granted: twitchUserScopes), + isEmpty, + ); + }); + }); + + group('unauthorizedDialogMessage', () { + test('explains expired session when logged out', () { + expect( + unauthorizedDialogMessage( + isLoggedIn: false, + grantedScopes: const [], + ), + contains('expired'), + ); + }); + + test('lists human-readable missing 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', + ], + ); + + 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'))); + }); + + test('falls back when logged in but no missing scopes are known', () { + expect( + unauthorizedDialogMessage( + isLoggedIn: true, + grantedScopes: twitchUserScopes, + ), + contains('missing permissions'), + ); + }); + }); +}