117 lines
3.9 KiB
Dart
117 lines
3.9 KiB
Dart
import 'dart:convert';
|
|
import 'dart:math';
|
|
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:dio/dio.dart';
|
|
import 'package:real_estate_mobile/config/app_config.dart';
|
|
|
|
/// Twitter OAuth 2.0 PKCE flow for mobile.
|
|
class TwitterAuthService {
|
|
static const _clientId = 'eWEzaDJ0al9lX1diOGY0ejRYeXM6MTpjaQ';
|
|
static const _clientSecret = 'HadGMSRKKswfXOajbmggrnmLAn9cxJsl8GtFxGHqjX-CJ0A2Tu';
|
|
static const _scope = 'tweet.read users.read offline.access';
|
|
|
|
/// Derive redirect URI from API base URL (matches next-auth callback)
|
|
static String get _redirectUri {
|
|
final apiUrl = AppConfig.apiBaseUrl; // e.g. https://dev.re-quest.com/api/v1
|
|
final base = apiUrl.replaceAll(RegExp(r'/api/v1/?$'), '');
|
|
return '$base/api/auth/callback/twitter';
|
|
}
|
|
|
|
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<Map<String, dynamic>?> exchangeCodeForUser(String code) async {
|
|
final dio = Dio();
|
|
|
|
// Exchange code for access token (confidential client — Basic auth)
|
|
final credentials = base64Encode(utf8.encode('$_clientId:$_clientSecret'));
|
|
final tokenBody =
|
|
'code=${Uri.encodeComponent(code)}'
|
|
'&grant_type=authorization_code'
|
|
'&redirect_uri=${Uri.encodeComponent(_redirectUri)}'
|
|
'&code_verifier=${Uri.encodeComponent(_codeVerifier)}';
|
|
|
|
final tokenResponse = await dio.post(
|
|
'https://api.twitter.com/2/oauth2/token',
|
|
options: Options(
|
|
contentType: 'application/x-www-form-urlencoded',
|
|
headers: {
|
|
'Authorization': 'Basic $credentials',
|
|
},
|
|
),
|
|
data: tokenBody,
|
|
);
|
|
|
|
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<String, dynamic>?;
|
|
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('=', '');
|
|
}
|
|
}
|