Implement OAuth2 secure token storage with automatic migration

Co-authored-by: animator <615622+animator@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-10-11 19:13:28 +00:00
parent be64fdbeb0
commit d3cb28025f
9 changed files with 229 additions and 13 deletions

View File

@@ -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<void> 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<String?> 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<void> 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<void> 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
}
}
}

View File

@@ -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<oauth2.Client> 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<oauth2.Client> 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<oauth2.Client> 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<oauth2.Client> 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

View File

@@ -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