diff --git a/examples/flutter-demo-app/README.md b/examples/flutter-demo-app/README.md index 2505a05..7969d3a 100644 --- a/examples/flutter-demo-app/README.md +++ b/examples/flutter-demo-app/README.md @@ -1,10 +1,10 @@ # Turnkey Flutter Demo App -This demo app leverages Turnkey's Dart/Flutter packages to demonstrate how they can be used to create a fully functional application. It includes a simple Node.js backend API server to facilitate server-side operations. +This demo app leverages [`turnkey_sdk_flutter`](../../packages/sdk-flutter/) to demonstrate how they can be used to create a fully functional application. ## Demo -https://github.com/user-attachments/assets/3d583ed8-1eff-4101-ae43-3c76c655e635 +![Demo](../../assets/demo.gif) ## Prerequisites @@ -16,49 +16,34 @@ https://github.com/user-attachments/assets/3d583ed8-1eff-4101-ae43-3c76c655e635 | Dart | >= 3.0.0 | | Xcode | >= 12.0 | | Android Studio | >= 4.0 | -| Node.js | >= 14.0 | ## Environment Variables Setup Create a `.env` file in the root directory of your project. You can use the provided `.env.example` file as a template: ```python +# Public Turnkey API TURNKEY_API_URL="https://api.turnkey.com" -BACKEND_API_URL="http://localhost:3000" -ORGANIZATION_ID="" +ORGANIZATION_ID="YOUR_ORGANIZATION_ID_HERE" -# PASSKEY ENV VARIABLES -RP_ID="" # This is the relying party ID that hosts your .well-known file +# Auth Proxy +AUTH_PROXY_URL="https://authproxy.turnkey.com" +AUTH_PROXY_CONFIG_ID="YOUR_AUTH_PROXY_CONFIG_ID" -# GOOGLE AUTH ENV VARIABLES -GOOGLE_CLIENT_ID="" -APP_SCHEME="flutter-demo-app" # This is the scheme used for OAuth redirects in the app. It should match the one used in the iOS and Android projects. - -#NODE SERVER ENV VARIABLES (Only used for the Node server in /api-server) -TURNKEY_API_PUBLIC_KEY="" -TURNKEY_API_PRIVATE_KEY="" -BACKEND_API_PORT="3000" -``` +# Passkey +RP_ID="YOUR_RP_ID_HERE" -## Backend API Server +# OAuth +APP_SCHEME="your-app-scheme" -This app must be connected to a backend server. You can use the included Node.js backend API server or set up your own. +GOOGLE_CLIENT_ID="YOUR_GOOGLE_CLIENT_ID" +APPLE_CLIENT_ID="YOUR_APPLE_CLIENT_ID" +X_CLIENT_ID="YOUR_X_CLIENT_ID" +DISCORD_CLIENT_ID="YOUR_DISCORD_CLIENT_ID" -### Install Dependencies - -Navigate to the api-server directory and install the dependencies: - -```bash -cd api-server -npm install ``` -Build and Run the Backend Server - -```bash -npm run build -npm start -``` +You can find your `ORGANIZATION_ID` and `AUTH_PROXY_CONFIG_ID` from the [Turnkey Dashboard](https://app.turnkey.com). ## Running the Flutter App @@ -82,7 +67,7 @@ You will be prompted to select a device to run the app on. You can also use the ## OAuth Configuration (optional) -This app includes an example for authenticating with Turnkey using a Google or Apple account. +This app includes an example for authenticating with Turnkey using a Google, Apple, X, or Discord account. ### Sign in with Google @@ -110,11 +95,7 @@ In your project's `.env` file, add the following: GOOGLE_CLIENT_ID="" ``` -### Sign in with Apple - -Signing in with Apple leverages the [sign_in_with_apple](https://pub.dev/packages/sign_in_with_apple) packages. This allows Apple's native [Sign in With Apple SDK](https://developer.apple.com/documentation/signinwithapple) to be used in Flutter. - -To enable this feature, simply [add the **Sign in with Apple** capability to your app in Xcode.](https://developer.apple.com/documentation/xcode/adding-capabilities-to-your-app) +For more information on how to setup OAuth in Turnkey powered Flutter apps, visit [our docs!](https://docs.turnkey.com/sdks/flutter) ## Passkey Configuration (optional) diff --git a/examples/flutter-demo-app/lib/main.dart b/examples/flutter-demo-app/lib/main.dart index f492fbc..8a4965a 100644 --- a/examples/flutter-demo-app/lib/main.dart +++ b/examples/flutter-demo-app/lib/main.dart @@ -12,24 +12,23 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); await loadEnv(); -void onSessionSelected(Session session) { - if (isValidSession(session)) { - WidgetsBinding.instance.addPostFrameCallback((_) { - navigatorKey.currentState?.pushReplacement( - MaterialPageRoute(builder: (context) => const DashboardScreen()), - ); - final ctx = navigatorKey.currentContext; - if (ctx != null) { - ScaffoldMessenger.of(ctx).showSnackBar( - const SnackBar( - content: Text('Logged in! Redirecting to the dashboard.'), - ), + void onSessionSelected(Session session) { + if (isValidSession(session)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + navigatorKey.currentState?.pushReplacement( + MaterialPageRoute(builder: (context) => const DashboardScreen()), ); - } - }); + final ctx = navigatorKey.currentContext; + if (ctx != null) { + ScaffoldMessenger.of(ctx).showSnackBar( + const SnackBar( + content: Text('Logged in! Redirecting to the dashboard.'), + ), + ); + } + }); + } } -} - void onSessionCleared(Session session) { navigatorKey.currentState?.pushReplacementNamed('/'); @@ -43,23 +42,9 @@ void onSessionSelected(Session session) { } } - void onInitialized(Object? error) { - final ctx = navigatorKey.currentContext; - if (error != null) { - debugPrint('Turnkey initialization failed: $error'); - if (ctx != null) { - ScaffoldMessenger.of(ctx).showSnackBar( - SnackBar(content: Text('Failed to initialize Turnkey: $error')), - ); - } - } else { - debugPrint('Turnkey initialized successfully'); - } - } - final createSubOrgParams = CreateSubOrgParams( customWallet: CustomWallet( - walletName: "Wallet 1", + walletName: 'Wallet 1', walletAccounts: [ v1WalletAccountParams( addressFormat: v1AddressFormat.address_format_ethereum, @@ -100,13 +85,10 @@ void onSessionSelected(Session session) { ), onSessionSelected: onSessionSelected, onSessionCleared: onSessionCleared, - onInitialized: onInitialized, ), ); - turnkeyProvider.ready.then((_) { - debugPrint('Turnkey is ready'); - }).catchError((error) { + turnkeyProvider.ready.catchError((error) { debugPrint('Caught from .ready: $error'); // Schedule the snackbar to show after the current frame @@ -134,29 +116,45 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - return MaterialApp( - navigatorKey: navigatorKey, - title: 'Flutter Demo', - theme: ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: const Color.fromARGB(255, 0, 26, 255)), - useMaterial3: true, - ), - home: const HomePage(title: 'Turnkey Flutter Demo App'), + return Consumer( + builder: (context, turnkey, _) { + return MaterialApp( + navigatorKey: navigatorKey, + title: 'Flutter Demo', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color.fromARGB(255, 0, 26, 255), + ), + useMaterial3: true, + ), + home: _buildHome(turnkey), + ); + }, ); } -} -class HomePage extends StatelessWidget { - const HomePage({super.key, required this.title}); - - final String title; + Widget _buildHome(TurnkeyProvider turnkey) { + switch (turnkey.authState) { + case AuthState.loading: + // Provider is booting: show splash / spinner. + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text(title)), - body: const LoginScreen(), - ); + case AuthState.unauthenticated: + // Turnkey is ready. Show the login screen. + return Scaffold( + appBar: AppBar( + title: Text('Turnkey Flutter Demo App'), + ), + body: LoginScreen(), + ); + // We'll have the `onSessionSelected` callback navigate to the dashboard screen. You can also add another case here for AuthState.authenticated if you want to handle it directly. + case AuthState.authenticated: + // Provider is booting: show splash / spinner. + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } } } diff --git a/examples/flutter-demo-app/lib/screens/dashboard.dart b/examples/flutter-demo-app/lib/screens/dashboard.dart index 90c90a4..9ff8a25 100644 --- a/examples/flutter-demo-app/lib/screens/dashboard.dart +++ b/examples/flutter-demo-app/lib/screens/dashboard.dart @@ -1,19 +1,16 @@ -import 'dart:convert'; - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import 'package:crypto/crypto.dart'; import 'package:turnkey_sdk_flutter/turnkey_sdk_flutter.dart'; class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); @override - _DashboardScreenState createState() => _DashboardScreenState(); + DashboardScreenState createState() => DashboardScreenState(); } -class _DashboardScreenState extends State { +class DashboardScreenState extends State { String? _signature; String? _exportedWallet; Wallet? _selectedWallet; @@ -44,7 +41,7 @@ class _DashboardScreenState extends State { } void _handleProviderUpdate() { - if (!mounted) return; + if (!context.mounted) return; _updateSelectedWalletFromProvider(_turnkeyProvider); } @@ -55,7 +52,7 @@ class _DashboardScreenState extends State { if (user == null || wallets == null || wallets.isEmpty) return; if (wallets.first.accounts.isEmpty) return; - if (!mounted) return; + if (!context.mounted) return; setState(() { _selectedWallet = wallets.first; _selectedAccount = wallets.first.accounts.first; @@ -65,28 +62,16 @@ class _DashboardScreenState extends State { Future handleSign( BuildContext context, String messageToSign, - String account, + v1WalletAccount account, void Function(void Function()) onStateUpdated, ) async { try { - final addressType = account.startsWith('0x') ? 'ETH' : 'SOL'; - final hashedMessage = addressType == 'ETH' - ? sha256.convert(utf8.encode(messageToSign)).toString() - : utf8 - .encode(messageToSign) - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(); - - final response = await _turnkeyProvider.signRawPayload( - signWith: account, - payload: hashedMessage, - encoding: v1PayloadEncoding.payload_encoding_hexadecimal, - hashFunction: addressType == 'ETH' - ? v1HashFunction.hash_function_no_op - : v1HashFunction.hash_function_not_applicable, + final response = await _turnkeyProvider.signMessage( + walletAccount: account, + message: messageToSign, ); - if (!mounted) return; + if (!context.mounted) return; onStateUpdated(() { _signature = 'r: ${response.r}, s: ${response.s}, v: ${response.v}'; @@ -96,7 +81,6 @@ class _DashboardScreenState extends State { const SnackBar(content: Text('Success! Message signed.')), ); } catch (error) { - if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Error signing message: $error')), ); @@ -109,14 +93,14 @@ class _DashboardScreenState extends State { walletId: wallet.id, ); - if (!mounted) return; + if (!context.mounted) return; _exportedWallet = export; - if (!mounted) return; + if (!context.mounted) return; Navigator.of(context).pop(); - if (!mounted) return; + if (!context.mounted) return; showDialog( context: context, builder: (BuildContext context) { @@ -177,7 +161,7 @@ class _DashboardScreenState extends State { }, ); } catch (e) { - if (!mounted) return; + if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Export failed: $e')), ); @@ -254,7 +238,7 @@ class _DashboardScreenState extends State { children: [ PopupMenuButton( onSelected: (wallet) { - if (!mounted) return; + if (!context.mounted) return; setState(() { _selectedWallet = wallet; _selectedAccount = wallet.accounts.first; @@ -365,23 +349,25 @@ class _DashboardScreenState extends State { ], ), ), - ...walletAccounts.map((account) { - return RadioListTile( - contentPadding: EdgeInsets.zero, - title: Text( - '${account.address.substring(0, 6)}...${account.address.substring(account.address.length - 6)}', - ), - onChanged: (Object? value) { - if (!mounted) return; - setState(() { - _selectedAccount = - (value as v1WalletAccount?); - }); - }, - value: account, - groupValue: _selectedAccount, - ); - }), + RadioGroup( + groupValue: _selectedAccount, + onChanged: (v1WalletAccount? newAccount) { + if (!context.mounted) return; + setState(() => _selectedAccount = newAccount); + }, + child: Column( + children: [ + for (final account in walletAccounts) + RadioListTile( + value: account, + title: Text( + '${account.address.substring(0, 6)}...${account.address.substring(account.address.length - 6)}', + ), + contentPadding: EdgeInsets.zero, + ), + ], + ), + ) ], ), ), @@ -437,7 +423,7 @@ class _DashboardScreenState extends State { await handleSign( context, signMessage, - _selectedAccount!.address, + _selectedAccount!, setState, ); }, @@ -467,10 +453,10 @@ class AddWalletDialog extends StatefulWidget { const AddWalletDialog({super.key}); @override - _AddWalletDialogState createState() => _AddWalletDialogState(); + AddWalletDialogState createState() => AddWalletDialogState(); } -class _AddWalletDialogState extends State { +class AddWalletDialogState extends State { final TextEditingController _walletNameController = TextEditingController(); final TextEditingController _seedPhraseController = TextEditingController(); bool _generateSeedPhrase = true; @@ -496,7 +482,7 @@ class _AddWalletDialogState extends State { ], ); } - if (!mounted) return; + if (!context.mounted) return; Navigator.of(context).pop(_walletNameController.text); } diff --git a/examples/flutter-demo-app/lib/utils/turnkey_rpc.dart b/examples/flutter-demo-app/lib/utils/turnkey_rpc.dart deleted file mode 100644 index 7aeec31..0000000 --- a/examples/flutter-demo-app/lib/utils/turnkey_rpc.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'dart:convert'; -import 'package:http/http.dart' as http; -import 'package:turnkey_flutter_demo_app/config.dart'; - -class TurnkeyRPCError implements Exception { - final String code; - final String message; - - TurnkeyRPCError({required this.code, required this.message}); - - @override - String toString() => 'TurnkeyRPCError(code: $code, message: $message)'; -} - -Future jsonRpcRequest( - String method, Map params) async { - final requestBody = { - 'method': method, - 'params': params, - }; - - final response = await http.post( - Uri.parse('${EnvConfig.backendApiUrl}/api'), - headers: { - 'Content-Type': 'application/json', - }, - body: jsonEncode(requestBody), - ); - - if (response.statusCode != 200) { - final error = jsonDecode(response.body)['error']; - throw TurnkeyRPCError(code: error['code'], message: error['message']); - } - - return jsonDecode(response.body) as T; -} - -Future> initOTPAuth(Map params) async { - return jsonRpcRequest('initOTPAuth', params); -} - -Future getSubOrgId(Map params) async { - final response = await jsonRpcRequest('getSubOrgId', params); - return response['organizationIds'][0]; -} - -Future> createSubOrg(Map params) async { - return jsonRpcRequest('createSubOrg', params); -} - -Future> getWhoami(Map params) async { - return jsonRpcRequest('getWhoami', params); -} - -Future> otpAuth(Map params) async { - return jsonRpcRequest('otpAuth', params); -} - -Future> oAuthLogin(Map params) async { - return jsonRpcRequest('oAuthLogin', params); -} diff --git a/packages/sdk-flutter/lib/src/stamper.dart b/packages/sdk-flutter/lib/src/internal/stamper.dart similarity index 98% rename from packages/sdk-flutter/lib/src/stamper.dart rename to packages/sdk-flutter/lib/src/internal/stamper.dart index 8108f66..aa4ff26 100644 --- a/packages/sdk-flutter/lib/src/stamper.dart +++ b/packages/sdk-flutter/lib/src/internal/stamper.dart @@ -1,7 +1,8 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:turnkey_crypto/turnkey_crypto.dart'; import 'package:turnkey_api_key_stamper/turnkey_api_key_stamper.dart'; -import 'package:turnkey_sdk_flutter/turnkey_sdk_flutter.dart'; +import 'package:turnkey_http/base.dart'; + /// Stores private keys in secure storage and signs payloads. class SecureStorageStamper implements TStamper { diff --git a/packages/sdk-flutter/lib/src/storage.dart b/packages/sdk-flutter/lib/src/internal/storage.dart similarity index 96% rename from packages/sdk-flutter/lib/src/storage.dart rename to packages/sdk-flutter/lib/src/internal/storage.dart index 3db890c..5df548f 100644 --- a/packages/sdk-flutter/lib/src/storage.dart +++ b/packages/sdk-flutter/lib/src/internal/storage.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:hive_flutter/hive_flutter.dart'; -import 'package:turnkey_sdk_flutter/turnkey_sdk_flutter.dart'; +import 'package:turnkey_sdk_flutter/src/utils/constants.dart'; +import 'package:turnkey_sdk_flutter/src/utils/types.dart'; class SessionStorageManager { static Future init() async { diff --git a/packages/sdk-flutter/lib/src/turnkey_helpers.dart b/packages/sdk-flutter/lib/src/internal/turnkey_helpers.dart similarity index 52% rename from packages/sdk-flutter/lib/src/turnkey_helpers.dart rename to packages/sdk-flutter/lib/src/internal/turnkey_helpers.dart index 72a57d3..6d1855c 100644 --- a/packages/sdk-flutter/lib/src/turnkey_helpers.dart +++ b/packages/sdk-flutter/lib/src/internal/turnkey_helpers.dart @@ -2,9 +2,14 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; +import 'dart:typed_data'; -import 'package:turnkey_sdk_flutter/turnkey_sdk_flutter.dart'; import 'package:crypto/crypto.dart'; +import 'package:turnkey_encoding/turnkey_encoding.dart'; +import 'package:turnkey_http/__generated__/models.dart'; +import 'package:turnkey_http/turnkey_http.dart'; +import 'package:turnkey_sdk_flutter/src/utils/constants.dart'; +import 'package:turnkey_sdk_flutter/src/utils/types.dart'; /// Fetches user details and associated wallets from the Turnkey API. /// @@ -251,10 +256,8 @@ final class PasskeyOverridedParams extends OverrideParams { }); } -CreateSubOrgParams getCreateSubOrgParams( - CreateSubOrgParams? createSubOrgParams, - TurnkeyConfig config, - OverrideParams overrideParams) { +CreateSubOrgParams getCreateSubOrgParams(CreateSubOrgParams? createSubOrgParams, + TurnkeyConfig config, OverrideParams overrideParams) { final configCreateSubOrgParams = config.authConfig?.createSubOrgParams; switch (overrideParams) { @@ -323,7 +326,8 @@ CreateSubOrgParams getCreateSubOrgParams( ], apiKeys: [ CreateSubOrgApiKey( - apiKeyName: 'passkey-auth-${overrideParams.temporaryPublicKey}', + apiKeyName: + 'passkey-auth-${overrideParams.temporaryPublicKey}', publicKey: overrideParams.temporaryPublicKey!, curveType: v1ApiKeyCurve.api_key_curve_p256, @@ -343,7 +347,8 @@ CreateSubOrgParams getCreateSubOrgParams( ], apiKeys: [ CreateSubOrgApiKey( - apiKeyName: 'passkey-auth-${overrideParams.temporaryPublicKey}', + apiKeyName: + 'passkey-auth-${overrideParams.temporaryPublicKey}', publicKey: overrideParams.temporaryPublicKey!, curveType: v1ApiKeyCurve.api_key_curve_p256, @@ -362,7 +367,8 @@ CreateSubOrgParams getCreateSubOrgParams( ], apiKeys: [ CreateSubOrgApiKey( - apiKeyName: 'passkey-auth-${overrideParams.temporaryPublicKey}', + apiKeyName: + 'passkey-auth-${overrideParams.temporaryPublicKey}', publicKey: overrideParams.temporaryPublicKey!, curveType: v1ApiKeyCurve.api_key_curve_p256, @@ -373,3 +379,257 @@ CreateSubOrgParams getCreateSubOrgParams( ); } } + +/// Returns the default hash function for a given address format. +v1HashFunction getHashFunction(v1AddressFormat addressFormat) { + final cfg = addressFormatConfig[addressFormat]; + if (cfg == null) { + throw Exception("Unsupported address format: $addressFormat"); + } + return cfg.hashFunction; +} + +/// Returns the default payload encoding for a given address format. +v1PayloadEncoding getEncodingType(v1AddressFormat addressFormat) { + final cfg = addressFormatConfig[addressFormat]; + if (cfg == null) { + throw Exception("Unsupported address format: $addressFormat"); + } + return cfg.encoding; +} + +/// Encodes raw bytes into the string representation expected by the API. +String getEncodedMessage(v1PayloadEncoding payloadEncoding, Uint8List raw) { + if (payloadEncoding == v1PayloadEncoding.payload_encoding_hexadecimal) { + return "0x${uint8ArrayToHexString(raw)}"; + } + return toUtf8String(raw); +} + +/// Byte/encoding helpers (used for signMessage) + +Uint8List toUtf8Bytes(String s) => Uint8List.fromList(utf8.encode(s)); + +String toUtf8String(Uint8List bytes) => utf8.decode(bytes); + +/// Config model & mapping (used for signMessage) + +class AddressFormatConfig { + final v1PayloadEncoding encoding; + final v1HashFunction hashFunction; + final String displayName; + + const AddressFormatConfig({ + required this.encoding, + required this.hashFunction, + required this.displayName, + }); +} + +const Map addressFormatConfig = { + v1AddressFormat.address_format_uncompressed: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_sha256, + displayName: "Uncompressed", + ), + v1AddressFormat.address_format_compressed: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_sha256, + displayName: "Compressed", + ), + v1AddressFormat.address_format_ethereum: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_keccak256, + displayName: "Ethereum", + ), + v1AddressFormat.address_format_solana: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_not_applicable, + displayName: "Solana", + ), + v1AddressFormat.address_format_cosmos: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_text_utf8, + hashFunction: v1HashFunction.hash_function_sha256, + displayName: "Cosmos", + ), + v1AddressFormat.address_format_tron: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_sha256, + displayName: "Tron", + ), + v1AddressFormat.address_format_sui: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_not_applicable, + displayName: "Sui", + ), + v1AddressFormat.address_format_aptos: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_not_applicable, + displayName: "Aptos", + ), + v1AddressFormat.address_format_bitcoin_mainnet_p2pkh: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_sha256, + displayName: "Bitcoin Mainnet P2PKH", + ), + v1AddressFormat.address_format_bitcoin_mainnet_p2sh: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_sha256, + displayName: "Bitcoin Mainnet P2SH", + ), + v1AddressFormat.address_format_bitcoin_mainnet_p2wpkh: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_sha256, + displayName: "Bitcoin Mainnet P2WPKH", + ), + v1AddressFormat.address_format_bitcoin_mainnet_p2wsh: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_sha256, + displayName: "Bitcoin Mainnet P2WSH", + ), + v1AddressFormat.address_format_bitcoin_mainnet_p2tr: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_sha256, + displayName: "Bitcoin Mainnet P2TR", + ), + v1AddressFormat.address_format_bitcoin_testnet_p2pkh: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_sha256, + displayName: "Bitcoin Testnet P2PKH", + ), + v1AddressFormat.address_format_bitcoin_testnet_p2sh: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_sha256, + displayName: "Bitcoin Testnet P2SH", + ), + v1AddressFormat.address_format_bitcoin_testnet_p2wpkh: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_sha256, + displayName: "Bitcoin Testnet P2WPKH", + ), + v1AddressFormat.address_format_bitcoin_testnet_p2wsh: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_sha256, + displayName: "Bitcoin Testnet P2WSH", + ), + v1AddressFormat.address_format_bitcoin_testnet_p2tr: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_sha256, + displayName: "Bitcoin Testnet P2TR", + ), + v1AddressFormat.address_format_bitcoin_signet_p2pkh: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_sha256, + displayName: "Bitcoin Signet P2PKH", + ), + v1AddressFormat.address_format_bitcoin_signet_p2sh: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_sha256, + displayName: "Bitcoin Signet P2SH", + ), + v1AddressFormat.address_format_bitcoin_signet_p2wpkh: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_sha256, + displayName: "Bitcoin Signet P2WPKH", + ), + v1AddressFormat.address_format_bitcoin_signet_p2wsh: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_sha256, + displayName: "Bitcoin Signet P2WSH", + ), + v1AddressFormat.address_format_bitcoin_signet_p2tr: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_sha256, + displayName: "Bitcoin Signet P2TR", + ), + v1AddressFormat.address_format_bitcoin_regtest_p2pkh: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_sha256, + displayName: "Bitcoin Regtest P2PKH", + ), + v1AddressFormat.address_format_bitcoin_regtest_p2sh: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_sha256, + displayName: "Bitcoin Regtest P2SH", + ), + v1AddressFormat.address_format_bitcoin_regtest_p2wpkh: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_sha256, + displayName: "Bitcoin Regtest P2WPKH", + ), + v1AddressFormat.address_format_bitcoin_regtest_p2wsh: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_sha256, + displayName: "Bitcoin Regtest P2WSH", + ), + v1AddressFormat.address_format_bitcoin_regtest_p2tr: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_sha256, + displayName: "Bitcoin Regtest P2TR", + ), + v1AddressFormat.address_format_sei: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_text_utf8, + hashFunction: v1HashFunction.hash_function_sha256, + displayName: "Sei", + ), + v1AddressFormat.address_format_xlm: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_not_applicable, + displayName: "XLM", + ), + v1AddressFormat.address_format_doge_mainnet: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_sha256, + displayName: "Doge Mainnet", + ), + v1AddressFormat.address_format_doge_testnet: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_sha256, + displayName: "Doge Testnet", + ), + v1AddressFormat.address_format_ton_v3r2: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_not_applicable, + displayName: "TON v3r2", + ), + v1AddressFormat.address_format_ton_v4r2: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_not_applicable, + displayName: "TON v4r2", + ), + v1AddressFormat.address_format_ton_v5r1: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_not_applicable, + displayName: "TON v5r1", + ), + v1AddressFormat.address_format_xrp: AddressFormatConfig( + encoding: v1PayloadEncoding.payload_encoding_hexadecimal, + hashFunction: v1HashFunction.hash_function_sha256, + displayName: "XRP", + ), +}; + +/// Computes a canonical signature for a *create* policy intent. +String getPolicySignature(v1CreatePolicyIntentV3 policy) { + // Keep keys stable and include nulls explicitly to avoid collisions. + final map = { + "policyName": policy.policyName, + "effect": policy.effect.name, + "condition": policy.condition ?? null, + "consensus": policy.consensus ?? null, + "notes": policy.notes ?? null, + }; + return jsonEncode(map); +} + +/// Computes the same signature for an *existing* policy record. +String getPolicySignatureFromExisting(v1Policy policy) { + final map = { + "policyName": policy.policyName, + "effect": policy.effect.name, + "condition": policy.condition, + "consensus": policy.consensus, + "notes": policy.notes, + }; + return jsonEncode(map); +} diff --git a/packages/sdk-flutter/lib/src/turnkey.dart b/packages/sdk-flutter/lib/src/turnkey.dart index 468df4c..801f8f6 100644 --- a/packages/sdk-flutter/lib/src/turnkey.dart +++ b/packages/sdk-flutter/lib/src/turnkey.dart @@ -1,25 +1,40 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'package:app_links/app_links.dart'; import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:turnkey_crypto/turnkey_crypto.dart'; import 'package:turnkey_flutter_passkey_stamper/turnkey_flutter_passkey_stamper.dart'; +import 'package:turnkey_http/__generated__/models.dart'; +import 'package:turnkey_http/base.dart'; +import 'package:turnkey_http/turnkey_http.dart'; +import 'package:turnkey_sdk_flutter/src/internal/turnkey_helpers.dart'; +import 'package:turnkey_sdk_flutter/src/utils/constants.dart'; +import 'package:turnkey_sdk_flutter/src/utils/types.dart'; import 'package:uuid/uuid.dart'; -import 'package:turnkey_sdk_flutter/src/stamper.dart'; -import 'package:turnkey_sdk_flutter/src/storage.dart'; -import 'package:turnkey_sdk_flutter/turnkey_sdk_flutter.dart'; +import 'package:turnkey_sdk_flutter/src/internal/stamper.dart'; +import 'package:turnkey_sdk_flutter/src/internal/storage.dart'; import 'package:crypto/crypto.dart'; +part 'turnkey_auth_proxy.dart'; +part 'turnkey_delegated_access.dart'; +part 'turnkey_oauth.dart'; +part 'turnkey_session.dart'; +part 'turnkey_signing.dart'; +part 'turnkey_user.dart'; +part 'turnkey_wallet.dart'; + class TurnkeyProvider with ChangeNotifier { // these are external Session? _session; TurnkeyClient? _client; v1User? _user; List? _wallets; + AuthState _authState = AuthState.loading; // these are internal TurnkeyConfig? _masterConfig; @@ -38,6 +53,7 @@ class TurnkeyProvider with ChangeNotifier { // these are externally used Session? get session => _session; + AuthState get authState => _authState; TurnkeyClient? get client => _client; v1User? get user => _user; List? get wallets => _wallets; @@ -63,6 +79,12 @@ class TurnkeyProvider with ChangeNotifier { notifyListeners(); } + set authState(AuthState next) { + if (_authState == next) return; + _authState = next; + notifyListeners(); + } + set client(TurnkeyClient? newClient) { _client = newClient; notifyListeners(); @@ -103,8 +125,9 @@ class TurnkeyProvider with ChangeNotifier { return proxyAuthConfig?.oauthRedirectUrl; } - final usingAuthProxy = (config.authProxyConfigId ?? '').isNotEmpty; - if (usingAuthProxy) { + final authProxyFetchEnabled = (config.authProxyConfigId ?? '').isNotEmpty && + config.authConfig?.autoFetchWalletKitConfig == true; + if (authProxyFetchEnabled) { if (config.authConfig?.sessionExpirationSeconds != null) { stderr.writeln( 'Turnkey SDK warning: `sessionExpirationSeconds` set directly in TurnkeyConfig will be ignored when using an auth proxy. Configure this in the Turnkey dashboard.', @@ -161,15 +184,18 @@ class TurnkeyProvider with ChangeNotifier { ); // --- proxy-only settings (read from proxy when available) ------------------ - final sessionExpirationSeconds = proxyAuthConfig - ?.sessionExpirationSeconds ?? - (usingAuthProxy ? null : config.authConfig?.sessionExpirationSeconds); + final sessionExpirationSeconds = + proxyAuthConfig?.sessionExpirationSeconds ?? + (authProxyFetchEnabled + ? null + : config.authConfig?.sessionExpirationSeconds ?? + AUTH_DEFAULT_EXPIRATION_SECONDS); final otpAlphanumeric = proxyAuthConfig?.otpAlphanumeric ?? - (usingAuthProxy ? null : config.authConfig?.otpAlphanumeric); + (authProxyFetchEnabled ? null : config.authConfig?.otpAlphanumeric); final otpLength = proxyAuthConfig?.otpLength ?? - (usingAuthProxy ? null : config.authConfig?.otpLength); + (authProxyFetchEnabled ? null : config.authConfig?.otpLength); final resolvedAuth = AuthConfig( methods: resolvedMethods, @@ -177,14 +203,24 @@ class TurnkeyProvider with ChangeNotifier { sessionExpirationSeconds: sessionExpirationSeconds, otpAlphanumeric: otpAlphanumeric, otpLength: otpLength, + autoFetchWalletKitConfig: + config.authConfig?.autoFetchWalletKitConfig ?? true, + autoRefreshManagedState: + config.authConfig?.autoRefreshManagedState ?? true, ); + // Note: it's not always possible to use masterConfig to get base urls. You'll notice in functions like createClient, we do this logic again. masterConfig is only available after boot so it's not safe to use it there. + final resolvedApiBaseUrl = config.apiBaseUrl ?? "https://api.turnkey.com"; + final resolvedAuthProxyBaseUrl = + config.authProxyBaseUrl ?? "https://authproxy.turnkey.com"; + return TurnkeyConfig( - apiBaseUrl: config.apiBaseUrl, + apiBaseUrl: resolvedApiBaseUrl, organizationId: config.organizationId, appScheme: config.appScheme, authConfig: resolvedAuth, - authProxyBaseUrl: config.authProxyBaseUrl, + passkeyConfig: config.passkeyConfig, + authProxyBaseUrl: resolvedAuthProxyBaseUrl, authProxyConfigId: config.authProxyConfigId, onSessionCreated: config.onSessionCreated, onSessionSelected: config.onSessionSelected, @@ -198,8 +234,10 @@ class TurnkeyProvider with ChangeNotifier { Future _boot() async { try { + authState = AuthState.loading; ProxyTGetWalletKitConfigResponse? proxy; - if ((config.authProxyConfigId ?? '').isNotEmpty) { + if ((config.authProxyConfigId ?? '').isNotEmpty && + config.authConfig?.autoFetchWalletKitConfig == true) { proxy = await _getAuthProxyConfig( config.authProxyConfigId!, config.authProxyBaseUrl, @@ -207,7 +245,7 @@ class TurnkeyProvider with ChangeNotifier { notifyListeners(); } - // we build the master config from oAuthproxy (can be null) + // we build the master config from Authproxy (can be null) _masterConfig = _buildConfig(proxyAuthConfig: proxy); notifyListeners(); } catch (e) { @@ -218,7 +256,7 @@ class TurnkeyProvider with ChangeNotifier { Future _getAuthProxyConfig( String configId, String? baseUrl) async { if (client == null) { - _createClient( + createClient( authProxyConfigId: configId, authProxyBaseUrl: baseUrl, ); @@ -232,21 +270,23 @@ class TurnkeyProvider with ChangeNotifier { /// Creates a new TurnkeyClient instance using the provided parameters. /// /// [organizationId] The ID of the organization to which the client will be associated. - /// [publicKey] The public key to use for the client. If null, the existing public key in the stamper will be used. + /// [publicKey] The public key to use for stamping. A key pair with this public key must exist in secure storage before passing it here. You can ensure the key pair exists using the createApiKeyPair method. If null, the existing public key in the stamper will be used. /// [apiBaseUrl] The base URL for the Turnkey API. If null, the value from the config or the default URL will be used. /// [authProxyConfigId] The configuration ID for the auth proxy. If null, the value from the config will be used. /// [authProxyBaseUrl] The base URL for the auth proxy. If null, the value from the config or the default URL will be used. + /// [overrideExisting] Whether to override the existing client instance with the newly created one. Defaults to true. /// Returns the newly created TurnkeyClient instance. - TurnkeyClient _createClient( + TurnkeyClient createClient( {String? organizationId, String? publicKey, String? apiBaseUrl, String? authProxyConfigId, - String? authProxyBaseUrl}) { + String? authProxyBaseUrl, + bool? overrideExisting = true}) { if (publicKey != null) secureStorageStamper.setPublicKey(publicKey); apiBaseUrl ??= config.apiBaseUrl ?? "https://api.turnkey.com"; authProxyBaseUrl ??= - config.authProxyBaseUrl ?? "https://auth-proxy.turnkey.com"; + config.authProxyBaseUrl ?? "https://authproxy.turnkey.com"; authProxyConfigId ??= config.authProxyConfigId; organizationId ??= config.organizationId; @@ -260,10 +300,67 @@ class TurnkeyProvider with ChangeNotifier { stamper: secureStorageStamper, ); - client = newClient; + if (overrideExisting == true) client = newClient; + return newClient; } + /// Creates a TurnkeyClient configured for Passkey stamping. + /// [organizationId] The ID of the organization to which the client will be associated. If null, the value from the config will be used. + /// [apiBaseUrl] The base URL for the Turnkey API. If null, the value from the config or the default URL will be used. + /// [authProxyConfigId] The configuration ID for the auth proxy. If null, the value from the config will be used. + /// [authProxyBaseUrl] The base URL for the auth proxy. If null, the value from the config or the default URL will be used. + /// [rpId] The Relying Party ID to use for Passkey authentication. If null, the value from the config's PasskeyStamperConfig will be used. + /// [overrideExisting] Whether to override the existing client instance with the newly created one. If true, all helper functions within the TurnkeyProvider will be using this client and thus, will be stamping using a passkey. Defaults to false. + /// Returns the newly created TurnkeyClient instance configured for Passkey stamping. + TurnkeyClient createPasskeyClient( + {String? organizationId, + String? apiBaseUrl, + String? authProxyConfigId, + String? authProxyBaseUrl, + PasskeyStamperConfig? passkeyStamperConfig, + bool? overrideExisting = false}) { + final rpId = passkeyStamperConfig?.rpId ?? config.passkeyConfig?.rpId; + if (rpId == null || rpId.isEmpty) { + throw Exception( + 'Relying Party ID (rpId) must be provided either in the passkeyStamperConfig parameter or in the TurnkeyConfig.passkeyConfig property.', + ); + } + + apiBaseUrl ??= config.apiBaseUrl ?? "https://api.turnkey.com"; + authProxyBaseUrl ??= + config.authProxyBaseUrl ?? "https://authproxy.turnkey.com"; + authProxyConfigId ??= config.authProxyConfigId; + organizationId ??= config.organizationId; + + final passkeyStamper = PasskeyStamper( + passkeyStamperConfig != null + ? PasskeyStamperConfig( + rpId: rpId, + timeout: passkeyStamperConfig.timeout, + userVerification: passkeyStamperConfig.userVerification, + allowCredentials: passkeyStamperConfig.allowCredentials, + mediation: passkeyStamperConfig.mediation, + preferImmediatelyAvailableCredentials: + passkeyStamperConfig.preferImmediatelyAvailableCredentials, + ) + : PasskeyStamperConfig(rpId: rpId), + ); + + final passkeyClient = TurnkeyClient( + config: THttpConfig( + organizationId: organizationId, + baseUrl: apiBaseUrl, + authProxyConfigId: authProxyConfigId, + authProxyBaseUrl: authProxyBaseUrl, + ), + stamper: passkeyStamper); + + if (overrideExisting == true) client = passkeyClient; + + return passkeyClient; + } + /// Initializes stored sessions on mount. /// /// This function retrieves all stored session keys, validates their expiration status, @@ -275,12 +372,13 @@ class TurnkeyProvider with ChangeNotifier { session = null; try { - _createClient(); + createClient(); // we get all stored sessions final allSessions = await getAllSessions(); if (allSessions == null || allSessions.isEmpty) { config.onSessionEmpty?.call(); + authState = AuthState.unauthenticated; _initCompleter.complete(); return; @@ -308,12 +406,15 @@ class TurnkeyProvider with ChangeNotifier { final activeSession = allSessions[activeSessionKey]; if (activeSession != null) { session = activeSession; - _createClient( + createClient( publicKey: activeSession.publicKey, organizationId: activeSession.organizationId, ); session = activeSession; + // We have a valid session + client: mark authenticated before fetching user/wallets. + authState = AuthState.authenticated; + await refreshUser(); await refreshWallets(); @@ -322,6 +423,7 @@ class TurnkeyProvider with ChangeNotifier { } else { // if no active session, fire the empty callback config.onSessionEmpty?.call(); + authState = AuthState.unauthenticated; } // we signal initialization complete @@ -329,49 +431,12 @@ class TurnkeyProvider with ChangeNotifier { config.onInitialized?.call(null); } catch (e, st) { stderr.writeln("TurnkeyProvider failed to initialize sessions: $e\n$st"); + authState = AuthState.unauthenticated; _initCompleter.completeError(e, st); config.onInitialized?.call(e); } } - /// Schedules the expiration of a session. - /// - /// Clears any existing timeout for the session to prevent duplicate timers. - /// Determines the time remaining until the session expires. - /// If the session is already expired, it triggers expiration immediately. - /// Otherwise, schedules a timeout to expire the session at the appropriate time. - /// Calls [clearSession] and invokes the [onSessionExpired] callback when the session expires. - /// - /// [sessionKey] The key of the session to schedule expiration for. - /// [expiry] The expiration time in seconds. - Future _scheduleSessionExpiration(String sessionKey, int expiry) async { - if (expiryTimers.isNotEmpty && expiryTimers.containsKey(sessionKey)) { - expiryTimers[sessionKey]?.cancel(); - expiryTimers.remove(sessionKey); - } - - final expireSession = () async { - final expiredSession = await getSession(sessionKey: sessionKey); - - if (expiredSession == null) return; - - await clearSession(sessionKey: sessionKey); - - config.onSessionExpired?.call(expiredSession); - }; - - final timeUntilExpiry = - (expiry * 1000) - DateTime.now().millisecondsSinceEpoch; - - if (timeUntilExpiry <= 0) { - await expireSession(); - } else { - expiryTimers.putIfAbsent(sessionKey, () { - return Timer(Duration(milliseconds: timeUntilExpiry), expireSession); - }); - } - } - /// Creates a new API key pair and optionally stores it as the active key. /// If `storeOverride` is true, the new key pair will replace the current active key in the client. /// @@ -394,7 +459,7 @@ class TurnkeyProvider with ChangeNotifier { // if `storeOverride` is true, we set the new key as the active key for this client instance if (storeOverride) { - _createClient( + createClient( publicKey: publicKey, ); } @@ -430,250 +495,6 @@ class TurnkeyProvider with ChangeNotifier { } } - /// Stores a new session in secure storage. - /// - /// Parses the provided session JWT and stores it under the specified session key. - /// Creates a new client instance using the session's organization ID and public key. - /// - /// [sessionJwt] The JWT string representing the session to store. - /// [sessionKey] An optional key to store the session under. If null, uses the default session key. - /// Returns the stored session if successful. - /// Throws an [Exception] if the session cannot be stored or parsed. - Future storeSession({ - required String sessionJwt, - String? sessionKey, - }) async { - sessionKey ??= StorageKeys.DefaultSession.value; - - // we enforce a session limit - final existingSessionKeys = await SessionStorageManager.listSessionKeys(); - if (existingSessionKeys.length >= MAX_SESSIONS) { - throw Exception( - 'Maximum session limit of $MAX_SESSIONS reached. Please clear an existing session before creating a new one.', - ); - } - - // we make sure the session key is unique - if (existingSessionKeys.contains(sessionKey)) { - clearSession(sessionKey: sessionKey); - throw Exception( - 'Session key "$sessionKey" already exists. Please choose a unique session key or clear the existing session.', - ); - } - - // we store and parse the session JWT - await SessionStorageManager.storeSession(sessionJwt, - sessionKey: sessionKey); - final session = await SessionStorageManager.getSession(sessionKey); - if (session == null) { - throw Exception("Failed to store or parse session"); - } - - // we mark the session as active if this is the first session - final isFirstSession = existingSessionKeys.isEmpty; - if (isFirstSession) { - await setActiveSession(sessionKey: sessionKey); - } - - // we fetch the user information - await refreshUser(); - await refreshWallets(); - if (user == null) { - throw Exception("Failed to fetch user"); - } - - // we schedule the session expiration - await _scheduleSessionExpiration(sessionKey, session.expiry); - - await deleteUnusedKeyPairs(); - - config.onSessionCreated?.call(session); - - return session; - } - - /// Sets the active session by its key. - /// [sessionKey] The key of the session to set as active. - Future setActiveSession({required String sessionKey}) async { - await SessionStorageManager.setActiveSessionKey(sessionKey); - final s = await SessionStorageManager.getSession(sessionKey); - - if (s == null) { - throw Exception("No session found with key: $sessionKey"); - } - - session = s; - _createClient( - publicKey: s.publicKey, - organizationId: s.organizationId, - ); - - config.onSessionSelected?.call(s); - } - - /// Gets the key of the currently active session. - /// Returns the active session key if it exists, otherwise `null`. - Future getActiveSessionKey() async { - return await SessionStorageManager.getActiveSessionKey(); - } - - /// Gets a stored session by its key. - /// [sessionKey] An optional key to retrieve the session from. If null, uses the default session key. - /// Returns the session if found, otherwise `null`. - Future getSession({String? sessionKey}) async { - final key = sessionKey ?? await SessionStorageManager.getActiveSessionKey(); - if (key == null) return null; - return await SessionStorageManager.getSession(key); - } - - /// Retrieves all stored sessions from secure storage. - /// Returns a map of session keys to their corresponding session objects. - Future?> getAllSessions() async { - final keys = await SessionStorageManager.listSessionKeys(); - if (keys.isEmpty) return null; - - final sessions = {}; - for (final key in keys) { - final session = await SessionStorageManager.getSession(key); - if (session != null) { - sessions[key] = session; - } - } - return sessions; - } - - /// Refreshes the specified or active session. - /// - /// Uses the existing session to stamp a new login session and extend its validity. - /// Generates a new key pair if no public key is provided. - /// Stores the refreshed session JWT and updates the current session state only - /// if it matches the active session key. - /// - /// [sessionKey] The key of the session to refresh. If null, uses the active session. - /// [expirationSeconds] The desired expiration time for the new session in seconds. - /// [publicKey] An optional public key to use for the new session. If null, a new key pair is generated. - /// [invalidateExisting] Whether to invalidate existing sessions when refreshing. - /// Returns the refreshed session result if successful, otherwise `null`. - /// Throws an [Exception] if the session cannot be refreshed. - Future refreshSession({ - String? sessionKey, - String expirationSeconds = OTP_AUTH_DEFAULT_EXPIRATION_SECONDS, - String? publicKey, - bool invalidateExisting = false, - }) async { - try { - final activeKey = await getActiveSessionKey(); - final key = sessionKey ?? activeKey; - if (key == null) throw Exception("No active session to refresh"); - - final currentSession = await getSession(sessionKey: key); - if (currentSession == null) - throw Exception("Session not found for key: $key"); - - // generate or use provided public key - final newPublicKey = publicKey ?? await createApiKeyPair(); - - // create a new session using the current session - final response = await requireClient.stampLogin( - input: TStampLoginBody( - organizationId: currentSession.organizationId, - publicKey: newPublicKey, - expirationSeconds: expirationSeconds, - invalidateExisting: invalidateExisting, - ), - ); - - final result = response.activity.result.stampLoginResult; - if (result?.session == null) { - throw Exception("No session found in refresh response"); - } - - // store the new session JWT - await SessionStorageManager.storeSession( - result?.session as String, - sessionKey: key, - ); - - final newSession = await SessionStorageManager.getSession(key); - if (newSession == null) { - throw Exception("Failed to store or parse new session"); - } - - // we only update the in-memory client/session if this is the active session - if (key == activeKey) { - session = newSession; - _createClient( - organizationId: newSession.organizationId, - publicKey: newSession.publicKey, - ); - } - - await _scheduleSessionExpiration(key, newSession.expiry); - - config.onSessionRefreshed?.call(newSession); - - return result; - } catch (error) { - await deleteUnusedKeyPairs(); - throw Exception('Failed to refresh session: $error'); - } - } - - /// Clears the current session from secure storage. - /// - /// Retrieves the session associated with the given [sessionKey]. - /// If the session being cleared is the currently selected session, it resets the state. - /// Deletes the session from secure storage. - /// Removes the session key from the session index. - /// Calls [onSessionCleared] callback if provided. - /// - /// Returns the cleared session if successful, otherwise `null`. - /// Throws an [Exception] if the session cannot be cleared. - /// - /// [sessionKey] The key of the session to clear. - Future clearSession({String? sessionKey}) async { - final activeSessionKey = await getActiveSessionKey(); - final key = sessionKey ?? activeSessionKey; - if (key == null) { - throw Exception("No active session to clear"); - } - - final sessionToClear = await SessionStorageManager.getSession(key); - if (sessionToClear == null) { - throw Exception("No session found with key: $key"); - } - - final activeKey = await getActiveSessionKey(); - if (key == activeKey) { - session = null; - user = null; - wallets = null; - client = _createClient(); - } - - // delete the keypair - await deleteApiKeyPair(sessionToClear.publicKey); - - // remove the session from storage - await SessionStorageManager.clearSession(key); - - config.onSessionCleared?.call(sessionToClear); - } - - /// Clears all stored sessions from secure storage. - /// - /// Retrieves all session keys and clears each session individually. - /// Calls [clearSession] for each stored session, which handles deletion - /// of associated API key pairs and invokes session cleared callbacks. - /// If no sessions are found, the method returns without performing any operations. - Future clearAllSessions() async { - final sessionKeys = await SessionStorageManager.listSessionKeys(); - if (sessionKeys.isEmpty) return; - - for (final key in sessionKeys) { - await clearSession(sessionKey: key); - } - } /// Logs in a user using a passkey. /// @@ -682,7 +503,7 @@ class TurnkeyProvider with ChangeNotifier { /// Stores the session JWT and manages session state. /// Cleans up the generated key pair if it was not used for the session. /// - /// [rpId] The relying party ID for the passkey authentication. + /// [rpId] An optional relying party ID for the passkey stamping. /// [sessionKey] An optional key to store the session under. If null, uses the default session key. /// [expirationSeconds] The desired expiration time for the session in seconds. /// [organizationId] An optional organization ID to associate with the session. @@ -690,30 +511,33 @@ class TurnkeyProvider with ChangeNotifier { /// Returns a [LoginWithPasskeyResult] containing the session token if successful. /// Throws an [Exception] if the login process fails. Future loginWithPasskey({ - required String rpId, + String? rpId, String? sessionKey, - String expirationSeconds = OTP_AUTH_DEFAULT_EXPIRATION_SECONDS, + String? expirationSeconds, String? organizationId, String? publicKey, }) async { sessionKey ??= StorageKeys.DefaultSession.value; + rpId ??= config.passkeyConfig?.rpId; + expirationSeconds ??= masterConfig?.authConfig?.sessionExpirationSeconds; + final apiBaseUrl = config.apiBaseUrl; String? generatedPublicKey; try { + if (rpId == null || rpId.isEmpty) { + throw Exception( + "Relying Party ID (rpId) must be provided either in the method call or in the TurnkeyConfig.passkeyConfig"); + } + generatedPublicKey = publicKey ?? await createApiKeyPair(storeOverride: true); - // TODO (Amir): We need to make it easier to create a passkeyclient. Maybe just expose it through the turnkeyProvider? - final passkeyStamper = PasskeyStamper(PasskeyStamperConfig(rpId: rpId)); - final passkeyClient = TurnkeyClient( - config: THttpConfig( - baseUrl: - apiBaseUrl ?? config.apiBaseUrl ?? "https://api.turnkey.com", - organizationId: organizationId ?? config.organizationId, - ), - stamper: passkeyStamper); + final passkeyClient = createPasskeyClient( + organizationId: organizationId, + apiBaseUrl: apiBaseUrl, + passkeyStamperConfig: PasskeyStamperConfig(rpId: rpId)); final loginResponse = await passkeyClient.stampLogin( input: TStampLoginBody( @@ -747,25 +571,32 @@ class TurnkeyProvider with ChangeNotifier { /// Stamps a login session for the new user and stores the session JWT. /// Cleans up the generated key pairs after use. /// - /// [rpId] The relying party ID for the passkey registration. + /// [rpId] An optional relying party ID for the passkey registration. + /// [rpName] An optional relying party name for the passkey registration. /// [sessionKey] An optional key to store the session under. If null, uses the default session key. /// [expirationSeconds] The desired expiration time for the session in seconds. /// [organizationId] An optional organization ID to associate with the session. /// [passkeyDisplayName] An optional display name for the passkey. + /// [challenge] An optional challenge string for the passkey registration. /// [createSubOrgParams] Optional parameters for creating the sub-organization user. /// [invalidateExisting] Whether to invalidate existing sessions when signing up. /// Returns a [SignUpWithPasskeyResult] containing the session token and credential ID if successful. /// Throws an [Exception] if the sign-up process fails. Future signUpWithPasskey({ - required String rpId, + String? rpId, + String? rpName, String? sessionKey, - String expirationSeconds = OTP_AUTH_DEFAULT_EXPIRATION_SECONDS, + String? expirationSeconds, String? organizationId, String? passkeyDisplayName, + String? challenge, CreateSubOrgParams? createSubOrgParams, bool invalidateExisting = false, }) async { sessionKey ??= StorageKeys.DefaultSession.value; + rpId ??= config.passkeyConfig?.rpId; + rpName ??= config.passkeyConfig?.rpName ?? "Flutter App"; + expirationSeconds ??= masterConfig?.authConfig?.sessionExpirationSeconds; String? generatedPublicKey; String? temporaryPublicKey; @@ -775,6 +606,12 @@ class TurnkeyProvider with ChangeNotifier { // which is added as an authentication method for the new sub-org user // this allows us to stamp the session creation request immediately after // without prompting the user + + if (rpId == null || rpId.isEmpty) { + throw Exception( + "Relying Party ID (rpId) must be provided either in the method call or in the TurnkeyConfig.passkeyConfig"); + } + temporaryPublicKey = await createApiKeyPair(storeOverride: true); final passkeyName = passkeyDisplayName ?? 'passkey-${DateTime.now().millisecondsSinceEpoch}'; @@ -784,12 +621,13 @@ class TurnkeyProvider with ChangeNotifier { PasskeyRegistrationConfig( rp: RelyingParty( id: rpId, - name: 'Flutter App', + name: rpName, ), user: WebAuthnUser( - id: const Uuid().v4(), - name: 'Anonymous User', - displayName: 'Anonymous User', + id: const Uuid() + .v4(), // WARNING: WebAuthnUser id must be a valid UUIDv4 on some devices + name: passkeyName, + displayName: passkeyName, ), authenticatorName: passkeyName, ), @@ -844,1081 +682,4 @@ class TurnkeyProvider with ChangeNotifier { throw Exception('Failed to sign up with passkey: $error'); } } - - /// Initializes an OTP (One-Time Password) request for the specified contact method. - /// - /// Sends a request to the backend to generate and send an OTP to the provided contact (email or phone number). - /// Returns the OTP ID that can be used for subsequent verification. - /// - /// [otpType] The type of OTP to initialize (email or SMS). - /// [contact] The contact information (email address or phone number) to send the OTP to. - /// Returns a [String] representing the OTP ID. - /// Throws an [Exception] if the OTP initialization fails. - Future initOtp( - {required OtpType otpType, required String contact}) async { - final res = await requireClient.proxyInitOtp( - input: ProxyTInitOtpBody( - contact: contact, - otpType: otpType.value, - )); - - return res.otpId; - } - - /// Verifies an OTP code and retrieves a verification token and sub-organization ID. - /// - /// Throws an [Exception] if the OTP verification fails or if the account cannot be retrieved. - /// - /// [otpCode] The OTP code to verify. - /// [otpId] The ID of the OTP to verify. - /// [contact] The contact information (email or phone number) associated with the OTP. - /// [otpType] The type of OTP (email or SMS). - /// Returns a [VerifyOtpResult] containing the verification token and sub-organization ID. - Future verifyOtp( - {required String otpCode, - required String otpId, - required String contact, - required OtpType otpType}) async { - final verifyOtpRes = await requireClient.proxyVerifyOtp( - input: ProxyTVerifyOtpBody( - otpCode: otpCode, - otpId: otpId, - )); - - if (verifyOtpRes.verificationToken.isEmpty) { - throw Exception("Failed to verify OTP"); - } - - final accountRes = await requireClient.proxyGetAccount( - input: ProxyTGetAccountBody( - filterType: otpTypeToFilterTypeMap[otpType]!.value, - filterValue: contact)); - - final subOrganizationId = accountRes.organizationId; - return VerifyOtpResult( - subOrganizationId: subOrganizationId, - verificationToken: verifyOtpRes.verificationToken); - } - - /// Logs in a user using an OTP (One-Time Password) verification token. - /// - /// Generates or uses an existing API key pair for authentication. - /// Sends a login request to the backend with the provided verification token and optional parameters. - /// Stores the session JWT and manages session state. - /// Cleans up the generated key pair if it was not used for the session. - /// - /// [verificationToken] The OTP verification token received after verifying the OTP code. - /// [organizationId] An optional organization ID to associate with the session. - /// [invalidateExisting] Whether to invalidate existing sessions when logging in. - /// [publicKey] An optional public key to use for the session. If null, a new key pair is generated. - /// [sessionKey] An optional key to store the session under. If null, uses the default session key. - /// Returns a [LoginWithOtpResult] containing the session token if successful. - /// Throws an [Exception] if the login process fails. - Future loginWithOtp({ - required String verificationToken, - String? organizationId, - bool invalidateExisting = false, - String? publicKey, - String? sessionKey, - }) async { - String? generatedPublicKey; - - try { - generatedPublicKey = publicKey ?? await createApiKeyPair(); - - final res = await requireClient.proxyOtpLogin( - input: ProxyTOtpLoginBody( - organizationId: organizationId, - publicKey: generatedPublicKey, - verificationToken: verificationToken, - invalidateExisting: invalidateExisting, - ), - ); - - await storeSession(sessionJwt: res.session, sessionKey: sessionKey); - - return LoginWithOtpResult( - sessionToken: res.session, - ); - } catch (error) { - await deleteUnusedKeyPairs(); - throw Exception('Failed to login with otp: $error'); - } - } - - /// Signs up a new user using an OTP (One-Time Password) verification token. - /// - /// Generates a temporary API key pair for OTP sign-up. - /// Creates a new sub-organization user with the provided contact information and verification token. - /// Stamps a login session for the new user and stores the session JWT. - /// Cleans up the generated key pair after use. - /// - /// [verificationToken] The OTP verification token received after verifying the OTP code. - /// [contact] The contact information (email address or phone number) associated with the OTP. - /// [otpType] The type of OTP (email or SMS). - /// [publicKey] An optional public key to use for the session. If null, a new key pair is generated. - /// [sessionKey] An optional key to store the session under. If null, uses the default session key. - /// [createSubOrgParams] Optional parameters for creating the sub-organization user. - /// [invalidateExisting] Whether to invalidate existing sessions when signing up. - /// Returns a [SignUpWithOtpResult] containing the session token if successful. - /// Throws an [Exception] if the sign-up process fails. - Future signUpWithOtp({ - required String verificationToken, - required String contact, - required OtpType otpType, - String? publicKey, - String? sessionKey, - CreateSubOrgParams? createSubOrgParams, - bool invalidateExisting = false, - }) async { - final overrideParams = OtpOverriredParams( - otpType: otpType, - contact: contact, - verificationToken: verificationToken, - ); - final updatedCreateSubOrgParams = - getCreateSubOrgParams(createSubOrgParams, config, overrideParams); - - final signUpBody = - buildSignUpBody(createSubOrgParams: updatedCreateSubOrgParams); - - try { - final res = await requireClient.proxySignup(input: signUpBody); - - final orgId = res.organizationId; - if (orgId.isEmpty) { - throw Exception("Sign up failed: No organizationId returned"); - } - - final response = await loginWithOtp( - organizationId: orgId, - verificationToken: verificationToken, - sessionKey: sessionKey, - invalidateExisting: invalidateExisting, - ); - - return SignUpWithOtpResult(sessionToken: response.sessionToken); - } catch (e) { - throw Exception("Sign up failed: $e"); - } - } - - /// Completes the OTP (One-Time Password) authentication process. - /// - /// Verifies the provided OTP code and determines whether to log in an existing user or sign up a new user. - /// If the user exists, logs them in and returns the session token. - /// If the user does not exist, signs them up and returns the session token. - /// Cleans up any generated key pairs after use. - /// - /// [otpId] The ID of the OTP to verify. - /// [otpCode] The OTP code to verify. - /// [contact] The contact information (email or phone number) associated with the OTP. - /// [otpType] The type of OTP (email or SMS). - /// [publicKey] An optional public key to use for the session. If null, a new key pair is generated. - /// [invalidateExisting] Whether to invalidate existing sessions when logging in or signing up. - /// [sessionKey] An optional key to store the session under. If null, uses the default session key. - /// [createSubOrgParams] Optional parameters for creating the sub-organization user during sign-up. - /// Returns a [LoginOrSignUpOtpResult] containing the session token and action (login or signup) if successful. - /// Throws an [Exception] if the OTP authentication process fails. - Future loginOrSignUpWithOtp({ - required String otpId, - required String otpCode, - required String contact, - required OtpType otpType, - String? publicKey = null, - bool invalidateExisting = false, - String? sessionKey = null, - CreateSubOrgParams? createSubOrgParams, - }) async { - try { - final result = await verifyOtp( - otpCode: otpCode, otpId: otpId, contact: contact, otpType: otpType); - - if (result.subOrganizationId != null && - result.subOrganizationId!.isNotEmpty) { - final loginResp = await loginWithOtp( - verificationToken: result.verificationToken, - organizationId: result.subOrganizationId, - invalidateExisting: invalidateExisting, - publicKey: publicKey, - sessionKey: sessionKey, - ); - - return LoginOrSignUpWithOtpResult( - sessionToken: loginResp.sessionToken, action: AuthAction.login); - } else { - final signUpRes = await signUpWithOtp( - verificationToken: result.verificationToken, - contact: contact, - otpType: otpType, - publicKey: publicKey, - sessionKey: sessionKey, - createSubOrgParams: createSubOrgParams, - invalidateExisting: invalidateExisting); - - return LoginOrSignUpWithOtpResult( - sessionToken: signUpRes.sessionToken, action: AuthAction.signup); - } - } catch (e) { - throw Exception("OTP authentication failed: $e"); - } - } - - /// Logs in a user using an OAuth token. - /// - /// Sends a login request to the backend with the provided OIDC token and public key. - /// Stores the session JWT and manages session state. - /// - /// [oidcToken] The OIDC token received from the OAuth provider. - /// [publicKey] The public key to use for the session. - /// [invalidateExisting] Whether to invalidate existing sessions when logging in. - /// [sessionKey] An optional key to store the session under. If null, uses the default session key. - /// Returns a [LoginWithOAuthResult] containing the session token if successful. - /// Throws an [Exception] if the login process fails. - Future loginWithOAuth({ - required String oidcToken, - required String publicKey, - bool? invalidateExisting = false, - String? sessionKey, - }) async { - try { - final loginRes = await requireClient.proxyOAuthLogin( - input: ProxyTOAuthLoginBody( - oidcToken: oidcToken, - publicKey: publicKey, - invalidateExisting: invalidateExisting)); - await storeSession(sessionJwt: loginRes.session, sessionKey: sessionKey); - return LoginWithOAuthResult(sessionToken: loginRes.session); - } catch (e) { - throw Exception("OAuth login failed: $e"); - } - } - - /// Signs up a new user using an OAuth token. - /// - /// Generates a temporary API key pair for OAuth sign-up. - /// Creates a new sub-organization user with the provided OIDC token and provider name. - /// Stamps a login session for the new user and stores the session JWT. - /// Cleans up the generated key pair after use. - /// - /// [oidcToken] The OIDC token received from the OAuth provider. - /// [publicKey] The public key to use for the session. - /// [providerName] The name of the OAuth provider (e.g., "google", "x", "discord"). - /// [sessionKey] An optional key to store the session under. If null, uses the default session key. - /// [createSubOrgParams] Optional parameters for creating the sub-organization user. - /// Returns a [SignUpWithOAuthResult] containing the session token if successful. - /// Throws an [Exception] if the sign-up process fails. - Future signUpWithOAuth({ - required String oidcToken, - required String publicKey, - required String providerName, - String? sessionKey, - CreateSubOrgParams? createSubOrgParams, - }) async { - final overrideParams = OAuthOverridedParams( - oidcToken: oidcToken, - providerName: providerName, - ); - final updatedCreateSubOrgParams = - getCreateSubOrgParams(createSubOrgParams, config, overrideParams); - - final signUpBody = - buildSignUpBody(createSubOrgParams: updatedCreateSubOrgParams); - - try { - final res = await requireClient.proxySignup(input: signUpBody); - - final organizationId = res.organizationId; - if (organizationId.isEmpty) { - throw Exception("Sign up failed: No organizationId returned"); - } - - final response = await loginWithOAuth( - oidcToken: oidcToken, - publicKey: publicKey, - sessionKey: sessionKey, - ); - - return SignUpWithOAuthResult(sessionToken: response.sessionToken); - } catch (e) { - throw Exception("Sign up failed: $e"); - } - } - - /// Completes the OAuth authentication process. - /// - /// Verifies the provided OIDC token and determines whether to log in an existing user or sign up a new user. - /// If the user exists, logs them in and returns the session token. - /// If the user does not exist, signs them up and returns the session token. - /// Cleans up any generated key pairs after use. - /// - /// [oidcToken] The OIDC token received from the OAuth provider. - /// [publicKey] The public key to use for the session. - /// [providerName] The name of the OAuth provider (e.g., "google", "x", "discord"). Required for sign-up. - /// [sessionKey] An optional key to store the session under. If null, uses the default session key. - /// [invalidateExisting] Whether to invalidate existing sessions when logging in or signing up. - /// [createSubOrgParams] Optional parameters for creating the sub-organization user during sign-up. - /// Returns a [LoginOrSignUpOAuthResult] containing the session token and action (login or signup) if successful. - /// Throws an [Exception] if the OAuth authentication process fails. - Future loginOrSignUpWithOAuth({ - required String oidcToken, - required String publicKey, - String? providerName, - String? sessionKey, - bool? invalidateExisting, - CreateSubOrgParams? createSubOrgParams, - }) async { - try { - final accountRes = await requireClient.proxyGetAccount( - input: ProxyTGetAccountBody( - filterType: "OIDC_TOKEN", filterValue: oidcToken)); - - if (accountRes.organizationId?.isNotEmpty == true) { - final loginRes = await loginWithOAuth( - oidcToken: oidcToken, - publicKey: publicKey, - sessionKey: sessionKey, - invalidateExisting: invalidateExisting, - ); - return LoginOrSignUpWithOAuthResult( - sessionToken: loginRes.sessionToken, action: AuthAction.login); - } else { - if (providerName == null || providerName.isEmpty) { - throw Exception("Provider name is required for sign up"); - } - final signUpRes = await signUpWithOAuth( - oidcToken: oidcToken, - publicKey: publicKey, - providerName: providerName, - sessionKey: sessionKey, - createSubOrgParams: createSubOrgParams); - - return LoginOrSignUpWithOAuthResult( - sessionToken: signUpRes.sessionToken, action: AuthAction.signup); - } - } catch (e) { - throw Exception("OAuth authentication failed: $e"); - } - } - - /// Handles the Google OAuth authentication flow. - /// - /// Initiates an in-app browser OAuth flow with the provided credentials and parameters. - /// After the OAuth flow completes successfully, it extracts the oidcToken from the callback URL - /// and invokes `loginOrSignUpWithOAuth` or the provided onSuccess callback. - /// - /// Throws an [Exception] if the authentication process fails or times out. - /// - /// [clientId] Optional client ID that overrides the default client ID passed into the config or pulled from the Wallet Kit dashboard for Google OAuth. - /// [originUri] Optional base URI to start the OAuth flow. Defaults to TURNKEY_OAUTH_ORIGIN_URL. - /// [redirectUri] Optional redirect URI for the OAuth flow. Defaults to a constructed URI with the provided scheme. - /// [onSuccess] Optional callback function that receives the oidcToken upon successful authentication, overrides default behavior. - Future handleGoogleOAuth({ - String? clientId, - String? originUri = TURNKEY_OAUTH_ORIGIN_URL, - String? redirectUri, - String? sessionKey, - bool? invalidateExisting, - void Function(String oidcToken)? onSuccess, - }) async { - final scheme = config.appScheme; - if (scheme == null) { - throw Exception( - "App scheme is not configured. Please set `appScheme` in TurnkeyConfig."); - } - - final AppLinks appLinks = AppLinks(); - - final targetPublicKey = await createApiKeyPair(); - try { - final nonce = sha256.convert(utf8.encode(targetPublicKey)).toString(); - final googleClientId = clientId ?? - masterConfig?.authConfig?.oAuthConfig?.googleClientId ?? - (throw Exception("Google Client ID not configured")); - final resolvedRedirectUri = redirectUri ?? - masterConfig?.authConfig?.oAuthConfig?.oauthRedirectUri ?? - '${TURNKEY_OAUTH_REDIRECT_URL}?scheme=${Uri.encodeComponent(scheme)}'; - - final oauthUrl = originUri! + - '?provider=google' + - '&clientId=${Uri.encodeComponent(googleClientId)}' + - '&redirectUri=${Uri.encodeComponent(resolvedRedirectUri)}' + - '&nonce=${Uri.encodeComponent(nonce)}'; - - // we create a completer to wait for the authentication result - final Completer authCompleter = Completer(); - - // set up a subscription for deep links - StreamSubscription? subscription; - subscription = appLinks.uriLinkStream.listen((Uri? uri) async { - if (uri != null && uri.toString().startsWith(scheme)) { - // we parse query parameters from the URI - final idToken = uri.queryParameters['id_token']; - - if (idToken != null) { - if (onSuccess != null) { - onSuccess(idToken); - } else { - await loginOrSignUpWithOAuth( - oidcToken: idToken, - publicKey: targetPublicKey, - providerName: 'google', - sessionKey: sessionKey, - invalidateExisting: invalidateExisting, - ); - } - - // complete the auth process - // this runs the `whenComplete()` callback - if (!authCompleter.isCompleted) { - authCompleter.complete(); - } - } - } - }); - - try { - final browser = _OAuthBrowser( - onBrowserClosed: () { - if (!authCompleter.isCompleted) { - subscription?.cancel(); - authCompleter.complete(); - return; - } - }, - ); - - await browser.open( - url: WebUri(oauthUrl), - settings: ChromeSafariBrowserSettings( - showTitle: true, - toolbarBackgroundColor: Colors.white, - ), - ); - - // set a timeout for the authentication process - await authCompleter.future.timeout( - const Duration(minutes: 10), - onTimeout: () { - subscription?.cancel(); - throw Exception('Authentication timed out'); - }, - ); - - await authCompleter.future.whenComplete(() async { - await browser.close(); - subscription?.cancel(); - }); - } catch (e) { - subscription.cancel(); - throw Exception('Google OAuth failed in browser: $e'); - } - } catch (error) { - await deleteUnusedKeyPairs(); - throw Exception('Failed to login or signup with Google: $error'); - } - } - - Future handleAppleOAuth({ - String? clientId, - String? originUri = APPLE_AUTH_URL, - String? redirectUri, - String? sessionKey, - bool? invalidateExisting, - void Function(String oidcToken)? onSuccess, - }) async { - final scheme = config.appScheme; - if (scheme == null) { - throw Exception( - "App scheme is not configured. Please set `appScheme` in TurnkeyConfig."); - } - - final AppLinks appLinks = AppLinks(); - - final targetPublicKey = await createApiKeyPair(); - try { - final nonce = sha256.convert(utf8.encode(targetPublicKey)).toString(); - final appleClientId = clientId ?? - masterConfig?.authConfig?.oAuthConfig?.appleClientId ?? - (throw Exception("Apple Client ID not configured")); - final resolvedRedirectUri = redirectUri ?? - masterConfig?.authConfig?.oAuthConfig?.oauthRedirectUri ?? - '${TURNKEY_OAUTH_REDIRECT_URL}?scheme=${Uri.encodeComponent(scheme)}'; - - final oauthUrl = originUri! + - '?provider=apple' + - '&clientId=${Uri.encodeComponent(appleClientId)}' + - '&redirectUri=${Uri.encodeComponent(resolvedRedirectUri)}' + - '&nonce=${Uri.encodeComponent(nonce)}'; - - final Completer authCompleter = Completer(); - - // set up a subscription for deep links - StreamSubscription? subscription; - subscription = appLinks.uriLinkStream.listen((Uri? uri) async { - if (uri != null && uri.toString().startsWith(scheme)) { - // we parse query parameters from the URI - final idToken = uri.queryParameters['id_token']; - - if (idToken != null) { - if (onSuccess != null) { - onSuccess(idToken); - } else { - await loginOrSignUpWithOAuth( - oidcToken: idToken, - publicKey: targetPublicKey, - providerName: 'apple', - sessionKey: sessionKey, - invalidateExisting: invalidateExisting, - ); - } - - // complete the auth process - // this runs the `whenComplete()` callback - if (!authCompleter.isCompleted) { - authCompleter.complete(); - } - } - } - }); - - try { - final browser = _OAuthBrowser( - onBrowserClosed: () { - if (!authCompleter.isCompleted) { - subscription?.cancel(); - authCompleter.complete(); - return; - } - }, - ); - - await browser.open( - url: WebUri(oauthUrl), - settings: ChromeSafariBrowserSettings( - showTitle: true, - toolbarBackgroundColor: Colors.white, - ), - ); - - // set a timeout for the authentication process - await authCompleter.future.timeout( - const Duration(minutes: 10), - onTimeout: () { - subscription?.cancel(); - throw Exception('Authentication timed out'); - }, - ); - - await authCompleter.future.whenComplete(() async { - await browser.close(); - subscription?.cancel(); - }); - } catch (e) { - subscription.cancel(); - throw Exception('Apple OAuth failed in browser: $e'); - } - } catch (error) { - await deleteUnusedKeyPairs(); - throw Exception('Failed to login or signup with Apple: $error'); - } - } - - /// Handles the X (formerly Twitter) OAuth authentication flow. - /// - /// Initiates an in-app browser OAuth flow with the provided credentials and parameters. - /// After the OAuth flow completes successfully, it extracts the oidcToken from the callback URL - /// and invokes `loginOrSignUpWithOAuth` or the provided onSuccess callback. - /// - /// Throws an [Exception] if the authentication process fails or times out. - /// - /// [clientId] Optional client ID that overrides the default client ID passed into the config or pulled from the Wallet Kit dashboard for X OAuth. - /// [originUri] Optional base URI to start the OAuth flow. Defaults to X_AUTH_URL. - /// [redirectUri] Optional redirect URI for the OAuth flow. Defaults to a constructed URI with the provided scheme. - /// [onSuccess] Optional callback function that receives the oidcToken upon successful authentication, overrides default behavior. - Future handleXOAuth({ - String? clientId, - String? originUri = X_AUTH_URL, - String? redirectUri, - String? sessionKey, - bool? invalidateExisting, - void Function(String oidcToken)? onSuccess, - }) async { - final scheme = config.appScheme; - if (scheme == null) { - throw Exception( - "App scheme is not configured. Please set `appScheme` in TurnkeyConfig."); - } - - final AppLinks appLinks = AppLinks(); - - final targetPublicKey = await createApiKeyPair(); - - try { - final nonce = sha256.convert(utf8.encode(targetPublicKey)).toString(); - final xClientId = clientId ?? - masterConfig?.authConfig?.oAuthConfig?.xClientId ?? - (throw Exception("X Client ID not configured")); - final resolvedRedirectUri = redirectUri ?? - masterConfig?.authConfig?.oAuthConfig?.oauthRedirectUri ?? - '${config.appScheme}://'; - - final challengePair = await generateChallengePair(); - final verifier = challengePair.verifier; - final codeChallenge = challengePair.codeChallenge; - - final state = - 'provider=twitter&flow=redirect&publicKey=${Uri.encodeComponent(targetPublicKey)}&nonce=${nonce}'; - - final xAuthUrl = originUri! + - '?client_id=${Uri.encodeComponent(xClientId)}' + - '&redirect_uri=${Uri.encodeComponent(resolvedRedirectUri)}' + - '&response_type=code' + - '&code_challenge=${Uri.encodeComponent(codeChallenge)}' + - '&code_challenge_method=S256' + - '&scope=${Uri.encodeComponent("tweet.read users.read")}' + - '&state=${Uri.encodeComponent(state)}'; - - // we create a completer to wait for the authentication result - final Completer authCompleter = Completer(); - - // set up a subscription for deep links - StreamSubscription? subscription; - subscription = appLinks.uriLinkStream.listen((Uri? uri) async { - if (uri != null && uri.toString().startsWith(scheme)) { - // we parse query parameters from the URI - final authCode = uri.queryParameters['code']; - - if (authCode != null) { - final res = await requireClient.proxyOAuth2Authenticate( - input: ProxyTOAuth2AuthenticateBody( - provider: v1Oauth2Provider.oauth2_provider_x, - authCode: authCode, - redirectUri: resolvedRedirectUri, - codeVerifier: verifier, - clientId: xClientId, - nonce: nonce)); - - final oidcToken = res.oidcToken; - - if (onSuccess != null) { - onSuccess(oidcToken); - } else { - await loginOrSignUpWithOAuth( - oidcToken: oidcToken, - publicKey: targetPublicKey, - providerName: 'x', - sessionKey: sessionKey, - invalidateExisting: invalidateExisting, - ); - } - - // complete the auth process - // this runs the `whenComplete()` callback - if (!authCompleter.isCompleted) { - authCompleter.complete(); - } - } - } - }); - - try { - final browser = _OAuthBrowser( - onBrowserClosed: () { - if (!authCompleter.isCompleted) { - subscription?.cancel(); - authCompleter.complete(); - return; - } - }, - ); - - await browser.open( - url: WebUri(xAuthUrl), - settings: ChromeSafariBrowserSettings( - showTitle: true, - toolbarBackgroundColor: Colors.white, - ), - ); - - // we set a timeout for the authentication process - await authCompleter.future.timeout( - const Duration(minutes: 10), - onTimeout: () { - subscription?.cancel(); - throw Exception('Authentication timed out'); - }, - ); - - await authCompleter.future.whenComplete(() async { - await browser.close(); - subscription?.cancel(); - }); - } catch (e) { - subscription.cancel(); - throw Exception('X OAuth failed in browser: $e'); - } - } catch (error) { - await deleteUnusedKeyPairs(); - throw Exception('Failed to login or signup with X: $error'); - } - } - - /// Handles the Discord OAuth authentication flow. - /// - /// Initiates an in-app browser OAuth flow with the provided credentials and parameters. - /// After the OAuth flow completes successfully, it extracts the oidcToken from the callback URL - /// and invokes `loginOrSignUpWithOAuth` or the provided onSuccess callback. - /// - /// Throws an [Exception] if the authentication process fails or times out. - /// - /// [clientId] Optional client ID that overrides the default client ID passed into the config or pulled from the Wallet Kit dashboard for Discord OAuth. - /// [originUri] Optional base URI to start the OAuth flow. Defaults to DISCORD_AUTH_URL. - /// [redirectUri] Optional redirect URI for the OAuth flow. Defaults to a constructed URI with the provided scheme. - /// [onSuccess] Optional callback function that receives the oidcToken upon successful authentication, overrides default behavior. - Future handleDiscordOAuth({ - String? clientId, - String? originUri = DISCORD_AUTH_URL, - String? redirectUri, - String? sessionKey, - String? invalidateExisting, - void Function(String oidcToken)? onSuccess, - }) async { - final scheme = config.appScheme; - if (scheme == null) { - throw Exception( - "App scheme is not configured. Please set `appScheme` in TurnkeyConfig."); - } - - final AppLinks appLinks = AppLinks(); - - final targetPublicKey = await createApiKeyPair(); - try { - final nonce = sha256.convert(utf8.encode(targetPublicKey)).toString(); - final discordClientId = clientId ?? - masterConfig?.authConfig?.oAuthConfig?.discordClientId ?? - (throw Exception("Discord Client ID not configured")); - final resolvedRedirectUri = redirectUri ?? - masterConfig?.authConfig?.oAuthConfig?.oauthRedirectUri ?? - '${scheme}://'; - - final challengePair = await generateChallengePair(); - final verifier = challengePair.verifier; - final codeChallenge = challengePair.codeChallenge; - - final state = - 'provider=discord&flow=redirect&publicKey=${Uri.encodeComponent(targetPublicKey)}&nonce=${nonce}'; - - final discordAuthUrl = originUri! + - '?client_id=${Uri.encodeComponent(discordClientId)}' + - '&redirect_uri=${Uri.encodeComponent(resolvedRedirectUri)}' + - '&response_type=code' + - '&code_challenge=${Uri.encodeComponent(codeChallenge)}' + - '&code_challenge_method=S256' + - '&scope=${Uri.encodeComponent("identify email")}' + - '&state=${Uri.encodeComponent(state)}'; - - // we create a completer to wait for the authentication result - final Completer authCompleter = Completer(); - - // set up a subscription for deep links - StreamSubscription? subscription; - subscription = appLinks.uriLinkStream.listen((Uri? uri) async { - if (uri != null && uri.toString().startsWith(scheme)) { - // we parse query parameters from the URI - final authCode = uri.queryParameters['code']; - - if (authCode != null) { - final res = await requireClient.proxyOAuth2Authenticate( - input: ProxyTOAuth2AuthenticateBody( - provider: v1Oauth2Provider.oauth2_provider_discord, - authCode: authCode, - redirectUri: resolvedRedirectUri, - codeVerifier: verifier, - clientId: discordClientId, - nonce: nonce)); - - final oidcToken = res.oidcToken; - - if (onSuccess != null) { - onSuccess(oidcToken); - } else { - await loginOrSignUpWithOAuth( - oidcToken: oidcToken, - publicKey: targetPublicKey, - providerName: 'discord', - ); - } - - // complete the auth process - // this runs the `whenComplete()` callback - if (!authCompleter.isCompleted) { - authCompleter.complete(); - } - } - } - }); - - try { - final browser = _OAuthBrowser( - onBrowserClosed: () { - if (!authCompleter.isCompleted) { - subscription?.cancel(); - authCompleter.complete(); - return; - } - }, - ); - - await browser.open( - url: WebUri(discordAuthUrl), - settings: ChromeSafariBrowserSettings( - showTitle: true, - toolbarBackgroundColor: Colors.white, - ), - ); - - // we set a timeout for the authentication process - await authCompleter.future.timeout( - const Duration(minutes: 10), - onTimeout: () { - subscription?.cancel(); - throw Exception('Authentication timed out'); - }, - ); - - await authCompleter.future.whenComplete(() async { - await browser.close(); - subscription?.cancel(); - }); - } catch (e) { - subscription.cancel(); - throw Exception('Discord OAuth failed in browser: $e'); - } - } catch (error) { - await deleteUnusedKeyPairs(); - throw Exception('Failed to login or signup with Discord: $error'); - } - } - - /// Refreshes the current user data. - /// - /// Fetches the latest user details from the API using the current session's client. - /// If the user data is successfully retrieved, updates the session with the new user details. - /// Saves the updated session and updates the state. - /// - /// Throws an [Exception] if the session or client is not initialized. - Future refreshUser() async { - if (session == null) { - throw Exception("Failed to refresh user. Sessions not initialized"); - } - user = await fetchUser( - requireClient, session!.organizationId, session!.userId); - } - - /// Refreshes the current wallets data. - /// - /// Fetches the latest wallet details from the API using the current session's client. - /// If the wallet data is successfully retrieved, updates the state with the new wallet information. - /// - /// Throws an [Exception] if the session is not initialized. - Future refreshWallets() async { - if (session == null) { - throw Exception("Failed to refresh wallets. No session initialized"); - } - wallets = await fetchWallets(requireClient, session!.organizationId); - } - - /// Creates a new wallet with the specified name and accounts. - /// - /// Throws an [Exception] if the client or user is not initialized. - /// - /// [walletName] The name of the wallet. - /// [accounts] The accounts to create in the wallet. - /// [mnemonicLength] The length of the mnemonic. - Future createWallet( - {required String walletName, - required List accounts, - int? mnemonicLength}) async { - if (session == null) { - throw Exception("No active session found. Please log in first."); - } - - final response = await requireClient.createWallet( - input: TCreateWalletBody( - accounts: accounts, - walletName: walletName, - mnemonicLength: mnemonicLength, - )); - final activity = response.activity; - if (activity.result.createWalletResult?.walletId != null) { - await refreshWallets(); - } - - return activity; - } - - /// Imports a wallet using a provided mnemonic and creates accounts. - /// - /// Throws an [Exception] if the client or user is not initialized. - /// - /// [mnemonic] The mnemonic to import. - /// [walletName] The name of the wallet. - /// [accounts] The accounts to create in the wallet. - /// [dangerouslyOverrideSignerPublicKey] An optional public key to override the signer. - Future importWallet( - {required String mnemonic, - required String walletName, - required List accounts, - String? dangerouslyOverrideSignerPublicKey}) async { - if (session == null) { - throw Exception("No active session found. Please log in first."); - } - - // this should never happen - if (user == null) { - throw Exception("No user found."); - } - - final initResponse = await requireClient.initImportWallet( - input: TInitImportWalletBody(userId: user!.userId)); - - final importBundle = - initResponse.activity.result.initImportWalletResult?.importBundle; - - if (importBundle == null) { - throw Exception("Failed to get import bundle"); - } - - final encryptedBundle = await encryptWalletToBundle( - mnemonic: mnemonic, - importBundle: importBundle, - userId: user!.userId, - organizationId: session!.organizationId, - dangerouslyOverrideSignerPublicKey: - dangerouslyOverrideSignerPublicKey, - ); - - final response = await requireClient.importWallet( - input: TImportWalletBody( - userId: user!.userId, - walletName: walletName, - encryptedBundle: encryptedBundle, - accounts: accounts)); - final activity = response.activity; - if (activity.result.importWalletResult?.walletId != null) { - await refreshWallets(); - } - } - - /// Exports an existing wallet by decrypting the stored mnemonic phrase. - /// - /// Throws an [Exception] if the client, user, or export bundle is not initialized. - /// - /// [walletId] The ID of the wallet to export. - /// [dangerouslyOverrideSignerPublicKey] An optional public key to override the signer. - Future exportWallet( - {required String walletId, - String? dangerouslyOverrideSignerPublicKey, - bool? returnMnemonic}) async { - if (session == null) { - throw Exception("No active session found. Please log in first."); - } - - final keyPair = await generateP256KeyPair(); - - final response = await requireClient.exportWallet( - input: TExportWalletBody( - walletId: walletId, - targetPublicKey: keyPair.publicKeyUncompressed)); - final exportBundle = - response.activity.result.exportWalletResult?.exportBundle; - - if (exportBundle == null) { - throw Exception("Export bundle, embedded key, or user not initialized"); - } - - await refreshWallets(); - - return await decryptExportBundle( - exportBundle: exportBundle, - embeddedKey: keyPair.privateKey, - organizationId: session!.organizationId, - dangerouslyOverrideSignerPublicKey: dangerouslyOverrideSignerPublicKey, - returnMnemonic: returnMnemonic ?? true); - } - - /// Signs a raw payload using the specified signing key and encoding parameters. - /// - /// Throws an [Exception] if the client or user is not initialized. - /// - /// [signWith] The key to sign with. - /// [payload] The payload to sign. - /// [encoding] The encoding of the payload. - /// [hashFunction] The hash function to use. - Future signRawPayload( - {required String signWith, - required String payload, - required v1PayloadEncoding encoding, - required v1HashFunction hashFunction}) async { - if (session == null) { - throw Exception("No active session found. Please log in first."); - } - - final response = await requireClient.signRawPayload( - input: TSignRawPayloadBody( - signWith: signWith, - payload: payload, - encoding: encoding, - hashFunction: hashFunction, - )); - - final signRawPayloadResult = response.activity.result.signRawPayloadResult; - if (signRawPayloadResult == null) { - throw Exception("Failed to sign raw payload"); - } - return signRawPayloadResult; - } - - /// Signs a transaction using the specified signing key and transaction parameters. - /// - /// Throws an [Exception] if the client or user is not initialized. - /// - /// [signWith] The key to sign with. - /// [unsignedTransaction] The unsigned transaction to sign. - /// [type] The type of the transaction from the [TransactionType] enum. - Future signTransaction( - {required String signWith, - required String unsignedTransaction, - required v1TransactionType type}) async { - if (session == null) { - throw Exception("No active session found. Please log in first."); - } - - final response = await requireClient.signTransaction( - input: TSignTransactionBody( - signWith: signWith, - unsignedTransaction: unsignedTransaction, - type: type)); - - final signTransactionResult = - response.activity.result.signTransactionResult; - if (signTransactionResult == null) { - throw Exception("Failed to sign transaction"); - } - return signTransactionResult; - } -} - -// we create a custom browser class to handle the onClosed event -class _OAuthBrowser extends ChromeSafariBrowser { - final VoidCallback onBrowserClosed; - - _OAuthBrowser({required this.onBrowserClosed}); - - @override - void onClosed() { - onBrowserClosed(); - super.onClosed(); - } } diff --git a/packages/sdk-flutter/lib/src/turnkey_auth_proxy.dart b/packages/sdk-flutter/lib/src/turnkey_auth_proxy.dart new file mode 100644 index 0000000..2e21470 --- /dev/null +++ b/packages/sdk-flutter/lib/src/turnkey_auth_proxy.dart @@ -0,0 +1,360 @@ +part of 'turnkey.dart'; + +extension AuthProxyExtension on TurnkeyProvider { + + /// Initializes an OTP (One-Time Password) request for the specified contact method. + /// + /// Sends a request to the backend to generate and send an OTP to the provided contact (email or phone number). + /// Returns the OTP ID that can be used for subsequent verification. + /// + /// [otpType] The type of OTP to initialize (email or SMS). + /// [contact] The contact information (email address or phone number) to send the OTP to. + /// Returns a [String] representing the OTP ID. + /// Throws an [Exception] if the OTP initialization fails. + Future initOtp( + {required OtpType otpType, required String contact}) async { + final res = await requireClient.proxyInitOtp( + input: ProxyTInitOtpBody( + contact: contact, + otpType: otpType.value, + )); + + return res.otpId; + } + + /// Verifies an OTP code and retrieves a verification token and sub-organization ID. + /// + /// Throws an [Exception] if the OTP verification fails or if the account cannot be retrieved. + /// + /// [otpCode] The OTP code to verify. + /// [otpId] The ID of the OTP to verify. + /// [contact] The contact information (email or phone number) associated with the OTP. + /// [otpType] The type of OTP (email or SMS). + /// Returns a [VerifyOtpResult] containing the verification token and sub-organization ID. + Future verifyOtp( + {required String otpCode, + required String otpId, + required String contact, + required OtpType otpType}) async { + final verifyOtpRes = await requireClient.proxyVerifyOtp( + input: ProxyTVerifyOtpBody( + otpCode: otpCode, + otpId: otpId, + )); + + if (verifyOtpRes.verificationToken.isEmpty) { + throw Exception("Failed to verify OTP"); + } + + final accountRes = await requireClient.proxyGetAccount( + input: ProxyTGetAccountBody( + filterType: otpTypeToFilterTypeMap[otpType]!.value, + filterValue: contact)); + + final subOrganizationId = accountRes.organizationId; + return VerifyOtpResult( + subOrganizationId: subOrganizationId, + verificationToken: verifyOtpRes.verificationToken); + } + + /// Logs in a user using an OTP (One-Time Password) verification token. + /// + /// Generates or uses an existing API key pair for authentication. + /// Sends a login request to the backend with the provided verification token and optional parameters. + /// Stores the session JWT and manages session state. + /// Cleans up the generated key pair if it was not used for the session. + /// + /// [verificationToken] The OTP verification token received after verifying the OTP code. + /// [organizationId] An optional organization ID to associate with the session. + /// [invalidateExisting] Whether to invalidate existing sessions when logging in. + /// [publicKey] An optional public key to use for the session. If null, a new key pair is generated. + /// [sessionKey] An optional key to store the session under. If null, uses the default session key. + /// Returns a [LoginWithOtpResult] containing the session token if successful. + /// Throws an [Exception] if the login process fails. + Future loginWithOtp({ + required String verificationToken, + String? organizationId, + bool invalidateExisting = false, + String? publicKey, + String? sessionKey, + }) async { + String? generatedPublicKey; + + try { + generatedPublicKey = publicKey ?? await createApiKeyPair(); + + final res = await requireClient.proxyOtpLogin( + input: ProxyTOtpLoginBody( + organizationId: organizationId, + publicKey: generatedPublicKey, + verificationToken: verificationToken, + invalidateExisting: invalidateExisting, + ), + ); + + await storeSession(sessionJwt: res.session, sessionKey: sessionKey); + + return LoginWithOtpResult( + sessionToken: res.session, + ); + } catch (error) { + await deleteUnusedKeyPairs(); + throw Exception('Failed to login with otp: $error'); + } + } + + /// Signs up a new user using an OTP (One-Time Password) verification token. + /// + /// Generates a temporary API key pair for OTP sign-up. + /// Creates a new sub-organization user with the provided contact information and verification token. + /// Stamps a login session for the new user and stores the session JWT. + /// Cleans up the generated key pair after use. + /// + /// [verificationToken] The OTP verification token received after verifying the OTP code. + /// [contact] The contact information (email address or phone number) associated with the OTP. + /// [otpType] The type of OTP (email or SMS). + /// [publicKey] An optional public key to use for the session. If null, a new key pair is generated. + /// [sessionKey] An optional key to store the session under. If null, uses the default session key. + /// [createSubOrgParams] Optional parameters for creating the sub-organization user. + /// [invalidateExisting] Whether to invalidate existing sessions when signing up. + /// Returns a [SignUpWithOtpResult] containing the session token if successful. + /// Throws an [Exception] if the sign-up process fails. + Future signUpWithOtp({ + required String verificationToken, + required String contact, + required OtpType otpType, + String? publicKey, + String? sessionKey, + CreateSubOrgParams? createSubOrgParams, + bool invalidateExisting = false, + }) async { + final overrideParams = OtpOverriredParams( + otpType: otpType, + contact: contact, + verificationToken: verificationToken, + ); + final updatedCreateSubOrgParams = + getCreateSubOrgParams(createSubOrgParams, config, overrideParams); + + final signUpBody = + buildSignUpBody(createSubOrgParams: updatedCreateSubOrgParams); + + try { + final res = await requireClient.proxySignup(input: signUpBody); + + final orgId = res.organizationId; + if (orgId.isEmpty) { + throw Exception("Sign up failed: No organizationId returned"); + } + + final response = await loginWithOtp( + organizationId: orgId, + verificationToken: verificationToken, + sessionKey: sessionKey, + invalidateExisting: invalidateExisting, + ); + + return SignUpWithOtpResult(sessionToken: response.sessionToken); + } catch (e) { + throw Exception("Sign up failed: $e"); + } + } + + /// Completes the OTP (One-Time Password) authentication process. + /// + /// Verifies the provided OTP code and determines whether to log in an existing user or sign up a new user. + /// If the user exists, logs them in and returns the session token. + /// If the user does not exist, signs them up and returns the session token. + /// Cleans up any generated key pairs after use. + /// + /// [otpId] The ID of the OTP to verify. + /// [otpCode] The OTP code to verify. + /// [contact] The contact information (email or phone number) associated with the OTP. + /// [otpType] The type of OTP (email or SMS). + /// [publicKey] An optional public key to use for the session. If null, a new key pair is generated. + /// [invalidateExisting] Whether to invalidate existing sessions when logging in or signing up. + /// [sessionKey] An optional key to store the session under. If null, uses the default session key. + /// [createSubOrgParams] Optional parameters for creating the sub-organization user during sign-up. + /// Returns a [LoginOrSignUpOtpResult] containing the session token and action (login or signup) if successful. + /// Throws an [Exception] if the OTP authentication process fails. + Future loginOrSignUpWithOtp({ + required String otpId, + required String otpCode, + required String contact, + required OtpType otpType, + String? publicKey = null, + bool invalidateExisting = false, + String? sessionKey = null, + CreateSubOrgParams? createSubOrgParams, + }) async { + try { + final result = await verifyOtp( + otpCode: otpCode, otpId: otpId, contact: contact, otpType: otpType); + + if (result.subOrganizationId != null && + result.subOrganizationId!.isNotEmpty) { + final loginResp = await loginWithOtp( + verificationToken: result.verificationToken, + organizationId: result.subOrganizationId, + invalidateExisting: invalidateExisting, + publicKey: publicKey, + sessionKey: sessionKey, + ); + + return LoginOrSignUpWithOtpResult( + sessionToken: loginResp.sessionToken, action: AuthAction.login); + } else { + final signUpRes = await signUpWithOtp( + verificationToken: result.verificationToken, + contact: contact, + otpType: otpType, + publicKey: publicKey, + sessionKey: sessionKey, + createSubOrgParams: createSubOrgParams, + invalidateExisting: invalidateExisting); + + return LoginOrSignUpWithOtpResult( + sessionToken: signUpRes.sessionToken, action: AuthAction.signup); + } + } catch (e) { + throw Exception("OTP authentication failed: $e"); + } + } + + /// Logs in a user using an OAuth token. + /// + /// Sends a login request to the backend with the provided OIDC token and public key. + /// Stores the session JWT and manages session state. + /// + /// [oidcToken] The OIDC token received from the OAuth provider. + /// [publicKey] The public key to use for the session. + /// [invalidateExisting] Whether to invalidate existing sessions when logging in. + /// [sessionKey] An optional key to store the session under. If null, uses the default session key. + /// Returns a [LoginWithOAuthResult] containing the session token if successful. + /// Throws an [Exception] if the login process fails. + Future loginWithOAuth({ + required String oidcToken, + required String publicKey, + bool? invalidateExisting = false, + String? sessionKey, + }) async { + try { + final loginRes = await requireClient.proxyOAuthLogin( + input: ProxyTOAuthLoginBody( + oidcToken: oidcToken, + publicKey: publicKey, + invalidateExisting: invalidateExisting)); + await storeSession(sessionJwt: loginRes.session, sessionKey: sessionKey); + return LoginWithOAuthResult(sessionToken: loginRes.session); + } catch (e) { + throw Exception("OAuth login failed: $e"); + } + } + + /// Signs up a new user using an OAuth token. + /// + /// Generates a temporary API key pair for OAuth sign-up. + /// Creates a new sub-organization user with the provided OIDC token and provider name. + /// Stamps a login session for the new user and stores the session JWT. + /// Cleans up the generated key pair after use. + /// + /// [oidcToken] The OIDC token received from the OAuth provider. + /// [publicKey] The public key to use for the session. + /// [providerName] The name of the OAuth provider (e.g., "google", "x", "discord"). + /// [sessionKey] An optional key to store the session under. If null, uses the default session key. + /// [createSubOrgParams] Optional parameters for creating the sub-organization user. + /// Returns a [SignUpWithOAuthResult] containing the session token if successful. + /// Throws an [Exception] if the sign-up process fails. + Future signUpWithOAuth({ + required String oidcToken, + required String publicKey, + required String providerName, + String? sessionKey, + CreateSubOrgParams? createSubOrgParams, + }) async { + final overrideParams = OAuthOverridedParams( + oidcToken: oidcToken, + providerName: providerName, + ); + final updatedCreateSubOrgParams = + getCreateSubOrgParams(createSubOrgParams, config, overrideParams); + + final signUpBody = + buildSignUpBody(createSubOrgParams: updatedCreateSubOrgParams); + + try { + final res = await requireClient.proxySignup(input: signUpBody); + + final organizationId = res.organizationId; + if (organizationId.isEmpty) { + throw Exception("Sign up failed: No organizationId returned"); + } + + final response = await loginWithOAuth( + oidcToken: oidcToken, + publicKey: publicKey, + sessionKey: sessionKey, + ); + + return SignUpWithOAuthResult(sessionToken: response.sessionToken); + } catch (e) { + throw Exception("Sign up failed: $e"); + } + } + + /// Completes the OAuth authentication process. + /// + /// Verifies the provided OIDC token and determines whether to log in an existing user or sign up a new user. + /// If the user exists, logs them in and returns the session token. + /// If the user does not exist, signs them up and returns the session token. + /// Cleans up any generated key pairs after use. + /// + /// [oidcToken] The OIDC token received from the OAuth provider. + /// [publicKey] The public key to use for the session. + /// [providerName] The name of the OAuth provider (e.g., "google", "x", "discord"). Required for sign-up. + /// [sessionKey] An optional key to store the session under. If null, uses the default session key. + /// [invalidateExisting] Whether to invalidate existing sessions when logging in or signing up. + /// [createSubOrgParams] Optional parameters for creating the sub-organization user during sign-up. + /// Returns a [LoginOrSignUpWithOAuthResult] containing the session token and action (login or signup) if successful. + /// Throws an [Exception] if the OAuth authentication process fails. + Future loginOrSignUpWithOAuth({ + required String oidcToken, + required String publicKey, + String? providerName, + String? sessionKey, + bool? invalidateExisting, + CreateSubOrgParams? createSubOrgParams, + }) async { + try { + final accountRes = await requireClient.proxyGetAccount( + input: ProxyTGetAccountBody( + filterType: "OIDC_TOKEN", filterValue: oidcToken)); + + if (accountRes.organizationId?.isNotEmpty == true) { + final loginRes = await loginWithOAuth( + oidcToken: oidcToken, + publicKey: publicKey, + sessionKey: sessionKey, + invalidateExisting: invalidateExisting, + ); + return LoginOrSignUpWithOAuthResult( + sessionToken: loginRes.sessionToken, action: AuthAction.login); + } else { + if (providerName == null || providerName.isEmpty) { + throw Exception("Provider name is required for sign up"); + } + final signUpRes = await signUpWithOAuth( + oidcToken: oidcToken, + publicKey: publicKey, + providerName: providerName, + sessionKey: sessionKey, + createSubOrgParams: createSubOrgParams); + + return LoginOrSignUpWithOAuthResult( + sessionToken: signUpRes.sessionToken, action: AuthAction.signup); + } + } catch (e) { + throw Exception("OAuth authentication failed: $e"); + } + } +} \ No newline at end of file diff --git a/packages/sdk-flutter/lib/src/turnkey_delegated_access.dart b/packages/sdk-flutter/lib/src/turnkey_delegated_access.dart new file mode 100644 index 0000000..137e90d --- /dev/null +++ b/packages/sdk-flutter/lib/src/turnkey_delegated_access.dart @@ -0,0 +1,212 @@ +part of 'turnkey.dart'; + +extension DelegatedAccessExtension on TurnkeyProvider { + /// Fetches an existing user by P-256 API key public key, or creates a new one if none exists. + /// + /// This function is idempotent: multiple calls with the same `publicKey` will always return the same user. + /// Attempts to find a user whose API keys include the given P-256 public key. + /// If a matching user is found, it is returned as-is. + /// If no matching user is found, a new user is created with the given public key as a P-256 API key. + /// + /// Throws an [Exception] if there is no active session, if input is invalid, or if + /// retrieval/creation fails. + /// + /// [publicKey] The P-256 public key to use for lookup and (if needed) creation. + /// [organizationId] Optional organization ID. Defaults to the current session’s organization ID. + /// [createParams] Optional parameters used only when creating a new user. + Future fetchOrCreateP256ApiKeyUser({ + required String publicKey, + String? organizationId, + CreateP256UserParams? createParams, + }) async { + if (session == null) { + throw Exception("No active session found. Please log in first."); + } + + final String? orgId = organizationId ?? session?.organizationId; + if (orgId == null || orgId.trim().isEmpty) { + throw Exception( + "Organization ID is required to fetch or create P-256 API key user.", + ); + } + + if (publicKey.trim().isEmpty) { + throw Exception("'publicKey' is required and cannot be empty."); + } + + // Fetch existing users + final usersResp = await requireClient.getUsers( + input: TGetUsersBody(organizationId: orgId), + ); + final List users = usersResp.users; + if (users.isEmpty) { + throw Exception("No users found in the organization."); + } + + // Try to find a user with the matching P-256 API key + v1User? userWithPublicKey; + for (final u in users) { + final apiKeys = u.apiKeys; + final match = apiKeys.any((k) { + final cred = k.credential; + return cred.publicKey == publicKey && + cred.type == v1CredentialType.credential_type_api_key_p256; + }); + if (match) { + userWithPublicKey = u; + break; + } + } + + // The user already exists, so we return it + if (userWithPublicKey != null) { + return userWithPublicKey; + } + + // At this point we know the user doesn't exist, so we create it + final String newUserName = + (createParams?.userName?.trim().isNotEmpty ?? false) + ? createParams!.userName!.trim() + : "Public Key User"; + + final String newApiKeyName = + (createParams?.apiKeyName?.trim().isNotEmpty ?? false) + ? createParams!.apiKeyName!.trim() + : "public-key-user-$publicKey"; + + final createUsersResp = await requireClient.createUsers( + input: TCreateUsersBody( + organizationId: orgId, + users: [ + v1UserParamsV3( + userName: newUserName, + userTags: const [], + apiKeys: [ + v1ApiKeyParamsV2( + apiKeyName: newApiKeyName, + curveType: v1ApiKeyCurve.api_key_curve_p256, + publicKey: publicKey, + ), + ], + authenticators: const [], + oauthProviders: const [], + ), + ], + ), + ); + + final List createdIds = createUsersResp.result?.userIds ?? const []; + if (createdIds.isEmpty || createdIds.first.isEmpty) { + throw Exception("Failed to create P-256 API key user."); + } + + final String newUserId = createdIds.first; + + // Fetch and return the newly created user + final created = await fetchUser(requireClient, orgId, newUserId); + + if (created == null) { + throw Exception("The newly created user could not be fetched."); + } + + return created; + } + + /// Fetches each requested policy if it exists, or creates it if it does not. + /// + /// This function is idempotent: multiple calls with the same policies will not create duplicates. + /// For every policy in the request: + /// If it already exists, it is returned with its `policyId`. + /// If it does not exist, it is created and returned with its new `policyId`. + /// Throws an [Exception] if there is no active session, if input is invalid, + /// or if fetching/creation fails. + /// + /// [policies] The list of policies to fetch or create. + /// [organizationId] Optional organization ID. Defaults to the current session’s organization ID. + /// + /// Returns a list of [Policy] containing `policyId`, `policyName`, + /// `effect`, and optional `condition`, `consensus`, `notes`. + Future> fetchOrCreatePolicies({ + required List policies, + String? organizationId, + }) async { + if (session == null) { + throw Exception("No active session found. Please log in first."); + } + if (policies.isEmpty) { + throw Exception( + "'policies' must be a non-empty list of policy definitions."); + } + + final String? orgId = organizationId ?? session?.organizationId; + if (orgId == null || orgId.trim().isEmpty) { + throw Exception( + "Organization ID is required to fetch or create policies."); + } + + // We first fetch existing policies + final existingResp = await requireClient.getPolicies( + input: TGetPoliciesBody(organizationId: orgId), + ); + final List existingPolicies = existingResp.policies; + + // We create a map of existing policies by their signature + // where the policySignature maps to its policyId + final Map existingSignatureToId = {}; + for (final ep in existingPolicies) { + final sig = getPolicySignatureFromExisting(ep); + if (ep.policyId.isNotEmpty) { + existingSignatureToId[sig] = ep.policyId; + } + } + + // We go through each requested policy and check if it already exists + // if it exists, we add it to the alreadyExistingPolicies list + // if it doesn't exist, we add it to the missingPolicies list + final List alreadyExisting = []; + final List missing = []; + + for (final p in policies) { + final sig = getPolicySignature(p); + final existingId = existingSignatureToId[sig]; + if (existingId != null) { + alreadyExisting + .add(Policy.fromCreateIntent(p, policyId: existingId)); + } else { + missing.add(p); + } + } + + // If there are no missing policies, that means we're done + // so we return them with their respective IDs + if (missing.isEmpty) { + return alreadyExisting; + } + + // At this point we know there is at least one missing policy. + // so we create the missing policies and then return the full list + final createResp = await requireClient.createPolicies( + input: TCreatePoliciesBody( + organizationId: orgId, + policies: missing, + ), + ); + + // Assign returned IDs back to the missing ones in order + final List newIds = createResp.result?.policyIds ?? const []; + if (newIds.length != missing.length) { + throw Exception("Failed to create missing policies."); + } + + final List newlyCreated = []; + for (var i = 0; i < missing.length; i++) { + newlyCreated.add( + Policy.fromCreateIntent(missing[i], policyId: newIds[i]), + ); + } + + // We return the full list of policies, both existing and the newly created + // which includes each of their respective IDs + return [...alreadyExisting, ...newlyCreated]; + } +} \ No newline at end of file diff --git a/packages/sdk-flutter/lib/src/turnkey_oauth.dart b/packages/sdk-flutter/lib/src/turnkey_oauth.dart new file mode 100644 index 0000000..1c9e9e7 --- /dev/null +++ b/packages/sdk-flutter/lib/src/turnkey_oauth.dart @@ -0,0 +1,586 @@ +part of 'turnkey.dart'; + +extension OAuthExtension on TurnkeyProvider { + /// Handles the Google OAuth authentication flow. + /// + /// Initiates an in-app browser OAuth flow with the provided credentials and parameters. + /// After the OAuth flow completes successfully, it extracts the oidcToken from the callback URL + /// and invokes `loginOrSignUpWithOAuth` or the provided onSuccess callback. + /// + /// Throws an [Exception] if the authentication process fails or times out. + /// + /// [clientId] Optional client ID that overrides the default client ID passed into the config or pulled from the Wallet Kit dashboard for Google OAuth. + /// [originUri] Optional base URI to start the OAuth flow. Defaults to TURNKEY_OAUTH_ORIGIN_URL. + /// [redirectUri] Optional redirect URI for the OAuth flow. Defaults to a constructed URI with the provided scheme. + /// [sessionKey] Optional session key to store the session under. If null, uses the default session key. + /// [invalidateExisting] Optional flag to invalidate existing sessions when logging in or signing up. + /// [publicKey] Optional public key to use for the session. If null, a new key pair is generated. + /// [onSuccess] Optional callback function that receives the oidcToken, publicKey and providerName upon successful authentication, overrides default behavior. + Future handleGoogleOAuth({ + String? clientId, + String? originUri = TURNKEY_OAUTH_ORIGIN_URL, + String? redirectUri, + String? sessionKey, + bool? invalidateExisting, + String? publicKey, + void Function( + {required String oidcToken, + required String publicKey, + required String providerName})? + onSuccess, + }) async { + final scheme = config.appScheme; + final providerName = 'google'; + if (scheme == null) { + throw Exception( + "App scheme is not configured. Please set `appScheme` in TurnkeyConfig."); + } + + final AppLinks appLinks = AppLinks(); + + final targetPublicKey = publicKey ?? await createApiKeyPair(); + try { + final nonce = sha256.convert(utf8.encode(targetPublicKey)).toString(); + final googleClientId = clientId ?? + masterConfig?.authConfig?.oAuthConfig?.googleClientId ?? + (throw Exception("Google Client ID not configured")); + final resolvedRedirectUri = redirectUri ?? + masterConfig?.authConfig?.oAuthConfig?.oauthRedirectUri ?? + '${TURNKEY_OAUTH_REDIRECT_URL}?scheme=${Uri.encodeComponent(scheme)}'; + + final oauthUrl = originUri! + + '?provider=${Uri.encodeComponent(providerName)}' + + '&clientId=${Uri.encodeComponent(googleClientId)}' + + '&redirectUri=${Uri.encodeComponent(resolvedRedirectUri)}' + + '&nonce=${Uri.encodeComponent(nonce)}'; + + // we create a completer to wait for the authentication result + final Completer authCompleter = Completer(); + + // set up a subscription for deep links + StreamSubscription? subscription; + subscription = appLinks.uriLinkStream.listen((Uri? uri) async { + if (uri != null && uri.toString().startsWith(scheme)) { + // we parse query parameters from the URI + final idToken = uri.queryParameters['id_token']; + + if (idToken != null) { + if (onSuccess != null) { + onSuccess( + oidcToken: idToken, + publicKey: targetPublicKey, + providerName: providerName); + } else { + await loginOrSignUpWithOAuth( + oidcToken: idToken, + publicKey: targetPublicKey, + providerName: providerName, + sessionKey: sessionKey, + invalidateExisting: invalidateExisting, + ); + } + + // complete the auth process + // this runs the `whenComplete()` callback + if (!authCompleter.isCompleted) { + authCompleter.complete(); + } + } + } + }); + + try { + final browser = _OAuthBrowser( + onBrowserClosed: () { + if (!authCompleter.isCompleted) { + subscription?.cancel(); + authCompleter.complete(); + return; + } + }, + ); + + await browser.open( + url: WebUri(oauthUrl), + settings: ChromeSafariBrowserSettings( + showTitle: true, + toolbarBackgroundColor: Colors.white, + ), + ); + + // set a timeout for the authentication process + await authCompleter.future.timeout( + const Duration(minutes: 10), + onTimeout: () { + subscription?.cancel(); + throw Exception('Authentication timed out'); + }, + ); + + await authCompleter.future.whenComplete(() async { + await browser.close(); + subscription?.cancel(); + }); + } catch (e) { + subscription.cancel(); + throw Exception('Google OAuth failed in browser: $e'); + } + } catch (error) { + await deleteUnusedKeyPairs(); + throw Exception('Failed to login or signup with Google: $error'); + } + } + + /// Handles the Apple OAuth authentication flow. + /// + /// Initiates an in-app browser OAuth flow with the provided credentials and parameters. + /// After the OAuth flow completes successfully, it extracts the oidcToken from the callback URL + /// and invokes `loginOrSignUpWithOAuth` or the provided onSuccess callback. + /// + /// Throws an [Exception] if the authentication process fails or times out. + /// + /// [clientId] Optional client ID that overrides the default client ID passed into the config or pulled from the Wallet Kit dashboard for Apple OAuth. + /// [originUri] Optional base URI to start the OAuth flow. Defaults to TURNKEY_OAUTH_ORIGIN_URL. + /// [redirectUri] Optional redirect URI for the OAuth flow. Defaults to a constructed URI with the provided scheme. + /// [sessionKey] Optional session key to store the session under. If null, uses the default session key. + /// [invalidateExisting] Optional flag to invalidate existing sessions when logging in or signing up. + /// [publicKey] Optional public key to use for the session. If null, a new key pair is generated. + /// [onSuccess] Optional callback function that receives the oidcToken, publicKey and providerName upon successful authentication, overrides default behavior. + Future handleAppleOAuth({ + String? clientId, + String? originUri = TURNKEY_OAUTH_ORIGIN_URL, + String? redirectUri, + String? sessionKey, + bool? invalidateExisting, + String? publicKey, + void Function( + {required String oidcToken, + required String publicKey, + required String providerName})? + onSuccess, + }) async { + final scheme = config.appScheme; + final providerName = 'apple'; + if (scheme == null) { + throw Exception( + "App scheme is not configured. Please set `appScheme` in TurnkeyConfig."); + } + + final AppLinks appLinks = AppLinks(); + + final targetPublicKey = publicKey ?? await createApiKeyPair(); + try { + final nonce = sha256.convert(utf8.encode(targetPublicKey)).toString(); + final appleClientId = clientId ?? + masterConfig?.authConfig?.oAuthConfig?.appleClientId ?? + (throw Exception("Apple Client ID not configured")); + final resolvedRedirectUri = redirectUri ?? + masterConfig?.authConfig?.oAuthConfig?.oauthRedirectUri ?? + '${TURNKEY_OAUTH_REDIRECT_URL}?scheme=${Uri.encodeComponent(scheme)}'; + + final oauthUrl = originUri! + + '?provider=${Uri.encodeComponent(providerName)}' + + '&clientId=${Uri.encodeComponent(appleClientId)}' + + '&redirectUri=${Uri.encodeComponent(resolvedRedirectUri)}' + + '&nonce=${Uri.encodeComponent(nonce)}'; + + final Completer authCompleter = Completer(); + + // set up a subscription for deep links + StreamSubscription? subscription; + subscription = appLinks.uriLinkStream.listen((Uri? uri) async { + if (uri != null && uri.toString().startsWith(scheme)) { + // we parse query parameters from the URI + final idToken = uri.queryParameters['id_token']; + + if (idToken != null) { + if (onSuccess != null) { + onSuccess( + oidcToken: idToken, + publicKey: targetPublicKey, + providerName: providerName); + } else { + await loginOrSignUpWithOAuth( + oidcToken: idToken, + publicKey: targetPublicKey, + providerName: providerName, + sessionKey: sessionKey, + invalidateExisting: invalidateExisting, + ); + } + + // complete the auth process + // this runs the `whenComplete()` callback + if (!authCompleter.isCompleted) { + authCompleter.complete(); + } + } + } + }); + + try { + final browser = _OAuthBrowser( + onBrowserClosed: () { + if (!authCompleter.isCompleted) { + subscription?.cancel(); + authCompleter.complete(); + return; + } + }, + ); + + await browser.open( + url: WebUri(oauthUrl), + settings: ChromeSafariBrowserSettings( + showTitle: true, + toolbarBackgroundColor: Colors.white, + ), + ); + + // set a timeout for the authentication process + await authCompleter.future.timeout( + const Duration(minutes: 10), + onTimeout: () { + subscription?.cancel(); + throw Exception('Authentication timed out'); + }, + ); + + await authCompleter.future.whenComplete(() async { + await browser.close(); + subscription?.cancel(); + }); + } catch (e) { + subscription.cancel(); + throw Exception('Apple OAuth failed in browser: $e'); + } + } catch (error) { + await deleteUnusedKeyPairs(); + throw Exception('Failed to login or signup with Apple: $error'); + } + } + + /// Handles the X (formerly Twitter) OAuth authentication flow. + /// + /// Initiates an in-app browser OAuth flow with the provided credentials and parameters. + /// After the OAuth flow completes successfully, it extracts the oidcToken from the callback URL + /// and invokes `loginOrSignUpWithOAuth` or the provided onSuccess callback. + /// + /// Throws an [Exception] if the authentication process fails or times out. + /// + /// [clientId] Optional client ID that overrides the default client ID passed into the config or pulled from the Wallet Kit dashboard for X OAuth. + /// [originUri] Optional base URI to start the OAuth flow. Defaults to X_AUTH_URL. + /// [redirectUri] Optional redirect URI for the OAuth flow. Defaults to a constructed URI with the provided scheme. + /// [sessionKey] Optional session key to store the session under. If null, uses the default session key. + /// [invalidateExisting] Optional flag to invalidate existing sessions when logging in or signing up. + /// [publicKey] Optional public key to use for the session. If null, a new key pair is generated. + /// [onSuccess] Optional callback function that receives the oidcToken, publicKey and providerName upon successful authentication, overrides default behavior. + Future handleXOAuth({ + String? clientId, + String? originUri = X_AUTH_URL, + String? redirectUri, + String? sessionKey, + bool? invalidateExisting, + String? publicKey, + void Function( + {required String oidcToken, + required String publicKey, + required String providerName})? + onSuccess, + }) async { + final scheme = config.appScheme; + final providerName = 'x'; + if (scheme == null) { + throw Exception( + "App scheme is not configured. Please set `appScheme` in TurnkeyConfig."); + } + + final AppLinks appLinks = AppLinks(); + + final targetPublicKey = publicKey ?? await createApiKeyPair(); + + try { + final nonce = sha256.convert(utf8.encode(targetPublicKey)).toString(); + final xClientId = clientId ?? + masterConfig?.authConfig?.oAuthConfig?.xClientId ?? + (throw Exception("X Client ID not configured")); + final resolvedRedirectUri = redirectUri ?? + masterConfig?.authConfig?.oAuthConfig?.oauthRedirectUri ?? + '${config.appScheme}://'; + + final challengePair = await generateChallengePair(); + final verifier = challengePair.verifier; + final codeChallenge = challengePair.codeChallenge; + + // random state + final state = Uuid().v4(); + + final xAuthUrl = originUri! + + '?client_id=${Uri.encodeComponent(xClientId)}' + + '&redirect_uri=${Uri.encodeComponent(resolvedRedirectUri)}' + + '&response_type=code' + + '&code_challenge=${Uri.encodeComponent(codeChallenge)}' + + '&code_challenge_method=S256' + + '&scope=${Uri.encodeComponent("tweet.read users.read")}' + + '&state=${Uri.encodeComponent(state)}'; + + // we create a completer to wait for the authentication result + final Completer authCompleter = Completer(); + + // set up a subscription for deep links + StreamSubscription? subscription; + subscription = appLinks.uriLinkStream.listen((Uri? uri) async { + if (uri != null && uri.toString().startsWith(scheme)) { + // we parse query parameters from the URI + final authCode = uri.queryParameters['code']; + + if (uri.queryParameters['state'] != state) { + subscription?.cancel(); + throw Exception('Invalid state parameter received'); + } + + if (authCode != null) { + final res = await requireClient.proxyOAuth2Authenticate( + input: ProxyTOAuth2AuthenticateBody( + provider: v1Oauth2Provider.oauth2_provider_x, + authCode: authCode, + redirectUri: resolvedRedirectUri, + codeVerifier: verifier, + clientId: xClientId, + nonce: nonce)); + + final oidcToken = res.oidcToken; + + if (onSuccess != null) { + onSuccess( + oidcToken: oidcToken, + publicKey: targetPublicKey, + providerName: providerName); + } else { + await loginOrSignUpWithOAuth( + oidcToken: oidcToken, + publicKey: targetPublicKey, + providerName: providerName, + sessionKey: sessionKey, + invalidateExisting: invalidateExisting, + ); + } + + // complete the auth process + // this runs the `whenComplete()` callback + if (!authCompleter.isCompleted) { + authCompleter.complete(); + } + } + } + }); + + try { + final browser = _OAuthBrowser( + onBrowserClosed: () { + if (!authCompleter.isCompleted) { + subscription?.cancel(); + authCompleter.complete(); + return; + } + }, + ); + + await browser.open( + url: WebUri(xAuthUrl), + settings: ChromeSafariBrowserSettings( + showTitle: true, + toolbarBackgroundColor: Colors.white, + ), + ); + + // we set a timeout for the authentication process + await authCompleter.future.timeout( + const Duration(minutes: 10), + onTimeout: () { + subscription?.cancel(); + throw Exception('Authentication timed out'); + }, + ); + + await authCompleter.future.whenComplete(() async { + await browser.close(); + subscription?.cancel(); + }); + } catch (e) { + subscription.cancel(); + throw Exception('X OAuth failed in browser: $e'); + } + } catch (error) { + await deleteUnusedKeyPairs(); + throw Exception('Failed to login or signup with X: $error'); + } + } + + /// Handles the Discord OAuth authentication flow. + /// + /// Initiates an in-app browser OAuth flow with the provided credentials and parameters. + /// After the OAuth flow completes successfully, it extracts the oidcToken from the callback URL + /// and invokes `loginOrSignUpWithOAuth` or the provided onSuccess callback. + /// + /// Throws an [Exception] if the authentication process fails or times out. + /// + /// [clientId] Optional client ID that overrides the default client ID passed into the config or pulled from the Wallet Kit dashboard for Discord OAuth. + /// [originUri] Optional base URI to start the OAuth flow. Defaults to DISCORD_AUTH_URL. + /// [redirectUri] Optional redirect URI for the OAuth flow. Defaults to a constructed URI with the provided scheme. + /// [sessionKey] Optional session key to store the session under. If null, uses the default session key. + /// [invalidateExisting] Optional flag to invalidate existing sessions when logging in or signing up. + /// [publicKey] Optional public key to use for the session. If null, a new key pair is generated. + /// [onSuccess] Optional callback function that receives the oidcToken, publicKey and providerName upon successful authentication, overrides default behavior. + Future handleDiscordOAuth({ + String? clientId, + String? originUri = DISCORD_AUTH_URL, + String? redirectUri, + String? sessionKey, + String? invalidateExisting, + String? publicKey, + void Function( + {required String oidcToken, + required String publicKey, + required String providerName})? + onSuccess, + }) async { + final scheme = config.appScheme; + final providerName = 'discord'; + if (scheme == null) { + throw Exception( + "App scheme is not configured. Please set `appScheme` in TurnkeyConfig."); + } + + final AppLinks appLinks = AppLinks(); + + final targetPublicKey = publicKey ?? await createApiKeyPair(); + try { + final nonce = sha256.convert(utf8.encode(targetPublicKey)).toString(); + final discordClientId = clientId ?? + masterConfig?.authConfig?.oAuthConfig?.discordClientId ?? + (throw Exception("Discord Client ID not configured")); + final resolvedRedirectUri = redirectUri ?? + masterConfig?.authConfig?.oAuthConfig?.oauthRedirectUri ?? + '${scheme}://'; + + final challengePair = await generateChallengePair(); + final verifier = challengePair.verifier; + final codeChallenge = challengePair.codeChallenge; + + // random state + final state = Uuid().v4(); + + final discordAuthUrl = originUri! + + '?client_id=${Uri.encodeComponent(discordClientId)}' + + '&redirect_uri=${Uri.encodeComponent(resolvedRedirectUri)}' + + '&response_type=code' + + '&code_challenge=${Uri.encodeComponent(codeChallenge)}' + + '&code_challenge_method=S256' + + '&scope=${Uri.encodeComponent("identify email")}' + + '&state=${Uri.encodeComponent(state)}'; + + // we create a completer to wait for the authentication result + final Completer authCompleter = Completer(); + + // set up a subscription for deep links + StreamSubscription? subscription; + subscription = appLinks.uriLinkStream.listen((Uri? uri) async { + if (uri != null && uri.toString().startsWith(scheme)) { + // we parse query parameters from the URI + final authCode = uri.queryParameters['code']; + + if (uri.queryParameters['state'] != state) { + subscription?.cancel(); + throw Exception('Invalid state parameter received'); + } + + if (authCode != null) { + final res = await requireClient.proxyOAuth2Authenticate( + input: ProxyTOAuth2AuthenticateBody( + provider: v1Oauth2Provider.oauth2_provider_discord, + authCode: authCode, + redirectUri: resolvedRedirectUri, + codeVerifier: verifier, + clientId: discordClientId, + nonce: nonce)); + + final oidcToken = res.oidcToken; + + if (onSuccess != null) { + onSuccess( + oidcToken: oidcToken, + publicKey: targetPublicKey, + providerName: providerName); + } else { + await loginOrSignUpWithOAuth( + oidcToken: oidcToken, + publicKey: targetPublicKey, + providerName: providerName, + ); + } + + // complete the auth process + // this runs the `whenComplete()` callback + if (!authCompleter.isCompleted) { + authCompleter.complete(); + } + } + } + }); + + try { + final browser = _OAuthBrowser( + onBrowserClosed: () { + if (!authCompleter.isCompleted) { + subscription?.cancel(); + authCompleter.complete(); + return; + } + }, + ); + + await browser.open( + url: WebUri(discordAuthUrl), + settings: ChromeSafariBrowserSettings( + showTitle: true, + toolbarBackgroundColor: Colors.white, + ), + ); + + // we set a timeout for the authentication process + await authCompleter.future.timeout( + const Duration(minutes: 10), + onTimeout: () { + subscription?.cancel(); + throw Exception('Authentication timed out'); + }, + ); + + await authCompleter.future.whenComplete(() async { + await browser.close(); + subscription?.cancel(); + }); + } catch (e) { + subscription.cancel(); + throw Exception('Discord OAuth failed in browser: $e'); + } + } catch (error) { + await deleteUnusedKeyPairs(); + throw Exception('Failed to login or signup with Discord: $error'); + } + } +} + +// we create a custom browser class to handle the onClosed event +class _OAuthBrowser extends ChromeSafariBrowser { + final VoidCallback onBrowserClosed; + + _OAuthBrowser({required this.onBrowserClosed}); + + @override + void onClosed() { + onBrowserClosed(); + super.onClosed(); + } +} \ No newline at end of file diff --git a/packages/sdk-flutter/lib/src/turnkey_session.dart b/packages/sdk-flutter/lib/src/turnkey_session.dart new file mode 100644 index 0000000..a08bf6d --- /dev/null +++ b/packages/sdk-flutter/lib/src/turnkey_session.dart @@ -0,0 +1,293 @@ +part of 'turnkey.dart'; + +extension SessionExtension on TurnkeyProvider { + /// Schedules the expiration of a session. + /// + /// Clears any existing timeout for the session to prevent duplicate timers. + /// Determines the time remaining until the session expires. + /// If the session is already expired, it triggers expiration immediately. + /// Otherwise, schedules a timeout to expire the session at the appropriate time. + /// Calls [clearSession] and invokes the [onSessionExpired] callback when the session expires. + /// + /// [sessionKey] The key of the session to schedule expiration for. + /// [expiry] The expiration time in seconds. + Future _scheduleSessionExpiration(String sessionKey, int expiry) async { + if (expiryTimers.isNotEmpty && expiryTimers.containsKey(sessionKey)) { + expiryTimers[sessionKey]?.cancel(); + expiryTimers.remove(sessionKey); + } + + final expireSession = () async { + final expiredSession = await getSession(sessionKey: sessionKey); + + if (expiredSession == null) return; + + await clearSession(sessionKey: sessionKey); + + config.onSessionExpired?.call(expiredSession); + }; + + final timeUntilExpiry = + (expiry * 1000) - DateTime.now().millisecondsSinceEpoch; + + if (timeUntilExpiry <= 0) { + await expireSession(); + } else { + expiryTimers.putIfAbsent(sessionKey, () { + return Timer(Duration(milliseconds: timeUntilExpiry), expireSession); + }); + } + } + + /// Stores a new session in secure storage. + /// + /// Parses the provided session JWT and stores it under the specified session key. + /// Creates a new client instance using the session's organization ID and public key. + /// + /// [sessionJwt] The JWT string representing the session to store. + /// [sessionKey] An optional key to store the session under. If null, uses the default session key. + /// Returns the stored session if successful. + /// Throws an [Exception] if the session cannot be stored or parsed. + Future storeSession({ + required String sessionJwt, + String? sessionKey, + }) async { + sessionKey ??= StorageKeys.DefaultSession.value; + + // we enforce a session limit + final existingSessionKeys = await SessionStorageManager.listSessionKeys(); + if (existingSessionKeys.length >= MAX_SESSIONS) { + throw Exception( + 'Maximum session limit of $MAX_SESSIONS reached. Please clear an existing session before creating a new one.', + ); + } + + // we make sure the session key is unique + if (existingSessionKeys.contains(sessionKey)) { + clearSession(sessionKey: sessionKey); + throw Exception( + 'Session key "$sessionKey" already exists. Please choose a unique session key or clear the existing session.', + ); + } + + // we store and parse the session JWT + await SessionStorageManager.storeSession(sessionJwt, + sessionKey: sessionKey); + final session = await SessionStorageManager.getSession(sessionKey); + if (session == null) { + throw Exception("Failed to store or parse session"); + } + + // we mark the session as active if this is the first session + final isFirstSession = existingSessionKeys.isEmpty; + if (isFirstSession) { + await setActiveSession(sessionKey: sessionKey); + authState = AuthState + .authenticated; // We can set authstate here since we have a valid session and it is active + } + + // we fetch the user information + await refreshUser(); + await refreshWallets(); + if (user == null) { + throw Exception("Failed to fetch user"); + } + + // we schedule the session expiration + await _scheduleSessionExpiration(sessionKey, session.expiry); + + await deleteUnusedKeyPairs(); + + config.onSessionCreated?.call(session); + + return session; + } + + /// Sets the active session by its key. + /// [sessionKey] The key of the session to set as active. + Future setActiveSession({required String sessionKey}) async { + await SessionStorageManager.setActiveSessionKey(sessionKey); + final s = await SessionStorageManager.getSession(sessionKey); + + if (s == null) { + throw Exception("No session found with key: $sessionKey"); + } + + session = s; + createClient( + publicKey: s.publicKey, + organizationId: s.organizationId, + ); + + authState = AuthState.authenticated; + config.onSessionSelected?.call(s); + } + + /// Gets the key of the currently active session. + /// Returns the active session key if it exists, otherwise `null`. + Future getActiveSessionKey() async { + return await SessionStorageManager.getActiveSessionKey(); + } + + /// Gets a stored session by its key. + /// [sessionKey] An optional key to retrieve the session from. If null, uses the default session key. + /// Returns the session if found, otherwise `null`. + Future getSession({String? sessionKey}) async { + final key = sessionKey ?? await SessionStorageManager.getActiveSessionKey(); + if (key == null) return null; + return await SessionStorageManager.getSession(key); + } + + /// Retrieves all stored sessions from secure storage. + /// Returns a map of session keys to their corresponding session objects. + Future?> getAllSessions() async { + final keys = await SessionStorageManager.listSessionKeys(); + if (keys.isEmpty) return null; + + final sessions = {}; + for (final key in keys) { + final session = await SessionStorageManager.getSession(key); + if (session != null) { + sessions[key] = session; + } + } + return sessions; + } + + /// Refreshes the specified or active session. + /// + /// Uses the existing session to stamp a new login session and extend its validity. + /// Generates a new key pair if no public key is provided. + /// Stores the refreshed session JWT and updates the current session state only + /// if it matches the active session key. + /// + /// [sessionKey] The key of the session to refresh. If null, uses the active session. + /// [expirationSeconds] The desired expiration time for the new session in seconds. + /// [publicKey] An optional public key to use for the new session. If null, a new key pair is generated. + /// [invalidateExisting] Whether to invalidate existing sessions when refreshing. + /// Returns the refreshed session result if successful, otherwise `null`. + /// Throws an [Exception] if the session cannot be refreshed. + Future refreshSession({ + String? sessionKey, + String? expirationSeconds, + String? publicKey, + bool invalidateExisting = false, + }) async { + expirationSeconds ??= masterConfig?.authConfig?.sessionExpirationSeconds; + + try { + final activeKey = await getActiveSessionKey(); + final key = sessionKey ?? activeKey; + if (key == null) throw Exception("No active session to refresh"); + + final currentSession = await getSession(sessionKey: key); + if (currentSession == null) + throw Exception("Session not found for key: $key"); + + // generate or use provided public key + final newPublicKey = publicKey ?? await createApiKeyPair(); + + // create a new session using the current session + final response = await requireClient.stampLogin( + input: TStampLoginBody( + organizationId: currentSession.organizationId, + publicKey: newPublicKey, + expirationSeconds: expirationSeconds, + invalidateExisting: invalidateExisting, + ), + ); + + final result = response.activity.result.stampLoginResult; + if (result?.session == null) { + throw Exception("No session found in refresh response"); + } + + // store the new session JWT + // TODO (Amir): Does this need to be the helper function? + await SessionStorageManager.storeSession( + result?.session as String, + sessionKey: key, + ); + + final newSession = await SessionStorageManager.getSession(key); + if (newSession == null) { + throw Exception("Failed to store or parse new session"); + } + + // we only update the in-memory client/session if this is the active session + if (key == activeKey) { + session = newSession; + createClient( + organizationId: newSession.organizationId, + publicKey: newSession.publicKey, + ); + } + + await _scheduleSessionExpiration(key, newSession.expiry); + + config.onSessionRefreshed?.call(newSession); + + return result; + } catch (error) { + await deleteUnusedKeyPairs(); + throw Exception('Failed to refresh session: $error'); + } + } + + /// Clears the current session from secure storage. + /// + /// Retrieves the session associated with the given [sessionKey]. + /// If the session being cleared is the currently selected session, it resets the state. + /// Deletes the session from secure storage. + /// Removes the session key from the session index. + /// Calls [onSessionCleared] callback if provided. + /// + /// Returns the cleared session if successful, otherwise `null`. + /// Throws an [Exception] if the session cannot be cleared. + /// + /// [sessionKey] The key of the session to clear. + Future clearSession({String? sessionKey}) async { + final activeSessionKey = await getActiveSessionKey(); + final key = sessionKey ?? activeSessionKey; + if (key == null) { + throw Exception("No active session to clear"); + } + + final sessionToClear = await SessionStorageManager.getSession(key); + if (sessionToClear == null) { + throw Exception("No session found with key: $key"); + } + + final activeKey = await getActiveSessionKey(); + if (key == activeKey) { + session = null; + user = null; + wallets = null; + client = createClient(); + authState = AuthState.unauthenticated; + } + + // delete the keypair + await deleteApiKeyPair(sessionToClear.publicKey); + + // remove the session from storage + await SessionStorageManager.clearSession(key); + + config.onSessionCleared?.call(sessionToClear); + } + + /// Clears all stored sessions from secure storage. + /// + /// Retrieves all session keys and clears each session individually. + /// Calls [clearSession] for each stored session, which handles deletion + /// of associated API key pairs and invokes session cleared callbacks. + /// If no sessions are found, the method returns without performing any operations. + Future clearAllSessions() async { + final sessionKeys = await SessionStorageManager.listSessionKeys(); + if (sessionKeys.isEmpty) return; + + for (final key in sessionKeys) { + await clearSession(sessionKey: key); + } + } +} \ No newline at end of file diff --git a/packages/sdk-flutter/lib/src/turnkey_signing.dart b/packages/sdk-flutter/lib/src/turnkey_signing.dart new file mode 100644 index 0000000..b783c78 --- /dev/null +++ b/packages/sdk-flutter/lib/src/turnkey_signing.dart @@ -0,0 +1,131 @@ +part of 'turnkey.dart'; + +extension SigningExtension on TurnkeyProvider { + /// Signs a raw payload using the specified signing key and encoding parameters. + /// + /// Throws an [Exception] if the client or user is not initialized. + /// + /// [signWith] The key to sign with. + /// [payload] The payload to sign. + /// [encoding] The encoding of the payload. + /// [hashFunction] The hash function to use. + Future signRawPayload( + {required String signWith, + required String payload, + required v1PayloadEncoding encoding, + required v1HashFunction hashFunction}) async { + if (session == null) { + throw Exception("No active session found. Please log in first."); + } + + final response = await requireClient.signRawPayload( + input: TSignRawPayloadBody( + signWith: signWith, + payload: payload, + encoding: encoding, + hashFunction: hashFunction, + )); + + final signRawPayloadResult = response.activity.result.signRawPayloadResult; + if (signRawPayloadResult == null) { + throw Exception("Failed to sign raw payload"); + } + return signRawPayloadResult; + } + + /// Signs a plaintext message using the specified wallet account. + /// + /// Automatically determines the payload encoding and hash function based on + /// the wallet account's address format, unless explicitly overridden. + /// Optionally applies the Ethereum signed message prefix before signing. + /// If you need more control over the signing process, consider using [signRawPayload] directly from the client. + /// + /// Throws an [Exception] if there is no active session or if signing fails. + /// + /// [message] The UTF-8 plaintext message to sign. + /// [walletAccount] The wallet account whose signing key will be used. + /// [encoding] Optional override for the payload encoding. Defaults to the encoding associated with the wallet account's address format. + /// [hashFunction] Optional override for the hash function. Defaults to the hash function associated with the wallet account's address format. + /// [addEthereumPrefix] Whether to add the Ethereum message prefix before signing. Defaults to `true` when the address format is Ethereum. + Future signMessage({ + required String message, + required v1WalletAccount walletAccount, + v1PayloadEncoding? encoding, + v1HashFunction? hashFunction, + bool? addEthereumPrefix, + }) async { + if (session == null) { + throw Exception("No active session found. Please log in first."); + } + + encoding ??= getEncodingType(walletAccount.addressFormat); + hashFunction ??= getHashFunction(walletAccount.addressFormat); + + final isEthereum = + walletAccount.addressFormat == v1AddressFormat.address_format_ethereum; + + Uint8List msgBytes = toUtf8Bytes(message); + + // Optionally apply Ethereum EIP-191 prefix + final bool shouldAddPrefix = isEthereum && (addEthereumPrefix ?? true); + if (shouldAddPrefix) { + final prefix = "\x19Ethereum Signed Message:\n${msgBytes.length}"; + final prefixBytes = toUtf8Bytes(prefix); + + final combined = Uint8List(prefixBytes.length + msgBytes.length); + combined.setRange(0, prefixBytes.length, prefixBytes); + combined.setRange(prefixBytes.length, combined.length, msgBytes); + + msgBytes = combined; + } + + // Encode the message according to the payload encoding + final String encodedMessage = getEncodedMessage(encoding, msgBytes); + + // Build the request body. + final body = TSignRawPayloadBody( + signWith: walletAccount.address, + payload: encodedMessage, + encoding: encoding, + hashFunction: hashFunction, + ); + + final response = await requireClient.signRawPayload(input: body); + + final result = response.activity.result.signRawPayloadResult; + if (result == null || response.activity.failure != null) { + throw Exception("Failed to sign message, no signed payload returned"); + } + + return result; + } + + /// Signs a transaction using the specified signing key and transaction parameters. + /// + /// Throws an [Exception] if the client or user is not initialized. + /// + /// [signWith] The key to sign with. + /// [unsignedTransaction] The unsigned transaction to sign. + /// [type] The type of the transaction from the [TransactionType] enum. + Future signTransaction( + {required String signWith, + required String unsignedTransaction, + required v1TransactionType type}) async { + if (session == null) { + throw Exception("No active session found. Please log in first."); + } + + final response = await requireClient.signTransaction( + input: TSignTransactionBody( + signWith: signWith, + unsignedTransaction: unsignedTransaction, + type: type)); + + final signTransactionResult = + response.activity.result.signTransactionResult; + if (signTransactionResult == null) { + throw Exception("Failed to sign transaction"); + } + return signTransactionResult; + } +} \ No newline at end of file diff --git a/packages/sdk-flutter/lib/src/turnkey_user.dart b/packages/sdk-flutter/lib/src/turnkey_user.dart new file mode 100644 index 0000000..e90cadd --- /dev/null +++ b/packages/sdk-flutter/lib/src/turnkey_user.dart @@ -0,0 +1,22 @@ +part of 'turnkey.dart'; + +extension on TurnkeyProvider { + /// Refreshes the current user data. + /// + /// Fetches the latest user details from the API using the current session's client. + /// If the user data is successfully retrieved, updates the session with the new user details. + /// Saves the updated session and updates the state. + /// + /// Throws an [Exception] if the session or client is not initialized. + Future refreshUser() async { + if (config.authConfig?.autoRefreshManagedState == false) { + return; + } + + if (session == null) { + throw Exception("Failed to refresh user. Sessions not initialized"); + } + user = await fetchUser( + requireClient, session!.organizationId, session!.userId); + } +} \ No newline at end of file diff --git a/packages/sdk-flutter/lib/src/turnkey_wallet.dart b/packages/sdk-flutter/lib/src/turnkey_wallet.dart new file mode 100644 index 0000000..a3fbaf1 --- /dev/null +++ b/packages/sdk-flutter/lib/src/turnkey_wallet.dart @@ -0,0 +1,139 @@ +part of 'turnkey.dart'; + +extension WalletExtension on TurnkeyProvider { + + /// Refreshes the current wallets data. + /// + /// Fetches the latest wallet details from the API using the current session's client. + /// If the wallet data is successfully retrieved, updates the state with the new wallet information. + /// + /// Throws an [Exception] if the session is not initialized. + Future refreshWallets() async { + if (config.authConfig?.autoRefreshManagedState == false) { + return; + } + + if (session == null) { + throw Exception("Failed to refresh wallets. No session initialized"); + } + wallets = await fetchWallets(requireClient, session!.organizationId); + } + + /// Creates a new wallet with the specified name and accounts. + /// + /// Throws an [Exception] if the client or user is not initialized. + /// + /// [walletName] The name of the wallet. + /// [accounts] The accounts to create in the wallet. + /// [mnemonicLength] The length of the mnemonic. + Future createWallet( + {required String walletName, + required List accounts, + int? mnemonicLength}) async { + if (session == null) { + throw Exception("No active session found. Please log in first."); + } + + final response = await requireClient.createWallet( + input: TCreateWalletBody( + accounts: accounts, + walletName: walletName, + mnemonicLength: mnemonicLength, + )); + final activity = response.activity; + if (activity.result.createWalletResult?.walletId != null) { + await refreshWallets(); + } + + return activity; + } + + /// Imports a wallet using a provided mnemonic and creates accounts. + /// + /// Throws an [Exception] if the client or user is not initialized. + /// + /// [mnemonic] The mnemonic to import. + /// [walletName] The name of the wallet. + /// [accounts] The accounts to create in the wallet. + /// [dangerouslyOverrideSignerPublicKey] An optional public key to override the signer. + Future importWallet( + {required String mnemonic, + required String walletName, + required List accounts, + String? dangerouslyOverrideSignerPublicKey}) async { + if (session == null) { + throw Exception("No active session found. Please log in first."); + } + + // this should never happen + if (user == null) { + throw Exception("No user found."); + } + + final initResponse = await requireClient.initImportWallet( + input: TInitImportWalletBody(userId: user!.userId)); + + final importBundle = + initResponse.activity.result.initImportWalletResult?.importBundle; + + if (importBundle == null) { + throw Exception("Failed to get import bundle"); + } + + final encryptedBundle = await encryptWalletToBundle( + mnemonic: mnemonic, + importBundle: importBundle, + userId: user!.userId, + organizationId: session!.organizationId, + dangerouslyOverrideSignerPublicKey: dangerouslyOverrideSignerPublicKey, + ); + + final response = await requireClient.importWallet( + input: TImportWalletBody( + userId: user!.userId, + walletName: walletName, + encryptedBundle: encryptedBundle, + accounts: accounts)); + final activity = response.activity; + if (activity.result.importWalletResult?.walletId != null) { + await refreshWallets(); + } + } + + /// Exports an existing wallet by decrypting the stored mnemonic phrase. + /// + /// Throws an [Exception] if the client, user, or export bundle is not initialized. + /// + /// [walletId] The ID of the wallet to export. + /// [dangerouslyOverrideSignerPublicKey] An optional public key to override the signer. + Future exportWallet( + {required String walletId, + String? dangerouslyOverrideSignerPublicKey, + bool? returnMnemonic}) async { + if (session == null) { + throw Exception("No active session found. Please log in first."); + } + + final keyPair = await generateP256KeyPair(); + + final response = await requireClient.exportWallet( + input: TExportWalletBody( + walletId: walletId, + targetPublicKey: keyPair.publicKeyUncompressed)); + final exportBundle = + response.activity.result.exportWalletResult?.exportBundle; + + if (exportBundle == null) { + throw Exception("Export bundle, embedded key, or user not initialized"); + } + + await refreshWallets(); + + return await decryptExportBundle( + exportBundle: exportBundle, + embeddedKey: keyPair.privateKey, + organizationId: session!.organizationId, + dangerouslyOverrideSignerPublicKey: dangerouslyOverrideSignerPublicKey, + returnMnemonic: returnMnemonic ?? true); + } +} \ No newline at end of file diff --git a/packages/sdk-flutter/lib/src/constants.dart b/packages/sdk-flutter/lib/src/utils/constants.dart similarity index 87% rename from packages/sdk-flutter/lib/src/constants.dart rename to packages/sdk-flutter/lib/src/utils/constants.dart index 381b52f..41a8753 100644 --- a/packages/sdk-flutter/lib/src/constants.dart +++ b/packages/sdk-flutter/lib/src/utils/constants.dart @@ -37,12 +37,18 @@ enum AuthAction { bool get isSignup => this == AuthAction.signup; } +enum AuthState { + loading, + unauthenticated, + authenticated, +} + const Map otpTypeToFilterTypeMap = { OtpType.Email: FilterType.Email, OtpType.SMS: FilterType.SMS, }; -const OTP_AUTH_DEFAULT_EXPIRATION_SECONDS = "900"; +const AUTH_DEFAULT_EXPIRATION_SECONDS = "900"; const MAX_SESSIONS = 15; @@ -51,7 +57,8 @@ const DISCORD_AUTH_URL = "https://discord.com/oauth2/authorize"; const TURNKEY_OAUTH_ORIGIN_URL = "https://oauth-origin.turnkey.com"; const TURNKEY_OAUTH_REDIRECT_URL = "https://oauth-redirect.turnkey.com"; const APPLE_AUTH_URL = "https://account.apple.com/auth/authorize"; -const APPLE_AUTH_SCRIPT_URL = "https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"; +const APPLE_AUTH_SCRIPT_URL = + "https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"; final DEFAULT_ETHEREUM_ACCOUNT = v1WalletAccountParams( curve: v1Curve.curve_secp256k1, diff --git a/packages/sdk-flutter/lib/src/types.dart b/packages/sdk-flutter/lib/src/utils/types.dart similarity index 87% rename from packages/sdk-flutter/lib/src/types.dart rename to packages/sdk-flutter/lib/src/utils/types.dart index 3016a90..2a518b3 100644 --- a/packages/sdk-flutter/lib/src/types.dart +++ b/packages/sdk-flutter/lib/src/utils/types.dart @@ -1,7 +1,6 @@ import 'dart:convert'; - import 'package:turnkey_http/__generated__/models.dart'; -import 'package:turnkey_sdk_flutter/src/constants.dart'; +import 'package:turnkey_sdk_flutter/src/utils/constants.dart'; /// A class representing a session with public and private keys and an expiry time. class Session { @@ -129,6 +128,8 @@ class TurnkeyConfig { final String? authProxyBaseUrl; final String? authProxyConfigId; final AuthConfig? authConfig; + final PasskeyConfig? passkeyConfig; + final String? appScheme; final void Function(Session session)? onSessionCreated; @@ -144,7 +145,9 @@ class TurnkeyConfig { this.apiBaseUrl, this.authProxyBaseUrl, this.authProxyConfigId, - this.authConfig, + this.authConfig = + const AuthConfig(), // Default to empty config. We set a default inside this object so we need to do this. + this.passkeyConfig, this.appScheme, this.onSessionCreated, this.onSessionSelected, @@ -166,7 +169,10 @@ class AuthConfig { /** length of the OTP. If using the auth proxy, you must configure this setting through the dashboard. Changing this through the TurnkeyProvider will have no effect. */ final String? otpLength; final MethodCreateSubOrgParams? createSubOrgParams; - + /** If true, will automatically fetch the WalletKit configuration specified in the Turnkey Dashboard upon initialization. Defaults to true. */ + final bool? autoFetchWalletKitConfig; + /** If true, managed state variables (such as wallets and user) will automatically refresh when necessary. Defaults to true. */ + final bool? autoRefreshManagedState; const AuthConfig({ this.methods, this.oAuthConfig, @@ -174,6 +180,19 @@ class AuthConfig { this.otpAlphanumeric, this.otpLength, this.createSubOrgParams, + // Default to true here. Usually we'd do this in the "buildConfig()" method but this is actually needed right before that function runs! + this.autoFetchWalletKitConfig = true, + this.autoRefreshManagedState = true, + }); +} + +class PasskeyConfig { + final String? rpId; + final String? rpName; + + const PasskeyConfig({ + this.rpId, + this.rpName, }); } @@ -466,3 +485,44 @@ class ChallengePair { final String codeChallenge; ChallengePair({required this.verifier, required this.codeChallenge}); } + +class CreateP256UserParams { + final String? userName; + final String? apiKeyName; + + const CreateP256UserParams({this.userName, this.apiKeyName}); +} + +class Policy extends v1CreatePolicyIntentV3 { + final String policyId; + + Policy({ + required this.policyId, + required String policyName, + required v1Effect effect, + String? condition, + String? consensus, + String? notes, + }) : super( + policyName: policyName, + effect: effect, + condition: condition, + consensus: consensus, + notes: notes, + ); + + /// Construct from a policy creation intent and attach a policyId. + factory Policy.fromCreateIntent( + v1CreatePolicyIntentV3 p, { + required String policyId, + }) { + return Policy( + policyId: policyId, + policyName: p.policyName, + effect: p.effect, + condition: p.condition, + consensus: p.consensus, + notes: p.notes, + ); + } +} diff --git a/packages/sdk-flutter/lib/turnkey_sdk_flutter.dart b/packages/sdk-flutter/lib/turnkey_sdk_flutter.dart index da431ec..a54c0d5 100644 --- a/packages/sdk-flutter/lib/turnkey_sdk_flutter.dart +++ b/packages/sdk-flutter/lib/turnkey_sdk_flutter.dart @@ -1,9 +1,9 @@ library; export 'src/turnkey.dart'; -export 'src/types.dart'; -export 'src/constants.dart'; -export 'src/turnkey_helpers.dart'; +export 'src/utils/types.dart'; +export 'src/utils/constants.dart'; +export 'src/internal/turnkey_helpers.dart'; export 'package:turnkey_http/turnkey_http.dart'; export 'package:turnkey_http/base.dart'; export 'package:turnkey_http/__generated__/models.dart';