From c4fb8b929bb83fcc7e8f3e35996006bddf11e80c Mon Sep 17 00:00:00 2001 From: Vincent Velociter <423393+veloce@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:24:39 +0100 Subject: [PATCH] OAuth 2.0 PKCE flow and redirect to system browser (#2787) This pull request implements a new OAuth 2.0 PKCE authentication flow for the app, replacing the previous flutter_appauth dependency and approach. The new flow launches an in-app browser (or fallback to the system browser) for user authentication and handles the redirect back to the app using a custom URI scheme. --- android/app/src/main/AndroidManifest.xml | 7 + ios/Podfile.lock | 15 --- lib/src/app.dart | 6 + lib/src/model/auth/auth_repository.dart | 157 ++++++++++++++++++----- lib/src/model/auth/oauth_callback.dart | 14 ++ pubspec.lock | 16 --- pubspec.yaml | 1 - 7 files changed, 153 insertions(+), 63 deletions(-) create mode 100644 lib/src/model/auth/oauth_callback.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a473d276b..67119cd74 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -56,6 +56,13 @@ + + + + + + + diff --git a/ios/Podfile.lock b/ios/Podfile.lock index ac1a364ca..87909f716 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3,12 +3,6 @@ PODS: - Flutter - app_settings (6.1.2): - Flutter - - AppAuth (2.0.0): - - AppAuth/Core (= 2.0.0) - - AppAuth/ExternalUserAgent (= 2.0.0) - - AppAuth/Core (2.0.0) - - AppAuth/ExternalUserAgent (2.0.0): - - AppAuth/Core - connectivity_plus (0.0.1): - Flutter - cupertino_http (0.0.1): @@ -111,9 +105,6 @@ PODS: - nanopb (~> 3.30910.0) - PromisesSwift (~> 2.1) - Flutter (1.0.0) - - flutter_appauth (0.0.1): - - AppAuth (= 2.0.0) - - Flutter - flutter_local_notifications (0.0.1): - Flutter - flutter_native_splash (2.4.3): @@ -200,7 +191,6 @@ DEPENDENCIES: - firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`) - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) - Flutter (from `Flutter`) - - flutter_appauth (from `.symlinks/plugins/flutter_appauth/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) @@ -220,7 +210,6 @@ DEPENDENCIES: SPEC REPOS: trunk: - - AppAuth - DKImagePickerController - DKPhotoGallery - Firebase @@ -261,8 +250,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/firebase_messaging/ios" Flutter: :path: Flutter - flutter_appauth: - :path: ".symlinks/plugins/flutter_appauth/ios" flutter_local_notifications: :path: ".symlinks/plugins/flutter_local_notifications/ios" flutter_native_splash: @@ -299,7 +286,6 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: app_links: a754cbec3c255bd4bbb4d236ecc06f28cd9a7ce8 app_settings: 0341ec6daa4f0c50f5a421bf0ad7c36084db6e90 - AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063 connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe @@ -319,7 +305,6 @@ SPEC CHECKSUMS: FirebaseRemoteConfigInterop: 765ee19cd2bfa8e54937c8dae901eb634ad6787d FirebaseSessions: a2d06fd980431fda934c7a543901aca05fc4edcc Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - flutter_appauth: 53aecd8e5a88b27c0457bf06aa755d3b43625e85 flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214 flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 diff --git a/lib/src/app.dart b/lib/src/app.dart index b34c5e11b..c46fd50da 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -12,6 +12,8 @@ import 'package:lichess_mobile/src/log.dart'; import 'package:lichess_mobile/src/model/account/account_service.dart'; import 'package:lichess_mobile/src/model/account/ongoing_game.dart'; import 'package:lichess_mobile/src/model/announce/announce_service.dart'; +import 'package:lichess_mobile/src/model/auth/auth_repository.dart'; +import 'package:lichess_mobile/src/model/auth/oauth_callback.dart'; import 'package:lichess_mobile/src/model/challenge/challenge_service.dart'; import 'package:lichess_mobile/src/model/common/preloaded_data.dart'; import 'package:lichess_mobile/src/model/correspondence/correspondence_service.dart'; @@ -162,6 +164,10 @@ class _AppState extends ConsumerState { if (uri.scheme == 'file' || uri.scheme == 'content') { return; } + if (uri.scheme == kOAuthRedirectUriScheme && uri.host == kOAuthRedirectUriHost) { + ref.read(oauthCallbackProvider).add(uri); + return; + } final context = _navigatorKey.currentContext; if (context != null && context.mounted) { handleAppLink(context, uri); diff --git a/lib/src/model/auth/auth_repository.dart b/lib/src/model/auth/auth_repository.dart index e8d2c8f6a..8fba025fa 100644 --- a/lib/src/model/auth/auth_repository.dart +++ b/lib/src/model/auth/auth_repository.dart @@ -1,59 +1,139 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter_appauth/flutter_appauth.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lichess_mobile/src/constants.dart'; import 'package:lichess_mobile/src/model/auth/auth_controller.dart'; import 'package:lichess_mobile/src/model/auth/bearer.dart'; +import 'package:lichess_mobile/src/model/auth/oauth_callback.dart'; import 'package:lichess_mobile/src/model/user/user.dart'; import 'package:lichess_mobile/src/network/http.dart'; import 'package:logging/logging.dart'; +import 'package:url_launcher/url_launcher.dart'; -const redirectUri = 'org.lichess.mobile://login-callback'; +const kOAuthRedirectUriScheme = 'org.lichess.mobile'; +const kOAuthRedirectUriHost = 'login-callback'; +const kOAuthRedirectUri = '$kOAuthRedirectUriScheme://$kOAuthRedirectUriHost'; const oauthScopes = ['web:mobile']; -/// A provider for [FlutterAppAuth]. -final appAuthProvider = Provider((Ref ref) { - return const FlutterAppAuth(); -}, name: 'AppAuthProvider'); - final authRepositoryProvider = Provider((Ref ref) { - final appAuth = ref.read(appAuthProvider); - return AuthRepository(ref, appAuth); + return AuthRepository(ref); }, name: 'AuthRepositoryProvider'); class AuthRepository { - AuthRepository(Ref ref, FlutterAppAuth appAuth) : _ref = ref, _appAuth = appAuth; + AuthRepository(Ref ref) : _ref = ref; final Ref _ref; final Logger _log = Logger('AuthRepository'); - final FlutterAppAuth _appAuth; + final _random = Random.secure(); LichessClient get _client => _ref.read(lichessClientProvider); - /// Sign in with Lichess. + /// Sign in with Lichess using OAuth 2.0 PKCE. /// - /// This method uses the [FlutterAppAuth] package to sign in with Lichess using - /// OAuth 2.0. It first calls [FlutterAppAuth.authorizeAndExchangeCode] to - /// get an access token, and then calls the Lichess API to get the user's - /// account information. + /// Opens an in-app browser (or fallback to the system default browser) to the Lichess + /// authorization page. + /// After the user authorizes, the browser redirects to [kOAuthRedirectUri] which + /// is caught by the app links handler and forwarded to [oauthCallbackProvider]. Future signIn() async { - final authResp = await _appAuth.authorizeAndExchangeCode( - AuthorizationTokenRequest( - kLichessClientId, - redirectUri, - allowInsecureConnections: kDebugMode, - serviceConfiguration: AuthorizationServiceConfiguration( - authorizationEndpoint: lichessUri('/oauth').toString(), - tokenEndpoint: lichessUri('/api/token').toString(), - ), - scopes: oauthScopes, - ), + final codeVerifier = _generateCodeVerifier(); + final codeChallenge = _generateCodeChallenge(codeVerifier); + final state = _generateState(); + + final authUrl = lichessUri('/oauth').replace( + queryParameters: { + 'response_type': 'code', + 'client_id': kLichessClientId, + 'redirect_uri': kOAuthRedirectUri, + 'scope': oauthScopes.join(' '), + 'code_challenge': codeChallenge, + 'code_challenge_method': 'S256', + 'state': state, + }, ); - _log.fine('Got oAuth response $authResp'); + final launched = await launchUrl(authUrl, mode: .inAppBrowserView); + if (!launched) { + throw Exception('Could not open browser for authentication.'); + } - final token = authResp.accessToken; + final callbackCompleter = Completer(); + // Listen for the redirect callback URI from the browser. + // Mismatched URIs (stale callbacks, unrelated deep links) are silently ignored and the listener + // keeps waiting. + final callbackSub = _ref + .read(oauthCallbackProvider) + .stream + .listen( + (uri) { + if (!callbackCompleter.isCompleted && + uri.scheme == kOAuthRedirectUriScheme && + uri.host == kOAuthRedirectUriHost && + uri.queryParameters['state'] == state) { + callbackCompleter.complete(uri); + } + }, + onError: (Object error) { + if (!callbackCompleter.isCompleted) { + callbackCompleter.completeError(error); + } + }, + ); + + // Cancel the flow when the user dismisses the browser without completing auth. When a redirect + // succeeds, the callback URI arrives in Dart before (or very shortly after) the resumed + // lifecycle event — a short delay prevents a false cancellation in the success path. + final lifecycleListener = AppLifecycleListener( + onResume: () => Future.delayed(const Duration(milliseconds: 300), () { + if (!callbackCompleter.isCompleted) { + callbackCompleter.completeError(Exception('Sign-in was cancelled.')); + } + }), + ); + + final Uri callbackUri; + try { + callbackUri = await callbackCompleter.future.timeout(const Duration(minutes: 5)); + } finally { + // Dismiss the in-app browser in all cases (no-op if already closed). + unawaited(closeInAppWebView()); + unawaited(callbackSub.cancel()); + lifecycleListener.dispose(); + } + + final error = callbackUri.queryParameters['error']; + if (error != null) { + final errorDescription = callbackUri.queryParameters['error_description']; + final message = errorDescription != null + ? 'OAuth error: $error - $errorDescription' + : 'OAuth error: $error'; + throw Exception(message); + } + + final code = callbackUri.queryParameters['code']; + if (code == null) { + throw Exception('Authorization code not found.'); + } + + final tokenResponse = await _client.postReadJson( + Uri(path: '/api/token'), + body: { + 'grant_type': 'authorization_code', + 'code': code, + 'code_verifier': codeVerifier, + 'redirect_uri': kOAuthRedirectUri, + 'client_id': kLichessClientId, + }, + mapper: (json) => json, + ); + + _log.fine('Got OAuth token response'); + + final token = tokenResponse['access_token'] as String?; if (token == null) { throw Exception('Access token not found.'); } @@ -66,7 +146,7 @@ class AuthRepository { return AuthUser(token: token, user: user.lightUser); } - /// Sign out the current user by revoking the authUser token. + /// Sign out the current user by revoking the auth token. Future signOut() async { await _client.deleteRead(Uri(path: '/api/token')); } @@ -79,4 +159,19 @@ class AuthRepository { .timeout(const Duration(seconds: 5)); return data[authUser.token] != null; } + + String _generateCodeVerifier() { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + return List.generate(64, (_) => chars[_random.nextInt(chars.length)]).join(); + } + + String _generateCodeChallenge(String codeVerifier) { + final digest = sha256.convert(utf8.encode(codeVerifier)); + return base64Url.encode(digest.bytes).replaceAll('=', ''); + } + + String _generateState() { + final bytes = List.generate(16, (_) => _random.nextInt(256)); + return base64Url.encode(bytes).replaceAll('=', ''); + } } diff --git a/lib/src/model/auth/oauth_callback.dart b/lib/src/model/auth/oauth_callback.dart new file mode 100644 index 000000000..a1df0c376 --- /dev/null +++ b/lib/src/model/auth/oauth_callback.dart @@ -0,0 +1,14 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// A broadcast stream controller that receives the OAuth redirect URI after the +/// user completes the authorization flow in the external browser. +/// +/// The [oauthCallbackProvider] receives the URI from app links. +/// listener and forwards it to any awaiting [AuthRepository.signIn] call. +final oauthCallbackProvider = Provider>((ref) { + final controller = StreamController.broadcast(); + ref.onDispose(controller.close); + return controller; +}); diff --git a/pubspec.lock b/pubspec.lock index bf6f884ec..a0696c37f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -527,22 +527,6 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_appauth: - dependency: "direct main" - description: - name: flutter_appauth - sha256: "93f2991422eab5c84c697c694ec2b2351447ad70c18a68ab3cb364ef6422ebe4" - url: "https://pub.dev" - source: hosted - version: "12.0.0" - flutter_appauth_platform_interface: - dependency: transitive - description: - name: flutter_appauth_platform_interface - sha256: a17fe4cd6afa30b634e21922c02657e41130bceb03952a43fa469b95a758c328 - url: "https://pub.dev" - source: hosted - version: "12.0.0" flutter_displaymode: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 2f47ab1a2..4c31f8661 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,7 +37,6 @@ dependencies: fl_chart: ^1.0.0 flutter: sdk: flutter - flutter_appauth: ^12.0.0 flutter_displaymode: ^0.7.0 flutter_layout_grid: ^2.0.1 flutter_linkify: ^6.0.0