diff --git a/packages/better_networking/lib/services/oauth2_secure_storage.dart b/packages/better_networking/lib/services/oauth2_secure_storage.dart new file mode 100644 index 00000000..9822c2a5 --- /dev/null +++ b/packages/better_networking/lib/services/oauth2_secure_storage.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; +import 'package:crypto/crypto.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Secure storage service for OAuth2 credentials +/// Uses platform-specific secure storage (Keychain on iOS, EncryptedSharedPreferences on Android) +class OAuth2SecureStorage { + static const FlutterSecureStorage _secureStorage = FlutterSecureStorage( + aOptions: AndroidOptions( + encryptedSharedPreferences: true, + ), + iOptions: IOSOptions( + accessibility: KeychainAccessibility.first_unlock, + ), + ); + + /// Generate a unique storage key from client ID and token URL + static String _generateKey(String clientId, String tokenUrl) { + final combined = '$clientId:$tokenUrl'; + final bytes = utf8.encode(combined); + final hash = sha256.convert(bytes); + return 'oauth2_cred_${hash.toString().substring(0, 16)}'; + } + + /// Store OAuth2 credentials securely + static Future storeCredentials({ + required String clientId, + required String tokenUrl, + required String credentialsJson, + }) async { + try { + final key = _generateKey(clientId, tokenUrl); + await _secureStorage.write(key: key, value: credentialsJson); + } catch (e) { + // Log error but don't fail - fallback to no storage + // In production, consider proper logging + } + } + + /// Retrieve OAuth2 credentials + static Future retrieveCredentials({ + required String clientId, + required String tokenUrl, + }) async { + try { + final key = _generateKey(clientId, tokenUrl); + return await _secureStorage.read(key: key); + } catch (e) { + // Log error but return null - will trigger fresh auth + return null; + } + } + + /// Delete OAuth2 credentials + static Future deleteCredentials({ + required String clientId, + required String tokenUrl, + }) async { + try { + final key = _generateKey(clientId, tokenUrl); + await _secureStorage.delete(key: key); + } catch (e) { + // Log error but don't fail + } + } + + /// Clear all OAuth2 credentials + static Future clearAllCredentials() async { + try { + final allKeys = await _secureStorage.readAll(); + for (final key in allKeys.keys) { + if (key.startsWith('oauth2_cred_')) { + await _secureStorage.delete(key: key); + } + } + } catch (e) { + // Log error but don't fail + } + } +} diff --git a/packages/better_networking/lib/utils/auth/oauth2_utils.dart b/packages/better_networking/lib/utils/auth/oauth2_utils.dart index 1a66df16..ab9583fb 100644 --- a/packages/better_networking/lib/utils/auth/oauth2_utils.dart +++ b/packages/better_networking/lib/utils/auth/oauth2_utils.dart @@ -6,6 +6,7 @@ import 'package:oauth2/oauth2.dart' as oauth2; import '../../models/auth/auth_oauth2_model.dart'; import '../../services/http_client_manager.dart'; import '../../services/oauth_callback_server.dart'; +import '../../services/oauth2_secure_storage.dart'; import '../platform_utils.dart'; /// Advanced OAuth2 authorization code grant handler that returns both the client and server @@ -23,13 +24,47 @@ Future<(oauth2.Client, OAuthCallbackServer?)> oAuth2AuthorizationCodeGrant({ String? state, String? scope, }) async { - // Check for existing credentials first + // Check for existing credentials first - try secure storage, then file + // Try secure storage first (preferred method) + try { + final secureCredJson = await OAuth2SecureStorage.retrieveCredentials( + clientId: identifier, + tokenUrl: tokenEndpoint.toString(), + ); + + if (secureCredJson != null) { + final credentials = oauth2.Credentials.fromJson(secureCredJson); + if (credentials.accessToken.isNotEmpty && !credentials.isExpired) { + return ( + oauth2.Client(credentials, identifier: identifier, secret: secret), + null, + ); + } + } + } catch (e) { + // Secure storage failed, try file fallback + } + + // Fallback to file-based storage for backward compatibility if (credentialsFile != null && await credentialsFile.exists()) { try { final json = await credentialsFile.readAsString(); final credentials = oauth2.Credentials.fromJson(json); if (credentials.accessToken.isNotEmpty && !credentials.isExpired) { + // Migrate to secure storage for future use + try { + await OAuth2SecureStorage.storeCredentials( + clientId: identifier, + tokenUrl: tokenEndpoint.toString(), + credentialsJson: json, + ); + // Delete old file after successful migration + await credentialsFile.delete(); + } catch (e) { + // Migration failed, keep using file + } + return ( oauth2.Client(credentials, identifier: identifier, secret: secret), null, @@ -124,8 +159,22 @@ Future<(oauth2.Client, OAuthCallbackServer?)> oAuth2AuthorizationCodeGrant({ callbackUriParsed.queryParameters, ); - if (credentialsFile != null) { - await credentialsFile.writeAsString(client.credentials.toJson()); + // Store credentials securely (preferred method) + try { + await OAuth2SecureStorage.storeCredentials( + clientId: identifier, + tokenUrl: tokenEndpoint.toString(), + credentialsJson: client.credentials.toJson(), + ); + } catch (e) { + // Secure storage failed, fallback to file if available + if (credentialsFile != null) { + try { + await credentialsFile.writeAsString(client.credentials.toJson()); + } catch (fileError) { + // Both storage methods failed - credentials won't persist + } + } } return (client, callbackServer); @@ -150,13 +199,46 @@ Future oAuth2ClientCredentialsGrantHandler({ required AuthOAuth2Model oauth2Model, required File? credentialsFile, }) async { - // Try to use saved credentials + // Try secure storage first + try { + final secureCredJson = await OAuth2SecureStorage.retrieveCredentials( + clientId: oauth2Model.clientId, + tokenUrl: oauth2Model.accessTokenUrl, + ); + + if (secureCredJson != null) { + final credentials = oauth2.Credentials.fromJson(secureCredJson); + if (credentials.accessToken.isNotEmpty && !credentials.isExpired) { + return oauth2.Client( + credentials, + identifier: oauth2Model.clientId, + secret: oauth2Model.clientSecret, + ); + } + } + } catch (e) { + // Secure storage failed, try file fallback + } + + // Fallback to file-based storage for backward compatibility if (credentialsFile != null && await credentialsFile.exists()) { try { final json = await credentialsFile.readAsString(); final credentials = oauth2.Credentials.fromJson(json); if (credentials.accessToken.isNotEmpty && !credentials.isExpired) { + // Migrate to secure storage + try { + await OAuth2SecureStorage.storeCredentials( + clientId: oauth2Model.clientId, + tokenUrl: oauth2Model.accessTokenUrl, + credentialsJson: json, + ); + await credentialsFile.delete(); + } catch (e) { + // Migration failed + } + return oauth2.Client( credentials, identifier: oauth2Model.clientId, @@ -184,12 +266,22 @@ Future oAuth2ClientCredentialsGrantHandler({ httpClient: baseClient, ); + // Store credentials securely try { - if (credentialsFile != null) { - await credentialsFile.writeAsString(client.credentials.toJson()); - } + await OAuth2SecureStorage.storeCredentials( + clientId: oauth2Model.clientId, + tokenUrl: oauth2Model.accessTokenUrl, + credentialsJson: client.credentials.toJson(), + ); } catch (e) { - // Ignore credential saving errors + // Secure storage failed, try file fallback + if (credentialsFile != null) { + try { + await credentialsFile.writeAsString(client.credentials.toJson()); + } catch (fileError) { + // Both storage methods failed + } + } } // Clean up the HTTP client @@ -207,13 +299,46 @@ Future oAuth2ResourceOwnerPasswordGrantHandler({ required AuthOAuth2Model oauth2Model, required File? credentialsFile, }) async { - // Try to use saved credentials + // Try secure storage first + try { + final secureCredJson = await OAuth2SecureStorage.retrieveCredentials( + clientId: oauth2Model.clientId, + tokenUrl: oauth2Model.accessTokenUrl, + ); + + if (secureCredJson != null) { + final credentials = oauth2.Credentials.fromJson(secureCredJson); + if (credentials.accessToken.isNotEmpty && !credentials.isExpired) { + return oauth2.Client( + credentials, + identifier: oauth2Model.clientId, + secret: oauth2Model.clientSecret, + ); + } + } + } catch (e) { + // Secure storage failed, try file fallback + } + + // Fallback to file-based storage for backward compatibility if (credentialsFile != null && await credentialsFile.exists()) { try { final json = await credentialsFile.readAsString(); final credentials = oauth2.Credentials.fromJson(json); if (credentials.accessToken.isNotEmpty && !credentials.isExpired) { + // Migrate to secure storage + try { + await OAuth2SecureStorage.storeCredentials( + clientId: oauth2Model.clientId, + tokenUrl: oauth2Model.accessTokenUrl, + credentialsJson: json, + ); + await credentialsFile.delete(); + } catch (e) { + // Migration failed + } + return oauth2.Client( credentials, identifier: oauth2Model.clientId, @@ -247,12 +372,22 @@ Future oAuth2ResourceOwnerPasswordGrantHandler({ httpClient: baseClient, ); + // Store credentials securely try { - if (credentialsFile != null) { - await credentialsFile.writeAsString(client.credentials.toJson()); - } + await OAuth2SecureStorage.storeCredentials( + clientId: oauth2Model.clientId, + tokenUrl: oauth2Model.accessTokenUrl, + credentialsJson: client.credentials.toJson(), + ); } catch (e) { - // Ignore credential saving errors + // Secure storage failed, try file fallback + if (credentialsFile != null) { + try { + await credentialsFile.writeAsString(client.credentials.toJson()); + } catch (fileError) { + // Both storage methods failed + } + } } // Clean up the HTTP client diff --git a/packages/better_networking/pubspec.yaml b/packages/better_networking/pubspec.yaml index 0b162b69..0d1d3d4b 100644 --- a/packages/better_networking/pubspec.yaml +++ b/packages/better_networking/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: convert: ^3.1.2 crypto: ^3.0.6 dart_jsonwebtoken: ^3.2.0 + flutter_secure_storage: ^9.0.0 http: ^1.3.0 http_parser: ^4.1.2 json5: ^0.8.2 diff --git a/SECURITY_CHECKLIST.md b/security/SECURITY_CHECKLIST.md similarity index 100% rename from SECURITY_CHECKLIST.md rename to security/SECURITY_CHECKLIST.md diff --git a/SECURITY_OVERVIEW.txt b/security/SECURITY_OVERVIEW.txt similarity index 100% rename from SECURITY_OVERVIEW.txt rename to security/SECURITY_OVERVIEW.txt diff --git a/SECURITY_README.md b/security/SECURITY_README.md similarity index 100% rename from SECURITY_README.md rename to security/SECURITY_README.md diff --git a/SECURITY_REMEDIATION.md b/security/SECURITY_REMEDIATION.md similarity index 100% rename from SECURITY_REMEDIATION.md rename to security/SECURITY_REMEDIATION.md diff --git a/SECURITY_SUMMARY.md b/security/SECURITY_SUMMARY.md similarity index 100% rename from SECURITY_SUMMARY.md rename to security/SECURITY_SUMMARY.md diff --git a/SECURITY_VULNERABILITIES.md b/security/SECURITY_VULNERABILITIES.md similarity index 100% rename from SECURITY_VULNERABILITIES.md rename to security/SECURITY_VULNERABILITIES.md