From 4a265e0fd2741a11863d442326c102757cfc3389 Mon Sep 17 00:00:00 2001 From: neckfire Date: Mon, 13 Jul 2026 10:56:19 +0200 Subject: [PATCH] fix(auth): flutter_web_auth_2 + PKCE manuel (retour OIDC fiable via CallbackActivity) Co-Authored-By: Claude Opus 4.8 --- lib/config.dart | 5 +- lib/services/auth_service.dart | 132 ++++++++++++++++++++++----------- pubspec.lock | 56 ++++++++++---- pubspec.yaml | 3 +- tool/patch_android.py | 35 +++------ 5 files changed, 146 insertions(+), 85 deletions(-) diff --git a/lib/config.dart b/lib/config.dart index 475c647..70f588f 100644 --- a/lib/config.dart +++ b/lib/config.dart @@ -5,7 +5,8 @@ class Config { // OIDC pocket-id (client public + PKCE) static const String oidcIssuer = 'https://pocket.nfteam.ovh'; static const String oidcClientId = '83678fb3-9268-42c1-8dcb-0fb141692a6d'; - // App Link HTTPS (fiable ; le schéma custom est bloqué par Chrome au retour) - static const String oidcRedirect = 'https://gamemanager-api.nfteam.ovh/oidc/callback'; + // Schéma custom capté par flutter_web_auth_2 (CallbackActivity dédiée) + static const String oidcScheme = 'nfteam.gamemanager'; + static const String oidcRedirect = 'nfteam.gamemanager://oidc'; static const List oidcScopes = ['openid', 'profile', 'email']; } diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index 23ac3f8..ef9e8dd 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -1,67 +1,113 @@ -import 'package:flutter_appauth/flutter_appauth.dart'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; +import 'package:http/http.dart' as http; import '../config.dart'; -/// Authentification OIDC pocket-id (Authorization Code + PKCE, client public). -/// Le jeton d'identité (JWT) est stocké de façon sécurisée et envoyé à l'API. +/// Auth OIDC pocket-id : Authorization Code + PKCE via flutter_web_auth_2 +/// (schéma custom capté de façon fiable), échange de token en direct. class AuthService { - final _appAuth = const FlutterAppAuth(); final _storage = const FlutterSecureStorage(); static const _kIdToken = 'id_token'; static const _kRefresh = 'refresh_token'; - static const _kExpiry = 'access_expiry'; + static const _kExpiry = 'expiry'; + + Map? _discovery; + + Future> _disco() async { + _discovery ??= jsonDecode((await http.get( + Uri.parse('${Config.oidcIssuer}/.well-known/openid-configuration'), + )) + .body) as Map; + return _discovery!; + } Future get idToken => _storage.read(key: _kIdToken); - Future isLoggedIn() async => (await idToken) != null; + String _rand(int n) { + final r = Random.secure(); + return base64UrlEncode(List.generate(n, (_) => r.nextInt(256))) + .replaceAll('=', '') + .substring(0, n); + } + Future login() async { - final result = await _appAuth.authorizeAndExchangeCode( - AuthorizationTokenRequest( - Config.oidcClientId, - Config.oidcRedirect, - issuer: Config.oidcIssuer, - scopes: Config.oidcScopes, - promptValues: ['login'], - ), + final disco = await _disco(); + final verifier = _rand(64); + final challenge = base64UrlEncode(sha256.convert(ascii.encode(verifier)).bytes) + .replaceAll('=', ''); + final state = _rand(24); + final authUrl = Uri.parse(disco['authorization_endpoint'] as String).replace(queryParameters: { + 'response_type': 'code', + 'client_id': Config.oidcClientId, + 'redirect_uri': Config.oidcRedirect, + 'scope': Config.oidcScopes.join(' '), + 'state': state, + 'code_challenge': challenge, + 'code_challenge_method': 'S256', + 'prompt': 'login', + }); + + final result = await FlutterWebAuth2.authenticate( + url: authUrl.toString(), + callbackUrlScheme: Config.oidcScheme, ); - if (result.idToken == null) return false; - await _storage.write(key: _kIdToken, value: result.idToken); - await _storage.write(key: _kRefresh, value: result.refreshToken); - await _storage.write( - key: _kExpiry, - value: result.accessTokenExpirationDateTime?.toIso8601String(), + final params = Uri.parse(result).queryParameters; + if (params['state'] != state || params['code'] == null) return false; + + final token = await http.post( + Uri.parse(disco['token_endpoint'] as String), + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: { + 'grant_type': 'authorization_code', + 'code': params['code']!, + 'redirect_uri': Config.oidcRedirect, + 'client_id': Config.oidcClientId, + 'code_verifier': verifier, + }, ); + if (token.statusCode != 200) return false; + await _store(jsonDecode(token.body) as Map); return true; } - /// Rafraîchit le jeton si expiré (via refresh_token), renvoie l'id_token courant. + Future _store(Map t) async { + await _storage.write(key: _kIdToken, value: t['id_token'] as String?); + if (t['refresh_token'] != null) { + await _storage.write(key: _kRefresh, value: t['refresh_token'] as String); + } + final expiresIn = (t['expires_in'] as num?)?.toInt() ?? 3600; + await _storage.write( + key: _kExpiry, + value: DateTime.now().add(Duration(seconds: expiresIn)).toIso8601String(), + ); + } + + /// Rafraîchit si expiré, renvoie l'id_token courant. Future validToken() async { - final expiry = await _storage.read(key: _kExpiry); + final expiry = DateTime.tryParse(await _storage.read(key: _kExpiry) ?? ''); final refresh = await _storage.read(key: _kRefresh); - if (expiry != null && refresh != null) { - final exp = DateTime.tryParse(expiry); - if (exp != null && exp.isBefore(DateTime.now().add(const Duration(seconds: 30)))) { - try { - final r = await _appAuth.token(TokenRequest( - Config.oidcClientId, - Config.oidcRedirect, - issuer: Config.oidcIssuer, - refreshToken: refresh, - scopes: Config.oidcScopes, - )); - if (r.idToken != null) { - await _storage.write(key: _kIdToken, value: r.idToken); - await _storage.write(key: _kRefresh, value: r.refreshToken ?? refresh); - await _storage.write( - key: _kExpiry, - value: r.accessTokenExpirationDateTime?.toIso8601String(), - ); - } - } catch (_) {/* on garde l'ancien token, l'API renverra 401 si mort */} - } + if (expiry != null && refresh != null && + expiry.isBefore(DateTime.now().add(const Duration(seconds: 30)))) { + try { + final disco = await _disco(); + final r = await http.post( + Uri.parse(disco['token_endpoint'] as String), + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: { + 'grant_type': 'refresh_token', + 'refresh_token': refresh, + 'client_id': Config.oidcClientId, + }, + ); + if (r.statusCode == 200) await _store(jsonDecode(r.body) as Map); + } catch (_) {/* garde l'ancien */} } return idToken; } diff --git a/pubspec.lock b/pubspec.lock index 57330c4..722df90 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -41,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + desktop_webview_window: + dependency: transitive + description: + name: desktop_webview_window + sha256: "57cf20d81689d5cbb1adfd0017e96b669398a669d927906073b0e42fc64111c0" + url: "https://pub.dev" + source: hosted + version: "0.2.3" ffi: dependency: transitive description: @@ -62,22 +78,6 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_appauth: - dependency: "direct main" - description: - name: flutter_appauth - sha256: "0aa449d8991f70e7847d55b8bff0890fb41dc62c1d8526337e4073e806813bcb" - url: "https://pub.dev" - source: hosted - version: "8.0.3" - flutter_appauth_platform_interface: - dependency: transitive - description: - name: flutter_appauth_platform_interface - sha256: ccf5e1d8c40dd35b297290b33cc1896648b4b92a2ec3f62a436c62a8eef9a9db - url: "https://pub.dev" - source: hosted - version: "8.0.0" flutter_lints: dependency: "direct dev" description: @@ -134,6 +134,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + flutter_web_auth_2: + dependency: "direct main" + description: + name: flutter_web_auth_2 + sha256: "3c14babeaa066c371f3a743f204dd0d348b7d42ffa6fae7a9847a521aff33696" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + flutter_web_auth_2_platform_interface: + dependency: transitive + description: + name: flutter_web_auth_2_platform_interface + sha256: c63a472c8070998e4e422f6b34a17070e60782ac442107c70000dd1bed645f4d + url: "https://pub.dev" + source: hosted + version: "4.1.0" flutter_web_plugins: dependency: transitive description: flutter @@ -456,6 +472,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.10.1" + window_to_front: + dependency: transitive + description: + name: window_to_front + sha256: "14fad8984db4415e2eeb30b04bb77140b180e260d6cb66b26de126a8657a9241" + url: "https://pub.dev" + source: hosted + version: "0.0.4" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index bc8e38e..b2ce81a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,7 +10,8 @@ dependencies: flutter: sdk: flutter http: ^1.2.2 - flutter_appauth: ^8.0.0 + flutter_web_auth_2: ^4.1.0 + crypto: ^3.0.6 flutter_secure_storage: ^9.2.2 shared_preferences: ^2.3.2 package_info_plus: ^8.1.1 diff --git a/tool/patch_android.py b/tool/patch_android.py index 9d01d3d..dac93e5 100644 --- a/tool/patch_android.py +++ b/tool/patch_android.py @@ -2,7 +2,7 @@ """Patche le scaffold Android généré par `flutter create` : - permissions (internet + auto-install APK) - signature release depuis key.properties - - schéma de redirection OIDC (flutter_appauth) + - CallbackActivity flutter_web_auth_2 (retour OIDC, schéma nfteam.gamemanager) Lancé dans la CI après `flutter create`. Idempotent.""" import os import re @@ -11,7 +11,7 @@ KSPW = os.environ["KSPW"] ALIAS = os.environ["ALIAS"] KEYSTORE_PATH = os.environ.get("KEYSTORE_PATH", os.path.abspath("gamemanager.keystore")) -# 1) key.properties (chemin absolu du keystore, robuste peu importe le cwd gradle) +# 1) key.properties (chemin absolu du keystore) with open("android/key.properties", "w") as f: f.write( f"storePassword={KSPW}\n" @@ -20,39 +20,34 @@ with open("android/key.properties", "w") as f: f"storeFile={KEYSTORE_PATH}\n" ) -# 2) AndroidManifest : permissions + App Link HTTPS (retour OIDC fiable) +# 2) AndroidManifest : permissions + CallbackActivity OIDC man = "android/app/src/main/AndroidManifest.xml" s = open(man).read() -# namespace tools (pour merger dans l'activity de flutter_appauth) -if "xmlns:tools" not in s: - s = s.replace("\n' ' \n' ) if "REQUEST_INSTALL_PACKAGES" not in s: s = s.replace("\n' - ' \n' +callback = ( + ' \n' + ' \n' ' \n' ' \n' ' \n' - ' \n' + ' \n' ' \n' ' \n' ) -if "gamemanager-api.nfteam.ovh" not in s: - s = s.replace("", applink + " ", 1) +if "flutter_web_auth_2.CallbackActivity" not in s: + s = s.replace("", callback + " ", 1) open(man, "w").write(s) -# 3) build.gradle : signature + schéma OIDC +# 3) build.gradle : signature release gr = "android/app/build.gradle" s = open(gr).read() -# ⚠️ Rien n'est autorisé AVANT le bloc plugins {} → on insère juste avant `android {` +# ⚠️ Rien avant plugins {} → insérer juste avant `android {` if "keystoreProperties" not in s: s = s.replace( "android {", @@ -62,12 +57,6 @@ if "keystoreProperties" not in s: 'android {', 1, ) -if "appAuthRedirectScheme" not in s: - s = s.replace( - "defaultConfig {", - 'defaultConfig {\n manifestPlaceholders += [appAuthRedirectScheme: "nfteam.gamemanager"]', - 1, - ) if "signingConfigs {" not in s: s = re.sub( r"(\n\s*buildTypes\s*\{)",