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.
This commit is contained in:
Vincent Velociter
2026-03-18 10:24:39 +01:00
committed by GitHub
parent e8b2cb4ae3
commit c4fb8b929b
7 changed files with 153 additions and 63 deletions
+7
View File
@@ -56,6 +56,13 @@
<data android:pathSuffix=".pgn" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="org.lichess.mobile" android:host="login-callback" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
-15
View File
@@ -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
+6
View File
@@ -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<Application> {
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);
+126 -31
View File
@@ -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<FlutterAppAuth>((Ref ref) {
return const FlutterAppAuth();
}, name: 'AppAuthProvider');
final authRepositoryProvider = Provider<AuthRepository>((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<AuthUser> 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<Uri>();
// 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<void>.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<void> 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<int>.generate(16, (_) => _random.nextInt(256));
return base64Url.encode(bytes).replaceAll('=', '');
}
}
+14
View File
@@ -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<StreamController<Uri>>((ref) {
final controller = StreamController<Uri>.broadcast();
ref.onDispose(controller.close);
return controller;
});
-16
View File
@@ -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:
-1
View File
@@ -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