From 67195e7e22fd3c8b31e397c812aa2d63a10824af Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 3 Dec 2024 21:01:00 +0100 Subject: [PATCH 1/5] Implemented deep link and local server options instead web_auth --- example/pubspec.lock | 114 +++++++++++++++++++++++-- lib/logto_client.dart | 104 +++++++++++++++++++--- lib/src/modules/callback_strategy.dart | 39 +++++++++ lib/src/utilities/stream_awaiter.dart | 32 +++++++ pubspec.yaml | 3 +- 5 files changed, 268 insertions(+), 24 deletions(-) create mode 100644 lib/src/modules/callback_strategy.dart create mode 100644 lib/src/utilities/stream_awaiter.dart diff --git a/example/pubspec.lock b/example/pubspec.lock index 013928c..bbac586 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -17,6 +17,38 @@ packages: url: "https://pub.dev" source: hosted version: "6.2.0" + app_links: + dependency: transitive + description: + name: app_links + sha256: ad1a6d598e7e39b46a34f746f9a8b011ee147e4c275d407fa457e7a62f84dd99 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" args: dependency: transitive description: @@ -203,14 +235,6 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_web_auth: - dependency: transitive - description: - name: flutter_web_auth - sha256: a69fa8f43b9e4d86ac72176bf747b735e7b977dd7cf215076d95b87cb05affdd - url: "https://pub.dev" - source: hosted - version: "0.5.0" flutter_web_plugins: dependency: transitive description: flutter @@ -232,6 +256,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + gtk: + dependency: transitive + description: + name: gtk + sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c + url: "https://pub.dev" + source: hosted + version: "2.1.0" http: dependency: "direct main" description: @@ -620,6 +652,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.2" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193" + url: "https://pub.dev" + source: hosted + version: "6.3.14" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: e43b677296fadce447e987a2f519dcf5f6d1e527dc35d01ffab4fff5b8a7063e + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "769549c999acdb42b8bcfa7c43d72bf79a382ca7441ab18a808e101149daf672" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: fff0932192afeedf63cdd50ecbb1bc825d31aed259f02bb8dba0f3b729a5e88b + url: "https://pub.dev" + source: hosted + version: "2.2.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "44cf3aabcedde30f2dba119a9dea3b0f2672fbe6fa96e85536251d678216b3c4" + url: "https://pub.dev" + source: hosted + version: "3.1.3" vector_math: dependency: transitive description: @@ -702,4 +798,4 @@ packages: version: "3.1.2" sdks: dart: ">=3.5.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.24.0" diff --git a/lib/logto_client.dart b/lib/logto_client.dart index b0dd8b8..f0a0bca 100644 --- a/lib/logto_client.dart +++ b/lib/logto_client.dart @@ -1,7 +1,13 @@ -import 'package:flutter_web_auth/flutter_web_auth.dart'; +import 'dart:async'; +import 'dart:io'; + +import 'package:app_links/app_links.dart'; +import 'package:url_launcher/url_launcher.dart'; import 'package:http/http.dart' as http; import 'package:jose/jose.dart'; +import '/src/utilities/stream_awaiter.dart'; +import '/src/modules/callback_strategy.dart'; import '/src/exceptions/logto_auth_exceptions.dart'; import '/src/interfaces/logto_interfaces.dart'; import '/src/modules/id_token.dart'; @@ -15,6 +21,7 @@ import '/src/utilities/constants.dart'; export '/src/exceptions/logto_auth_exceptions.dart'; export '/src/interfaces/logto_interfaces.dart'; export '/src/utilities/constants.dart'; +export '/src/modules/callback_strategy.dart'; /** * LogtoClient @@ -35,6 +42,8 @@ export '/src/utilities/constants.dart'; * * final logtoClient = LogtoClient(config); */ +final appLinks = AppLinks(); + class LogtoClient { final LogtoConfig config; @@ -53,18 +62,32 @@ class LogtoClient { OidcProviderConfig? _oidcConfig; - LogtoClient({ - required this.config, - LogtoStorageStrategy? storageProvider, - http.Client? httpClient, - }) { + late final CallbackStrategy _callbackStrategy; + + LogtoClient( + {required this.config, + LogtoStorageStrategy? storageProvider, + http.Client? httpClient, + CallbackStrategy? callbackStrategy}) { _httpClient = httpClient; _tokenStorage = TokenStorage(storageProvider); + _callbackStrategy = callbackStrategy ?? SchemeStrategy(); + } + + final _authController = StreamController.broadcast(); + + Stream get isAuthenticatedStream => _authController.stream; + + void init() async { + bool value = await isAuthenticated; + + _authController.sink.add(value); } // Use idToken to check if the user is authenticated. Future get isAuthenticated async { - return await _tokenStorage.idToken != null; + bool result = await _tokenStorage.idToken != null; + return result; } Future get idToken async { @@ -159,6 +182,8 @@ class LogtoClient { final idToken = IdToken.unverified(response.idToken!); await _verifyIdToken(idToken, oidcConfig); await _tokenStorage.setIdToken(idToken); + + _authController.sink.add(true); } return await _tokenStorage.getAccessToken( @@ -227,13 +252,7 @@ class LogtoClient { extraParams: extraParams, ); - final redirectUriScheme = Uri.parse(redirectUri).scheme; - - final String callbackUri = await FlutterWebAuth.authenticate( - url: signInUri.toString(), - callbackUrlScheme: redirectUriScheme, - preferEphemeral: true, - ); + final String callbackUri = await _getCallbackUrl(signInUri); await _handleSignInCallback(callbackUri, redirectUri, httpClient); } finally { @@ -272,6 +291,8 @@ class LogtoClient { refreshToken: tokenResponse.refreshToken, expiresIn: tokenResponse.expiresIn, scopes: tokenResponse.scope.split(' ')); + + _authController.sink.add(true); } // Sign out the user. @@ -306,6 +327,7 @@ class LogtoClient { } await _tokenStorage.clear(); + _authController.sink.add(false); } finally { if (_httpClient == null) { httpClient.close(); @@ -338,4 +360,58 @@ class LogtoClient { if (_httpClient == null) httpClient.close(); } } + + Future _getCallbackUrl(Uri url) async { + if (!await launchUrl(url)) { + throw Exception('Could not launch ${url.toString()}'); + } + + var result = await _athuneticateUserFlow(); + + return result.toString(); + } + + Future _athuneticateUserFlow() async { + late Uri result; + + if (_callbackStrategy.strategy == CallbackStrategyType.scheme) + { + await awaitUriLinkStream( + appLinks.uriLinkStream, + (uri) async { + if (uri != null) { + result = uri; + } else { + throw Exception("Failed to authorize"); + } + }, + ); + + return result; + } + + final server = await HttpServer.bind(InternetAddress.anyIPv4, (_callbackStrategy as LocalServerStrategy).port); + + await for (HttpRequest request in server) { + // Handle the request + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.html + ..writeln(""" + + + +"""); + await request.response.close(); + result = request.requestedUri; + await server.close(); + break; // Exit the loop + } + + return result; + } + + void dispose() { + _authController.close(); + } } diff --git a/lib/src/modules/callback_strategy.dart b/lib/src/modules/callback_strategy.dart new file mode 100644 index 0000000..ceab5c8 --- /dev/null +++ b/lib/src/modules/callback_strategy.dart @@ -0,0 +1,39 @@ +enum CallbackStrategyType { + scheme, + localServer +} + +abstract class CallbackStrategy { + final CallbackStrategyType _strategy = CallbackStrategyType.scheme; + + CallbackStrategyType get strategy => _strategy; +} + +class SchemeStrategy implements CallbackStrategy { + @override + late CallbackStrategyType _strategy; + + SchemeStrategy(){ + _strategy = CallbackStrategyType.scheme; + } + + @override + CallbackStrategyType get strategy => _strategy; + +} + +class LocalServerStrategy implements CallbackStrategy { + @override + late CallbackStrategyType _strategy; + final int _port; + + LocalServerStrategy(this._port) { + _strategy = CallbackStrategyType.localServer; + } + + @override + CallbackStrategyType get strategy => _strategy; + + int get port => _port; + +} \ No newline at end of file diff --git a/lib/src/utilities/stream_awaiter.dart b/lib/src/utilities/stream_awaiter.dart new file mode 100644 index 0000000..3ef701c --- /dev/null +++ b/lib/src/utilities/stream_awaiter.dart @@ -0,0 +1,32 @@ +import 'dart:async'; + +Future awaitUriLinkStream(Stream uriLinkStream, Future Function(Uri? uri) onUri) async { + final completer = Completer(); + late StreamSubscription subscription; + + subscription = uriLinkStream.listen( + (uri) async { + try { + await onUri(uri); + completer.complete(); + } catch (e) { + completer.completeError(e); + } finally { + await subscription.cancel(); // Ensure subscription is canceled + } + }, + onError: (error) { + if (!completer.isCompleted) { + completer.completeError(error); + } + }, + onDone: () { + if (!completer.isCompleted) { + completer.complete(); + } + }, + cancelOnError: true, + ); + + return completer.future; +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 1db1d4b..76744b7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,8 +17,9 @@ dependencies: jose: ^0.3.2 json_annotation: ^4.6.0 path: ^1.8.1 - flutter_web_auth: ^0.6.0 collection: ^1.17.1 + url_launcher: ^6.3.1 + app_links: ^6.3.2 dev_dependencies: flutter_test: From 26e4ce4e65350fb3fdd38d489102e7504b53672e Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 4 Dec 2024 08:41:29 +0100 Subject: [PATCH 2/5] Added option to force browser open externally --- lib/logto_client.dart | 16 ++++++++----- lib/src/modules/callback_strategy.dart | 32 +++++++++++++++++--------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/lib/logto_client.dart b/lib/logto_client.dart index f0a0bca..f89df8d 100644 --- a/lib/logto_client.dart +++ b/lib/logto_client.dart @@ -86,8 +86,7 @@ class LogtoClient { // Use idToken to check if the user is authenticated. Future get isAuthenticated async { - bool result = await _tokenStorage.idToken != null; - return result; + return await _tokenStorage.idToken != null; } Future get idToken async { @@ -362,7 +361,12 @@ class LogtoClient { } Future _getCallbackUrl(Uri url) async { - if (!await launchUrl(url)) { + final LaunchMode launchMode = + _callbackStrategy.launchMode == BrowserLaunchMode.platformDefault + ? LaunchMode.platformDefault + : LaunchMode.externalApplication; + + if (!await launchUrl(url, mode: launchMode)) { throw Exception('Could not launch ${url.toString()}'); } @@ -374,8 +378,7 @@ class LogtoClient { Future _athuneticateUserFlow() async { late Uri result; - if (_callbackStrategy.strategy == CallbackStrategyType.scheme) - { + if (_callbackStrategy.strategy == CallbackStrategyType.scheme) { await awaitUriLinkStream( appLinks.uriLinkStream, (uri) async { @@ -390,7 +393,8 @@ class LogtoClient { return result; } - final server = await HttpServer.bind(InternetAddress.anyIPv4, (_callbackStrategy as LocalServerStrategy).port); + final server = await HttpServer.bind(InternetAddress.anyIPv4, + (_callbackStrategy as LocalServerStrategy).port); await for (HttpRequest request in server) { // Handle the request diff --git a/lib/src/modules/callback_strategy.dart b/lib/src/modules/callback_strategy.dart index ceab5c8..6a819a7 100644 --- a/lib/src/modules/callback_strategy.dart +++ b/lib/src/modules/callback_strategy.dart @@ -3,32 +3,39 @@ enum CallbackStrategyType { localServer } -abstract class CallbackStrategy { - final CallbackStrategyType _strategy = CallbackStrategyType.scheme; +enum BrowserLaunchMode { + platformDefault, + external +} - CallbackStrategyType get strategy => _strategy; +abstract class CallbackStrategy { + CallbackStrategyType get strategy; + BrowserLaunchMode get launchMode; } class SchemeStrategy implements CallbackStrategy { - @override - late CallbackStrategyType _strategy; + late BrowserLaunchMode _launchMode; + final CallbackStrategyType _strategy = CallbackStrategyType.scheme; - SchemeStrategy(){ - _strategy = CallbackStrategyType.scheme; + SchemeStrategy({BrowserLaunchMode? launchMode}){ + _launchMode = launchMode ?? BrowserLaunchMode.platformDefault; } @override CallbackStrategyType get strategy => _strategy; + + @override + BrowserLaunchMode get launchMode => _launchMode; } class LocalServerStrategy implements CallbackStrategy { - @override - late CallbackStrategyType _strategy; + late BrowserLaunchMode _launchMode; + final CallbackStrategyType _strategy = CallbackStrategyType.localServer; final int _port; - LocalServerStrategy(this._port) { - _strategy = CallbackStrategyType.localServer; + LocalServerStrategy(this._port,{BrowserLaunchMode? launchMode}){ + _launchMode = launchMode ?? BrowserLaunchMode.platformDefault; } @override @@ -36,4 +43,7 @@ class LocalServerStrategy implements CallbackStrategy { int get port => _port; + @override + BrowserLaunchMode get launchMode => throw _launchMode; + } \ No newline at end of file From 5b72faefe1695ea508c7f671c28ad49777ee781d Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 6 Feb 2025 19:04:59 +0100 Subject: [PATCH 3/5] Fixed unnecessary throwing --- lib/src/modules/callback_strategy.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/modules/callback_strategy.dart b/lib/src/modules/callback_strategy.dart index 6a819a7..d5bd555 100644 --- a/lib/src/modules/callback_strategy.dart +++ b/lib/src/modules/callback_strategy.dart @@ -44,6 +44,6 @@ class LocalServerStrategy implements CallbackStrategy { int get port => _port; @override - BrowserLaunchMode get launchMode => throw _launchMode; + BrowserLaunchMode get launchMode => _launchMode; } \ No newline at end of file From 30b7f42cd3caf5e3e0602b9b6196987b399c1ebe Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 15 Feb 2025 17:10:35 +0100 Subject: [PATCH 4/5] Renamed custom client class --- lib/{logto_client.dart => logto_client_custom.dart} | 4 ++-- lib/logto_dart_sdk.dart | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) rename lib/{logto_client.dart => logto_client_custom.dart} (99%) diff --git a/lib/logto_client.dart b/lib/logto_client_custom.dart similarity index 99% rename from lib/logto_client.dart rename to lib/logto_client_custom.dart index f89df8d..bd1880d 100644 --- a/lib/logto_client.dart +++ b/lib/logto_client_custom.dart @@ -44,7 +44,7 @@ export '/src/modules/callback_strategy.dart'; */ final appLinks = AppLinks(); -class LogtoClient { +class LogtoClientCustom { final LogtoConfig config; late PKCE _pkce; @@ -64,7 +64,7 @@ class LogtoClient { late final CallbackStrategy _callbackStrategy; - LogtoClient( + LogtoClientCustom( {required this.config, LogtoStorageStrategy? storageProvider, http.Client? httpClient, diff --git a/lib/logto_dart_sdk.dart b/lib/logto_dart_sdk.dart index b4a2398..73894f2 100644 --- a/lib/logto_dart_sdk.dart +++ b/lib/logto_dart_sdk.dart @@ -1,4 +1,5 @@ library logto_dart_sdk; +export 'logto_client_custom.dart'; export 'logto_client.dart'; export 'logto_core.dart'; From f4c208de07039085708ebf9047d0a1183c88e914 Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 15 Feb 2025 17:49:53 +0100 Subject: [PATCH 5/5] Handled merge --- example/pubspec.lock | 128 ++++++------ lib/logto_client.dart | 373 +++++++++++++++++++++++++++++++++++ lib/logto_client_custom.dart | 6 + 3 files changed, 436 insertions(+), 71 deletions(-) create mode 100644 lib/logto_client.dart diff --git a/example/pubspec.lock b/example/pubspec.lock index 97b1c8a..f8b4d46 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -5,31 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" + sha256: dc27559385e905ad30838356c5f5d574014ba39872d732111cd07ac0beff4c57 url: "https://pub.dev" source: hosted - version: "76.0.0" - _macros: - dependency: transitive - description: dart - source: sdk - version: "0.3.3" + version: "80.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" + sha256: "192d1c5b944e7e53b24b5586db760db934b177d4147c42fbca8c8c5f1eb8d11e" url: "https://pub.dev" source: hosted - version: "6.2.0" + version: "7.3.0" app_links: dependency: transitive description: name: app_links - sha256: ad1a6d598e7e39b46a34f746f9a8b011ee147e4c275d407fa457e7a62f84dd99 + sha256: "433df2e61b10519407475d7f69e470789d23d593f28224c38ba1068597be7950" url: "https://pub.dev" source: hosted - version: "6.3.2" + version: "6.3.3" app_links_linux: dependency: transitive description: @@ -54,7 +49,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" - version: "6.11.0" args: dependency: transitive description: @@ -67,50 +61,50 @@ packages: dependency: transitive description: name: asn1lib - sha256: "4bae5ae63e6d6dd17c4aac8086f3dec26c0236f6a0f03416c6c19d830c367cf5" + sha256: "1c296cd268f486cabcc3930e9b93a8133169305f18d722916e675959a88f6d2c" url: "https://pub.dev" source: hosted - version: "1.5.8" + version: "1.5.9" async: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.12.0" boolean_selector: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" characters: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" clock: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" collection: dependency: transitive description: name: collection - sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.19.1" convert: dependency: transitive description: @@ -163,10 +157,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.2" ffi: dependency: transitive description: @@ -282,10 +276,10 @@ packages: dependency: transitive description: name: glob - sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" gtk: dependency: transitive description: @@ -354,18 +348,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" + sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec url: "https://pub.dev" source: hosted - version: "10.0.7" + version: "10.0.8" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 url: "https://pub.dev" source: hosted - version: "3.0.8" + version: "3.0.9" leak_tracker_testing: dependency: transitive description: @@ -397,22 +391,14 @@ packages: relative: true source: path version: "3.0.0" - macros: - dependency: transitive - description: - name: macros - sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" - url: "https://pub.dev" - source: hosted - version: "0.1.3-main.0" matcher: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.17" material_color_utilities: dependency: transitive description: @@ -425,10 +411,10 @@ packages: dependency: transitive description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.16.0" mime: dependency: transitive description: @@ -465,10 +451,10 @@ packages: dependency: transitive description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" path_provider: dependency: transitive description: @@ -593,10 +579,10 @@ packages: dependency: transitive description: name: shelf_web_socket - sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.0" sky_engine: dependency: transitive description: flutter @@ -622,66 +608,66 @@ packages: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.10.1" stack_trace: dependency: transitive description: name: stack_trace - sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" string_scanner: dependency: transitive description: name: string_scanner - sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.1" term_glyph: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" test: dependency: transitive description: name: test - sha256: "713a8789d62f3233c46b4a90b174737b2c04cb6ae4500f2aa8b1be8f03f5e67f" + sha256: "301b213cd241ca982e9ba50266bd3f5bd1ea33f1455554c5abb85d1be0e2d87e" url: "https://pub.dev" source: hosted - version: "1.25.8" + version: "1.25.15" test_api: dependency: transitive description: name: test_api - sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd url: "https://pub.dev" source: hosted - version: "0.7.3" + version: "0.7.4" test_core: dependency: transitive description: name: test_core - sha256: "12391302411737c176b0b5d6491f466b0dd56d4763e347b6714efbaa74d7953d" + sha256: "84d17c3486c8dfdbe5e12a50c8ae176d15e2a771b96909a9442b40173649ccaa" url: "https://pub.dev" source: hosted - version: "0.6.5" + version: "0.6.8" typed_data: dependency: transitive description: @@ -766,10 +752,10 @@ packages: dependency: transitive description: name: vm_service - sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" url: "https://pub.dev" source: hosted - version: "14.3.0" + version: "14.3.1" watcher: dependency: transitive description: @@ -798,10 +784,10 @@ packages: dependency: transitive description: name: web_socket_channel - sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" + sha256: "0b8e2457400d8a859b7b2030786835a28a8e80836ef64402abef392ff4f1d0e5" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" webkit_inspection_protocol: dependency: transitive description: @@ -814,10 +800,10 @@ packages: dependency: transitive description: name: win32 - sha256: "154360849a56b7b67331c21f09a386562d88903f90a1099c5987afc1912e1f29" + sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e url: "https://pub.dev" source: hosted - version: "5.10.0" + version: "5.10.1" window_to_front: dependency: transitive description: @@ -851,5 +837,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.6.0 <4.0.0" + dart: ">=3.7.0-0 <4.0.0" flutter: ">=3.27.0" diff --git a/lib/logto_client.dart b/lib/logto_client.dart new file mode 100644 index 0000000..dc14665 --- /dev/null +++ b/lib/logto_client.dart @@ -0,0 +1,373 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:jose/jose.dart'; +import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; + +import '/src/exceptions/logto_auth_exceptions.dart'; +import '/src/interfaces/logto_interfaces.dart'; +import '/src/modules/id_token.dart'; +import '/src/modules/logto_storage_strategy.dart'; +import '/src/modules/pkce.dart'; +import '/src/modules/token_storage.dart'; +import '/src/utilities/constants.dart'; +import '/src/utilities/utils.dart' as utils; +import 'logto_core.dart' as logto_core; + +export '/src/exceptions/logto_auth_exceptions.dart'; +export '/src/interfaces/logto_interfaces.dart'; +export '/src/utilities/constants.dart'; + +/** + * LogtoClient + * + * The main class for the Logto SDK. + * It provides all the user authentication and authorization methods. + * + * @param config: LogtoConfig - the basic configuration object for the Logto SDK. + * @param storageProvider: LogtoStorageStrategy (optional) - default is [InMemoryTokenStorage] used for storing tokens. + * @param httpClient: http.Client (optional) - custom [http.Client] to be used for making http requests. + * + * Example: + * ```dart + * final config = LogtoConfig( + * appId: 'oOeT50aNvY7QbLci6XJZt', + * endpoint: 'http://localhost:3001/', + * ); + * + * final logtoClient = LogtoClient(config); + */ +class LogtoClient { + final LogtoConfig config; + + late PKCE _pkce; + late String _state; + + static late TokenStorage _tokenStorage; + + // Custom [http.Client]. + // Note that you will have to call `close()` yourself when passing a [http.Client] instance. + late final http.Client? _httpClient; + + bool _loading = false; + + bool get loading => _loading; + + OidcProviderConfig? _oidcConfig; + + LogtoClient({ + required this.config, + LogtoStorageStrategy? storageProvider, + http.Client? httpClient, + }) { + _httpClient = httpClient; + _tokenStorage = TokenStorage(storageProvider); + } + + // Use idToken to check if the user is authenticated. + Future get isAuthenticated async { + return await _tokenStorage.idToken != null; + } + + Future get idToken async { + final token = await _tokenStorage.idToken; + return token?.serialization; + } + + Future get idTokenClaims async { + final token = await _tokenStorage.idToken; + return token?.claims; + } + + Future _getOidcConfig(http.Client httpClient) async { + if (_oidcConfig != null) { + return _oidcConfig!; + } + + final discoveryUri = + utils.appendUriPath(config.endpoint, logto_core.discoveryPath); + _oidcConfig = await logto_core.fetchOidcConfig(httpClient, discoveryUri); + + return _oidcConfig!; + } + + // Get the access token by resource indicator or organizationId. + Future getAccessToken( + {String? resource, String? organizationId}) async { + final accessToken = await _tokenStorage.getAccessToken( + resource: resource, organizationId: organizationId); + + if (accessToken != null) { + return accessToken; + } + + final token = await _getAccessTokenByRefreshToken( + resource: resource, organizationId: organizationId); + + return token; + } + + // Get the access token for the organization by organizationId. + Future getOrganizationToken(String organizationId) async { + if (config.scopes == null || + !config.scopes! + .contains(logto_core.LogtoUserScope.organizations.value)) { + throw LogtoAuthException(LogtoAuthExceptions.missingScopeError, + 'organizations scope is not specified'); + } + + return await getAccessToken(organizationId: organizationId); + } + + // Fetch the access token by refresh token. + // No need to specify the scopes for the resource, all the related scopes in the refresh token's grant list will be returned. + Future _getAccessTokenByRefreshToken( + {String? resource, String? organizationId}) async { + final refreshToken = await _tokenStorage.refreshToken; + + if (refreshToken == null) { + throw LogtoAuthException( + LogtoAuthExceptions.authenticationError, 'not_authenticated'); + } + + final httpClient = _httpClient ?? http.Client(); + + try { + final oidcConfig = await _getOidcConfig(httpClient); + + final response = await logto_core.fetchTokenByRefreshToken( + httpClient: httpClient, + tokenEndPoint: oidcConfig.tokenEndpoint, + clientId: config.appId, + refreshToken: refreshToken, + resource: resource, + organizationId: organizationId); + + final scopes = response.scope.split(' '); + + await _tokenStorage.setAccessToken(response.accessToken, + expiresIn: response.expiresIn, + resource: resource, + organizationId: organizationId, + scopes: scopes); + + // renew refresh token + if (response.refreshToken != null) { + await _tokenStorage.setRefreshToken(response.refreshToken); + } + + // verify and store id_token if not null + if (response.idToken != null) { + final idToken = IdToken.unverified(response.idToken!); + await _verifyIdToken(idToken, oidcConfig); + await _tokenStorage.setIdToken(idToken); + } + + return await _tokenStorage.getAccessToken( + resource: resource, organizationId: organizationId); + } finally { + if (_httpClient == null) httpClient.close(); + } + } + + Future _verifyIdToken( + IdToken idToken, OidcProviderConfig oidcConfig) async { + final keyStore = JsonWebKeyStore() + ..addKeySetUrl(Uri.parse(oidcConfig.jwksUri)); + + if (!await idToken.verify(keyStore)) { + throw LogtoAuthException( + LogtoAuthExceptions.idTokenValidationError, 'invalid jws signature'); + } + + final violations = idToken.claims + .validate(issuer: Uri.parse(oidcConfig.issuer), clientId: config.appId); + + if (violations.isNotEmpty) { + throw LogtoAuthException( + LogtoAuthExceptions.idTokenValidationError, '$violations'); + } + } + + // Clear the access token by resource indicator or organizationId. + Future clearAccessToken({String? resource, String? organizationId}) { + return _tokenStorage.deleteAccessToken( + resource: resource, organizationId: organizationId); + } + + // Sign in using the PKCE flow. + Future signIn( + String redirectUri, { + logto_core.InteractionMode? interactionMode, + String? loginHint, + String? directSignIn, + FirstScreen? firstScreen, + List? identifiers, + Map? extraParams, + }) async { + if (_loading) { + throw LogtoAuthException( + LogtoAuthExceptions.isLoadingError, 'Already signing in...'); + } + + final httpClient = _httpClient ?? http.Client(); + + try { + _loading = true; + _pkce = PKCE.generate(); + _state = utils.generateRandomString(); + _tokenStorage.setIdToken(null); + final oidcConfig = await _getOidcConfig(httpClient); + + final signInUri = logto_core.generateSignInUri( + authorizationEndpoint: oidcConfig.authorizationEndpoint, + clientId: config.appId, + redirectUri: redirectUri, + codeChallenge: _pkce.codeChallenge, + state: _state, + resources: config.resources, + scopes: config.scopes, + interactionMode: interactionMode, + loginHint: loginHint, + firstScreen: firstScreen, + directSignIn: directSignIn, + identifiers: identifiers, + extraParams: extraParams, + ); + + final redirectUriScheme = Uri.parse(redirectUri).scheme; + + final String callbackUri = await FlutterWebAuth2.authenticate( + url: signInUri.toString(), + callbackUrlScheme: redirectUriScheme, + options: const FlutterWebAuth2Options( + /// Prefer ephemeral web views for the sign-in flow. Only has an effect on Android. + intentFlags: ephemeralIntentFlags, + + /// Prefer ephemeral web views for the sign-in flow. Only has an effect on iOS. + preferEphemeral: true, + ), + ); + + await _handleSignInCallback(callbackUri, redirectUri, httpClient); + } finally { + _loading = false; + if (_httpClient == null) httpClient.close(); + } + } + + // Handle the sign-in callback and complete the token exchange process. + Future _handleSignInCallback( + String callbackUri, String redirectUri, http.Client httpClient) async { + final code = logto_core.verifyAndParseCodeFromCallbackUri( + callbackUri, + redirectUri, + _state, + ); + + final oidcConfig = await _getOidcConfig(httpClient); + + final tokenResponse = await logto_core.fetchTokenByAuthorizationCode( + httpClient: httpClient, + tokenEndPoint: oidcConfig.tokenEndpoint, + code: code, + codeVerifier: _pkce.codeVerifier, + clientId: config.appId, + redirectUri: redirectUri, + ); + + final idToken = IdToken.unverified(tokenResponse.idToken); + + await _verifyIdToken(idToken, oidcConfig); + + await _tokenStorage.save( + idToken: idToken, + accessToken: tokenResponse.accessToken, + refreshToken: tokenResponse.refreshToken, + expiresIn: tokenResponse.expiresIn, + scopes: tokenResponse.scope.split(' ')); + } + + // Sign out the user. + Future signOut(String redirectUri) async { + // Throw error is authentication status not found + final idToken = await _tokenStorage.idToken; + + final httpClient = _httpClient ?? http.Client(); + + if (idToken == null) { + throw LogtoAuthException( + LogtoAuthExceptions.authenticationError, 'not authenticated'); + } + + try { + final oidcConfig = await _getOidcConfig(httpClient); + + // Revoke refresh token if exist + final refreshToken = await _tokenStorage.refreshToken; + + if (refreshToken != null) { + try { + await logto_core.revoke( + httpClient: httpClient, + revocationEndpoint: oidcConfig.revocationEndpoint, + clientId: config.appId, + token: refreshToken, + ); + } catch (e) { + // Do Nothing silently revoke the token + } + } + + await _tokenStorage.clear(); + + // Redirect to the end session endpoint it the platform is not iOS + // iOS uses the preferEphemeral flag on the sign-in flow, it will not preserve the session. + // For Android and Web, we need to redirect to the end session endpoint to clear the session manually. + if (kIsWeb || !Platform.isIOS) { + final signOutUri = logto_core.generateSignOutUri( + endSessionEndpoint: oidcConfig.endSessionEndpoint, + clientId: config.appId, + postLogoutRedirectUri: Uri.parse(redirectUri)); + final redirectUriScheme = Uri.parse(redirectUri).scheme; + + // Execute the sign-out flow asynchronously, this should not block the main app to render the UI. + await FlutterWebAuth2.authenticate( + url: signOutUri.toString(), + callbackUrlScheme: redirectUriScheme, + options: const FlutterWebAuth2Options( + intentFlags: ephemeralIntentFlags)); + } + } finally { + if (_httpClient == null) { + httpClient.close(); + } + } + } + + // Fetch user info from the user info endpoint. + Future getUserInfo() async { + final httpClient = _httpClient ?? http.Client(); + + try { + final oidcConfig = await _getOidcConfig(httpClient); + + final accessToken = await getAccessToken(); + + if (accessToken == null) { + throw LogtoAuthException( + LogtoAuthExceptions.authenticationError, 'not authenticated'); + } + + final userInfoResponse = await logto_core.fetchUserInfo( + httpClient: httpClient, + userInfoEndpoint: oidcConfig.userInfoEndpoint, + accessToken: accessToken.token, + ); + + return userInfoResponse; + } finally { + if (_httpClient == null) httpClient.close(); + } + } +} \ No newline at end of file diff --git a/lib/logto_client_custom.dart b/lib/logto_client_custom.dart index bd1880d..47d8125 100644 --- a/lib/logto_client_custom.dart +++ b/lib/logto_client_custom.dart @@ -211,6 +211,12 @@ class LogtoClientCustom { } } + // Clear the access token by resource indicator or organizationId. + Future clearAccessToken({String? resource, String? organizationId}) { + return _tokenStorage.deleteAccessToken( + resource: resource, organizationId: organizationId); + } + // Sign in using the PKCE flow. Future signIn( String redirectUri, {