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