mirror of
https://github.com/lichess-org/mobile.git
synced 2026-05-26 13:50:52 +00:00
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:
committed by
GitHub
parent
e8b2cb4ae3
commit
c4fb8b929b
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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('=', '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user