From 2bdd66f1c3814ac7c53d544963ad2ddce93112dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ga=C3=A1l?= Date: Wed, 22 Oct 2025 16:16:46 +0200 Subject: [PATCH 1/4] feat: entry url handling added --- strivacity_flutter/README.md | 6 ++ strivacity_flutter/example/ios/Podfile.lock | 11 +-- strivacity_flutter/example/lib/main.dart | 6 +- .../example/lib/pages/entry.dart | 86 +++++++++++++++++++ .../example/lib/pages/login.dart | 5 ++ .../example/lib/view_factory.dart | 6 ++ .../example/lib/widgets/close_widget.dart | 66 ++++++++++++++ .../example/lib/widgets/loading_screen.dart | 12 --- strivacity_flutter/example/macos/Podfile | 2 +- strivacity_flutter/example/macos/Podfile.lock | 8 +- .../macos/Runner.xcodeproj/project.pbxproj | 6 +- .../xcshareddata/xcschemes/Runner.xcscheme | 1 + strivacity_flutter/example/pubspec.lock | 26 +++--- .../lib/src/login_renderer.dart | 17 +++- strivacity_flutter/lib/src/sdk.dart | 69 +++++++++++++++ strivacity_flutter/lib/src/view_factory.dart | 3 + .../lib/strivacity_flutter.dart | 1 + .../method_channel_strivacity_flutter.dart | 5 ++ .../lib/src/models.dart | 57 ++++++++++++ .../lib/src/strivacity_flutter_platform.dart | 5 ++ 20 files changed, 356 insertions(+), 42 deletions(-) create mode 100644 strivacity_flutter/example/lib/pages/entry.dart create mode 100644 strivacity_flutter/example/lib/widgets/close_widget.dart delete mode 100644 strivacity_flutter/example/lib/widgets/loading_screen.dart diff --git a/strivacity_flutter/README.md b/strivacity_flutter/README.md index 51ff00d..70d2572 100644 --- a/strivacity_flutter/README.md +++ b/strivacity_flutter/README.md @@ -74,6 +74,7 @@ import 'widgets/phone_widget.dart'; import 'widgets/select_widget.dart'; import 'widgets/static_widget.dart'; import 'widgets/submit_widget.dart'; +import 'widgets/close_widget.dart'; import 'widgets/container_widget.dart'; import 'widgets/loading_widget.dart'; @@ -138,6 +139,11 @@ class CustomViewFactory implements ViewFactory { return SubmitWidget(key: key, formId: formId, config: config, loginContext: loginContext); } + @override + Widget getCloseWidget({required Key key, required String formId, required CloseWidgetModel config, required LoginContext loginContext}) { + return CloseWidget(key: key, formId: formId, config: config, loginContext: loginContext); + } + @override Widget getStaticWidget({required Key key, required StaticWidgetModel config}) { return StaticWidget(key: key, config: config); diff --git a/strivacity_flutter/example/ios/Podfile.lock b/strivacity_flutter/example/ios/Podfile.lock index cb068ec..9a2003d 100644 --- a/strivacity_flutter/example/ios/Podfile.lock +++ b/strivacity_flutter/example/ios/Podfile.lock @@ -8,11 +8,9 @@ PODS: - Flutter - fluttertoast (0.0.2): - Flutter - - Toast - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - Toast (4.1.1) - url_launcher_ios (0.0.1): - Flutter - webview_flutter_wkwebview (0.0.1): @@ -29,10 +27,6 @@ DEPENDENCIES: - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`) -SPEC REPOS: - trunk: - - Toast - EXTERNAL SOURCES: app_links: :path: ".symlinks/plugins/app_links/ios" @@ -52,13 +46,12 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" SPEC CHECKSUMS: - app_links: 3da4c36b46cac3bf24eb897f1a6ce80bda109874 + app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_custom_tabs_ios: dd647919edd75e82ba6b00009eb3460a28c011b8 flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 - fluttertoast: 76fea30fcf04176325f6864c87306927bd7d2038 + fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - Toast: 1f5ea13423a1e6674c4abdac5be53587ae481c4e url_launcher_ios: 694010445543906933d732453a59da0a173ae33d webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c diff --git a/strivacity_flutter/example/lib/main.dart b/strivacity_flutter/example/lib/main.dart index b7610b7..d3a14a2 100644 --- a/strivacity_flutter/example/lib/main.dart +++ b/strivacity_flutter/example/lib/main.dart @@ -10,6 +10,7 @@ import 'package:strivacity_flutter/strivacity_flutter.dart'; import 'styles.dart'; import 'storage.dart'; import 'pages/init.dart'; +import 'pages/entry.dart'; import 'pages/home.dart'; import 'pages/profile.dart'; import 'pages/login.dart'; @@ -66,7 +67,9 @@ class _MyAppState extends State { _nav.currentState!.popUntil((r) => r.isFirst); if (uri.queryParameters['session_id'] != null) { - _nav.currentState!.pushReplacementNamed('/login', arguments: {'session_id': uri.queryParameters['session_id']}); + _nav.currentState!.pushReplacementNamed('/login', arguments: uri.queryParameters); + } else if (uri.queryParameters['challenge'] != null) { + _nav.currentState!.pushReplacementNamed('/entry', arguments: uri.queryParameters); } else { try { await sdk.tokenExchange(uri.queryParameters); @@ -114,6 +117,7 @@ class _MyAppState extends State { routes: { '/init': (context) => InitPage(sdk: sdk), '/home': (context) => HomePage(), + '/entry': (context) => EntryPage(sdk: sdk), '/profile': (context) => ProfilePage(sdk: sdk), '/login': (context) => LoginPage(sdk: sdk), '/login-fallback': (context) => LoginFallbackPage(sdk: sdk), diff --git a/strivacity_flutter/example/lib/pages/entry.dart b/strivacity_flutter/example/lib/pages/entry.dart new file mode 100644 index 0000000..3839f2d --- /dev/null +++ b/strivacity_flutter/example/lib/pages/entry.dart @@ -0,0 +1,86 @@ +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:strivacity_flutter/strivacity_flutter.dart'; + +class EntryPage extends StatefulWidget { + final StrivacitySDK sdk; + + const EntryPage({super.key, required this.sdk}); + + @override + State createState() => _EntryPageState(); +} + +class _EntryPageState extends State { + final Map _params = {}; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _extractArguments(); + _handleEntry(); + }); + } + + void _extractArguments() { + final arguments = ModalRoute.of(context)?.settings.arguments as Map?; + + if (arguments != null) { + for (final entry in arguments.entries) { + if (entry.value != null) { + _params[entry.key] = entry.value.toString(); + } + } + } + } + + Future _handleEntry() async { + try { + final sessionId = await widget.sdk.entry(_params); + + if (sessionId.isNotEmpty) { + if (mounted) { + Navigator.of(context).pushReplacementNamed('/login', arguments: {'session_id': sessionId}); + } + } else { + if (mounted) { + Navigator.of(context).pushReplacementNamed('/'); + } + } + } catch (error) { + if (mounted) { + _showAlert(error.toString()); + Navigator.of(context).pushReplacementNamed('/'); + } + } + } + + void _showAlert(String message) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text('Error'), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text('OK'), + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: CircularProgressIndicator(), + ), + ); + } +} diff --git a/strivacity_flutter/example/lib/pages/login.dart b/strivacity_flutter/example/lib/pages/login.dart index 25adf86..f12f9aa 100644 --- a/strivacity_flutter/example/lib/pages/login.dart +++ b/strivacity_flutter/example/lib/pages/login.dart @@ -67,6 +67,10 @@ class _LoginPageState extends State { Navigator.of(context).pushReplacementNamed('/login-fallback', arguments: {'url': uri.toString()}); } + void _onClose(BuildContext context) { + Navigator.of(context).pushReplacementNamed('/home'); + } + _onGlobalMessage(String msg) { Fluttertoast.showToast( msg: msg, @@ -95,6 +99,7 @@ class _LoginPageState extends State { onLogin: (_) => _onLogin(context), onError: (e, stackTrace) => _onError(context, e, stackTrace), onFallback: (uri, errorMessage) => _onFallback(context, uri, errorMessage), + onClose: () => _onClose(context), onGlobalMessage: _onGlobalMessage, ), ), diff --git a/strivacity_flutter/example/lib/view_factory.dart b/strivacity_flutter/example/lib/view_factory.dart index a915ad4..e5418af 100644 --- a/strivacity_flutter/example/lib/view_factory.dart +++ b/strivacity_flutter/example/lib/view_factory.dart @@ -12,6 +12,7 @@ import 'widgets/phone_widget.dart'; import 'widgets/select_widget.dart'; import 'widgets/static_widget.dart'; import 'widgets/submit_widget.dart'; +import 'widgets/close_widget.dart'; import 'widgets/container_widget.dart'; import 'widgets/loading_widget.dart'; @@ -76,6 +77,11 @@ class CustomViewFactory implements ViewFactory { return SubmitWidget(key: key, formId: formId, config: config, loginContext: loginContext); } + @override + Widget getCloseWidget({required Key key, required String formId, required CloseWidgetModel config, required LoginContext loginContext}) { + return CloseWidget(key: key, formId: formId, config: config, loginContext: loginContext); + } + @override Widget getStaticWidget({required Key key, required StaticWidgetModel config}) { return StaticWidget(key: key, config: config); diff --git a/strivacity_flutter/example/lib/widgets/close_widget.dart b/strivacity_flutter/example/lib/widgets/close_widget.dart new file mode 100644 index 0000000..9ee169b --- /dev/null +++ b/strivacity_flutter/example/lib/widgets/close_widget.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:strivacity_flutter/strivacity_flutter.dart'; + +import '../styles.dart'; + +class CloseWidget extends StatelessWidget { + final CloseWidgetModel config; + final String formId; + final LoginContext loginContext; + + const CloseWidget({ + super.key, + required this.formId, + required this.loginContext, + required this.config, + }); + + bool get loading { + return loginContext.loading; + } + + void onPressed() { + loginContext.triggerClose(); + } + + @override + Widget build(BuildContext context) { + if (config.render.type == 'button') { + return Container( + width: double.infinity, + margin: EdgeInsets.only(top: Styles.marginLarge, bottom: Styles.marginMedium), + child: ElevatedButton( + onPressed: loading ? null : () => onPressed(), + style: ElevatedButton.styleFrom( + backgroundColor: config.render.bgColor ?? (config.render.hint?.variant == 'primary' ? Styles.primaryColor : Styles.whiteColor), + foregroundColor: config.render.textColor ?? (config.render.hint?.variant == 'primary' ? Styles.whiteColor : Styles.primaryColor), + alignment: Alignment.center, + padding: EdgeInsets.symmetric(vertical: Styles.paddingMedium, horizontal: 0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(Styles.borderRadiusMedium)), + ), + child: Text( + config.label ?? '', + style: TextStyle( + fontSize: Styles.textSizeMedium, + fontWeight: FontWeight.bold, + ), + ), + )); + } else { + return GestureDetector( + onTap: loading ? null : () => onPressed(), + child: Container( + padding: EdgeInsets.symmetric(vertical: Styles.marginSmall, horizontal: Styles.marginSmall), + color: Styles.transparentColor, + child: Text( + config.label ?? '', + style: TextStyle( + color: Styles.primaryColor, + fontSize: Styles.textSizeMedium, + ), + ), + ), + ); + } + } +} diff --git a/strivacity_flutter/example/lib/widgets/loading_screen.dart b/strivacity_flutter/example/lib/widgets/loading_screen.dart deleted file mode 100644 index e77543c..0000000 --- a/strivacity_flutter/example/lib/widgets/loading_screen.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:flutter/material.dart'; - -class LoadingScreen extends StatelessWidget { - const LoadingScreen({super.key}); - - @override - Widget build(BuildContext context) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); - } -} diff --git a/strivacity_flutter/example/macos/Podfile b/strivacity_flutter/example/macos/Podfile index c795730..b52666a 100644 --- a/strivacity_flutter/example/macos/Podfile +++ b/strivacity_flutter/example/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '10.15' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/strivacity_flutter/example/macos/Podfile.lock b/strivacity_flutter/example/macos/Podfile.lock index d4330bd..99eb5ea 100644 --- a/strivacity_flutter/example/macos/Podfile.lock +++ b/strivacity_flutter/example/macos/Podfile.lock @@ -1,6 +1,8 @@ PODS: - app_links (1.0.0): - FlutterMacOS + - flutter_secure_storage_macos (6.1.3): + - FlutterMacOS - FlutterMacOS (1.0.0) - path_provider_foundation (0.0.1): - Flutter @@ -13,6 +15,7 @@ PODS: DEPENDENCIES: - app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`) + - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) @@ -21,6 +24,8 @@ DEPENDENCIES: EXTERNAL SOURCES: app_links: :path: Flutter/ephemeral/.symlinks/plugins/app_links/macos + flutter_secure_storage_macos: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos FlutterMacOS: :path: Flutter/ephemeral path_provider_foundation: @@ -31,7 +36,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin SPEC CHECKSUMS: - app_links: 9028728e32c83a0831d9db8cf91c526d16cc5468 + app_links: afe860c55c7ef176cea7fb630a2b7d7736de591d + flutter_secure_storage_macos: 7f45e30f838cf2659862a4e4e3ee1c347c2b3b54 FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 diff --git a/strivacity_flutter/example/macos/Runner.xcodeproj/project.pbxproj b/strivacity_flutter/example/macos/Runner.xcodeproj/project.pbxproj index 8f04f2c..5e024db 100644 --- a/strivacity_flutter/example/macos/Runner.xcodeproj/project.pbxproj +++ b/strivacity_flutter/example/macos/Runner.xcodeproj/project.pbxproj @@ -557,7 +557,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -639,7 +639,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -689,7 +689,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/strivacity_flutter/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/strivacity_flutter/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 15368ec..ac78810 100644 --- a/strivacity_flutter/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/strivacity_flutter/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -59,6 +59,7 @@ ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> diff --git a/strivacity_flutter/example/pubspec.lock b/strivacity_flutter/example/pubspec.lock index 825e2c7..eaa7711 100644 --- a/strivacity_flutter/example/pubspec.lock +++ b/strivacity_flutter/example/pubspec.lock @@ -133,10 +133,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.3.3" ffi: dependency: transitive description: @@ -316,26 +316,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.8" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -511,10 +511,10 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.6" typed_data: dependency: transitive description: @@ -599,10 +599,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: @@ -668,5 +668,5 @@ packages: source: hosted version: "1.1.0" sdks: - dart: ">=3.7.0 <4.0.0" + dart: ">=3.8.0-0 <4.0.0" flutter: ">=3.29.0" diff --git a/strivacity_flutter/lib/src/login_renderer.dart b/strivacity_flutter/lib/src/login_renderer.dart index 70f4270..cd39e63 100644 --- a/strivacity_flutter/lib/src/login_renderer.dart +++ b/strivacity_flutter/lib/src/login_renderer.dart @@ -25,10 +25,13 @@ class LoginContext { /// A function to trigger the fallback process with an optional hosted URL. void Function([String? hostedUrl]) triggerFallback; + /// A function to trigger the closing of the login flow. + void Function() triggerClose; + /// Creates a new instance of [LoginContext]. /// /// The [submitForm] and [triggerFallback] parameters are required. - LoginContext({required this.submitForm, required this.triggerFallback}); + LoginContext({required this.submitForm, required this.triggerFallback, required this.triggerClose}); /// Sets the form state for the given [formId] and [widgetId] with the provided [value]. void setFormState(String formId, String widgetId, dynamic value) { @@ -68,6 +71,9 @@ class LoginRenderer extends StatefulWidget { /// Callback function to be called when a fallback is triggered. final FutureOr Function(Uri uri, String? errorMessage)? onFallback; + /// Callback function to be called when the login flow is closed. + final FutureOr Function()? onClose; + /// Callback function to be called for global messages. final FutureOr Function(String text)? onGlobalMessage; @@ -81,6 +87,7 @@ class LoginRenderer extends StatefulWidget { this.onLogin, this.onError, this.onFallback, + this.onClose, this.onGlobalMessage, required this.sdk, required this.viewFactory, @@ -100,7 +107,7 @@ class _LoginRendererState extends State { super.initState(); _loginHandler = widget.sdk.login(widget.params); - _loginContext = LoginContext(submitForm: _submitForm, triggerFallback: _triggerFallback); + _loginContext = LoginContext(submitForm: _submitForm, triggerFallback: _triggerFallback, triggerClose: _triggerClose); _init(); } @@ -201,6 +208,10 @@ class _LoginRendererState extends State { widget.onFallback?.call(Uri.parse(hostedUrl), message); } + void _triggerClose() { + widget.onClose?.call(); + } + List _renderComponents(List items) { return items.map((item) { if (item['type'] == 'widget') { @@ -242,6 +253,8 @@ class _LoginRendererState extends State { return widget.viewFactory.getSelectWidget(key: Key('${f.id}|${w.id}'), formId: f.id, loginContext: _loginContext, config: w as SelectWidgetModel); case 'submit': return widget.viewFactory.getSubmitWidget(key: Key('${f.id}|${w.id}'), formId: f.id, loginContext: _loginContext, config: w as SubmitWidgetModel); + case 'close': + return widget.viewFactory.getCloseWidget(key: Key('${f.id}|${w.id}'), formId: f.id, loginContext: _loginContext, config: w as CloseWidgetModel); case 'static': return widget.viewFactory.getStaticWidget(key: Key('${f.id}|${w.id}'), config: w as StaticWidgetModel); default: diff --git a/strivacity_flutter/lib/src/sdk.dart b/strivacity_flutter/lib/src/sdk.dart index ca2ea08..31f1859 100644 --- a/strivacity_flutter/lib/src/sdk.dart +++ b/strivacity_flutter/lib/src/sdk.dart @@ -177,6 +177,75 @@ class StrivacitySDK extends StrivacityFlutterPlatform { } } + /// Handles the entry process. + @override + Future entry([Map params = const {}]) async { + final entryUri = Uri.parse('${tenantConfiguration.issuer}/provider/flow/entry'); + + final queryParams = { + 'client_id': tenantConfiguration.clientId, + 'redirect_uri': tenantConfiguration.redirectUri.toString(), + ...params, + }; + + final finalUri = entryUri.replace(queryParameters: queryParams); + + try { + final response = await _httpClient.followUntil(finalUri.toString(), (HttpResponse httpResponse) { + if (!httpResponse.headers.containsKey('location')) { + return true; + } + + final redirectUri = Uri.parse(httpResponse.headers['location']!.first); + return tenantConfiguration.redirectUri.host == redirectUri.host || + (tenantConfiguration.issuer.host == redirectUri.host && redirectUri.path == '/oauth2/error'); + }); + + if (response.responseCode == 400) { + String message = 'Entry request failed with status 400'; + + try { + final data = jsonDecode(response.body); + if (data is Map) { + if (data['error'] != null) { + message = '${data['error']}: ${data['error_description'] ?? ''}'; + } else if (data['errorKey'] != null) { + message = data['errorKey']; + } + } + } catch (e) { + // If JSON parsing fails, use default message + } + + throw OIDCError('Entry Error', message); + } + + if (response.headers['location'] == null) { + throw OIDCError('OIDC Error', response.body); + } + + final redirectUri = Uri.parse(response.headers['location']!.first); + + if (!redirectUri.toString().startsWith(tenantConfiguration.redirectUri.toString())) { + throw OIDCError('OIDC Error', 'Invalid redirect URI'); + } + + final sessionId = redirectUri.queryParameters['session_id']; + + if (sessionId == null || sessionId.isEmpty) { + throw Exception('Session ID not found in entry response'); + } + + return sessionId; + } catch (e) { + if (e is OIDCError) { + rethrow; + } + + throw OIDCError('Entry Error', e.toString()); + } + } + /// Exchanges the authorization code for tokens. @override Future tokenExchange([Map params = const {}]) async { diff --git a/strivacity_flutter/lib/src/view_factory.dart b/strivacity_flutter/lib/src/view_factory.dart index 0201172..3bfb404 100644 --- a/strivacity_flutter/lib/src/view_factory.dart +++ b/strivacity_flutter/lib/src/view_factory.dart @@ -40,4 +40,7 @@ abstract class ViewFactory { /// Returns a submit widget with the specified parameters. Widget getSubmitWidget({required Key key, required String formId, required SubmitWidgetModel config, required LoginContext loginContext}); + + /// Returns a close widget with the specified parameters. + Widget getCloseWidget({required Key key, required String formId, required CloseWidgetModel config, required LoginContext loginContext}); } diff --git a/strivacity_flutter/lib/strivacity_flutter.dart b/strivacity_flutter/lib/strivacity_flutter.dart index 8f385d9..2b306d4 100644 --- a/strivacity_flutter/lib/strivacity_flutter.dart +++ b/strivacity_flutter/lib/strivacity_flutter.dart @@ -27,6 +27,7 @@ export 'package:strivacity_flutter_platform_interface/strivacity_flutter_platfor MultiSelectWidgetModel, StaticWidgetModel, SubmitWidgetModel, + CloseWidgetModel, BaseWidgetValidator, DateWidgetValidator, InputWidgetValidator, diff --git a/strivacity_flutter_platform_interface/lib/src/method_channel_strivacity_flutter.dart b/strivacity_flutter_platform_interface/lib/src/method_channel_strivacity_flutter.dart index 48b9115..73ec5fc 100644 --- a/strivacity_flutter_platform_interface/lib/src/method_channel_strivacity_flutter.dart +++ b/strivacity_flutter_platform_interface/lib/src/method_channel_strivacity_flutter.dart @@ -31,6 +31,11 @@ class MethodChannelStrivacityFlutter extends StrivacityFlutterPlatform { await _channel.invokeMethod('revoke'); } + @override + Future entry([Map params = const {}]) async { + await _channel.invokeMethod('entry'); + } + @override Future tokenExchange([Map params = const {}]) async { await _channel.invokeMethod('exchangeToken', [params]); diff --git a/strivacity_flutter_platform_interface/lib/src/models.dart b/strivacity_flutter_platform_interface/lib/src/models.dart index 7689c29..ce7a2d2 100644 --- a/strivacity_flutter_platform_interface/lib/src/models.dart +++ b/strivacity_flutter_platform_interface/lib/src/models.dart @@ -373,6 +373,25 @@ class SubmitWidgetModel extends BaseWidgetModel { } } +class CloseWidgetModel extends BaseWidgetModel { + final String? label; + final CloseWidgetRender render; + + CloseWidgetModel({ + required super.id, + required this.label, + required this.render, + }) : super(type: 'close'); + + factory CloseWidgetModel.fromJson(Map json) { + return CloseWidgetModel( + id: json['id'], + label: json['label'], + render: CloseWidgetRender.fromJson(json['render']), + ); + } +} + class FormWidgetModel extends BaseWidgetModel { final List widgets; @@ -410,6 +429,8 @@ class FormWidgetModel extends BaseWidgetModel { return StaticWidgetModel.fromJson(json); case 'submit': return SubmitWidgetModel.fromJson(json); + case 'close': + return CloseWidgetModel.fromJson(json); default: throw Exception('Unknown widget type: ${json['type']}'); } @@ -462,6 +483,42 @@ class SubmitWidgetHint { } } +class CloseWidgetRender { + final String type; + final Color? textColor; + final Color? bgColor; + final CloseWidgetHint? hint; + + CloseWidgetRender({required this.type, required this.textColor, required this.bgColor, required this.hint}); + + factory CloseWidgetRender.fromJson(Map json) { + return CloseWidgetRender( + type: json['type'], + textColor: json['textColor'] != null ? Color(_hexToColor(json['textColor'])) : null, + bgColor: json['bgColor'] != null ? Color(_hexToColor(json['bgColor'])) : null, + hint: json['hint'] != null ? CloseWidgetHint.fromJson(json['hint']) : null); + } + + static int _hexToColor(String hex) { + hex = hex.replaceFirst('#', ''); + if (hex.length == 6) { + hex = 'FF$hex'; + } + return int.parse(hex, radix: 16); + } +} + +class CloseWidgetHint { + final String? icon; + final String? variant; + + CloseWidgetHint({required this.icon, required this.variant}); + + factory CloseWidgetHint.fromJson(Map json) { + return CloseWidgetHint(icon: json['icon'], variant: json['variant']); + } +} + class CheckboxWidgetRender { final String type; final String labelType; diff --git a/strivacity_flutter_platform_interface/lib/src/strivacity_flutter_platform.dart b/strivacity_flutter_platform_interface/lib/src/strivacity_flutter_platform.dart index 3711937..564e72d 100644 --- a/strivacity_flutter_platform_interface/lib/src/strivacity_flutter_platform.dart +++ b/strivacity_flutter_platform_interface/lib/src/strivacity_flutter_platform.dart @@ -45,6 +45,11 @@ abstract class StrivacityFlutterPlatform extends PlatformInterface { throw UnimplementedError('revoke() has not been implemented'); } + /// Handles the entry process. + Future entry([Map params = const {}]) { + throw UnimplementedError('entry() has not been implemented'); + } + /// Exchanges a code token for an access token. Future tokenExchange([Map params = const {}]) { throw UnimplementedError('exchangeToken() has not been implemented'); From 0103408c50a317b6aa19e1679f118fb679ff5230 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ga=C3=A1l?= Date: Fri, 14 Nov 2025 12:14:34 +0100 Subject: [PATCH 2/4] fix: example app widget default value placement fixed --- .../example/lib/widgets/date_widget.dart | 15 ++- .../example/lib/widgets/input_widget.dart | 1 + .../example/lib/widgets/phone_widget.dart | 3 +- .../example/lib/widgets/select_widget.dart | 100 +++++++++--------- 4 files changed, 60 insertions(+), 59 deletions(-) diff --git a/strivacity_flutter/example/lib/widgets/date_widget.dart b/strivacity_flutter/example/lib/widgets/date_widget.dart index e8c15f4..be98e59 100644 --- a/strivacity_flutter/example/lib/widgets/date_widget.dart +++ b/strivacity_flutter/example/lib/widgets/date_widget.dart @@ -47,6 +47,7 @@ class _DateWidgetState extends State { if (widget.config.value != null) { controller.text = widget.config.value!; + widget.loginContext.setFormState(widget.formId, widget.config.id, widget.config.value); } focusNode.addListener(() { @@ -149,8 +150,7 @@ class _DateWidgetState extends State { lastDate: DateTime(2100), ); if (pickedDate != null) { - String formattedDate = - pickedDate.toLocal().toString().split(' ')[0]; + String formattedDate = pickedDate.toLocal().toString().split(' ')[0]; controller.text = formattedDate; onChanged(formattedDate); } @@ -160,7 +160,7 @@ class _DateWidgetState extends State { controller: controller, focusNode: focusNode, enabled: !disabled, - onChanged: (value) => onChanged(value), + onChanged: (value) => onChanged(value), onFieldSubmitted: (value) => onSubmitted(value), style: Styles.setInputTextStyle(), decoration: Styles.setInputDecoration( @@ -175,19 +175,17 @@ class _DateWidgetState extends State { ), ), ), - if (controller.text.isNotEmpty) Positioned( - right: 40, + right: 40, child: GestureDetector( onTap: () { controller.clear(); - onChanged(""); + onChanged(""); }, child: Icon(Icons.clear, color: Colors.grey), ), ), - Positioned( right: 8, child: GestureDetector( @@ -200,8 +198,7 @@ class _DateWidgetState extends State { lastDate: DateTime(2100), ); if (pickedDate != null) { - String formattedDate = - pickedDate.toLocal().toString().split(' ')[0]; + String formattedDate = pickedDate.toLocal().toString().split(' ')[0]; controller.text = formattedDate; onChanged(formattedDate); } diff --git a/strivacity_flutter/example/lib/widgets/input_widget.dart b/strivacity_flutter/example/lib/widgets/input_widget.dart index af33435..23a591a 100644 --- a/strivacity_flutter/example/lib/widgets/input_widget.dart +++ b/strivacity_flutter/example/lib/widgets/input_widget.dart @@ -43,6 +43,7 @@ class _InputWidgetState extends State { if (widget.config.value != null) { controller.text = widget.config.value!; + widget.loginContext.setFormState(widget.formId, widget.config.id, widget.config.value); } focusNode.addListener(() { diff --git a/strivacity_flutter/example/lib/widgets/phone_widget.dart b/strivacity_flutter/example/lib/widgets/phone_widget.dart index 890ae1f..eb65999 100644 --- a/strivacity_flutter/example/lib/widgets/phone_widget.dart +++ b/strivacity_flutter/example/lib/widgets/phone_widget.dart @@ -43,6 +43,7 @@ class _PhoneWidgetState extends State { if (widget.config.value != null) { controller.text = widget.config.value!; + widget.loginContext.setFormState(widget.formId, widget.config.id, widget.config.value); } focusNode.addListener(() { @@ -52,7 +53,7 @@ class _PhoneWidgetState extends State { } }); - if(widget.config.validator!.required) { + if (widget.config.validator!.required) { modifiedLabel = widget.config.label; } else { modifiedLabel = '${widget.config.label} (Optional)'; diff --git a/strivacity_flutter/example/lib/widgets/select_widget.dart b/strivacity_flutter/example/lib/widgets/select_widget.dart index e4c989b..3382d97 100644 --- a/strivacity_flutter/example/lib/widgets/select_widget.dart +++ b/strivacity_flutter/example/lib/widgets/select_widget.dart @@ -108,55 +108,57 @@ class _SelectWidgetState extends State { style: TextStyle(fontSize: Styles.textSizeMedium, color: Styles.textColor), ), ), - ...widget.config.options.map((option) { - if (option is SelectWidgetOptionGroup) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - option.label ?? '', - style: TextStyle(fontSize: Styles.textSizeMedium, color: Styles.textColor), - ), - ...option.options.map((subOption) { - return RadioListTile( - value: subOption.value, - groupValue: widget.loginContext.formContexts[widget.formId]?[widget.config.id], - onChanged: disabled ? null : (value) => onChanged(value), - title: Text( - subOption.label ?? '', - style: TextStyle(color: Styles.textColor), - ), - contentPadding: EdgeInsets.zero, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: const VisualDensity( - horizontal: VisualDensity.minimumDensity, - vertical: VisualDensity.minimumDensity, + RadioGroup( + onChanged: disabled ? (_) {} : onChanged, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: widget.config.options.map((option) { + if (option is SelectWidgetOptionGroup) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + option.label ?? '', + style: TextStyle(fontSize: Styles.textSizeMedium, color: Styles.textColor), ), - activeColor: Styles.primaryColor, - ); - }), - ], - ); - } else if (option is SelectWidgetOption) { - return RadioListTile( - value: option.value, - groupValue: widget.loginContext.formContexts[widget.formId]?[widget.config.id], - onChanged: disabled ? null : (value) => onChanged(value), - title: Text( - option.label ?? '', - style: TextStyle(color: Styles.textColor), - ), - contentPadding: EdgeInsets.zero, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: const VisualDensity( - horizontal: VisualDensity.minimumDensity, - vertical: VisualDensity.minimumDensity, - ), - activeColor: Styles.primaryColor, - ); - } - return Container(); - }), + ...option.options.map((subOption) { + return RadioListTile( + value: subOption.value, + title: Text( + subOption.label ?? '', + style: TextStyle(color: Styles.textColor), + ), + contentPadding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: const VisualDensity( + horizontal: VisualDensity.minimumDensity, + vertical: VisualDensity.minimumDensity, + ), + activeColor: Styles.primaryColor, + ); + }), + ], + ); + } else if (option is SelectWidgetOption) { + return RadioListTile( + value: option.value, + title: Text( + option.label ?? '', + style: TextStyle(color: Styles.textColor), + ), + contentPadding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: const VisualDensity( + horizontal: VisualDensity.minimumDensity, + vertical: VisualDensity.minimumDensity, + ), + activeColor: Styles.primaryColor, + ); + } + return Container(); + }).toList(), + ), + ), ], ); } else { @@ -167,7 +169,7 @@ class _SelectWidgetState extends State { Expanded( child: DropdownButtonFormField( isExpanded: true, - value: dropdownSelectValue, + initialValue: dropdownSelectValue, decoration: Styles.setInputDecoration( InputDecoration( labelText: widget.config.label, From 0c32eda5ab6407bba665c8971f4d74d6ccffa2e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ga=C3=A1l?= Date: Fri, 7 Nov 2025 12:19:29 +0100 Subject: [PATCH 3/4] feat: webauthn support added --- strivacity_flutter/README.md | 24 +++ .../example/lib/view_factory.dart | 24 +++ .../lib/widgets/passkey_enroll_widget.dart | 75 +++++++++ .../lib/widgets/passkey_login_widget.dart | 75 +++++++++ .../lib/widgets/webauthn_enroll_widget.dart | 75 +++++++++ .../lib/widgets/webauthn_login_widget.dart | 75 +++++++++ .../lib/src/login_renderer.dart | 12 ++ strivacity_flutter/lib/src/view_factory.dart | 12 ++ .../lib/strivacity_flutter.dart | 4 + .../lib/src/models.dart | 142 ++++++++++++++++++ 10 files changed, 518 insertions(+) create mode 100644 strivacity_flutter/example/lib/widgets/passkey_enroll_widget.dart create mode 100644 strivacity_flutter/example/lib/widgets/passkey_login_widget.dart create mode 100644 strivacity_flutter/example/lib/widgets/webauthn_enroll_widget.dart create mode 100644 strivacity_flutter/example/lib/widgets/webauthn_login_widget.dart diff --git a/strivacity_flutter/README.md b/strivacity_flutter/README.md index 70d2572..d481e51 100644 --- a/strivacity_flutter/README.md +++ b/strivacity_flutter/README.md @@ -75,6 +75,10 @@ import 'widgets/select_widget.dart'; import 'widgets/static_widget.dart'; import 'widgets/submit_widget.dart'; import 'widgets/close_widget.dart'; +import 'widgets/passkey_login_widget.dart'; +import 'widgets/passkey_enroll_widget.dart'; +import 'widgets/webauthn_login_widget.dart'; +import 'widgets/webauthn_enroll_widget.dart'; import 'widgets/container_widget.dart'; import 'widgets/loading_widget.dart'; @@ -144,6 +148,26 @@ class CustomViewFactory implements ViewFactory { return CloseWidget(key: key, formId: formId, config: config, loginContext: loginContext); } + @override + Widget getPasskeyLoginWidget({required Key key, required String formId, required PasskeyLoginWidgetModel config, required LoginContext loginContext}) { + return PasskeyLoginWidget(key: key, formId: formId, config: config, loginContext: loginContext); + } + + @override + Widget getPasskeyEnrollWidget({required Key key, required String formId, required PasskeyEnrollWidgetModel config, required LoginContext loginContext}) { + return PasskeyEnrollWidget(key: key, formId: formId, config: config, loginContext: loginContext); + } + + @override + Widget getWebauthnLoginWidget({required Key key, required String formId, required WebauthnLoginWidgetModel config, required LoginContext loginContext}) { + return WebauthnLoginWidget(key: key, formId: formId, config: config, loginContext: loginContext); + } + + @override + Widget getWebauthnEnrollWidget({required Key key, required String formId, required WebauthnEnrollWidgetModel config, required LoginContext loginContext}) { + return WebauthnEnrollWidget(key: key, formId: formId, config: config, loginContext: loginContext); + } + @override Widget getStaticWidget({required Key key, required StaticWidgetModel config}) { return StaticWidget(key: key, config: config); diff --git a/strivacity_flutter/example/lib/view_factory.dart b/strivacity_flutter/example/lib/view_factory.dart index e5418af..9df8495 100644 --- a/strivacity_flutter/example/lib/view_factory.dart +++ b/strivacity_flutter/example/lib/view_factory.dart @@ -13,6 +13,10 @@ import 'widgets/select_widget.dart'; import 'widgets/static_widget.dart'; import 'widgets/submit_widget.dart'; import 'widgets/close_widget.dart'; +import 'widgets/passkey_login_widget.dart'; +import 'widgets/passkey_enroll_widget.dart'; +import 'widgets/webauthn_login_widget.dart'; +import 'widgets/webauthn_enroll_widget.dart'; import 'widgets/container_widget.dart'; import 'widgets/loading_widget.dart'; @@ -82,6 +86,26 @@ class CustomViewFactory implements ViewFactory { return CloseWidget(key: key, formId: formId, config: config, loginContext: loginContext); } + @override + Widget getPasskeyLoginWidget({required Key key, required String formId, required PasskeyLoginWidgetModel config, required LoginContext loginContext}) { + return PasskeyLoginWidget(key: key, formId: formId, config: config, loginContext: loginContext); + } + + @override + Widget getPasskeyEnrollWidget({required Key key, required String formId, required PasskeyEnrollWidgetModel config, required LoginContext loginContext}) { + return PasskeyEnrollWidget(key: key, formId: formId, config: config, loginContext: loginContext); + } + + @override + Widget getWebauthnLoginWidget({required Key key, required String formId, required WebauthnLoginWidgetModel config, required LoginContext loginContext}) { + return WebauthnLoginWidget(key: key, formId: formId, config: config, loginContext: loginContext); + } + + @override + Widget getWebauthnEnrollWidget({required Key key, required String formId, required WebauthnEnrollWidgetModel config, required LoginContext loginContext}) { + return WebauthnEnrollWidget(key: key, formId: formId, config: config, loginContext: loginContext); + } + @override Widget getStaticWidget({required Key key, required StaticWidgetModel config}) { return StaticWidget(key: key, config: config); diff --git a/strivacity_flutter/example/lib/widgets/passkey_enroll_widget.dart b/strivacity_flutter/example/lib/widgets/passkey_enroll_widget.dart new file mode 100644 index 0000000..3b5ef96 --- /dev/null +++ b/strivacity_flutter/example/lib/widgets/passkey_enroll_widget.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:strivacity_flutter/strivacity_flutter.dart'; + +import '../styles.dart'; + +class PasskeyEnrollWidget extends StatelessWidget { + final PasskeyEnrollWidgetModel config; + final String formId; + final LoginContext loginContext; + + const PasskeyEnrollWidget({ + super.key, + required this.formId, + required this.loginContext, + required this.config, + }); + + bool get loading { + return loginContext.loading; + } + + Future onPressed() async { + try { + final result = await PasskeyService.enroll(config.enrollOptions); + + loginContext.setFormState(formId, config.id, result); + await loginContext.submitForm(formId); + } on PasskeyException catch (e) { + loginContext.setMessage('global', 'passkey', e.message); + } catch (e) { + loginContext.setMessage('global', 'passkey', 'Authentication failed: $e'); + } + } + + @override + Widget build(BuildContext context) { + if (config.render.type == 'button') { + return Container( + width: double.infinity, + margin: EdgeInsets.only(top: Styles.marginLarge, bottom: Styles.marginMedium), + child: ElevatedButton( + onPressed: loading ? null : () => onPressed(), + style: ElevatedButton.styleFrom( + backgroundColor: config.render.hint?.variant == 'primary' ? Styles.primaryColor : Styles.whiteColor, + foregroundColor: config.render.hint?.variant == 'primary' ? Styles.whiteColor : Styles.primaryColor, + alignment: Alignment.center, + padding: EdgeInsets.symmetric(vertical: Styles.paddingMedium, horizontal: 0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(Styles.borderRadiusMedium)), + ), + child: Text( + config.label ?? '', + style: TextStyle( + fontSize: Styles.textSizeMedium, + fontWeight: FontWeight.bold, + ), + ), + )); + } else { + return GestureDetector( + onTap: loading ? null : () => onPressed(), + child: Container( + padding: EdgeInsets.symmetric(vertical: Styles.marginSmall, horizontal: Styles.marginSmall), + color: Styles.transparentColor, + child: Text( + config.label ?? '', + style: TextStyle( + color: Styles.primaryColor, + fontSize: Styles.textSizeMedium, + ), + ), + ), + ); + } + } +} diff --git a/strivacity_flutter/example/lib/widgets/passkey_login_widget.dart b/strivacity_flutter/example/lib/widgets/passkey_login_widget.dart new file mode 100644 index 0000000..36911b2 --- /dev/null +++ b/strivacity_flutter/example/lib/widgets/passkey_login_widget.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:strivacity_flutter/strivacity_flutter.dart'; + +import '../styles.dart'; + +class PasskeyLoginWidget extends StatelessWidget { + final PasskeyLoginWidgetModel config; + final String formId; + final LoginContext loginContext; + + const PasskeyLoginWidget({ + super.key, + required this.formId, + required this.loginContext, + required this.config, + }); + + bool get loading { + return loginContext.loading; + } + + Future onPressed() async { + try { + final result = await PasskeyService.authenticate(config.assertionOptions); + + loginContext.setFormState(formId, config.id, result); + await loginContext.submitForm(formId); + } on PasskeyException catch (e) { + loginContext.setMessage('global', 'passkey', e.message); + } catch (e) { + loginContext.setMessage('global', 'passkey', 'Authentication failed: $e'); + } + } + + @override + Widget build(BuildContext context) { + if (config.render.type == 'button') { + return Container( + width: double.infinity, + margin: EdgeInsets.only(top: Styles.marginLarge, bottom: Styles.marginMedium), + child: ElevatedButton( + onPressed: loading ? null : () => onPressed(), + style: ElevatedButton.styleFrom( + backgroundColor: config.render.hint?.variant == 'primary' ? Styles.primaryColor : Styles.whiteColor, + foregroundColor: config.render.hint?.variant == 'primary' ? Styles.whiteColor : Styles.primaryColor, + alignment: Alignment.center, + padding: EdgeInsets.symmetric(vertical: Styles.paddingMedium, horizontal: 0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(Styles.borderRadiusMedium)), + ), + child: Text( + config.label ?? '', + style: TextStyle( + fontSize: Styles.textSizeMedium, + fontWeight: FontWeight.bold, + ), + ), + )); + } else { + return GestureDetector( + onTap: loading ? null : () => onPressed(), + child: Container( + padding: EdgeInsets.symmetric(vertical: Styles.marginSmall, horizontal: Styles.marginSmall), + color: Styles.transparentColor, + child: Text( + config.label ?? '', + style: TextStyle( + color: Styles.primaryColor, + fontSize: Styles.textSizeMedium, + ), + ), + ), + ); + } + } +} diff --git a/strivacity_flutter/example/lib/widgets/webauthn_enroll_widget.dart b/strivacity_flutter/example/lib/widgets/webauthn_enroll_widget.dart new file mode 100644 index 0000000..99c51bd --- /dev/null +++ b/strivacity_flutter/example/lib/widgets/webauthn_enroll_widget.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:strivacity_flutter/strivacity_flutter.dart'; + +import '../styles.dart'; + +class WebauthnEnrollWidget extends StatelessWidget { + final WebauthnEnrollWidgetModel config; + final String formId; + final LoginContext loginContext; + + const WebauthnEnrollWidget({ + super.key, + required this.formId, + required this.loginContext, + required this.config, + }); + + bool get loading { + return loginContext.loading; + } + + Future onPressed() async { + try { + final result = await PasskeyService.enroll(config.enrollOptions); + + loginContext.setFormState(formId, config.id, result); + await loginContext.submitForm(formId); + } on PasskeyException catch (e) { + loginContext.setMessage('global', 'passkey', e.message); + } catch (e) { + loginContext.setMessage('global', 'passkey', 'Authentication failed: $e'); + } + } + + @override + Widget build(BuildContext context) { + if (config.render.type == 'button') { + return Container( + width: double.infinity, + margin: EdgeInsets.only(top: Styles.marginLarge, bottom: Styles.marginMedium), + child: ElevatedButton( + onPressed: loading ? null : () => onPressed(), + style: ElevatedButton.styleFrom( + backgroundColor: config.render.hint?.variant == 'primary' ? Styles.primaryColor : Styles.whiteColor, + foregroundColor: config.render.hint?.variant == 'primary' ? Styles.whiteColor : Styles.primaryColor, + alignment: Alignment.center, + padding: EdgeInsets.symmetric(vertical: Styles.paddingMedium, horizontal: 0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(Styles.borderRadiusMedium)), + ), + child: Text( + config.label ?? '', + style: TextStyle( + fontSize: Styles.textSizeMedium, + fontWeight: FontWeight.bold, + ), + ), + )); + } else { + return GestureDetector( + onTap: loading ? null : () => onPressed(), + child: Container( + padding: EdgeInsets.symmetric(vertical: Styles.marginSmall, horizontal: Styles.marginSmall), + color: Styles.transparentColor, + child: Text( + config.label ?? '', + style: TextStyle( + color: Styles.primaryColor, + fontSize: Styles.textSizeMedium, + ), + ), + ), + ); + } + } +} diff --git a/strivacity_flutter/example/lib/widgets/webauthn_login_widget.dart b/strivacity_flutter/example/lib/widgets/webauthn_login_widget.dart new file mode 100644 index 0000000..5ee2c59 --- /dev/null +++ b/strivacity_flutter/example/lib/widgets/webauthn_login_widget.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:strivacity_flutter/strivacity_flutter.dart'; + +import '../styles.dart'; + +class WebauthnLoginWidget extends StatelessWidget { + final WebauthnLoginWidgetModel config; + final String formId; + final LoginContext loginContext; + + const WebauthnLoginWidget({ + super.key, + required this.formId, + required this.loginContext, + required this.config, + }); + + bool get loading { + return loginContext.loading; + } + + Future onPressed() async { + try { + final result = await PasskeyService.authenticate(config.assertionOptions); + + loginContext.setFormState(formId, config.id, result); + await loginContext.submitForm(formId); + } on PasskeyException catch (e) { + loginContext.setMessage('global', 'passkey', e.message); + } catch (e) { + loginContext.setMessage('global', 'passkey', 'Authentication failed: $e'); + } + } + + @override + Widget build(BuildContext context) { + if (config.render.type == 'button') { + return Container( + width: double.infinity, + margin: EdgeInsets.only(top: Styles.marginLarge, bottom: Styles.marginMedium), + child: ElevatedButton( + onPressed: loading ? null : () => onPressed(), + style: ElevatedButton.styleFrom( + backgroundColor: config.render.hint?.variant == 'primary' ? Styles.primaryColor : Styles.whiteColor, + foregroundColor: config.render.hint?.variant == 'primary' ? Styles.whiteColor : Styles.primaryColor, + alignment: Alignment.center, + padding: EdgeInsets.symmetric(vertical: Styles.paddingMedium, horizontal: 0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(Styles.borderRadiusMedium)), + ), + child: Text( + config.label ?? '', + style: TextStyle( + fontSize: Styles.textSizeMedium, + fontWeight: FontWeight.bold, + ), + ), + )); + } else { + return GestureDetector( + onTap: loading ? null : () => onPressed(), + child: Container( + padding: EdgeInsets.symmetric(vertical: Styles.marginSmall, horizontal: Styles.marginSmall), + color: Styles.transparentColor, + child: Text( + config.label ?? '', + style: TextStyle( + color: Styles.primaryColor, + fontSize: Styles.textSizeMedium, + ), + ), + ), + ); + } + } +} diff --git a/strivacity_flutter/lib/src/login_renderer.dart b/strivacity_flutter/lib/src/login_renderer.dart index cd39e63..a46d651 100644 --- a/strivacity_flutter/lib/src/login_renderer.dart +++ b/strivacity_flutter/lib/src/login_renderer.dart @@ -255,6 +255,18 @@ class _LoginRendererState extends State { return widget.viewFactory.getSubmitWidget(key: Key('${f.id}|${w.id}'), formId: f.id, loginContext: _loginContext, config: w as SubmitWidgetModel); case 'close': return widget.viewFactory.getCloseWidget(key: Key('${f.id}|${w.id}'), formId: f.id, loginContext: _loginContext, config: w as CloseWidgetModel); + case 'passkeyLogin': + return widget.viewFactory + .getPasskeyLoginWidget(key: Key('${f.id}|${w.id}'), formId: f.id, loginContext: _loginContext, config: w as PasskeyLoginWidgetModel); + case 'passkeyEnroll': + return widget.viewFactory + .getPasskeyEnrollWidget(key: Key('${f.id}|${w.id}'), formId: f.id, loginContext: _loginContext, config: w as PasskeyEnrollWidgetModel); + case 'webauthnLogin': + return widget.viewFactory + .getWebauthnLoginWidget(key: Key('${f.id}|${w.id}'), formId: f.id, loginContext: _loginContext, config: w as PasskeyLoginWidgetModel); + case 'webauthnEnroll': + return widget.viewFactory + .getWebauthnEnrollWidget(key: Key('${f.id}|${w.id}'), formId: f.id, loginContext: _loginContext, config: w as WebauthnEnrollWidgetModel); case 'static': return widget.viewFactory.getStaticWidget(key: Key('${f.id}|${w.id}'), config: w as StaticWidgetModel); default: diff --git a/strivacity_flutter/lib/src/view_factory.dart b/strivacity_flutter/lib/src/view_factory.dart index 3bfb404..edcdbc2 100644 --- a/strivacity_flutter/lib/src/view_factory.dart +++ b/strivacity_flutter/lib/src/view_factory.dart @@ -43,4 +43,16 @@ abstract class ViewFactory { /// Returns a close widget with the specified parameters. Widget getCloseWidget({required Key key, required String formId, required CloseWidgetModel config, required LoginContext loginContext}); + + /// Returns a passkey login widget with the specified parameters. + Widget getPasskeyLoginWidget({required Key key, required String formId, required PasskeyLoginWidget config, required LoginContext loginContext}); + + /// Returns a passkey enroll widget with the specified parameters. + Widget getPasskeyEnrollWidget({required Key key, required String formId, required PasskeyEnrollWidget config, required LoginContext loginContext}); + + /// Returns a webauthn login widget with the specified parameters. + Widget getWebauthnLoginWidget({required Key key, required String formId, required WebauthnLoginWidget config, required LoginContext loginContext}); + + /// Returns a webauthn enroll widget with the specified parameters. + Widget getWebauthnEnrollWidget({required Key key, required String formId, required WebauthnEnrollWidget config, required LoginContext loginContext}); } diff --git a/strivacity_flutter/lib/strivacity_flutter.dart b/strivacity_flutter/lib/strivacity_flutter.dart index 2b306d4..0fc0232 100644 --- a/strivacity_flutter/lib/strivacity_flutter.dart +++ b/strivacity_flutter/lib/strivacity_flutter.dart @@ -28,6 +28,10 @@ export 'package:strivacity_flutter_platform_interface/strivacity_flutter_platfor StaticWidgetModel, SubmitWidgetModel, CloseWidgetModel, + PasskeyLoginWidgetModel, + PasskeyEnrollWidgetModel, + WebauthnLoginWidgetModel, + WebauthnEnrollWidgetModel, BaseWidgetValidator, DateWidgetValidator, InputWidgetValidator, diff --git a/strivacity_flutter_platform_interface/lib/src/models.dart b/strivacity_flutter_platform_interface/lib/src/models.dart index ce7a2d2..bd06e08 100644 --- a/strivacity_flutter_platform_interface/lib/src/models.dart +++ b/strivacity_flutter_platform_interface/lib/src/models.dart @@ -431,6 +431,14 @@ class FormWidgetModel extends BaseWidgetModel { return SubmitWidgetModel.fromJson(json); case 'close': return CloseWidgetModel.fromJson(json); + case 'passkeyLogin': + return PasskeyLoginWidgetModel.fromJson(json); + case 'passkeyEnroll': + return PasskeyEnrollWidgetModel.fromJson(json); + case 'webauthnLogin': + return WebauthnLoginWidgetModel.fromJson(json); + case 'webauthnEnroll': + return WebauthnEnrollWidgetModel.fromJson(json); default: throw Exception('Unknown widget type: ${json['type']}'); } @@ -730,3 +738,137 @@ class LoginFlowState { ); } } + +class PasskeyLoginWidgetModel extends BaseWidgetModel { + final String? label; + final ButtonWidgetRender render; + final Map assertionOptions; + + PasskeyLoginWidgetModel({ + required super.id, + this.label, + required this.render, + required this.assertionOptions, + }) : super(type: 'passkeyLogin'); + + factory PasskeyLoginWidgetModel.fromJson(Map json) { + return PasskeyLoginWidgetModel( + id: json['id'], + label: json['label'], + render: ButtonWidgetRender.fromJson(json['render']), + assertionOptions: json['assertionOptions'], + ); + } +} + +class PasskeyEnrollWidgetModel extends BaseWidgetModel { + final String? label; + final ButtonWidgetRender render; + final Map enrollOptions; + + PasskeyEnrollWidgetModel({ + required super.id, + this.label, + required this.render, + required this.enrollOptions, + }) : super(type: 'passkeyEnroll'); + + factory PasskeyEnrollWidgetModel.fromJson(Map json) { + return PasskeyEnrollWidgetModel( + id: json['id'], + label: json['label'], + render: ButtonWidgetRender.fromJson(json['render']), + enrollOptions: json['enrollOptions'], + ); + } +} + +class WebauthnLoginWidgetModel extends BaseWidgetModel { + final String? label; + final String authenticatorType; + final ButtonWidgetRender render; + final Map assertionOptions; + + WebauthnLoginWidgetModel({ + required super.id, + this.label, + required this.authenticatorType, + required this.render, + required this.assertionOptions, + }) : super(type: 'webauthnLogin'); + + factory WebauthnLoginWidgetModel.fromJson(Map json) { + return WebauthnLoginWidgetModel( + id: json['id'], + label: json['label'], + authenticatorType: json['authenticatorType'], + render: ButtonWidgetRender.fromJson(json['render']), + assertionOptions: json['assertionOptions'], + ); + } +} + +class WebauthnEnrollWidgetModel extends BaseWidgetModel { + final String? label; + final String authenticatorType; + final ButtonWidgetRender render; + final Map enrollOptions; + + WebauthnEnrollWidgetModel({ + required super.id, + this.label, + required this.authenticatorType, + required this.render, + required this.enrollOptions, + }) : super(type: 'webauthnEnroll'); + + factory WebauthnEnrollWidgetModel.fromJson(Map json) { + return WebauthnEnrollWidgetModel( + id: json['id'], + label: json['label'], + authenticatorType: json['authenticatorType'], + render: ButtonWidgetRender.fromJson(json['render']), + enrollOptions: json['enrollOptions'], + ); + } +} + +class ButtonWidgetRender { + final String type; + final ButtonWidgetHint? hint; + final ButtonWidgetNotification? notification; + + ButtonWidgetRender({ + required this.type, + this.hint, + this.notification, + }); + + factory ButtonWidgetRender.fromJson(Map json) { + return ButtonWidgetRender( + type: json['type'], + hint: json['hint'] != null ? ButtonWidgetHint.fromJson(json['hint']) : null, + notification: json['notification'] != null ? ButtonWidgetNotification.fromJson(json['notification']) : null, + ); + } +} + +class ButtonWidgetHint { + final String? variant; + + ButtonWidgetHint({this.variant}); + + factory ButtonWidgetHint.fromJson(Map json) { + return ButtonWidgetHint(variant: json['variant']); + } +} + +class ButtonWidgetNotification { + final String? cancelled; + + ButtonWidgetNotification({this.cancelled}); + + factory ButtonWidgetNotification.fromJson(Map json) { + return ButtonWidgetNotification(cancelled: json['cancelled']); + } +} From f7b5a458bee60b45a844c15821679cca2b2c18e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ga=C3=A1l?= Date: Tue, 11 Nov 2025 13:54:13 +0100 Subject: [PATCH 4/4] feat: platform specific packages added --- strivacity_flutter/README.md | 42 +++ .../example/android/app/build.gradle | 2 +- .../android/app/src/main/AndroidManifest.xml | 8 + .../ios/Flutter/AppFrameworkInfo.plist | 2 +- strivacity_flutter/example/ios/Podfile | 2 +- strivacity_flutter/example/ios/Podfile.lock | 10 +- .../ios/Runner.xcodeproj/project.pbxproj | 16 +- .../xcshareddata/xcschemes/Runner.xcscheme | 2 + .../example/ios/Runner/Runner.entitlements | 10 + strivacity_flutter/example/pubspec.lock | 14 + strivacity_flutter/example/pubspec.yaml | 8 + .../lib/src/login_renderer.dart | 19 +- .../lib/src/passkey_service.dart | 91 +++++++ strivacity_flutter/lib/src/view_factory.dart | 8 +- .../lib/strivacity_flutter.dart | 1 + strivacity_flutter/pubspec.yaml | 6 + strivacity_flutter_android/.gitignore | 33 +++ strivacity_flutter_android/.metadata | 30 ++ strivacity_flutter_android/CHANGELOG.md | 3 + strivacity_flutter_android/LICENSE | 1 + strivacity_flutter_android/README.md | 15 + .../analysis_options.yaml | 4 + strivacity_flutter_android/android/.gitignore | 9 + .../android/build.gradle | 70 +++++ .../android/settings.gradle | 1 + .../android/src/main/AndroidManifest.xml | 3 + .../android/StrivacityFlutterAndroidPlugin.kt | 216 +++++++++++++++ .../StrivacityFlutterAndroidPluginTest.kt | 27 ++ .../lib/strivacity_flutter_android.dart | 8 + ...vacity_flutter_android_method_channel.dart | 17 ++ ...ty_flutter_android_platform_interface.dart | 29 ++ strivacity_flutter_android/pubspec.yaml | 75 +++++ ...y_flutter_android_method_channel_test.dart | 27 ++ .../test/strivacity_flutter_android_test.dart | 29 ++ strivacity_flutter_ios/.gitignore | 33 +++ strivacity_flutter_ios/.metadata | 30 ++ strivacity_flutter_ios/CHANGELOG.md | 3 + strivacity_flutter_ios/LICENSE | 1 + strivacity_flutter_ios/README.md | 15 + strivacity_flutter_ios/analysis_options.yaml | 4 + strivacity_flutter_ios/ios/.gitignore | 38 +++ strivacity_flutter_ios/ios/Assets/.gitkeep | 0 .../Classes/StrivacityFlutterIosPlugin.swift | 256 ++++++++++++++++++ .../ios/Resources/PrivacyInfo.xcprivacy | 14 + .../ios/strivacity_flutter_ios.podspec | 29 ++ .../lib/strivacity_flutter_ios.dart | 8 + ...strivacity_flutter_ios_method_channel.dart | 17 ++ ...vacity_flutter_ios_platform_interface.dart | 29 ++ strivacity_flutter_ios/pubspec.yaml | 74 +++++ ...acity_flutter_ios_method_channel_test.dart | 27 ++ .../test/strivacity_flutter_ios_test.dart | 29 ++ 51 files changed, 1429 insertions(+), 16 deletions(-) create mode 100644 strivacity_flutter/example/ios/Runner/Runner.entitlements create mode 100644 strivacity_flutter/lib/src/passkey_service.dart create mode 100644 strivacity_flutter_android/.gitignore create mode 100644 strivacity_flutter_android/.metadata create mode 100644 strivacity_flutter_android/CHANGELOG.md create mode 100644 strivacity_flutter_android/LICENSE create mode 100644 strivacity_flutter_android/README.md create mode 100644 strivacity_flutter_android/analysis_options.yaml create mode 100644 strivacity_flutter_android/android/.gitignore create mode 100644 strivacity_flutter_android/android/build.gradle create mode 100644 strivacity_flutter_android/android/settings.gradle create mode 100644 strivacity_flutter_android/android/src/main/AndroidManifest.xml create mode 100644 strivacity_flutter_android/android/src/main/kotlin/com/strivacity/flutter/android/StrivacityFlutterAndroidPlugin.kt create mode 100644 strivacity_flutter_android/android/src/test/kotlin/com/example/strivacity_flutter_android/StrivacityFlutterAndroidPluginTest.kt create mode 100644 strivacity_flutter_android/lib/strivacity_flutter_android.dart create mode 100644 strivacity_flutter_android/lib/strivacity_flutter_android_method_channel.dart create mode 100644 strivacity_flutter_android/lib/strivacity_flutter_android_platform_interface.dart create mode 100644 strivacity_flutter_android/pubspec.yaml create mode 100644 strivacity_flutter_android/test/strivacity_flutter_android_method_channel_test.dart create mode 100644 strivacity_flutter_android/test/strivacity_flutter_android_test.dart create mode 100644 strivacity_flutter_ios/.gitignore create mode 100644 strivacity_flutter_ios/.metadata create mode 100644 strivacity_flutter_ios/CHANGELOG.md create mode 100644 strivacity_flutter_ios/LICENSE create mode 100644 strivacity_flutter_ios/README.md create mode 100644 strivacity_flutter_ios/analysis_options.yaml create mode 100644 strivacity_flutter_ios/ios/.gitignore create mode 100644 strivacity_flutter_ios/ios/Assets/.gitkeep create mode 100644 strivacity_flutter_ios/ios/Classes/StrivacityFlutterIosPlugin.swift create mode 100644 strivacity_flutter_ios/ios/Resources/PrivacyInfo.xcprivacy create mode 100644 strivacity_flutter_ios/ios/strivacity_flutter_ios.podspec create mode 100644 strivacity_flutter_ios/lib/strivacity_flutter_ios.dart create mode 100644 strivacity_flutter_ios/lib/strivacity_flutter_ios_method_channel.dart create mode 100644 strivacity_flutter_ios/lib/strivacity_flutter_ios_platform_interface.dart create mode 100644 strivacity_flutter_ios/pubspec.yaml create mode 100644 strivacity_flutter_ios/test/strivacity_flutter_ios_method_channel_test.dart create mode 100644 strivacity_flutter_ios/test/strivacity_flutter_ios_test.dart diff --git a/strivacity_flutter/README.md b/strivacity_flutter/README.md index d481e51..d7c880c 100644 --- a/strivacity_flutter/README.md +++ b/strivacity_flutter/README.md @@ -263,6 +263,48 @@ class _LoginPageState extends State { The `LoginRenderer` widget is responsible for rendering the login UI using the provided `StrivacitySDK` and `CustomViewFactory`. It also handles various events such as login success, errors, fallback URLs, and global messages. +## Passkey Configuration + +To enable passkey support in your Flutter application, you need to configure platform-specific settings for both iOS and Android. + +### iOS Configuration + +Add the associated domain to your `ios/Runner/Runner.entitlements` file: + +```xml + + + + + com.apple.developer.associated-domains + + webcredentials:your-cluster-domain?mode=developer + + + +``` + +Replace `your-cluster-domain` with your Strivacity cluster domain (e.g., `example.strivacity.com`). + +**Note:** Remove the `?mode=developer` parameter when deploying to production. + +### Android Configuration + +Add the Digital Asset Links intent filter to your `android/app/src/main/AndroidManifest.xml` file inside the `` tag: + +```xml + + + + + + + + +``` + +Replace `your-cluster-domain` with your Strivacity cluster domain (e.g., `example.strivacity.com`). + ## Contributing Please see our [contributing guide](https://github.com/Strivacity/sdk-flutter/blob/main/strivacity_flutter/CONTRIBUTING.md). diff --git a/strivacity_flutter/example/android/app/build.gradle b/strivacity_flutter/example/android/app/build.gradle index b5511a9..53f244a 100644 --- a/strivacity_flutter/example/android/app/build.gradle +++ b/strivacity_flutter/example/android/app/build.gradle @@ -24,7 +24,7 @@ android { applicationId = "com.example.example" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion + minSdk = 28 targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName diff --git a/strivacity_flutter/example/android/app/src/main/AndroidManifest.xml b/strivacity_flutter/example/android/app/src/main/AndroidManifest.xml index e35163f..89ea74e 100644 --- a/strivacity_flutter/example/android/app/src/main/AndroidManifest.xml +++ b/strivacity_flutter/example/android/app/src/main/AndroidManifest.xml @@ -31,6 +31,14 @@ + + + + + + + + diff --git a/strivacity_flutter/example/ios/Flutter/AppFrameworkInfo.plist b/strivacity_flutter/example/ios/Flutter/AppFrameworkInfo.plist index 7c56964..1dc6cf7 100644 --- a/strivacity_flutter/example/ios/Flutter/AppFrameworkInfo.plist +++ b/strivacity_flutter/example/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 12.0 + 13.0 diff --git a/strivacity_flutter/example/ios/Podfile b/strivacity_flutter/example/ios/Podfile index d97f17e..e51a31d 100644 --- a/strivacity_flutter/example/ios/Podfile +++ b/strivacity_flutter/example/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '12.0' +# platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/strivacity_flutter/example/ios/Podfile.lock b/strivacity_flutter/example/ios/Podfile.lock index 9a2003d..f4316ca 100644 --- a/strivacity_flutter/example/ios/Podfile.lock +++ b/strivacity_flutter/example/ios/Podfile.lock @@ -11,6 +11,8 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - strivacity_flutter_ios (1.0.0): + - Flutter - url_launcher_ios (0.0.1): - Flutter - webview_flutter_wkwebview (0.0.1): @@ -24,6 +26,7 @@ DEPENDENCIES: - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - strivacity_flutter_ios (from `.symlinks/plugins/strivacity_flutter_ios/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`) @@ -40,6 +43,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/fluttertoast/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" + strivacity_flutter_ios: + :path: ".symlinks/plugins/strivacity_flutter_ios/ios" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" webview_flutter_wkwebview: @@ -47,14 +52,15 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7 - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_custom_tabs_ios: dd647919edd75e82ba6b00009eb3460a28c011b8 flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + strivacity_flutter_ios: 4750b2a8b6932016928fb4b8ca611433bc9e4cdc url_launcher_ios: 694010445543906933d732453a59da0a173ae33d webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c -PODFILE CHECKSUM: 819463e6a0290f5a72f145ba7cde16e8b6ef0796 +PODFILE CHECKSUM: 4f1c12611da7338d21589c0b2ecd6bd20b109694 COCOAPODS: 1.16.2 diff --git a/strivacity_flutter/example/ios/Runner.xcodeproj/project.pbxproj b/strivacity_flutter/example/ios/Runner.xcodeproj/project.pbxproj index efbdb66..3e17970 100644 --- a/strivacity_flutter/example/ios/Runner.xcodeproj/project.pbxproj +++ b/strivacity_flutter/example/ios/Runner.xcodeproj/project.pbxproj @@ -64,6 +64,7 @@ B2D7677B52FFE4229FEFB2E6 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; B47D6C20D0B7BB700B9DCFE8 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; CF7CDE7AC9AD47A8926D13DC /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D00740C22EC74265002E13AE /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; E802DDAF2E1F01DEA1DC7093 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -130,6 +131,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + D00740C22EC74265002E13AE /* Runner.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, @@ -161,7 +163,6 @@ 505B5842CC5B1004AA6039BD /* Pods-RunnerTests.release.xcconfig */, B47D6C20D0B7BB700B9DCFE8 /* Pods-RunnerTests.profile.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -316,10 +317,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -455,7 +460,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -470,6 +475,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -584,7 +590,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -635,7 +641,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -652,6 +658,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -674,6 +681,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/strivacity_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/strivacity_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 15cada4..e3773d4 100644 --- a/strivacity_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/strivacity_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -26,6 +26,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + com.apple.developer.associated-domains + + webcredentials:your-cluster-domain?mode=developer + + + diff --git a/strivacity_flutter/example/pubspec.lock b/strivacity_flutter/example/pubspec.lock index eaa7711..2593f71 100644 --- a/strivacity_flutter/example/pubspec.lock +++ b/strivacity_flutter/example/pubspec.lock @@ -492,6 +492,20 @@ packages: relative: true source: path version: "1.2.0" + strivacity_flutter_android: + dependency: "direct main" + description: + path: "../../strivacity_flutter_android" + relative: true + source: path + version: "1.0.0" + strivacity_flutter_ios: + dependency: "direct main" + description: + path: "../../strivacity_flutter_ios" + relative: true + source: path + version: "1.0.0" strivacity_flutter_platform_interface: dependency: "direct overridden" description: diff --git a/strivacity_flutter/example/pubspec.yaml b/strivacity_flutter/example/pubspec.yaml index 0f6855b..7d2c8a3 100644 --- a/strivacity_flutter/example/pubspec.yaml +++ b/strivacity_flutter/example/pubspec.yaml @@ -12,6 +12,10 @@ dependencies: sdk: flutter strivacity_flutter: path: ../ + strivacity_flutter_android: + path: ../../strivacity_flutter_android + strivacity_flutter_ios: + path: ../../strivacity_flutter_ios cupertino_icons: ^1.0.8 flutter_secure_storage: ^9.2.4 fluttertoast: ^8.2.12 @@ -25,6 +29,10 @@ dependencies: dependency_overrides: strivacity_flutter_platform_interface: path: ../../strivacity_flutter_platform_interface + strivacity_flutter_android: + path: ../../strivacity_flutter_android + strivacity_flutter_ios: + path: ../../strivacity_flutter_ios dev_dependencies: flutter_test: diff --git a/strivacity_flutter/lib/src/login_renderer.dart b/strivacity_flutter/lib/src/login_renderer.dart index a46d651..5563964 100644 --- a/strivacity_flutter/lib/src/login_renderer.dart +++ b/strivacity_flutter/lib/src/login_renderer.dart @@ -28,10 +28,13 @@ class LoginContext { /// A function to trigger the closing of the login flow. void Function() triggerClose; + /// A function to trigger global messages. + void Function(String text)? onGlobalMessage; + /// Creates a new instance of [LoginContext]. /// /// The [submitForm] and [triggerFallback] parameters are required. - LoginContext({required this.submitForm, required this.triggerFallback, required this.triggerClose}); + LoginContext({required this.submitForm, required this.triggerFallback, required this.triggerClose, this.onGlobalMessage}); /// Sets the form state for the given [formId] and [widgetId] with the provided [value]. void setFormState(String formId, String widgetId, dynamic value) { @@ -44,6 +47,11 @@ class LoginContext { /// Sets the message for the given [formId] and [widgetId] with the provided [value]. void setMessage(String formId, String widgetId, String? value) { + if (formId == 'global') { + onGlobalMessage?.call(value ?? ''); + return; + } + messageContexts[formId]![widgetId] = value; } } @@ -107,7 +115,12 @@ class _LoginRendererState extends State { super.initState(); _loginHandler = widget.sdk.login(widget.params); - _loginContext = LoginContext(submitForm: _submitForm, triggerFallback: _triggerFallback, triggerClose: _triggerClose); + _loginContext = LoginContext( + submitForm: _submitForm, + triggerFallback: _triggerFallback, + triggerClose: _triggerClose, + onGlobalMessage: widget.onGlobalMessage, + ); _init(); } @@ -263,7 +276,7 @@ class _LoginRendererState extends State { .getPasskeyEnrollWidget(key: Key('${f.id}|${w.id}'), formId: f.id, loginContext: _loginContext, config: w as PasskeyEnrollWidgetModel); case 'webauthnLogin': return widget.viewFactory - .getWebauthnLoginWidget(key: Key('${f.id}|${w.id}'), formId: f.id, loginContext: _loginContext, config: w as PasskeyLoginWidgetModel); + .getWebauthnLoginWidget(key: Key('${f.id}|${w.id}'), formId: f.id, loginContext: _loginContext, config: w as WebauthnLoginWidgetModel); case 'webauthnEnroll': return widget.viewFactory .getWebauthnEnrollWidget(key: Key('${f.id}|${w.id}'), formId: f.id, loginContext: _loginContext, config: w as WebauthnEnrollWidgetModel); diff --git a/strivacity_flutter/lib/src/passkey_service.dart b/strivacity_flutter/lib/src/passkey_service.dart new file mode 100644 index 0000000..fde3af5 --- /dev/null +++ b/strivacity_flutter/lib/src/passkey_service.dart @@ -0,0 +1,91 @@ +import 'package:flutter/services.dart'; + +/// Service for handling passkey (WebAuthn) operations. +/// +/// This service provides methods for passkey authentication and enrollment +/// using platform-specific implementations (Android Credential Manager API +/// and iOS ASAuthorization API). +class PasskeyService { + static const MethodChannel _channel = MethodChannel('strivacity_flutter/passkey'); + + /// Authenticates a user using a passkey (WebAuthn assertion). + /// + /// Takes [assertionOptions] containing the WebAuthn PublicKeyCredentialRequestOptions + /// from the server and returns the WebAuthn assertion response. + /// + /// Throws [PlatformException] if the authentication fails or is cancelled. + /// + /// Example: + /// ```dart + /// final result = await PasskeyService.authenticate({ + /// 'challenge': 'base64-challenge', + /// 'rpId': 'example.com', + /// 'allowCredentials': [...] + /// }); + /// ``` + static Future> authenticate(Map assertionOptions) async { + try { + final result = await _channel.invokeMethod('authenticate', assertionOptions); + return Map.from(result as Map); + } on PlatformException catch (e) { + throw PasskeyException( + 'Authentication failed: ${e.message}', + code: e.code, + details: e.details, + ); + } + } + + /// Registers a new passkey (WebAuthn attestation). + /// + /// Takes [enrollOptions] containing the WebAuthn PublicKeyCredentialCreationOptions + /// from the server and returns the WebAuthn attestation response. + /// + /// Throws [PlatformException] if the enrollment fails or is cancelled. + /// + /// Example: + /// ```dart + /// final result = await PasskeyService.enroll({ + /// 'challenge': 'base64-challenge', + /// 'rp': {'id': 'example.com', 'name': 'Example'}, + /// 'user': {...}, + /// 'pubKeyCredParams': [...] + /// }); + /// ``` + static Future> enroll(Map enrollOptions) async { + try { + final result = await _channel.invokeMethod('enroll', enrollOptions); + return Map.from(result as Map); + } on PlatformException catch (e) { + throw PasskeyException( + 'Enrollment failed: ${e.message}', + code: e.code, + details: e.details, + ); + } + } + + /// Checks if passkey is available on the current device. + /// + /// Returns `true` if the device supports passkey operations, `false` otherwise. + static Future isAvailable() async { + try { + final result = await _channel.invokeMethod('isAvailable'); + return result ?? false; + } catch (e) { + return false; + } + } +} + +/// Exception thrown when passkey operations fail. +class PasskeyException implements Exception { + final String message; + final String? code; + final dynamic details; + + PasskeyException(this.message, {this.code, this.details}); + + @override + String toString() => 'PasskeyException: $message (code: $code)'; +} diff --git a/strivacity_flutter/lib/src/view_factory.dart b/strivacity_flutter/lib/src/view_factory.dart index edcdbc2..f4d37ce 100644 --- a/strivacity_flutter/lib/src/view_factory.dart +++ b/strivacity_flutter/lib/src/view_factory.dart @@ -45,14 +45,14 @@ abstract class ViewFactory { Widget getCloseWidget({required Key key, required String formId, required CloseWidgetModel config, required LoginContext loginContext}); /// Returns a passkey login widget with the specified parameters. - Widget getPasskeyLoginWidget({required Key key, required String formId, required PasskeyLoginWidget config, required LoginContext loginContext}); + Widget getPasskeyLoginWidget({required Key key, required String formId, required PasskeyLoginWidgetModel config, required LoginContext loginContext}); /// Returns a passkey enroll widget with the specified parameters. - Widget getPasskeyEnrollWidget({required Key key, required String formId, required PasskeyEnrollWidget config, required LoginContext loginContext}); + Widget getPasskeyEnrollWidget({required Key key, required String formId, required PasskeyEnrollWidgetModel config, required LoginContext loginContext}); /// Returns a webauthn login widget with the specified parameters. - Widget getWebauthnLoginWidget({required Key key, required String formId, required WebauthnLoginWidget config, required LoginContext loginContext}); + Widget getWebauthnLoginWidget({required Key key, required String formId, required WebauthnLoginWidgetModel config, required LoginContext loginContext}); /// Returns a webauthn enroll widget with the specified parameters. - Widget getWebauthnEnrollWidget({required Key key, required String formId, required WebauthnEnrollWidget config, required LoginContext loginContext}); + Widget getWebauthnEnrollWidget({required Key key, required String formId, required WebauthnEnrollWidgetModel config, required LoginContext loginContext}); } diff --git a/strivacity_flutter/lib/strivacity_flutter.dart b/strivacity_flutter/lib/strivacity_flutter.dart index 0fc0232..5ab3039 100644 --- a/strivacity_flutter/lib/strivacity_flutter.dart +++ b/strivacity_flutter/lib/strivacity_flutter.dart @@ -44,3 +44,4 @@ export 'src/login_renderer.dart' show LoginRenderer, LoginContext; export 'src/sdk.dart' show StrivacitySDK; export 'src/view_factory.dart' show ViewFactory; export 'src/utils/http_client.dart' show HttpResponse; +export 'src/passkey_service.dart' show PasskeyService, PasskeyException; diff --git a/strivacity_flutter/pubspec.yaml b/strivacity_flutter/pubspec.yaml index 34d8777..7ce6c48 100644 --- a/strivacity_flutter/pubspec.yaml +++ b/strivacity_flutter/pubspec.yaml @@ -11,6 +11,8 @@ dependencies: flutter: sdk: flutter strivacity_flutter_platform_interface: ^1.0.0 + strivacity_flutter_android: ^1.0.0 + strivacity_flutter_ios: ^1.0.0 crypto: ^3.0.6 cookie_jar: ^4.0.8 dio: ^5.8.0 @@ -20,6 +22,10 @@ dependencies: dependency_overrides: strivacity_flutter_platform_interface: path: ../strivacity_flutter_platform_interface + strivacity_flutter_android: + path: ../strivacity_flutter_android + strivacity_flutter_ios: + path: ../strivacity_flutter_ios dev_dependencies: flutter_lints: ^5.0.0 diff --git a/strivacity_flutter_android/.gitignore b/strivacity_flutter_android/.gitignore new file mode 100644 index 0000000..b9d7f25 --- /dev/null +++ b/strivacity_flutter_android/.gitignore @@ -0,0 +1,33 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins-dependencies +/build/ +/coverage/ diff --git a/strivacity_flutter_android/.metadata b/strivacity_flutter_android/.metadata new file mode 100644 index 0000000..e9afb13 --- /dev/null +++ b/strivacity_flutter_android/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "d693b4b9dbac2acd4477aea4555ca6dcbea44ba2" + channel: "stable" + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + - platform: android + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/strivacity_flutter_android/CHANGELOG.md b/strivacity_flutter_android/CHANGELOG.md new file mode 100644 index 0000000..41cc7d8 --- /dev/null +++ b/strivacity_flutter_android/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/strivacity_flutter_android/LICENSE b/strivacity_flutter_android/LICENSE new file mode 100644 index 0000000..ba75c69 --- /dev/null +++ b/strivacity_flutter_android/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/strivacity_flutter_android/README.md b/strivacity_flutter_android/README.md new file mode 100644 index 0000000..71e4422 --- /dev/null +++ b/strivacity_flutter_android/README.md @@ -0,0 +1,15 @@ +# strivacity_flutter_android + +A new Flutter plugin project. + +## Getting Started + +This project is a starting point for a Flutter +[plug-in package](https://flutter.dev/to/develop-plugins), +a specialized package that includes platform-specific implementation code for +Android and/or iOS. + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev), which offers tutorials, +samples, guidance on mobile development, and a full API reference. + diff --git a/strivacity_flutter_android/analysis_options.yaml b/strivacity_flutter_android/analysis_options.yaml new file mode 100644 index 0000000..a5744c1 --- /dev/null +++ b/strivacity_flutter_android/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/strivacity_flutter_android/android/.gitignore b/strivacity_flutter_android/android/.gitignore new file mode 100644 index 0000000..161bdcd --- /dev/null +++ b/strivacity_flutter_android/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/strivacity_flutter_android/android/build.gradle b/strivacity_flutter_android/android/build.gradle new file mode 100644 index 0000000..59b8678 --- /dev/null +++ b/strivacity_flutter_android/android/build.gradle @@ -0,0 +1,70 @@ +group = "com.example.strivacity_flutter_android" +version = "1.0-SNAPSHOT" + +buildscript { + ext.kotlin_version = "2.1.0" + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:8.9.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +android { + namespace = "com.strivacity.flutter.android" + + compileSdk = 36 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11 + } + + sourceSets { + main.java.srcDirs += "src/main/kotlin" + test.java.srcDirs += "src/test/kotlin" + } + + defaultConfig { + minSdk = 28 + } + + dependencies { + implementation("androidx.credentials:credentials:1.3.0") + implementation("androidx.credentials:credentials-play-services-auth:1.3.0") + implementation("com.google.android.gms:play-services-auth:21.2.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.mockito:mockito-core:5.0.0") + } + + testOptions { + unitTests.all { + useJUnitPlatform() + + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } +} diff --git a/strivacity_flutter_android/android/settings.gradle b/strivacity_flutter_android/android/settings.gradle new file mode 100644 index 0000000..9d4e6db --- /dev/null +++ b/strivacity_flutter_android/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'strivacity_flutter_android' diff --git a/strivacity_flutter_android/android/src/main/AndroidManifest.xml b/strivacity_flutter_android/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..fbb2ef8 --- /dev/null +++ b/strivacity_flutter_android/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/strivacity_flutter_android/android/src/main/kotlin/com/strivacity/flutter/android/StrivacityFlutterAndroidPlugin.kt b/strivacity_flutter_android/android/src/main/kotlin/com/strivacity/flutter/android/StrivacityFlutterAndroidPlugin.kt new file mode 100644 index 0000000..527effe --- /dev/null +++ b/strivacity_flutter_android/android/src/main/kotlin/com/strivacity/flutter/android/StrivacityFlutterAndroidPlugin.kt @@ -0,0 +1,216 @@ +package com.strivacity.flutter.android + +import android.app.Activity +import android.os.CancellationSignal +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.CreatePublicKeyCredentialResponse +import androidx.credentials.GetCredentialResponse +import androidx.credentials.PublicKeyCredential +import androidx.credentials.exceptions.CreateCredentialException +import androidx.credentials.exceptions.GetCredentialException +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject + +/** StrivacityFlutterAndroidPlugin */ +class StrivacityFlutterAndroidPlugin : + FlutterPlugin, + MethodCallHandler, + ActivityAware { + + private lateinit var channel: MethodChannel + private var activity: Activity? = null + private lateinit var credentialManager: CredentialManager + + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel(flutterPluginBinding.binaryMessenger, "strivacity_flutter/passkey") + channel.setMethodCallHandler(this) + credentialManager = CredentialManager.create(flutterPluginBinding.applicationContext) + } + + override fun onMethodCall( + call: MethodCall, + result: Result + ) { + when (call.method) { + "authenticate" -> { + val assertionOptions = call.arguments as? Map + if (assertionOptions == null) { + result.error("INVALID_ARGS", "Assertion options are required", null) + return + } + handleAuthenticate(assertionOptions, result) + } + "enroll" -> { + val enrollOptions = call.arguments as? Map + if (enrollOptions == null) { + result.error("INVALID_ARGS", "Enroll options are required", null) + return + } + handleEnroll(enrollOptions, result) + } + "isAvailable" -> { + result.success(true) + } + else -> { + result.notImplemented() + } + } + } + + private fun handleAuthenticate(assertionOptions: Map, result: Result) { + val currentActivity = activity + if (currentActivity == null) { + result.error("NO_ACTIVITY", "Activity not available", null) + return + } + + CoroutineScope(Dispatchers.Main).launch { + try { + val requestJson = JSONObject(assertionOptions).toString() + val getPublicKeyCredentialOption = GetPublicKeyCredentialOption(requestJson = requestJson) + val getCredentialRequest = GetCredentialRequest(listOf(getPublicKeyCredentialOption)) + val credentialResponse = withContext(Dispatchers.IO) { + credentialManager.getCredential( + request = getCredentialRequest, + context = currentActivity + ) + } + + handleGetCredentialResponse(credentialResponse, result) + } catch (e: GetCredentialException) { + result.error("AUTHENTICATION_FAILED", e.message ?: "Unknown error", mapOf( + "type" to e.type, + "errorMessage" to (e.errorMessage ?: ""), + "stackTrace" to e.stackTraceToString() + )) + } catch (e: Exception) { + result.error("ERROR", e.message ?: "Unknown error", e.stackTraceToString()) + } + } + } + + private fun handleEnroll(enrollOptions: Map, result: Result) { + val currentActivity = activity + if (currentActivity == null) { + result.error("NO_ACTIVITY", "Activity not available", null) + return + } + + CoroutineScope(Dispatchers.Main).launch { + try { + val requestJson = JSONObject(enrollOptions).toString() + + val createPublicKeyCredentialRequest = CreatePublicKeyCredentialRequest( + requestJson = requestJson, + preferImmediatelyAvailableCredentials = false + ) + + val credentialResponse = withContext(Dispatchers.IO) { + credentialManager.createCredential( + request = createPublicKeyCredentialRequest, + context = currentActivity + ) + } + + handleCreateCredentialResponse(credentialResponse, result) + } catch (e: CreateCredentialException) { + result.error("ENROLLMENT_FAILED", e.message ?: "Unknown error", mapOf( + "type" to e.type, + "errorMessage" to (e.errorMessage ?: ""), + "stackTrace" to e.stackTraceToString() + )) + } catch (e: Exception) { + result.error("ERROR", e.message ?: "Unknown error", e.stackTraceToString()) + } + } + } + + private fun handleGetCredentialResponse(response: GetCredentialResponse, result: Result) { + when (val credential = response.credential) { + is PublicKeyCredential -> { + val responseJson = credential.authenticationResponseJson + val jsonObject = JSONObject(responseJson) + val resultMap = jsonToMap(jsonObject) + result.success(resultMap) + } + else -> { + result.error("UNKNOWN_CREDENTIAL", "Unexpected credential type", null) + } + } + } + + private fun handleCreateCredentialResponse(response: androidx.credentials.CreateCredentialResponse, result: Result) { + when (response) { + is CreatePublicKeyCredentialResponse -> { + val responseJson = response.registrationResponseJson + val jsonObject = JSONObject(responseJson) + val resultMap = jsonToMap(jsonObject) + result.success(resultMap) + } + else -> { + result.error("UNKNOWN_RESPONSE", "Unexpected response type", null) + } + } + } + + private fun jsonToMap(json: JSONObject): Map { + val map = mutableMapOf() + json.keys().forEach { key -> + val value = json.get(key) + map[key] = when (value) { + is JSONObject -> jsonToMap(value) + is org.json.JSONArray -> jsonArrayToList(value) + JSONObject.NULL -> null + else -> value + } + } + return map + } + + private fun jsonArrayToList(jsonArray: org.json.JSONArray): List { + val list = mutableListOf() + for (i in 0 until jsonArray.length()) { + val value = jsonArray.get(i) + list.add(when (value) { + is JSONObject -> jsonToMap(value) + is org.json.JSONArray -> jsonArrayToList(value) + JSONObject.NULL -> null + else -> value + }) + } + return list + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + activity = binding.activity + } + + override fun onDetachedFromActivityForConfigChanges() { + activity = null + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + activity = binding.activity + } + + override fun onDetachedFromActivity() { + activity = null + } +} diff --git a/strivacity_flutter_android/android/src/test/kotlin/com/example/strivacity_flutter_android/StrivacityFlutterAndroidPluginTest.kt b/strivacity_flutter_android/android/src/test/kotlin/com/example/strivacity_flutter_android/StrivacityFlutterAndroidPluginTest.kt new file mode 100644 index 0000000..bab6acf --- /dev/null +++ b/strivacity_flutter_android/android/src/test/kotlin/com/example/strivacity_flutter_android/StrivacityFlutterAndroidPluginTest.kt @@ -0,0 +1,27 @@ +package com.example.strivacity_flutter_android + +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import org.mockito.Mockito +import kotlin.test.Test + +/* + * This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation. + * + * Once you have built the plugin's example app, you can run these tests from the command + * line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or + * you can run them directly from IDEs that support JUnit such as Android Studio. + */ + +internal class StrivacityFlutterAndroidPluginTest { + @Test + fun onMethodCall_getPlatformVersion_returnsExpectedValue() { + val plugin = StrivacityFlutterAndroidPlugin() + + val call = MethodCall("getPlatformVersion", null) + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + plugin.onMethodCall(call, mockResult) + + Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE) + } +} diff --git a/strivacity_flutter_android/lib/strivacity_flutter_android.dart b/strivacity_flutter_android/lib/strivacity_flutter_android.dart new file mode 100644 index 0000000..c6b89a9 --- /dev/null +++ b/strivacity_flutter_android/lib/strivacity_flutter_android.dart @@ -0,0 +1,8 @@ + +import 'strivacity_flutter_android_platform_interface.dart'; + +class StrivacityFlutterAndroid { + Future getPlatformVersion() { + return StrivacityFlutterAndroidPlatform.instance.getPlatformVersion(); + } +} diff --git a/strivacity_flutter_android/lib/strivacity_flutter_android_method_channel.dart b/strivacity_flutter_android/lib/strivacity_flutter_android_method_channel.dart new file mode 100644 index 0000000..9d98b93 --- /dev/null +++ b/strivacity_flutter_android/lib/strivacity_flutter_android_method_channel.dart @@ -0,0 +1,17 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'strivacity_flutter_android_platform_interface.dart'; + +/// An implementation of [StrivacityFlutterAndroidPlatform] that uses method channels. +class MethodChannelStrivacityFlutterAndroid extends StrivacityFlutterAndroidPlatform { + /// The method channel used to interact with the native platform. + @visibleForTesting + final methodChannel = const MethodChannel('strivacity_flutter_android'); + + @override + Future getPlatformVersion() async { + final version = await methodChannel.invokeMethod('getPlatformVersion'); + return version; + } +} diff --git a/strivacity_flutter_android/lib/strivacity_flutter_android_platform_interface.dart b/strivacity_flutter_android/lib/strivacity_flutter_android_platform_interface.dart new file mode 100644 index 0000000..63a64d6 --- /dev/null +++ b/strivacity_flutter_android/lib/strivacity_flutter_android_platform_interface.dart @@ -0,0 +1,29 @@ +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import 'strivacity_flutter_android_method_channel.dart'; + +abstract class StrivacityFlutterAndroidPlatform extends PlatformInterface { + /// Constructs a StrivacityFlutterAndroidPlatform. + StrivacityFlutterAndroidPlatform() : super(token: _token); + + static final Object _token = Object(); + + static StrivacityFlutterAndroidPlatform _instance = MethodChannelStrivacityFlutterAndroid(); + + /// The default instance of [StrivacityFlutterAndroidPlatform] to use. + /// + /// Defaults to [MethodChannelStrivacityFlutterAndroid]. + static StrivacityFlutterAndroidPlatform get instance => _instance; + + /// Platform-specific implementations should set this with their own + /// platform-specific class that extends [StrivacityFlutterAndroidPlatform] when + /// they register themselves. + static set instance(StrivacityFlutterAndroidPlatform instance) { + PlatformInterface.verifyToken(instance, _token); + _instance = instance; + } + + Future getPlatformVersion() { + throw UnimplementedError('platformVersion() has not been implemented.'); + } +} diff --git a/strivacity_flutter_android/pubspec.yaml b/strivacity_flutter_android/pubspec.yaml new file mode 100644 index 0000000..6447c04 --- /dev/null +++ b/strivacity_flutter_android/pubspec.yaml @@ -0,0 +1,75 @@ +name: strivacity_flutter_android +description: "Android implementation of the strivacity_flutter plugin." +version: 1.0.0 +homepage: https://github.com/Strivacity/sdk-flutter + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.0.0" + +dependencies: + flutter: + sdk: flutter + strivacity_flutter_platform_interface: ^1.0.0 + +dependency_overrides: + strivacity_flutter_platform_interface: + path: ../strivacity_flutter_platform_interface + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + # This section identifies this Flutter project as a plugin project. + # The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.) + # which should be registered in the plugin registry. This is required for + # using method channels. + # The Android 'package' specifies package in which the registered class is. + # This is required for using method channels on Android. + # The 'ffiPlugin' specifies that native code should be built and bundled. + # This is required for using `dart:ffi`. + # All these are used by the tooling to maintain consistency when + # adding or updating assets for this project. + plugin: + implements: strivacity_flutter + platforms: + android: + package: com.strivacity.flutter.android + pluginClass: StrivacityFlutterAndroidPlugin + + # To add assets to your plugin package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/to/asset-from-package + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # To add custom fonts to your plugin package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/to/font-from-package diff --git a/strivacity_flutter_android/test/strivacity_flutter_android_method_channel_test.dart b/strivacity_flutter_android/test/strivacity_flutter_android_method_channel_test.dart new file mode 100644 index 0000000..99b40c3 --- /dev/null +++ b/strivacity_flutter_android/test/strivacity_flutter_android_method_channel_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:strivacity_flutter_android/strivacity_flutter_android_method_channel.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + MethodChannelStrivacityFlutterAndroid platform = MethodChannelStrivacityFlutterAndroid(); + const MethodChannel channel = MethodChannel('strivacity_flutter_android'); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (MethodCall methodCall) async { + return '42'; + }, + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); + }); + + test('getPlatformVersion', () async { + expect(await platform.getPlatformVersion(), '42'); + }); +} diff --git a/strivacity_flutter_android/test/strivacity_flutter_android_test.dart b/strivacity_flutter_android/test/strivacity_flutter_android_test.dart new file mode 100644 index 0000000..c15663e --- /dev/null +++ b/strivacity_flutter_android/test/strivacity_flutter_android_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:strivacity_flutter_android/strivacity_flutter_android.dart'; +import 'package:strivacity_flutter_android/strivacity_flutter_android_platform_interface.dart'; +import 'package:strivacity_flutter_android/strivacity_flutter_android_method_channel.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +class MockStrivacityFlutterAndroidPlatform + with MockPlatformInterfaceMixin + implements StrivacityFlutterAndroidPlatform { + + @override + Future getPlatformVersion() => Future.value('42'); +} + +void main() { + final StrivacityFlutterAndroidPlatform initialPlatform = StrivacityFlutterAndroidPlatform.instance; + + test('$MethodChannelStrivacityFlutterAndroid is the default instance', () { + expect(initialPlatform, isInstanceOf()); + }); + + test('getPlatformVersion', () async { + StrivacityFlutterAndroid strivacityFlutterAndroidPlugin = StrivacityFlutterAndroid(); + MockStrivacityFlutterAndroidPlatform fakePlatform = MockStrivacityFlutterAndroidPlatform(); + StrivacityFlutterAndroidPlatform.instance = fakePlatform; + + expect(await strivacityFlutterAndroidPlugin.getPlatformVersion(), '42'); + }); +} diff --git a/strivacity_flutter_ios/.gitignore b/strivacity_flutter_ios/.gitignore new file mode 100644 index 0000000..b9d7f25 --- /dev/null +++ b/strivacity_flutter_ios/.gitignore @@ -0,0 +1,33 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins-dependencies +/build/ +/coverage/ diff --git a/strivacity_flutter_ios/.metadata b/strivacity_flutter_ios/.metadata new file mode 100644 index 0000000..4e06789 --- /dev/null +++ b/strivacity_flutter_ios/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "d693b4b9dbac2acd4477aea4555ca6dcbea44ba2" + channel: "stable" + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + - platform: ios + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/strivacity_flutter_ios/CHANGELOG.md b/strivacity_flutter_ios/CHANGELOG.md new file mode 100644 index 0000000..41cc7d8 --- /dev/null +++ b/strivacity_flutter_ios/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/strivacity_flutter_ios/LICENSE b/strivacity_flutter_ios/LICENSE new file mode 100644 index 0000000..ba75c69 --- /dev/null +++ b/strivacity_flutter_ios/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/strivacity_flutter_ios/README.md b/strivacity_flutter_ios/README.md new file mode 100644 index 0000000..393cce3 --- /dev/null +++ b/strivacity_flutter_ios/README.md @@ -0,0 +1,15 @@ +# strivacity_flutter_ios + +A new Flutter plugin project. + +## Getting Started + +This project is a starting point for a Flutter +[plug-in package](https://flutter.dev/to/develop-plugins), +a specialized package that includes platform-specific implementation code for +Android and/or iOS. + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev), which offers tutorials, +samples, guidance on mobile development, and a full API reference. + diff --git a/strivacity_flutter_ios/analysis_options.yaml b/strivacity_flutter_ios/analysis_options.yaml new file mode 100644 index 0000000..a5744c1 --- /dev/null +++ b/strivacity_flutter_ios/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/strivacity_flutter_ios/ios/.gitignore b/strivacity_flutter_ios/ios/.gitignore new file mode 100644 index 0000000..034771f --- /dev/null +++ b/strivacity_flutter_ios/ios/.gitignore @@ -0,0 +1,38 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +.DS_Store +*.swp +profile + +DerivedData/ +build/ +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +/Flutter/Generated.xcconfig +/Flutter/ephemeral/ +/Flutter/flutter_export_environment.sh diff --git a/strivacity_flutter_ios/ios/Assets/.gitkeep b/strivacity_flutter_ios/ios/Assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/strivacity_flutter_ios/ios/Classes/StrivacityFlutterIosPlugin.swift b/strivacity_flutter_ios/ios/Classes/StrivacityFlutterIosPlugin.swift new file mode 100644 index 0000000..7ab2691 --- /dev/null +++ b/strivacity_flutter_ios/ios/Classes/StrivacityFlutterIosPlugin.swift @@ -0,0 +1,256 @@ +import Flutter +import UIKit +import AuthenticationServices + +public class StrivacityFlutterIosPlugin: NSObject, FlutterPlugin { + private var pendingResult: FlutterResult? + private var authenticationAnchor: ASPresentationAnchor? + + private func base64UrlDecode(_ base64Url: String) -> Data? { + var base64 = base64Url + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + + let paddingLength = (4 - (base64.count % 4)) % 4 + base64 += String(repeating: "=", count: paddingLength) + + return Data(base64Encoded: base64) + } + + private func base64UrlEncode(_ data: Data) -> String { + return data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "strivacity_flutter/passkey", binaryMessenger: registrar.messenger()) + let instance = StrivacityFlutterIosPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "authenticate": + guard let assertionOptions = call.arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGS", message: "Assertion options are required", details: nil)) + return + } + if #available(iOS 15.0, *) { + handleAuthenticate(assertionOptions: assertionOptions, result: result) + } else { + result(FlutterError(code: "UNSUPPORTED", message: "Passkey authentication requires iOS 15.0 or newer", details: nil)) + } + + case "enroll": + guard let enrollOptions = call.arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGS", message: "Enroll options are required", details: nil)) + return + } + if #available(iOS 15.0, *) { + handleEnroll(enrollOptions: enrollOptions, result: result) + } else { + result(FlutterError(code: "UNSUPPORTED", message: "Passkey enrollment requires iOS 15.0 or newer", details: nil)) + } + + case "isAvailable": + if #available(iOS 15.0, *) { + result(true) + } else { + result(false) + } + + default: + result(FlutterMethodNotImplemented) + } + } + + @available(iOS 15.0, *) + private func handleAuthenticate(assertionOptions: [String: Any], result: @escaping FlutterResult) { + guard let challenge = assertionOptions["challenge"] as? String else { + result(FlutterError(code: "INVALID_CHALLENGE", message: "Challenge is missing", details: nil)) + return + } + + guard let challengeData = base64UrlDecode(challenge) else { + result(FlutterError(code: "INVALID_CHALLENGE", message: "Challenge is not valid base64url", details: nil)) + return + } + + let rpId = assertionOptions["rpId"] as? String ?? "" + let platformProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rpId) + let platformKeyRequest = platformProvider.createCredentialAssertionRequest(challenge: challengeData) + + if let allowCredentials = assertionOptions["allowCredentials"] as? [[String: Any]] { + var descriptors: [ASAuthorizationPlatformPublicKeyCredentialDescriptor] = [] + for credential in allowCredentials { + if let idString = credential["id"] as? String, + let idData = Data(base64Encoded: idString) { + let descriptor = ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: idData) + descriptors.append(descriptor) + } + } + if !descriptors.isEmpty { + platformKeyRequest.allowedCredentials = descriptors + } + } + + if let userVerification = assertionOptions["userVerification"] as? String { + switch userVerification { + case "required": + platformKeyRequest.userVerificationPreference = .required + case "preferred": + platformKeyRequest.userVerificationPreference = .preferred + case "discouraged": + platformKeyRequest.userVerificationPreference = .discouraged + default: + platformKeyRequest.userVerificationPreference = .preferred + } + } + + let authController = ASAuthorizationController(authorizationRequests: [platformKeyRequest]) + authController.delegate = self + authController.presentationContextProvider = self + + pendingResult = result + authController.performRequests() + } + + @available(iOS 15.0, *) + private func handleEnroll(enrollOptions: [String: Any], result: @escaping FlutterResult) { + guard let challenge = enrollOptions["challenge"] as? String else { + result(FlutterError(code: "INVALID_CHALLENGE", message: "Challenge is missing", details: nil)) + return + } + + guard let challengeData = base64UrlDecode(challenge) else { + result(FlutterError(code: "INVALID_CHALLENGE", message: "Challenge is not valid base64url", details: nil)) + return + } + + guard let rp = enrollOptions["rp"] as? [String: Any], + let rpId = rp["id"] as? String, + let rpName = rp["name"] as? String else { + result(FlutterError(code: "INVALID_RP", message: "Invalid relying party", details: nil)) + return + } + + guard let user = enrollOptions["user"] as? [String: Any], + let userIdString = user["id"] as? String, + let userName = user["name"] as? String, + let userDisplayName = user["displayName"] as? String else { + result(FlutterError(code: "INVALID_USER", message: "Invalid user information", details: nil)) + return + } + + guard let userId = base64UrlDecode(userIdString) else { + result(FlutterError(code: "INVALID_USER_ID", message: "User ID is not valid base64url", details: nil)) + return + } + + let platformProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rpId) + let platformKeyRequest = platformProvider.createCredentialRegistrationRequest( + challenge: challengeData, + name: userName, + userID: userId + ) + + if let userVerification = enrollOptions["authenticatorSelection"] as? [String: Any], + let uvPref = userVerification["userVerification"] as? String { + switch uvPref { + case "required": + platformKeyRequest.userVerificationPreference = .required + case "preferred": + platformKeyRequest.userVerificationPreference = .preferred + case "discouraged": + platformKeyRequest.userVerificationPreference = .discouraged + default: + platformKeyRequest.userVerificationPreference = .preferred + } + } + + let authController = ASAuthorizationController(authorizationRequests: [platformKeyRequest]) + authController.delegate = self + authController.presentationContextProvider = self + + pendingResult = result + authController.performRequests() + } +} + +@available(iOS 15.0, *) +extension StrivacityFlutterIosPlugin: ASAuthorizationControllerDelegate { + public func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + guard let result = pendingResult else { return } + + if let credential = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion { + let response: [String: Any] = [ + "id": base64UrlEncode(credential.credentialID), + "rawId": base64UrlEncode(credential.credentialID), + "type": "public-key", + "response": [ + "clientDataJSON": base64UrlEncode(credential.rawClientDataJSON), + "authenticatorData": base64UrlEncode(credential.rawAuthenticatorData), + "signature": base64UrlEncode(credential.signature), + "userHandle": base64UrlEncode(credential.userID) + ] + ] + result(response) + } else if let credential = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration { + let response: [String: Any] = [ + "id": base64UrlEncode(credential.credentialID), + "rawId": base64UrlEncode(credential.credentialID), + "type": "public-key", + "response": [ + "clientDataJSON": base64UrlEncode(credential.rawClientDataJSON), + "attestationObject": credential.rawAttestationObject.map { base64UrlEncode($0) } ?? "" + ] + ] + result(response) + } else { + result(FlutterError(code: "UNKNOWN_CREDENTIAL", message: "Unknown credential type", details: nil)) + } + + pendingResult = nil + } + + public func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { + guard let result = pendingResult else { return } + + let authError = error as NSError + let errorCode: String + let errorMessage: String + + switch authError.code { + case ASAuthorizationError.canceled.rawValue: + errorCode = "CANCELED" + errorMessage = "User canceled the operation" + case ASAuthorizationError.failed.rawValue: + errorCode = "FAILED" + errorMessage = "Authentication failed" + case ASAuthorizationError.invalidResponse.rawValue: + errorCode = "INVALID_RESPONSE" + errorMessage = "Invalid response" + case ASAuthorizationError.notHandled.rawValue: + errorCode = "NOT_HANDLED" + errorMessage = "Request not handled" + case ASAuthorizationError.unknown.rawValue: + errorCode = "UNKNOWN" + errorMessage = "Unknown error" + default: + errorCode = "ERROR" + errorMessage = error.localizedDescription + } + + result(FlutterError(code: errorCode, message: errorMessage, details: authError.userInfo)) + pendingResult = nil + } +} + +@available(iOS 15.0, *) +extension StrivacityFlutterIosPlugin: ASAuthorizationControllerPresentationContextProviding { + public func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + return UIApplication.shared.windows.first { $0.isKeyWindow } ?? ASPresentationAnchor() + } +} diff --git a/strivacity_flutter_ios/ios/Resources/PrivacyInfo.xcprivacy b/strivacity_flutter_ios/ios/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..a34b7e2 --- /dev/null +++ b/strivacity_flutter_ios/ios/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyTrackingDomains + + NSPrivacyAccessedAPITypes + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + diff --git a/strivacity_flutter_ios/ios/strivacity_flutter_ios.podspec b/strivacity_flutter_ios/ios/strivacity_flutter_ios.podspec new file mode 100644 index 0000000..5e34e59 --- /dev/null +++ b/strivacity_flutter_ios/ios/strivacity_flutter_ios.podspec @@ -0,0 +1,29 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint strivacity_flutter_ios.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'strivacity_flutter_ios' + s.version = '1.0.0' + s.summary = 'iOS implementation of the strivacity_flutter plugin.' + s.description = <<-DESC +iOS implementation of the strivacity_flutter plugin. + DESC + s.homepage = 'https://github.com/Strivacity/sdk-flutter' + s.license = { :file => '../LICENSE' } + s.author = { 'Strivacity' => 'support@strivacity.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '13.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' + + # If your plugin requires a privacy manifest, for example if it uses any + # required reason APIs, update the PrivacyInfo.xcprivacy file to describe your + # plugin's privacy impact, and then uncomment this line. For more information, + # see https://developer.apple.com/documentation/bundleresources/privacy_manifest_files + # s.resource_bundles = {'strivacity_flutter_ios_privacy' => ['Resources/PrivacyInfo.xcprivacy']} +end diff --git a/strivacity_flutter_ios/lib/strivacity_flutter_ios.dart b/strivacity_flutter_ios/lib/strivacity_flutter_ios.dart new file mode 100644 index 0000000..0aee102 --- /dev/null +++ b/strivacity_flutter_ios/lib/strivacity_flutter_ios.dart @@ -0,0 +1,8 @@ + +import 'strivacity_flutter_ios_platform_interface.dart'; + +class StrivacityFlutterIos { + Future getPlatformVersion() { + return StrivacityFlutterIosPlatform.instance.getPlatformVersion(); + } +} diff --git a/strivacity_flutter_ios/lib/strivacity_flutter_ios_method_channel.dart b/strivacity_flutter_ios/lib/strivacity_flutter_ios_method_channel.dart new file mode 100644 index 0000000..48687c2 --- /dev/null +++ b/strivacity_flutter_ios/lib/strivacity_flutter_ios_method_channel.dart @@ -0,0 +1,17 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'strivacity_flutter_ios_platform_interface.dart'; + +/// An implementation of [StrivacityFlutterIosPlatform] that uses method channels. +class MethodChannelStrivacityFlutterIos extends StrivacityFlutterIosPlatform { + /// The method channel used to interact with the native platform. + @visibleForTesting + final methodChannel = const MethodChannel('strivacity_flutter_ios'); + + @override + Future getPlatformVersion() async { + final version = await methodChannel.invokeMethod('getPlatformVersion'); + return version; + } +} diff --git a/strivacity_flutter_ios/lib/strivacity_flutter_ios_platform_interface.dart b/strivacity_flutter_ios/lib/strivacity_flutter_ios_platform_interface.dart new file mode 100644 index 0000000..0f51e90 --- /dev/null +++ b/strivacity_flutter_ios/lib/strivacity_flutter_ios_platform_interface.dart @@ -0,0 +1,29 @@ +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import 'strivacity_flutter_ios_method_channel.dart'; + +abstract class StrivacityFlutterIosPlatform extends PlatformInterface { + /// Constructs a StrivacityFlutterIosPlatform. + StrivacityFlutterIosPlatform() : super(token: _token); + + static final Object _token = Object(); + + static StrivacityFlutterIosPlatform _instance = MethodChannelStrivacityFlutterIos(); + + /// The default instance of [StrivacityFlutterIosPlatform] to use. + /// + /// Defaults to [MethodChannelStrivacityFlutterIos]. + static StrivacityFlutterIosPlatform get instance => _instance; + + /// Platform-specific implementations should set this with their own + /// platform-specific class that extends [StrivacityFlutterIosPlatform] when + /// they register themselves. + static set instance(StrivacityFlutterIosPlatform instance) { + PlatformInterface.verifyToken(instance, _token); + _instance = instance; + } + + Future getPlatformVersion() { + throw UnimplementedError('platformVersion() has not been implemented.'); + } +} diff --git a/strivacity_flutter_ios/pubspec.yaml b/strivacity_flutter_ios/pubspec.yaml new file mode 100644 index 0000000..472d3e3 --- /dev/null +++ b/strivacity_flutter_ios/pubspec.yaml @@ -0,0 +1,74 @@ +name: strivacity_flutter_ios +description: "iOS implementation of the strivacity_flutter plugin." +version: 1.0.0 +homepage: https://github.com/Strivacity/sdk-flutter + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.0.0" + +dependencies: + flutter: + sdk: flutter + strivacity_flutter_platform_interface: ^1.0.0 + +dependency_overrides: + strivacity_flutter_platform_interface: + path: ../strivacity_flutter_platform_interface + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + # This section identifies this Flutter project as a plugin project. + # The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.) + # which should be registered in the plugin registry. This is required for + # using method channels. + # The Android 'package' specifies package in which the registered class is. + # This is required for using method channels on Android. + # The 'ffiPlugin' specifies that native code should be built and bundled. + # This is required for using `dart:ffi`. + # All these are used by the tooling to maintain consistency when + # adding or updating assets for this project. + plugin: + implements: strivacity_flutter + platforms: + ios: + pluginClass: StrivacityFlutterIosPlugin + + # To add assets to your plugin package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/to/asset-from-package + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # To add custom fonts to your plugin package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/to/font-from-package diff --git a/strivacity_flutter_ios/test/strivacity_flutter_ios_method_channel_test.dart b/strivacity_flutter_ios/test/strivacity_flutter_ios_method_channel_test.dart new file mode 100644 index 0000000..d0141b8 --- /dev/null +++ b/strivacity_flutter_ios/test/strivacity_flutter_ios_method_channel_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:strivacity_flutter_ios/strivacity_flutter_ios_method_channel.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + MethodChannelStrivacityFlutterIos platform = MethodChannelStrivacityFlutterIos(); + const MethodChannel channel = MethodChannel('strivacity_flutter_ios'); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (MethodCall methodCall) async { + return '42'; + }, + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); + }); + + test('getPlatformVersion', () async { + expect(await platform.getPlatformVersion(), '42'); + }); +} diff --git a/strivacity_flutter_ios/test/strivacity_flutter_ios_test.dart b/strivacity_flutter_ios/test/strivacity_flutter_ios_test.dart new file mode 100644 index 0000000..b6557a6 --- /dev/null +++ b/strivacity_flutter_ios/test/strivacity_flutter_ios_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:strivacity_flutter_ios/strivacity_flutter_ios.dart'; +import 'package:strivacity_flutter_ios/strivacity_flutter_ios_platform_interface.dart'; +import 'package:strivacity_flutter_ios/strivacity_flutter_ios_method_channel.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +class MockStrivacityFlutterIosPlatform + with MockPlatformInterfaceMixin + implements StrivacityFlutterIosPlatform { + + @override + Future getPlatformVersion() => Future.value('42'); +} + +void main() { + final StrivacityFlutterIosPlatform initialPlatform = StrivacityFlutterIosPlatform.instance; + + test('$MethodChannelStrivacityFlutterIos is the default instance', () { + expect(initialPlatform, isInstanceOf()); + }); + + test('getPlatformVersion', () async { + StrivacityFlutterIos strivacityFlutterIosPlugin = StrivacityFlutterIos(); + MockStrivacityFlutterIosPlatform fakePlatform = MockStrivacityFlutterIosPlatform(); + StrivacityFlutterIosPlatform.instance = fakePlatform; + + expect(await strivacityFlutterIosPlugin.getPlatformVersion(), '42'); + }); +}