import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter_appauth/flutter_appauth.dart'; import 'package:http/http.dart' as http; import '../config/intra_config.dart'; import 'intra_http.dart'; import 'intra_oauth_tokens.dart'; /// OAuth2 Intra : access + refresh token, un jeton réutilisé pour les requêtes API. class IntraAuthService { IntraAuthService._(); static final IntraAuthService instance = IntraAuthService._(); final FlutterAppAuth _appAuth = const FlutterAppAuth(); String? _accessToken; String? _refreshToken; Future? _refreshInFlight; String? get accessToken => _accessToken; bool get isAuthenticated => _accessToken != null && _accessToken!.isNotEmpty; bool get canRefresh => _refreshToken != null && _refreshToken!.isNotEmpty; static final AuthorizationServiceConfiguration _serviceConfiguration = AuthorizationServiceConfiguration( authorizationEndpoint: IntraConfig.authorizeUrl, tokenEndpoint: IntraConfig.tokenUrl, ); Future signIn() async { if (!IntraConfig.isConfigured) { throw StateError( 'CLIENT_ID, CLIENT_SECRET ou REDIRECT_URI manquant dans .env', ); } final result = await _appAuth.authorizeAndExchangeCode( AuthorizationTokenRequest( IntraConfig.clientId, IntraConfig.redirectUri, clientSecret: IntraConfig.clientSecret, serviceConfiguration: _serviceConfiguration, scopes: ['public', 'projects'], ), ); _applyOAuthResult(result.accessToken, result.refreshToken); if (!isAuthenticated) { throw StateError('Connexion 42 annulée ou token absent'); } } void _applyOAuthResult(String? access, String? refresh) { _accessToken = access; if (refresh != null && refresh.isNotEmpty) { _refreshToken = refresh; } } void signOut() { _accessToken = null; _refreshToken = null; _refreshInFlight = null; } /// Rafraîchit l’access token (bonus). Retourne `false` si impossible. Future refreshAccessToken() async { if (!canRefresh) return false; final inFlight = _refreshInFlight; if (inFlight != null) return inFlight; final future = _refreshAccessTokenOnce(); _refreshInFlight = future; try { return await future; } finally { if (identical(_refreshInFlight, future)) { _refreshInFlight = null; } } } Future _refreshAccessTokenOnce() async { if (!canRefresh) return false; final response = await http.post( Uri.parse(IntraConfig.tokenUrl), headers: const {'Accept': 'application/json'}, body: { 'grant_type': 'refresh_token', 'client_id': IntraConfig.clientId, 'client_secret': IntraConfig.clientSecret, 'refresh_token': _refreshToken!, }, ); if (response.statusCode != 200) return false; final decoded = jsonDecode(response.body); if (decoded is! Map) return false; final pair = parseOAuthTokenResponse(decoded); if (pair == null) return false; _accessToken = pair.accessToken; if (pair.refreshToken != null) { _refreshToken = pair.refreshToken; } return true; } /// Header Authorization pour les appels `api.intra.42.fr`. Map authHeaders() { if (!isAuthenticated) { throw StateError('Non connecté à l’Intra'); } return { 'Authorization': 'Bearer $_accessToken', 'Accept': 'application/json', }; } /// En-têtes pour télécharger la photo sur `cdn.intra.42.fr`. /// /// L’URL vient du JSON user (`image.link` / `versions`). Le CDN refuse souvent /// les requêtes sans Bearer (403). On réutilise le **même** access token que pour /// l’API — pas un second OAuth. On n’envoie pas `Accept: application/json` ici. Map cdnImageHeaders() { if (!isAuthenticated) return const {}; return {'Authorization': 'Bearer $_accessToken'}; } Future> fetchMe() async { final response = await intraApiGet( Uri.parse('${IntraConfig.apiBaseUrl}/me'), auth: this, ); return jsonDecode(response.body) as Map; } @visibleForTesting void debugSetTokens({String? access, String? refresh}) { _accessToken = access; _refreshToken = refresh; } /// Simule un access token expiré (garde le refresh) — démo bonus, appelé depuis l’UI en `kDebugMode`. void debugInvalidateAccessToken() { if (!canRefresh) return; _accessToken = 'invalid_access_token_for_refresh_test'; } }