guidesoutenance
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
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 : un token réutilisé pour toutes les requêtes API.
|
||||
/// OAuth2 Intra : access + refresh token, un jeton réutilisé pour les requêtes API.
|
||||
class IntraAuthService {
|
||||
IntraAuthService._();
|
||||
static final IntraAuthService instance = IntraAuthService._();
|
||||
@@ -13,9 +16,13 @@ class IntraAuthService {
|
||||
final FlutterAppAuth _appAuth = const FlutterAppAuth();
|
||||
|
||||
String? _accessToken;
|
||||
String? _refreshToken;
|
||||
Future<bool>? _refreshInFlight;
|
||||
|
||||
String? get accessToken => _accessToken;
|
||||
bool get isAuthenticated => _accessToken != null && _accessToken!.isNotEmpty;
|
||||
bool get canRefresh =>
|
||||
_refreshToken != null && _refreshToken!.isNotEmpty;
|
||||
|
||||
static final AuthorizationServiceConfiguration _serviceConfiguration =
|
||||
AuthorizationServiceConfiguration(
|
||||
@@ -36,20 +43,75 @@ class IntraAuthService {
|
||||
IntraConfig.redirectUri,
|
||||
clientSecret: IntraConfig.clientSecret,
|
||||
serviceConfiguration: _serviceConfiguration,
|
||||
// `projects` requis pour projects_users ; skills via cursus_users (pas /experiences).
|
||||
scopes: ['public', 'projects'],
|
||||
),
|
||||
);
|
||||
|
||||
_accessToken = result.accessToken;
|
||||
_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<bool> 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<bool> _refreshAccessTokenOnce() async {
|
||||
if (!canRefresh) return false;
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse(IntraConfig.tokenUrl),
|
||||
headers: const {'Accept': 'application/json'},
|
||||
body: <String, String>{
|
||||
'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<String, dynamic>) 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`.
|
||||
@@ -64,15 +126,23 @@ class IntraAuthService {
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> fetchMe() async {
|
||||
final response = await http.get(
|
||||
final response = await intraApiGet(
|
||||
Uri.parse('${IntraConfig.apiBaseUrl}/me'),
|
||||
headers: authHeaders(),
|
||||
auth: this,
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('GET /v2/me a échoué (${response.statusCode})');
|
||||
}
|
||||
|
||||
return jsonDecode(response.body) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void debugSetTokens({String? access, String? refresh}) {
|
||||
_accessToken = access;
|
||||
_refreshToken = refresh;
|
||||
}
|
||||
|
||||
/// Simule un access token expiré (garde le refresh) — tests bonus en debug.
|
||||
@visibleForTesting
|
||||
void debugInvalidateAccessToken() {
|
||||
if (!canRefresh) return;
|
||||
_accessToken = 'invalid_access_token_for_refresh_test';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,26 @@ import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'intra_api_exception.dart';
|
||||
import 'intra_auth_service.dart';
|
||||
|
||||
/// GET API 42 avec gestion 401 / 429 (retry + backoff).
|
||||
/// GET API 42 avec gestion 401 (refresh token), 429 (retry + backoff).
|
||||
Future<http.Response> intraApiGet(
|
||||
Uri uri, {
|
||||
required Map<String, String> headers,
|
||||
required IntraAuthService auth,
|
||||
int maxAttempts = 4,
|
||||
}) async {
|
||||
var retriedAfterRefresh = false;
|
||||
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
final response = await http.get(uri, headers: headers);
|
||||
final response = await http.get(uri, headers: auth.authHeaders());
|
||||
|
||||
if (response.statusCode == 401) {
|
||||
if (!retriedAfterRefresh && await auth.refreshAccessToken()) {
|
||||
retriedAfterRefresh = true;
|
||||
continue;
|
||||
}
|
||||
throw const IntraUnauthorizedException();
|
||||
}
|
||||
|
||||
if (response.statusCode == 429) {
|
||||
if (attempt + 1 >= maxAttempts) {
|
||||
@@ -35,7 +46,6 @@ Duration _retryDelay(http.Response response, int attempt) {
|
||||
return Duration(seconds: seconds.clamp(1, 30));
|
||||
}
|
||||
}
|
||||
// 0.8s, 1.6s, 3.2s…
|
||||
final ms = 800 * (1 << attempt);
|
||||
return Duration(milliseconds: ms.clamp(800, 8000));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/// Résultat d’un `POST /oauth/token` (connexion ou refresh).
|
||||
class OAuthTokenPair {
|
||||
const OAuthTokenPair({
|
||||
required this.accessToken,
|
||||
this.refreshToken,
|
||||
});
|
||||
|
||||
final String accessToken;
|
||||
final String? refreshToken;
|
||||
}
|
||||
|
||||
OAuthTokenPair? parseOAuthTokenResponse(Map<String, dynamic> json) {
|
||||
final access = json['access_token'];
|
||||
if (access is! String || access.isEmpty) return null;
|
||||
|
||||
final refresh = json['refresh_token'];
|
||||
return OAuthTokenPair(
|
||||
accessToken: access,
|
||||
refreshToken: refresh is String && refresh.isNotEmpty ? refresh : null,
|
||||
);
|
||||
}
|
||||
@@ -132,10 +132,7 @@ class IntraProfileService {
|
||||
final uri = Uri.parse('${IntraConfig.apiBaseUrl}$path').replace(
|
||||
queryParameters: query,
|
||||
);
|
||||
final response = await intraApiGet(
|
||||
uri,
|
||||
headers: _auth.authHeaders(),
|
||||
);
|
||||
final response = await intraApiGet(uri, auth: _auth);
|
||||
return decodeIntraBody(response);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,10 +29,7 @@ class IntraUserService {
|
||||
);
|
||||
|
||||
try {
|
||||
final response = await intraApiGet(
|
||||
uri,
|
||||
headers: _auth.authHeaders(),
|
||||
);
|
||||
final response = await intraApiGet(uri, auth: _auth);
|
||||
return _parseUserListResponse(response, trimmed);
|
||||
} on IntraApiException {
|
||||
rethrow;
|
||||
|
||||
Reference in New Issue
Block a user