import 'dart:convert'; import 'dart:math'; import 'package:crypto/crypto.dart'; import 'package:dio/dio.dart'; /// Twitter OAuth 2.0 PKCE flow for mobile. class TwitterAuthService { static const _clientId = 'eWEzaDJ0al9lX1diOGY0ejRYeXM6MTpjaQ'; // Must match the callback URL configured in Twitter Developer Portal static const _redirectUri = 'https://dev.re-quest.com/auth/callback/twitter'; static const _scope = 'tweet.read users.read offline.access'; late final String _codeVerifier; late final String _codeChallenge; late final String _stateParam; TwitterAuthService() { _codeVerifier = _generateCodeVerifier(); _codeChallenge = _generateCodeChallenge(_codeVerifier); _stateParam = _generateRandomString(32); } /// Build the Twitter authorization URL to open in a webview. String get authorizationUrl { final params = { 'response_type': 'code', 'client_id': _clientId, 'redirect_uri': _redirectUri, 'scope': _scope, 'state': _stateParam, 'code_challenge': _codeChallenge, 'code_challenge_method': 'S256', }; final query = params.entries .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') .join('&'); return 'https://twitter.com/i/oauth2/authorize?$query'; } /// Check if a URL is the redirect callback and extract the auth code. String? extractCodeFromUrl(String url) { if (!url.startsWith(_redirectUri)) return null; final uri = Uri.parse(url); final state = uri.queryParameters['state']; if (state != _stateParam) return null; return uri.queryParameters['code']; } /// Exchange the auth code for tokens and fetch user info. Future?> exchangeCodeForUser(String code) async { final dio = Dio(); // Exchange code for access token final tokenResponse = await dio.post( 'https://api.twitter.com/2/oauth2/token', options: Options( contentType: 'application/x-www-form-urlencoded', headers: { 'Authorization': 'Basic ${base64Encode(utf8.encode('$_clientId:'))}', }, ), data: { 'code': code, 'grant_type': 'authorization_code', 'redirect_uri': _redirectUri, 'code_verifier': _codeVerifier, }, ); final accessToken = tokenResponse.data['access_token'] as String?; if (accessToken == null) return null; // Fetch user info final userResponse = await dio.get( 'https://api.twitter.com/2/users/me', queryParameters: {'user.fields': 'id,name,username,profile_image_url'}, options: Options( headers: {'Authorization': 'Bearer $accessToken'}, ), ); final userData = userResponse.data['data'] as Map?; if (userData == null) return null; return { 'id': userData['id'], 'name': userData['name'], 'username': userData['username'], 'avatar': userData['profile_image_url'], }; } String _generateRandomString(int length) { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; final random = Random.secure(); return List.generate(length, (_) => chars[random.nextInt(chars.length)]).join(); } String _generateCodeVerifier() => _generateRandomString(64); String _generateCodeChallenge(String verifier) { final bytes = utf8.encode(verifier); final digest = sha256.convert(bytes); return base64Url.encode(digest.bytes).replaceAll('=', ''); } }