fix(auth): list missing Twitch scopes in the re-login dialog
Existing tokens lack the newer mod scopes, so Helix 401s looked like a vague permissions failure. Cache scopes from /oauth2/validate and show which ones need re-authorization. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
11cd05e6a8
commit
9888985deb
7 changed files with 188 additions and 36 deletions
|
|
@ -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<bool> 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<List<String>?> validateToken({required String token}) async {
|
||||
try {
|
||||
await get<JsonMap>(
|
||||
final data = await get<JsonMap>(
|
||||
'$_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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 <String>[];
|
||||
|
||||
List<String> get grantedScopes => _grantedScopes;
|
||||
|
||||
/// Authentication headers for Twitch API requests.
|
||||
@computed
|
||||
Map<String, String> 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<String> 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<void> 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;
|
||||
|
|
|
|||
60
lib/utils/twitch_oauth_scopes.dart
Normal file
60
lib/utils/twitch_oauth_scopes.dart
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/// OAuth scopes Frosty requests for a logged-in Twitch user session.
|
||||
const twitchUserScopes = <String>[
|
||||
'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 = <String, String>{
|
||||
'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<String> missingTwitchScopes({
|
||||
required Iterable<String> granted,
|
||||
Iterable<String> 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<String> 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.';
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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', () {
|
||||
|
|
|
|||
66
test/utils/twitch_oauth_scopes_test.dart
Normal file
66
test/utils/twitch_oauth_scopes_test.dart
Normal file
|
|
@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue