Files
youtube_personal/lib/services/google_auth_service.dart
T

736 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}