736 lines
16 KiB
Dart
736 lines
16 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
import 'dart:math';
|
||
|
||
import 'package:crypto/crypto.dart';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:google_sign_in/google_sign_in.dart';
|
||
import 'package:http/http.dart' as http;
|
||
import 'package:url_launcher/url_launcher.dart';
|
||
|
||
import 'google_oauth_config_service.dart';
|
||
|
||
class GoogleSession {
|
||
const GoogleSession({
|
||
required this.accessToken,
|
||
required this.displayName,
|
||
this.email,
|
||
this.photoUrl,
|
||
});
|
||
|
||
final String accessToken;
|
||
final String displayName;
|
||
final String? email;
|
||
final String? photoUrl;
|
||
|
||
GoogleSession copyWith({
|
||
String? accessToken,
|
||
String? displayName,
|
||
String? email,
|
||
String? photoUrl,
|
||
}) {
|
||
return GoogleSession(
|
||
accessToken: accessToken ?? this.accessToken,
|
||
displayName: displayName ?? this.displayName,
|
||
email: email ?? this.email,
|
||
photoUrl: photoUrl ?? this.photoUrl,
|
||
);
|
||
}
|
||
}
|
||
|
||
class GoogleAuthConfigurationException implements Exception {
|
||
const GoogleAuthConfigurationException(this.message);
|
||
|
||
final String message;
|
||
|
||
@override
|
||
String toString() => message;
|
||
}
|
||
|
||
class GoogleAuthService {
|
||
GoogleAuthService._();
|
||
|
||
static final GoogleAuthService instance =
|
||
GoogleAuthService._();
|
||
|
||
static const String youtubeReadonlyScope =
|
||
'https://www.googleapis.com/auth/youtube.readonly';
|
||
|
||
static const String _envWebClientId =
|
||
String.fromEnvironment(
|
||
'GOOGLE_WEB_CLIENT_ID',
|
||
);
|
||
|
||
static const String _envDesktopClientId =
|
||
String.fromEnvironment(
|
||
'GOOGLE_DESKTOP_CLIENT_ID',
|
||
);
|
||
|
||
static const String _envDesktopClientSecret =
|
||
String.fromEnvironment(
|
||
'GOOGLE_DESKTOP_CLIENT_SECRET',
|
||
);
|
||
|
||
final ValueNotifier<GoogleSession?> session =
|
||
ValueNotifier<GoogleSession?>(null);
|
||
|
||
bool _googleSignInInitialized = false;
|
||
String? _initializedAndroidWebClientId;
|
||
GoogleSignInAccount? _androidAccount;
|
||
|
||
String? _windowsRefreshToken;
|
||
DateTime? _windowsAccessTokenExpiry;
|
||
|
||
GoogleOauthConfig get _savedConfig =>
|
||
GoogleOauthConfigService.instance.config.value;
|
||
|
||
String get _webClientId {
|
||
final environment = _envWebClientId.trim();
|
||
|
||
if (environment.isNotEmpty) {
|
||
return environment;
|
||
}
|
||
|
||
return _savedConfig.webClientId.trim();
|
||
}
|
||
|
||
String get _desktopClientId {
|
||
final environment = _envDesktopClientId.trim();
|
||
|
||
if (environment.isNotEmpty) {
|
||
return environment;
|
||
}
|
||
|
||
return _savedConfig.desktopClientId.trim();
|
||
}
|
||
|
||
String get _desktopClientSecret {
|
||
final environment = _envDesktopClientSecret.trim();
|
||
|
||
if (environment.isNotEmpty) {
|
||
return environment;
|
||
}
|
||
|
||
return _savedConfig.desktopClientSecret.trim();
|
||
}
|
||
|
||
bool get isConfigured {
|
||
if (Platform.isAndroid) {
|
||
return _webClientId.isNotEmpty;
|
||
}
|
||
|
||
if (Platform.isWindows) {
|
||
return _desktopClientId.isNotEmpty;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
String get configurationHint {
|
||
if (Platform.isAndroid) {
|
||
return 'Client ID Web OAuth Google manquant';
|
||
}
|
||
|
||
if (Platform.isWindows) {
|
||
return 'Client ID OAuth "Desktop app" manquant';
|
||
}
|
||
|
||
return 'Plateforme non prise en charge';
|
||
}
|
||
|
||
Future<void> initialize() async {
|
||
await GoogleOauthConfigService.instance.initialize();
|
||
|
||
if (!Platform.isAndroid || !isConfigured) {
|
||
return;
|
||
}
|
||
|
||
await _initializeAndroidGoogleSignIn();
|
||
|
||
try {
|
||
final future =
|
||
GoogleSignIn.instance
|
||
.attemptLightweightAuthentication();
|
||
|
||
if (future == null) {
|
||
return;
|
||
}
|
||
|
||
final account = await future;
|
||
|
||
if (account == null) {
|
||
return;
|
||
}
|
||
|
||
_androidAccount = account;
|
||
|
||
final authorization =
|
||
await account.authorizationClient
|
||
.authorizationForScopes(
|
||
const [youtubeReadonlyScope],
|
||
);
|
||
|
||
if (authorization == null) {
|
||
return;
|
||
}
|
||
|
||
session.value = GoogleSession(
|
||
accessToken: authorization.accessToken,
|
||
displayName:
|
||
account.displayName ?? account.email,
|
||
email: account.email,
|
||
photoUrl: account.photoUrl,
|
||
);
|
||
} catch (error) {
|
||
debugPrint(
|
||
'[GOOGLE-AUTH] Restauration ignorée: $error',
|
||
);
|
||
}
|
||
}
|
||
|
||
Future<bool> saveAndroidConfiguration(
|
||
String webClientId,
|
||
) async {
|
||
final previous = _webClientId;
|
||
|
||
await GoogleOauthConfigService.instance.saveAndroid(
|
||
webClientId: webClientId,
|
||
);
|
||
|
||
if (!Platform.isAndroid) {
|
||
return true;
|
||
}
|
||
|
||
final next = _webClientId;
|
||
|
||
if (next.isEmpty) {
|
||
return true;
|
||
}
|
||
|
||
if (!_googleSignInInitialized) {
|
||
await _initializeAndroidGoogleSignIn();
|
||
return true;
|
||
}
|
||
|
||
return previous == next;
|
||
}
|
||
|
||
Future<void> saveWindowsConfiguration({
|
||
required String desktopClientId,
|
||
required String desktopClientSecret,
|
||
}) async {
|
||
await GoogleOauthConfigService.instance.saveWindows(
|
||
desktopClientId: desktopClientId,
|
||
desktopClientSecret: desktopClientSecret,
|
||
);
|
||
}
|
||
|
||
Future<GoogleSession> signIn() async {
|
||
if (!isConfigured) {
|
||
throw GoogleAuthConfigurationException(
|
||
configurationHint,
|
||
);
|
||
}
|
||
|
||
if (Platform.isAndroid) {
|
||
return _signInAndroid();
|
||
}
|
||
|
||
if (Platform.isWindows) {
|
||
return _signInWindows();
|
||
}
|
||
|
||
throw const GoogleAuthConfigurationException(
|
||
'Connexion Google non prise en charge '
|
||
'sur cette plateforme.',
|
||
);
|
||
}
|
||
|
||
Future<String> getAccessToken() async {
|
||
final current = session.value;
|
||
|
||
if (current == null) {
|
||
throw StateError(
|
||
'Aucune session Google active.',
|
||
);
|
||
}
|
||
|
||
if (Platform.isAndroid) {
|
||
final account = _androidAccount;
|
||
|
||
if (account != null) {
|
||
final authorization =
|
||
await account.authorizationClient
|
||
.authorizationForScopes(
|
||
const [youtubeReadonlyScope],
|
||
);
|
||
|
||
if (authorization != null) {
|
||
final nextAccessToken =
|
||
authorization.accessToken;
|
||
|
||
// Ne notifie les listeners que si le jeton a réellement
|
||
// changé. Avant, chaque simple lecture du jeton réémettait
|
||
// session.value et pouvait relancer le chargement des
|
||
// abonnements en boucle.
|
||
if (nextAccessToken != current.accessToken) {
|
||
session.value = current.copyWith(
|
||
accessToken: nextAccessToken,
|
||
);
|
||
}
|
||
|
||
return nextAccessToken;
|
||
}
|
||
}
|
||
|
||
return current.accessToken;
|
||
}
|
||
|
||
if (Platform.isWindows) {
|
||
final expiry = _windowsAccessTokenExpiry;
|
||
|
||
if (expiry != null &&
|
||
DateTime.now().isAfter(
|
||
expiry.subtract(
|
||
const Duration(minutes: 2),
|
||
),
|
||
) &&
|
||
_windowsRefreshToken != null) {
|
||
await _refreshWindowsAccessToken();
|
||
}
|
||
|
||
return session.value?.accessToken ??
|
||
current.accessToken;
|
||
}
|
||
|
||
return current.accessToken;
|
||
}
|
||
|
||
Future<void> signOut() async {
|
||
if (Platform.isAndroid &&
|
||
_googleSignInInitialized) {
|
||
try {
|
||
await GoogleSignIn.instance.signOut();
|
||
} catch (error) {
|
||
debugPrint(
|
||
'[GOOGLE-AUTH] signOut Android: $error',
|
||
);
|
||
}
|
||
}
|
||
|
||
_androidAccount = null;
|
||
_windowsRefreshToken = null;
|
||
_windowsAccessTokenExpiry = null;
|
||
session.value = null;
|
||
}
|
||
|
||
Future<void> _initializeAndroidGoogleSignIn() async {
|
||
if (_googleSignInInitialized) {
|
||
return;
|
||
}
|
||
|
||
final clientId = _webClientId;
|
||
|
||
if (clientId.isEmpty) {
|
||
throw const GoogleAuthConfigurationException(
|
||
'Client ID Web OAuth Google manquant.',
|
||
);
|
||
}
|
||
|
||
await GoogleSignIn.instance.initialize(
|
||
serverClientId: clientId,
|
||
);
|
||
|
||
_googleSignInInitialized = true;
|
||
_initializedAndroidWebClientId = clientId;
|
||
}
|
||
|
||
Future<GoogleSession> _signInAndroid() async {
|
||
await _initializeAndroidGoogleSignIn();
|
||
|
||
if (_initializedAndroidWebClientId !=
|
||
_webClientId) {
|
||
throw const GoogleAuthConfigurationException(
|
||
'Le Client ID Android a été modifié. '
|
||
'Redémarre l’application avant de te connecter.',
|
||
);
|
||
}
|
||
|
||
if (!GoogleSignIn.instance.supportsAuthenticate()) {
|
||
throw StateError(
|
||
'Google Sign-In interactif '
|
||
'n’est pas disponible.',
|
||
);
|
||
}
|
||
|
||
final account =
|
||
await GoogleSignIn.instance.authenticate(
|
||
scopeHint: const [youtubeReadonlyScope],
|
||
);
|
||
|
||
_androidAccount = account;
|
||
|
||
var authorization =
|
||
await account.authorizationClient
|
||
.authorizationForScopes(
|
||
const [youtubeReadonlyScope],
|
||
);
|
||
|
||
authorization ??=
|
||
await account.authorizationClient
|
||
.authorizeScopes(
|
||
const [youtubeReadonlyScope],
|
||
);
|
||
|
||
final result = GoogleSession(
|
||
accessToken: authorization.accessToken,
|
||
displayName:
|
||
account.displayName ?? account.email,
|
||
email: account.email,
|
||
photoUrl: account.photoUrl,
|
||
);
|
||
|
||
session.value = result;
|
||
return result;
|
||
}
|
||
|
||
Future<GoogleSession> _signInWindows() async {
|
||
final server = await HttpServer.bind(
|
||
InternetAddress.loopbackIPv4,
|
||
0,
|
||
);
|
||
|
||
final redirectUri = Uri.parse(
|
||
'http://127.0.0.1:${server.port}/',
|
||
);
|
||
|
||
final verifier = _randomUrlSafeString(96);
|
||
final state = _randomUrlSafeString(32);
|
||
|
||
final challenge = base64UrlEncode(
|
||
sha256.convert(ascii.encode(verifier)).bytes,
|
||
).replaceAll('=', '');
|
||
|
||
const windowsScopes = <String>[
|
||
'openid',
|
||
'email',
|
||
'profile',
|
||
youtubeReadonlyScope,
|
||
];
|
||
|
||
final authorizationUri = Uri.https(
|
||
'accounts.google.com',
|
||
'/o/oauth2/v2/auth',
|
||
{
|
||
'client_id': _desktopClientId,
|
||
'redirect_uri': redirectUri.toString(),
|
||
'response_type': 'code',
|
||
'scope': windowsScopes.join(' '),
|
||
'code_challenge': challenge,
|
||
'code_challenge_method': 'S256',
|
||
'state': state,
|
||
'access_type': 'offline',
|
||
'prompt': 'consent',
|
||
},
|
||
);
|
||
|
||
final launched = await launchUrl(
|
||
authorizationUri,
|
||
mode: LaunchMode.externalApplication,
|
||
);
|
||
|
||
if (!launched) {
|
||
await server.close(force: true);
|
||
|
||
throw StateError(
|
||
'Impossible d’ouvrir le navigateur '
|
||
'pour Google.',
|
||
);
|
||
}
|
||
|
||
HttpRequest request;
|
||
|
||
try {
|
||
request = await server.first.timeout(
|
||
const Duration(minutes: 3),
|
||
);
|
||
} on TimeoutException {
|
||
await server.close(force: true);
|
||
|
||
throw StateError(
|
||
'La connexion Google a expiré.',
|
||
);
|
||
}
|
||
|
||
final params = request.uri.queryParameters;
|
||
|
||
request.response
|
||
..statusCode = HttpStatus.ok
|
||
..headers.contentType = ContentType.html
|
||
..write(
|
||
'<!doctype html><html><head>'
|
||
'<meta charset="utf-8">'
|
||
'<title>YouTube Personal</title></head>'
|
||
'<body style="font-family:Segoe UI,Arial;'
|
||
'padding:40px;background:#111;color:#fff">'
|
||
'<h2>Connexion terminée</h2>'
|
||
'<p>Tu peux fermer cet onglet et revenir '
|
||
'dans YouTube Personal.</p></body></html>',
|
||
);
|
||
|
||
await request.response.close();
|
||
await server.close(force: true);
|
||
|
||
if (params['state'] != state) {
|
||
throw StateError(
|
||
'Réponse OAuth Google invalide '
|
||
'(state incorrect).',
|
||
);
|
||
}
|
||
|
||
final error = params['error'];
|
||
|
||
if (error != null) {
|
||
throw StateError(
|
||
'Connexion Google refusée: $error',
|
||
);
|
||
}
|
||
|
||
final code = params['code'];
|
||
|
||
if (code == null || code.isEmpty) {
|
||
throw StateError(
|
||
'Google n’a pas renvoyé de '
|
||
'code d’autorisation.',
|
||
);
|
||
}
|
||
|
||
final tokenBody = <String, String>{
|
||
'client_id': _desktopClientId,
|
||
'code': code,
|
||
'code_verifier': verifier,
|
||
'grant_type': 'authorization_code',
|
||
'redirect_uri': redirectUri.toString(),
|
||
};
|
||
|
||
if (_desktopClientSecret.isNotEmpty) {
|
||
tokenBody['client_secret'] =
|
||
_desktopClientSecret;
|
||
}
|
||
|
||
final tokenResponse = await http.post(
|
||
Uri.parse(
|
||
'https://oauth2.googleapis.com/token',
|
||
),
|
||
headers: const {
|
||
'Content-Type':
|
||
'application/x-www-form-urlencoded',
|
||
},
|
||
body: tokenBody,
|
||
);
|
||
|
||
if (tokenResponse.statusCode < 200 ||
|
||
tokenResponse.statusCode >= 300) {
|
||
throw StateError(
|
||
'Échange OAuth Google impossible '
|
||
'(${tokenResponse.statusCode}): '
|
||
'${tokenResponse.body}',
|
||
);
|
||
}
|
||
|
||
final decoded = jsonDecode(
|
||
tokenResponse.body,
|
||
);
|
||
|
||
if (decoded is! Map) {
|
||
throw StateError(
|
||
'Réponse OAuth Google invalide.',
|
||
);
|
||
}
|
||
|
||
final accessToken = decoded['access_token'];
|
||
|
||
if (accessToken is! String ||
|
||
accessToken.isEmpty) {
|
||
throw StateError(
|
||
'Aucun jeton d’accès Google reçu.',
|
||
);
|
||
}
|
||
|
||
final refreshToken = decoded['refresh_token'];
|
||
|
||
if (refreshToken is String &&
|
||
refreshToken.isNotEmpty) {
|
||
_windowsRefreshToken = refreshToken;
|
||
}
|
||
|
||
final expiresIn = decoded['expires_in'];
|
||
|
||
if (expiresIn is num) {
|
||
_windowsAccessTokenExpiry =
|
||
DateTime.now().add(
|
||
Duration(
|
||
seconds: expiresIn.toInt(),
|
||
),
|
||
);
|
||
}
|
||
|
||
final profile = await _fetchGoogleProfile(
|
||
accessToken,
|
||
);
|
||
|
||
final result = GoogleSession(
|
||
accessToken: accessToken,
|
||
displayName:
|
||
profile.name ??
|
||
profile.email ??
|
||
'Compte Google',
|
||
email: profile.email,
|
||
photoUrl: profile.photoUrl,
|
||
);
|
||
|
||
session.value = result;
|
||
return result;
|
||
}
|
||
|
||
Future<void> _refreshWindowsAccessToken() async {
|
||
final refreshToken = _windowsRefreshToken;
|
||
|
||
if (refreshToken == null ||
|
||
refreshToken.isEmpty) {
|
||
return;
|
||
}
|
||
|
||
final body = <String, String>{
|
||
'client_id': _desktopClientId,
|
||
'refresh_token': refreshToken,
|
||
'grant_type': 'refresh_token',
|
||
};
|
||
|
||
if (_desktopClientSecret.isNotEmpty) {
|
||
body['client_secret'] =
|
||
_desktopClientSecret;
|
||
}
|
||
|
||
final response = await http.post(
|
||
Uri.parse(
|
||
'https://oauth2.googleapis.com/token',
|
||
),
|
||
headers: const {
|
||
'Content-Type':
|
||
'application/x-www-form-urlencoded',
|
||
},
|
||
body: body,
|
||
);
|
||
|
||
if (response.statusCode < 200 ||
|
||
response.statusCode >= 300) {
|
||
throw StateError(
|
||
'Impossible de renouveler '
|
||
'la session Google.',
|
||
);
|
||
}
|
||
|
||
final decoded = jsonDecode(response.body);
|
||
|
||
if (decoded is! Map ||
|
||
decoded['access_token'] is! String) {
|
||
throw StateError(
|
||
'Réponse de renouvellement '
|
||
'Google invalide.',
|
||
);
|
||
}
|
||
|
||
final token =
|
||
decoded['access_token'] as String;
|
||
|
||
final expiresIn = decoded['expires_in'];
|
||
|
||
if (expiresIn is num) {
|
||
_windowsAccessTokenExpiry =
|
||
DateTime.now().add(
|
||
Duration(
|
||
seconds: expiresIn.toInt(),
|
||
),
|
||
);
|
||
}
|
||
|
||
final current = session.value;
|
||
|
||
if (current != null) {
|
||
session.value = current.copyWith(
|
||
accessToken: token,
|
||
);
|
||
}
|
||
}
|
||
|
||
Future<_GoogleProfile> _fetchGoogleProfile(
|
||
String accessToken,
|
||
) async {
|
||
try {
|
||
final response = await http.get(
|
||
Uri.parse(
|
||
'https://www.googleapis.com/oauth2/v3/userinfo',
|
||
),
|
||
headers: {
|
||
'Authorization': 'Bearer $accessToken',
|
||
'Accept': 'application/json',
|
||
},
|
||
);
|
||
|
||
if (response.statusCode < 200 ||
|
||
response.statusCode >= 300) {
|
||
return const _GoogleProfile();
|
||
}
|
||
|
||
final decoded = jsonDecode(response.body);
|
||
|
||
if (decoded is! Map) {
|
||
return const _GoogleProfile();
|
||
}
|
||
|
||
return _GoogleProfile(
|
||
name: decoded['name'] is String
|
||
? decoded['name'] as String
|
||
: null,
|
||
email: decoded['email'] is String
|
||
? decoded['email'] as String
|
||
: null,
|
||
photoUrl: decoded['picture'] is String
|
||
? decoded['picture'] as String
|
||
: null,
|
||
);
|
||
} catch (_) {
|
||
return const _GoogleProfile();
|
||
}
|
||
}
|
||
|
||
String _randomUrlSafeString(int length) {
|
||
const chars =
|
||
'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||
'abcdefghijklmnopqrstuvwxyz'
|
||
'0123456789-._~';
|
||
|
||
final random = Random.secure();
|
||
|
||
return List.generate(
|
||
length,
|
||
(_) => chars[
|
||
random.nextInt(chars.length)],
|
||
).join();
|
||
}
|
||
}
|
||
|
||
class _GoogleProfile {
|
||
const _GoogleProfile({
|
||
this.name,
|
||
this.email,
|
||
this.photoUrl,
|
||
});
|
||
|
||
final String? name;
|
||
final String? email;
|
||
final String? photoUrl;
|
||
}
|