From 9306d9c48d3381b5079850a85484a2f6d6fd7410 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Fri, 9 Jan 2026 02:14:52 -0500 Subject: [PATCH] fix useragent breaking login on ios --- lib/screens/settings/stores/auth_store.dart | 719 ++++++++++---------- 1 file changed, 361 insertions(+), 358 deletions(-) diff --git a/lib/screens/settings/stores/auth_store.dart b/lib/screens/settings/stores/auth_store.dart index e8e1ae24..19d66731 100644 --- a/lib/screens/settings/stores/auth_store.dart +++ b/lib/screens/settings/stores/auth_store.dart @@ -1,358 +1,361 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:frosty/apis/base_api_client.dart'; -import 'package:frosty/apis/twitch_api.dart'; -import 'package:frosty/constants.dart'; -import 'package:frosty/main.dart'; -import 'package:frosty/screens/settings/stores/user_store.dart'; -import 'package:frosty/widgets/frosty_dialog.dart'; -import 'package:mobx/mobx.dart'; -import 'package:webview_flutter/webview_flutter.dart'; - -part 'auth_store.g.dart'; - -class AuthStore = AuthBase with _$AuthStore; - -abstract class AuthBase with Store { - /// Secure storage to store tokens. - static const _storage = FlutterSecureStorage(); - - /// The shared_preferences key for the default token. - static const _defaultTokenKey = 'default_token'; - - /// The shared_preferences key for the user token. - static const _userTokenKey = 'user_token'; - - /// 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; - - /// Retry count for reconnection attempts. - var _reconnectAttempts = 0; - - /// Maximum number of reconnection attempts before giving up. - static const _maxReconnectAttempts = 5; - - /// The MobX store containing information relevant to the current user. - final UserStore user; - - /// The user token used to authenticate with the Twitch API. - @readonly - String? _token; - - /// Whether the user is logged in or not. - @readonly - var _isLoggedIn = false; - - /// Authentication headers for Twitch API requests. - @computed - Map get headersTwitch => { - 'Authorization': 'Bearer $_token', - 'Client-Id': clientId, - }; - - /// Error flag that will be non-null and contain an error message if login failed. - @readonly - String? _error; - - /// Navigation handler for the login webview. Fires on every navigation request (whenever the URL changes). - FutureOr handleNavigation({ - required NavigationRequest request, - Widget? routeAfter, - }) { - // Check if the URL is the redirect URI. - if (request.url.startsWith('https://twitch.tv/login')) { - // Extract the token from the query parameters. - final uri = Uri.parse(request.url.replaceFirst('#', '?')); - final token = uri.queryParameters['access_token']; - - // Login with the provided token. - if (token != null) login(token: token); - } - - // Check if the the URL has been redirected to "https://www.twitch.tv/?no-reload=true". - // When redirected to the redirect_uri, there will be another redirect to "https://www.twitch.tv/?no-reload=true". - // 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') { - if (routeAfter != null) { - navigatorKey.currentState?.pop(); - navigatorKey.currentState?.push( - MaterialPageRoute(builder: (context) => routeAfter), - ); - } else { - // Pop the WebView to return to the previous screen - navigatorKey.currentState?.pop(); - } - } - - // Always allow navigation to the next URL. - return NavigationDecision.navigate; - } - - WebViewController createAuthWebViewController({Widget? routeAfter}) { - final webViewController = WebViewController() - ..setJavaScriptMode(JavaScriptMode.unrestricted) - // Use a standard Chrome user agent to allow Google OAuth sign-in. - // Google blocks OAuth in embedded WebViews (error 403: disallowed_useragent) - // by detecting the "; wv" marker. This generic Chrome UA works cross-platform. - ..setUserAgent( - 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', - ); - - return webViewController - ..setNavigationDelegate( - NavigationDelegate( - onNavigationRequest: (request) => - handleNavigation(request: request, routeAfter: routeAfter), - onWebResourceError: (error) { - debugPrint('Auth WebView error: ${error.description}'); - }, - onPageFinished: (_) async { - try { - await webViewController.runJavaScript(''' - { - function modifyElement(element) { - element.style.maxHeight = '20vh'; - element.style.overflow = 'auto'; - } - - const observer = new MutationObserver((mutations) => { - for (let mutation of mutations) { - if (mutation.type === 'childList') { - const element = document.querySelector('.fAVISI'); - if (element) { - modifyElement(element); - observer.disconnect(); - break; - } - } - } - }); - - observer.observe(document.body, { - childList: true, - subtree: true - }); - } - '''); - } catch (e) { - debugPrint('Auth WebView JavaScript error: $e'); - } - }, - ), - ) - ..loadRequest( - Uri( - scheme: 'https', - host: 'id.twitch.tv', - path: '/oauth2/authorize', - queryParameters: { - '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', - 'force_verify': 'true', - }, - ), - ); - } - - /// Shows a dialog verifying that the user is sure they want to block/unblock the target user. - Future showBlockDialog( - BuildContext context, { - required String targetUser, - required String targetUserId, - }) { - final isBlocked = user.blockedUsers - .where((blockedUser) => blockedUser.userId == targetUserId) - .isNotEmpty; - - final title = isBlocked ? 'Unblock' : 'Block'; - - final message = - 'Are you sure you want to ${isBlocked ? 'unblock "$targetUser"?' : 'block "$targetUser"? This will remove them from channel lists, search results, and chat messages.'}'; - - void onPressed() { - if (isBlocked) { - user.unblock(targetId: targetUserId); - } else { - user.block(targetId: targetUserId, displayName: targetUser); - } - Navigator.pop(context); - } - - return showDialog( - context: context, - builder: (context) => FrostyDialog( - title: title, - message: message, - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: const Text('Cancel'), - ), - FilledButton(onPressed: onPressed, child: const Text('Yes')), - ], - ), - ); - } - - AuthBase({required this.twitchApi}) : user = UserStore(twitchApi: twitchApi); - - /// 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); - - // If the token does not exist, get the default token. - // Otherwise, log in. - if (_token == null) { - // 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!)) { - _token = await twitchApi.getDefaultToken(); - await _storage.write(key: _defaultTokenKey, value: _token); - } - } else { - // Validate the existing token. If it fails, start reconnect loop. - try { - _tokenIsValid = await twitchApi.validateToken(token: _token!); - } on ApiException catch (e) { - debugPrint('Token validation failed: $e'); - _isLoggedIn = false; - _startReconnectLoop(); - return; - } - - // If the token is invalid, logout. - if (!_tokenIsValid) return await logout(); - - // Initialize the user store. - await user.init(); - - if (user.details != null) { - _isLoggedIn = true; - _stopReconnectLoop(); - } - } - - _error = null; - } catch (e) { - debugPrint(e.toString()); - _error = e.toString(); - } - } - - /// Logs in the user with the provided [token] and updates fields accordingly upon successful login. - @action - Future login({required String token}) async { - try { - // Validate the custom token. - _tokenIsValid = await twitchApi.validateToken(token: token); - if (!_tokenIsValid) return; - - // Replace the current default token with the new custom token. - _token = token; - - // Store the user token. - await _storage.write(key: _userTokenKey, value: token); - - // Initialize the user with the new token. - await user.init(); - - // Set the login status to logged in. - if (user.details != null) { - _isLoggedIn = true; - _stopReconnectLoop(); - } - } catch (e) { - debugPrint('Login failed due to $e'); - } - } - - /// Logs out the current user and updates fields accordingly. - @action - Future logout() async { - try { - _stopReconnectLoop(); - // Delete the existing user token. - await _storage.delete(key: _userTokenKey); - _token = null; - - // Clear the user info. - user.dispose(); - - // If the default token already exists, set it. - _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!)) { - _token = await twitchApi.getDefaultToken(); - await _storage.write(key: _defaultTokenKey, value: _token); - } - - // Set the login status to logged out. - _isLoggedIn = false; - - debugPrint('Successfully logged out'); - } catch (e) { - debugPrint(e.toString()); - } - } - - void _startReconnectLoop() { - if (_reconnectTimer != null) return; - _reconnectAttempts = 0; - _reconnectTimer = Timer.periodic(const Duration(seconds: 10), (_) async { - try { - _reconnectAttempts++; - if (_reconnectAttempts > _maxReconnectAttempts) { - await logout(); - return; - } - - final stored = await _storage.read(key: _userTokenKey); - if (stored == null) { - _stopReconnectLoop(); - return; - } - - final isValid = await twitchApi.validateToken(token: stored); - if (!isValid) { - await logout(); - return; - } - - // Token valid again — restore session. - _token = stored; - await user.init(); - if (user.details != null) { - _isLoggedIn = true; - _error = null; - _stopReconnectLoop(); - } - } on ApiException catch (_) { - // Continue trying - } catch (e) { - debugPrint('Reconnect loop error: $e'); - } - }); - } - - void _stopReconnectLoop() { - _reconnectTimer?.cancel(); - _reconnectTimer = null; - _reconnectAttempts = 0; - } -} +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/constants.dart'; +import 'package:frosty/main.dart'; +import 'package:frosty/screens/settings/stores/user_store.dart'; +import 'package:frosty/widgets/frosty_dialog.dart'; +import 'package:mobx/mobx.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +part 'auth_store.g.dart'; + +class AuthStore = AuthBase with _$AuthStore; + +abstract class AuthBase with Store { + /// Secure storage to store tokens. + static const _storage = FlutterSecureStorage(); + + /// The shared_preferences key for the default token. + static const _defaultTokenKey = 'default_token'; + + /// The shared_preferences key for the user token. + static const _userTokenKey = 'user_token'; + + /// 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; + + /// Retry count for reconnection attempts. + var _reconnectAttempts = 0; + + /// Maximum number of reconnection attempts before giving up. + static const _maxReconnectAttempts = 5; + + /// The MobX store containing information relevant to the current user. + final UserStore user; + + /// The user token used to authenticate with the Twitch API. + @readonly + String? _token; + + /// Whether the user is logged in or not. + @readonly + var _isLoggedIn = false; + + /// Authentication headers for Twitch API requests. + @computed + Map get headersTwitch => { + 'Authorization': 'Bearer $_token', + 'Client-Id': clientId, + }; + + /// Error flag that will be non-null and contain an error message if login failed. + @readonly + String? _error; + + /// Navigation handler for the login webview. Fires on every navigation request (whenever the URL changes). + FutureOr handleNavigation({ + required NavigationRequest request, + Widget? routeAfter, + }) { + // Check if the URL is the redirect URI. + if (request.url.startsWith('https://twitch.tv/login')) { + // Extract the token from the query parameters. + final uri = Uri.parse(request.url.replaceFirst('#', '?')); + final token = uri.queryParameters['access_token']; + + // Login with the provided token. + if (token != null) login(token: token); + } + + // Check if the the URL has been redirected to "https://www.twitch.tv/?no-reload=true". + // When redirected to the redirect_uri, there will be another redirect to "https://www.twitch.tv/?no-reload=true". + // 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') { + if (routeAfter != null) { + navigatorKey.currentState?.pop(); + navigatorKey.currentState?.push( + MaterialPageRoute(builder: (context) => routeAfter), + ); + } else { + // Pop the WebView to return to the previous screen + navigatorKey.currentState?.pop(); + } + } + + // Always allow navigation to the next URL. + return NavigationDecision.navigate; + } + + WebViewController createAuthWebViewController({Widget? routeAfter}) { + final webViewController = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + // Use platform-specific user agents to allow Google OAuth sign-in. + // Google blocks OAuth in embedded WebViews (error 403: disallowed_useragent) + // by detecting WebView markers. These standard browser UAs work around that. + ..setUserAgent( + Platform.isIOS + ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1' + : 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Mobile Safari/537.36', + ); + + return webViewController + ..setNavigationDelegate( + NavigationDelegate( + onNavigationRequest: (request) => + handleNavigation(request: request, routeAfter: routeAfter), + onWebResourceError: (error) { + debugPrint('Auth WebView error: ${error.description}'); + }, + onPageFinished: (_) async { + try { + await webViewController.runJavaScript(''' + { + function modifyElement(element) { + element.style.maxHeight = '20vh'; + element.style.overflow = 'auto'; + } + + const observer = new MutationObserver((mutations) => { + for (let mutation of mutations) { + if (mutation.type === 'childList') { + const element = document.querySelector('.fAVISI'); + if (element) { + modifyElement(element); + observer.disconnect(); + break; + } + } + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true + }); + } + '''); + } catch (e) { + debugPrint('Auth WebView JavaScript error: $e'); + } + }, + ), + ) + ..loadRequest( + Uri( + scheme: 'https', + host: 'id.twitch.tv', + path: '/oauth2/authorize', + queryParameters: { + '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', + 'force_verify': 'true', + }, + ), + ); + } + + /// Shows a dialog verifying that the user is sure they want to block/unblock the target user. + Future showBlockDialog( + BuildContext context, { + required String targetUser, + required String targetUserId, + }) { + final isBlocked = user.blockedUsers + .where((blockedUser) => blockedUser.userId == targetUserId) + .isNotEmpty; + + final title = isBlocked ? 'Unblock' : 'Block'; + + final message = + 'Are you sure you want to ${isBlocked ? 'unblock "$targetUser"?' : 'block "$targetUser"? This will remove them from channel lists, search results, and chat messages.'}'; + + void onPressed() { + if (isBlocked) { + user.unblock(targetId: targetUserId); + } else { + user.block(targetId: targetUserId, displayName: targetUser); + } + Navigator.pop(context); + } + + return showDialog( + context: context, + builder: (context) => FrostyDialog( + title: title, + message: message, + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text('Cancel'), + ), + FilledButton(onPressed: onPressed, child: const Text('Yes')), + ], + ), + ); + } + + AuthBase({required this.twitchApi}) : user = UserStore(twitchApi: twitchApi); + + /// 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); + + // If the token does not exist, get the default token. + // Otherwise, log in. + if (_token == null) { + // 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!)) { + _token = await twitchApi.getDefaultToken(); + await _storage.write(key: _defaultTokenKey, value: _token); + } + } else { + // Validate the existing token. If it fails, start reconnect loop. + try { + _tokenIsValid = await twitchApi.validateToken(token: _token!); + } on ApiException catch (e) { + debugPrint('Token validation failed: $e'); + _isLoggedIn = false; + _startReconnectLoop(); + return; + } + + // If the token is invalid, logout. + if (!_tokenIsValid) return await logout(); + + // Initialize the user store. + await user.init(); + + if (user.details != null) { + _isLoggedIn = true; + _stopReconnectLoop(); + } + } + + _error = null; + } catch (e) { + debugPrint(e.toString()); + _error = e.toString(); + } + } + + /// Logs in the user with the provided [token] and updates fields accordingly upon successful login. + @action + Future login({required String token}) async { + try { + // Validate the custom token. + _tokenIsValid = await twitchApi.validateToken(token: token); + if (!_tokenIsValid) return; + + // Replace the current default token with the new custom token. + _token = token; + + // Store the user token. + await _storage.write(key: _userTokenKey, value: token); + + // Initialize the user with the new token. + await user.init(); + + // Set the login status to logged in. + if (user.details != null) { + _isLoggedIn = true; + _stopReconnectLoop(); + } + } catch (e) { + debugPrint('Login failed due to $e'); + } + } + + /// Logs out the current user and updates fields accordingly. + @action + Future logout() async { + try { + _stopReconnectLoop(); + // Delete the existing user token. + await _storage.delete(key: _userTokenKey); + _token = null; + + // Clear the user info. + user.dispose(); + + // If the default token already exists, set it. + _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!)) { + _token = await twitchApi.getDefaultToken(); + await _storage.write(key: _defaultTokenKey, value: _token); + } + + // Set the login status to logged out. + _isLoggedIn = false; + + debugPrint('Successfully logged out'); + } catch (e) { + debugPrint(e.toString()); + } + } + + void _startReconnectLoop() { + if (_reconnectTimer != null) return; + _reconnectAttempts = 0; + _reconnectTimer = Timer.periodic(const Duration(seconds: 10), (_) async { + try { + _reconnectAttempts++; + if (_reconnectAttempts > _maxReconnectAttempts) { + await logout(); + return; + } + + final stored = await _storage.read(key: _userTokenKey); + if (stored == null) { + _stopReconnectLoop(); + return; + } + + final isValid = await twitchApi.validateToken(token: stored); + if (!isValid) { + await logout(); + return; + } + + // Token valid again — restore session. + _token = stored; + await user.init(); + if (user.details != null) { + _isLoggedIn = true; + _error = null; + _stopReconnectLoop(); + } + } on ApiException catch (_) { + // Continue trying + } catch (e) { + debugPrint('Reconnect loop error: $e'); + } + }); + } + + void _stopReconnectLoop() { + _reconnectTimer?.cancel(); + _reconnectTimer = null; + _reconnectAttempts = 0; + } +}