diff --git a/.gitignore b/.gitignore index 322db657..b7cb2d71 100644 --- a/.gitignore +++ b/.gitignore @@ -304,3 +304,10 @@ app.*.symbols !**/ios/**/default.perspectivev3 !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages !/dev/ci/**/Gemfile.lock + +#----------------------------------------------------------------------- +# Claude +#----------------------------------------------------------------------- + +CLAUDE.md +.claude/* \ No newline at end of file diff --git a/example/.metadata b/example/.metadata index 715c7087..9b9642c2 100644 --- a/example/.metadata +++ b/example/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa" + revision: "ff37bef603469fb030f2b72995ab929ccfc227f0" channel: "stable" project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa - base_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa - - platform: linux - create_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa - base_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + - platform: windows + create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 # User provided section diff --git a/example/lib/main.dart b/example/lib/main.dart index 9346ef00..c04d2b7b 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -93,6 +93,16 @@ class DemoPod extends StatelessWidget { infoButtonStyle: InfoButtonStyle( tooltip: 'Visit the DemoPod documentation.', ), + clientId: + 'https://anushkavidanage.github.io/solidpod/example/client-profile.jsonld', + // Use the following schemas depending on the platform + // Web: https://anushkavidanage.github.io/solidpod/example/redirect.html + // Mobile: com.example.demopod://redirect + // Desktop: http://localhost:4400/redirect + // (can use any port as long as it matches with the one in your id document) + redirectUri: 'com.example.demopod://redirect', + postLogoutRedirectUri: 'com.example.demopod://redirect', + autoLogin: true, child: Home(), ), ); diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 9eca0ad2..e7c73121 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -22,6 +22,10 @@ dependencies: dependency_overrides: solidpod: path: .. + solidui: + git: + url: https://github.com/anusii/solidui.git + ref: av/40_migrate_oidc_implementation dev_dependencies: dependency_validator: ^5.0.4 diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart new file mode 100644 index 00000000..855af6c8 --- /dev/null +++ b/example/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +// import 'package:flutter_test/flutter_test.dart'; + +// import 'package:demopod/main.dart'; + +// void main() { +// testWidgets('Counter increments smoke test', (WidgetTester tester) async { +// // Build our app and trigger a frame. +// await tester.pumpWidget(const MyApp()); + +// // Verify that our counter starts at 0. +// expect(find.text('0'), findsOneWidget); +// expect(find.text('1'), findsNothing); + +// // Tap the '+' icon and trigger a frame. +// await tester.tap(find.byIcon(Icons.add)); +// await tester.pump(); + +// // Verify that our counter has incremented. +// expect(find.text('0'), findsNothing); +// expect(find.text('1'), findsOneWidget); +// }); +// } diff --git a/lib/solidpod.dart b/lib/solidpod.dart index f9bfd4d5..9c062c6b 100644 --- a/lib/solidpod.dart +++ b/lib/solidpod.dart @@ -49,7 +49,7 @@ export 'src/solid/constants/predicates.dart'; /// Solid authentication function -export 'src/solid/authenticate.dart' show solidAuthenticate; +export 'src/solid/authenticate.dart' show solidAuthenticate, tryRestoreSession; /// Status class to represent different function outputs diff --git a/lib/src/solid/authenticate.dart b/lib/src/solid/authenticate.dart index ef23b4c5..f51bc498 100644 --- a/lib/src/solid/authenticate.dart +++ b/lib/src/solid/authenticate.dart @@ -1,4 +1,4 @@ -/// Authenticate against a solid server and return null if authentication fails. +/// Authenticate against a Solid server using Solid-OIDC. /// // Time-stamp: /// @@ -26,112 +26,133 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. /// -/// Authors: Zheyuan Xu, Graham Williams - -// ignore_for_file: use_build_context_synchronously +/// Authors: Zheyuan Xu, Graham Williams, Anushka Vidanage library; import 'dart:convert'; -import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart' show debugPrint; +import 'package:flutter/material.dart' show BuildContext; -import 'package:solid_auth/solid_auth.dart'; +import 'package:solid_auth/solid_auth.dart' + show SolidAuthManager, SolidOidcConfig; import 'package:solidpod/src/solid/api/rest_api.dart'; import 'package:solidpod/src/solid/utils/authdata_manager.dart' show AuthDataManager; -import 'package:solidpod/src/solid/utils/misc.dart' - show isUserLoggedIn, logoutPod; - -// Scopes variables used in the authentication process. - -final List _scopes = [ - 'openid', - 'profile', - 'offline_access', - 'webid', // web ID is necessary to get refresh token -]; +import 'package:solidpod/src/solid/utils/misc.dart' show isUserLoggedIn; /// Asynchronously authenticate a user against a Solid server [serverId]. /// -/// [serverId] is an issuer URI and is essential for the -/// authentication process with the POD (Personal Online Datastore) issuer. +/// [serverId] is the user's WebID or an issuer URI. Issuer resolution is +/// handled internally by [SolidAuthManager]. /// -/// [context] of the current widget is required for the authenticate process. +/// [context] is kept for API compatibility but is no longer required by the +/// underlying Solid-OIDC flow. /// -/// Return a list containing authentication data: user's webId; profile data. +/// [clientId] must be the URL of the app's client profile JSON-LD +/// document (or a pre-registered client ID). Required. /// -/// Error Handling: The function has a catch all to return null if any exception -/// occurs during the authentication process. - +/// [redirectUri] is the custom URL scheme for the OAuth to redirect to +/// after authentication. +/// +/// [postLogoutRedirectUri] is an optional redirect URI for logout. If not +/// set assign the same value as [redirectUri]. +/// +/// Returns `[SolidAuthData, webId, profileTurtle]` on success, null on failure. Future?> solidAuthenticate( String serverId, - BuildContext context, -) async { + BuildContext context, { + required String clientId, + required String redirectUri, + String? postLogoutRedirectUri, +}) async { try { - final loggedIn = await isUserLoggedIn(); - Map? authData; - if (loggedIn) { - authData = await AuthDataManager.loadAuthData(); - if (authData == null) { - // Fall through to re-authenticate + // Return existing session without re-authenticating. + if (await isUserLoggedIn()) { + final authData = await AuthDataManager.loadAuthData(); + if (authData != null) { + final profData = utf8.decode( + await getResource(authData.webId.replaceAll('#me', '')), + ); + return [authData, authData.webId, profData]; } } - // If not logged in or load failed, perform new authentication - if (!loggedIn || authData == null) { - debugPrint('solidAuthenticate() => solid_auth.authenticate($serverId)'); - // Authentication process for the POD issuer. - - final issuerUri = await getIssuer(serverId); - authData = await authenticate(Uri.parse(issuerUri), _scopes, context); - - // Validate authentication response before saving - if (authData.isEmpty) { - return null; - } - - if (authData.containsKey('error')) { - return null; - } - - // Validate that required authentication fields are present - if (!authData.containsKey('accessToken') || - authData['accessToken'] == null) { - return null; - } - - // Let saveAuthData() decode the JWT and extract webId - // If webId extraction fails, saveAuthData() will handle it and skip saving - await AuthDataManager.saveAuthData(authData); - - // Verify that webId was successfully extracted and saved - final webId = await AuthDataManager.getWebId(); - if (webId == null || webId.isEmpty) { - return null; - } - - // Proceed to fetch profile data with the authenticated credentials - final profCardUrl = webId.replaceAll('#me', ''); - final profData = utf8.decode(await getResource(profCardUrl)); - - return [authData, webId, profData]; + if (clientId.isEmpty) { + throw Exception( + 'oidcClientId is required for Solid-OIDC authentication. ' + 'Provide the URL of your app\'s client profile JSON-LD document.', + ); } - // Already logged in successfully - fetch profile data - final webId = await AuthDataManager.getWebId(); - if (webId == null || webId.isEmpty) { - await logoutPod(); - return null; - } + // Check if post logout redirect URI is set. If not use the + // same value of redirect URI. + postLogoutRedirectUri ??= redirectUri; + + final authManager = SolidAuthManager( + config: SolidOidcConfig( + clientId: clientId, + redirectUri: Uri.parse(redirectUri), + postLogoutRedirectUri: Uri.parse(postLogoutRedirectUri), + ), + ); + + final solidAuthData = await authManager.authenticate(serverId); + if (solidAuthData == null) return null; + + await AuthDataManager.saveAuthData( + solidAuthData, + authManager, + oidcClientId: clientId, + redirectUri: redirectUri.toString(), + ); + + final profData = utf8.decode( + await getResource(solidAuthData.webId.replaceAll('#me', '')), + ); + + return [solidAuthData, solidAuthData.webId, profData]; + } on Object catch (e) { + debugPrint('Solid Authenticate Failed: $e'); + return null; + } +} - final profCardUrl = webId.replaceAll('#me', ''); - final profData = utf8.decode(await getResource(profCardUrl)); +/// Silently restores a previously saved login session without browser interaction. +/// +/// Returns `[SolidAuthData, webId, profileTurtle]` if a valid persisted session +/// is found, or `null` if there is no session or it cannot be restored. +/// +/// Unlike [solidAuthenticate], this never opens a browser window. Use it on +/// app startup to automatically skip the login page when the user is still +/// logged in. +Future?> tryRestoreSession() async { + try { + final authData = await AuthDataManager.loadAuthData(); + if (authData == null) return null; - return [authData, webId, profData]; + final profData = utf8.decode( + await getResource(authData.webId.replaceAll('#me', '')), + ); + return [authData, authData.webId, profData]; } on Object catch (e) { - debugPrint('Solid Authenticate Failed: $e'); + debugPrint('tryRestoreSession failed: $e'); return null; } } + +// /// Builds the OAuth redirect URI from [appUrlScheme] or [frontendRedirectUrl]. +// Uri _buildRedirectUri(String? appUrlScheme, String? frontendRedirectUrl) { +// if (frontendRedirectUrl != null && frontendRedirectUrl.isNotEmpty) { +// return Uri.parse(frontendRedirectUrl); +// } +// if (appUrlScheme != null && appUrlScheme.isNotEmpty) { +// return Uri.parse('$appUrlScheme://callback'); +// } +// throw Exception( +// 'Either appUrlScheme or frontendRedirectUrl must be provided ' +// 'for the OAuth redirect.', +// ); +// } diff --git a/lib/src/solid/utils/authdata_manager.dart b/lib/src/solid/utils/authdata_manager.dart index cd07a6b6..9a76ce46 100644 --- a/lib/src/solid/utils/authdata_manager.dart +++ b/lib/src/solid/utils/authdata_manager.dart @@ -1,3 +1,5 @@ +/// Manages authentication state for Solid-OIDC sessions. +/// /// Copyright (C) 2024, Software Innovation Institute, ANU. /// /// Licensed under the MIT License (the "License"). @@ -22,7 +24,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. /// -/// Authors: Dawei Chen +/// Authors: Dawei Chen, Anushka Vidanage library; @@ -30,12 +32,8 @@ import 'dart:convert' show jsonEncode, jsonDecode; import 'package:flutter/foundation.dart' show ValueNotifier; -import 'package:fast_rsa/fast_rsa.dart' show KeyPair; -import 'package:jwt_decoder/jwt_decoder.dart' show JwtDecoder; -import 'package:solid_auth/solid_auth.dart'; -// ignore: implementation_imports -import 'package:solid_auth/src/openid/openid_client.dart' - show Credential, TokenResponse; +import 'package:solid_auth/solid_auth.dart' + show SolidAuthData, SolidAuthManager, SolidOidcConfig; import 'package:solidpod/src/solid/constants/common.dart' show secureStorage; import 'package:solidpod/src/solid/utils/misc.dart' show writeToSecureStorage; @@ -44,245 +42,173 @@ import 'package:solidpod/src/solid/utils/misc.dart' show writeToSecureStorage; /// Listen to this to get notified when login/logout happens. final ValueNotifier authStateNotifier = ValueNotifier(false); -/// [AuthDataManager] is a class to manage auth data returned by -/// solid-auth authenticate, including: -/// - save auth data to secure storage -/// - load auth data from secure storage -/// - delete saved auth data from secure storage -/// - refresh access token if necessary +/// [AuthDataManager] manages the Solid-OIDC authentication session: +/// - persists enough config to restore [SolidAuthManager] across app restarts +/// - caches auth data in memory to avoid repeated secure-storage reads +/// - delegates token refresh and logout to [SolidAuthManager] class AuthDataManager { - /// The web ID - static String? _webId; - - /// The URL for logging out - static String? _logoutUrl; - - /// The RSA keypair and their JWK format. - // - // It seems [String] as the first between the angle brackets does not work - static Map? _rsaInfo; + /// In-memory [SolidAuthManager] for the active session. + static SolidAuthManager? _authManager; - /// The authentication response - static Credential? _authResponse; + /// Cached webId for fast access without loading the full session. + static String? _webId; - /// The string key for storing auth data in secure storage + /// Secure-storage key for the session config needed to recreate [SolidAuthManager]. static const String _authDataSecureStorageKey = '_solid_auth_data'; - /// Save the auth data returned by solid-auth authenticate in secure storage - // - // It seems [String] as the first between the angle brackets does not work - static Future saveAuthData(Map authData) async { - const keys = [ - 'client', - 'rsaInfo', - 'authResponse', - 'tokenResponse', - 'accessToken', - 'idToken', - 'refreshToken', - 'expiresIn', - 'logoutUrl', - ]; - - for (final key in keys) { - assert(authData.containsKey(key)); - } - - final decodedToken = JwtDecoder.decode(authData['accessToken'] as String); - _webId = decodedToken['webid'] as String; - _logoutUrl = authData['logoutUrl'] as String; - _rsaInfo = authData['rsaInfo'] as Map; // Note that use Map does not seem to work - _authResponse = authData['authResponse'] as Credential; - + /// Save auth data after a successful login. + /// + /// Stores [authData] and [authManager] in memory, and persists [oidcClientId] + /// and [redirectUri] to secure storage so [SolidAuthManager] can be + /// reconstructed on app restart. DPoP key pair and OIDC tokens are persisted + /// by [SolidAuthManager] internally via [SolidAuthSessionStore]. + static Future saveAuthData( + SolidAuthData authData, + SolidAuthManager authManager, { + String? oidcClientId, + String? redirectUri, + }) async { + _authManager = authManager; + _webId = authData.webId; + + // Persist only the config needed to reconstruct SolidAuthManager on restart. + // DPoP key pair and OIDC tokens are persisted by solid_auth internally via + // SolidAuthSessionStore (called automatically inside SolidAuthManager.login()). await writeToSecureStorage( _authDataSecureStorageKey, jsonEncode({ - 'web_id': _webId, - 'logout_url': _logoutUrl, - 'rsa_info': jsonEncode({ - ..._rsaInfo!, - // Overwrite the 'rsa' keypair in rsaInfo - 'rsa': { - 'public_key': _rsaInfo!['rsa'].publicKey as String, - 'private_key': _rsaInfo!['rsa'].privateKey as String, - }, - }), - 'auth_response': _authResponse!.toJson(), + 'web_id': authData.webId, + 'oidc_client_id': oidcClientId ?? '', + 'redirect_uri': redirectUri ?? '', }), ); - // Notify listeners that auth state has changed authStateNotifier.value = true; - - // debugPrint('AuthDataManager => saveAuthData() done'); } - /// Retrieve (and reconstruct) auth data from secure storage - // - // It seems [String] as the first between the angle brackets does not work - static Future?> loadAuthData() async { - if (_logoutUrl == null || _rsaInfo == null || _authResponse == null) { - final loaded = await _loadData(); - if (!loaded) { - // debugPrint('AuthDataManager => loadAuthData() failed'); - return null; - } + /// Returns current [SolidAuthData], refreshing the token if expired. + /// + /// If no in-memory manager exists, attempts to restore the session from + /// secure storage using [SolidAuthManager.initForIssuer]. Returns null + /// when the session cannot be restored (forces re-login). + static Future loadAuthData() async { + // Check if live manager already in memory. + if (_authManager != null) { + return _getRefreshedAuthData(_authManager!); } - assert(_logoutUrl != null && _rsaInfo != null && _authResponse != null); + // Slow path: try to restore from secure storage. + final dataStr = await secureStorage.read(key: _authDataSecureStorageKey); + if (dataStr == null) return null; + try { - final tokenResponse = await _getTokenResponse(); - if (tokenResponse == null) { - throw Exception('Refreshing access token failed'); + final dataMap = jsonDecode(dataStr) as Map; + final storedClientId = dataMap['oidc_client_id'] as String? ?? ''; + final storedRedirectUri = dataMap['redirect_uri'] as String? ?? ''; + _webId = dataMap['web_id'] as String?; + + if (storedClientId.isEmpty || storedRedirectUri.isEmpty) { + return null; + } + + final restoredManager = SolidAuthManager( + config: SolidOidcConfig( + clientId: storedClientId, + redirectUri: Uri.parse(storedRedirectUri), + ), + ); + + // Delegate to solid_auth's tryRestoreSession(), which handles: + // 1. Loading the stored issuer, scopes, and DPoP key pair PEMs from + // SolidAuthSessionStore (persisted at login time). + // 2. Restoring the DpopKeyManager singleton with the original key pair + // so proofs still match the cnf.jkt in the stored access token. + // 3. Calling initForIssuer() → OidcUserManager.init() which reloads + // and transparently refreshes the OIDC tokens if needed. + final authData = await restoredManager.tryRestoreSession(); + if (authData != null) { + _authManager = restoredManager; + _webId = authData.webId; + authStateNotifier.value = true; } - return { - 'client': _authResponse!.client, - 'rsaInfo': _rsaInfo, - 'authResponse': _authResponse, - 'tokenResponse': tokenResponse, - 'accessToken': tokenResponse.accessToken, - 'idToken': _authResponse!.idToken, - 'refreshToken': _authResponse!.refreshToken, - 'expiresIn': tokenResponse.expiresIn, - 'logoutUrl': _logoutUrl, - }; + + return authData; } on Object { - // Catch any object thrown (Dart programs can throw any non-null object) - // debugPrint('AuthDataManager => loadAuthData() failed: $e'); + return null; } - return null; } - /// Remove/delete auth data from secure storage + /// Clears cached state and removes persisted config from secure storage. + /// + /// Does NOT contact the IdP — call [getAuthManager]?.logout() or + /// [getAuthManager]?.forgetUser() before this if needed. static Future removeAuthData() async { try { + _authManager = null; + _webId = null; + if (await secureStorage.containsKey(key: _authDataSecureStorageKey)) { await secureStorage.delete(key: _authDataSecureStorageKey); - _webId = null; - _logoutUrl = null; - _rsaInfo = null; - _authResponse = null; } - // Notify listeners that auth state has changed authStateNotifier.value = false; - return true; } on Object { - // debugPrint('AuthDataManager => removeAuthData() failed: $e'); + return false; } - return false; } - /// Returns the (refreshed) access token + /// Returns the current (refreshed if expired) access token, or null. static Future getAccessToken() async { - final tokenResponse = await _getTokenResponse(); - if (tokenResponse != null) { - return tokenResponse.accessToken; - } else { - // debugPrint('AuthDataManager => getAccessToken() failed'); - } - return null; - } - - /// Returns the (updated) token response - static Future _getTokenResponse() async { - if (_authResponse == null) { - final loaded = await _loadData(); - if (!loaded) { - // debugPrint('AuthDataManager => _getTokenResponse() failed'); - return null; - } - } - assert(_authResponse != null); - - try { - var tokenResponse = TokenResponse.fromJson(_authResponse!.response!); - if (JwtDecoder.isExpired(tokenResponse.accessToken!)) { - // debugPrint( - // 'AuthDataManager => _getTokenResponse() refreshing expired token', - // ); - assert(_rsaInfo != null); - final rsaKeyPair = _rsaInfo!['rsa'] as KeyPair; - final publicKeyJwk = _rsaInfo!['pubKeyJwk']; - final tokenEndpoint = - _authResponse!.client.issuer.metadata['token_endpoint'] as String; - final dPopToken = genDpopToken( - tokenEndpoint, - rsaKeyPair, - publicKeyJwk, - 'POST', - ); - tokenResponse = await _authResponse!.getTokenResponse( - forceRefresh: true, - dPoPToken: dPopToken, - ); - // TODO dc 20250106: Save refreshed token in secure storage - } - return tokenResponse; - } on Object { - // debugPrint('AuthDataManager => _getTokenResponse() failed: $e'); - } - return null; + final authData = await loadAuthData(); + return authData?.accessToken; } - /// Returns the web ID + /// Returns the cached WebID, falling back to secure storage. static Future getWebId() async { - if (_webId == null) { - final loaded = await _loadData(); - if (!loaded) { - // debugPrint('AuthDataManager => getWebId() failed'); - return null; + if (_webId != null) return _webId; + + final dataStr = await secureStorage.read(key: _authDataSecureStorageKey); + if (dataStr != null) { + try { + final dataMap = jsonDecode(dataStr) as Map; + _webId = dataMap['web_id'] as String?; + } on Object { + _webId = null; } } - assert(_webId != null); return _webId; } - /// Returns the logout URL + /// Returns the logout endpoint URI from the OIDC discovery document, or null. + /// + /// Provided for backwards compatibility. Prefer calling + /// [getAuthManager]?.logout() directly. static Future getLogoutUrl() async { - if (_logoutUrl == null) { - final loaded = await _loadData(); - if (!loaded) { - // debugPrint('AuthDataManager => getLogoutUrl() failed'); - return null; - } + try { + return _authManager?.oidcManager.discoveryDocument.endSessionEndpoint + ?.toString(); + } on Object { + return null; } - assert(_logoutUrl != null); - return _logoutUrl; } - /// Reconstruct the rsaInfo from JSON string - static Map _getRsaInfo(String rsaJson) { - final rsaInfo_ = jsonDecode(rsaJson) as Map; - final publicKey = rsaInfo_['rsa']['public_key'] as String; - final privateKey = rsaInfo_['rsa']['private_key'] as String; - - return {...rsaInfo_, 'rsa': KeyPair(publicKey, privateKey)}; - } - - /// Retrieve auth data from secure storage - static Future _loadData() async { - final dataStr = await secureStorage.read(key: _authDataSecureStorageKey); - - if (dataStr != null) { - try { - final dataMap = jsonDecode(dataStr) as Map; - _webId = dataMap['web_id'] as String; - _logoutUrl = dataMap['logout_url'] as String; - _rsaInfo = _getRsaInfo(dataMap['rsa_info'] as String); - _authResponse = Credential.fromJson( - (dataMap['auth_response'] as Map).cast(), - ); + /// Exposes the live [SolidAuthManager] for DPoP proof generation and logout. + static SolidAuthManager? getAuthManager() => _authManager; - return true; - } on Object { - // debugPrint('AuthDataManager => _loadData() failed: $e'); - return false; + /// Returns [SolidAuthData] from [manager], refreshing the token if expired. + static Future _getRefreshedAuthData( + SolidAuthManager manager, + ) async { + try { + var authData = manager.currentAuthData; + if (authData != null && authData.isExpired) { + authData = await manager.refreshToken(); } + return authData; + } on Object { + return null; } - return false; } } diff --git a/lib/src/solid/utils/misc.dart b/lib/src/solid/utils/misc.dart index 06f1fbf4..c2952a12 100644 --- a/lib/src/solid/utils/misc.dart +++ b/lib/src/solid/utils/misc.dart @@ -33,13 +33,12 @@ library; import 'package:flutter/foundation.dart' show debugPrint; import 'package:encrypter_plus/encrypter_plus.dart'; -import 'package:fast_rsa/fast_rsa.dart' show KeyPair; import 'package:http/http.dart' as http; import 'package:intl/intl.dart'; import 'package:jwt_decoder/jwt_decoder.dart'; import 'package:path/path.dart' as path; import 'package:rdflib/rdflib.dart'; -import 'package:solid_auth/solid_auth.dart' show genDpopToken, logout; +import 'package:solid_auth/solid_auth.dart' show DpopTokenGenerator; import 'package:solidpod/src/solid/api/rest_api.dart'; import 'package:solidpod/src/solid/constants/common.dart'; @@ -428,13 +427,21 @@ Future<({String accessToken, String dPopToken})> getTokensForResource( throw Exception('Authentication data not available. Please login first.'); } - final rsaInfo = authData['rsaInfo']; - final rsaKeyPair = rsaInfo['rsa'] as KeyPair; - final publicKeyJwk = rsaInfo['pubKeyJwk']; + final authManager = AuthDataManager.getAuthManager(); + if (authManager == null) { + throw Exception('Auth manager not available. Please login first.'); + } + + final dPopToken = await DpopTokenGenerator.generateForRequest( + endpointUrl: resourceUrl, + httpMethod: httpMethod, + accessToken: authData.accessToken, + keyManager: authManager.keyManager, + ); return ( - accessToken: authData['accessToken'] as String, - dPopToken: genDpopToken(resourceUrl, rsaKeyPair, publicKeyJwk, httpMethod), + accessToken: authData.accessToken, + dPopToken: dPopToken, ); } @@ -442,79 +449,40 @@ Future<({String accessToken, String dPopToken})> getTokensForResource( /// /// This function performs a complete logout that includes: /// 1. Clearing all encryption keys from memory -/// 2. Removing authentication data from secure storage -/// 3. Calling the OAuth2 logout endpoint (with error tolerance on web) +/// 2. Clearing application-specific caches +/// 3. Calling the OIDC logout endpoint via SolidAuthManager (with error tolerance) +/// 4. Removing authentication data from secure storage /// -/// Returns true if logout succeeds or critical operations complete, -/// false only if critical operations (key/auth cleanup) fail. +/// Returns true if critical cleanup (key/auth data) succeeds. Future logoutPod() async { try { - // Step 1: Clear all cached encryption keys and security data from memory - // This is CRITICAL and must be done regardless of other failures await KeyManager.clear(); - debugPrint('logoutPod() => KeyManager.clear() completed'); - - // Step 2: Get the logout URL before removing auth data - final logoutUrl = await AuthDataManager.getLogoutUrl(); - - // Step 3: Remove authentication data from secure storage - // This is CRITICAL - must succeed - final authDataRemoved = await AuthDataManager.removeAuthData(); - if (!authDataRemoved) { - debugPrint( - 'logoutPod() => WARNING: AuthDataManager.removeAuthData() failed', - ); - // Don't return false yet - logout endpoint is still needed - } - // Step 3.5: Clear application-specific caches BEFORE network call - // This is CRITICAL to prevent race conditions where UI reads stale cache - // during logout, especially when network is slow + // Clear app caches before any network operations to prevent race conditions. if (_onLogoutClearCaches != null) { try { await _onLogoutClearCaches!(); } on Object catch (e) { - debugPrint( - 'logoutPod() => WARNING: Application cache callback failed (non-critical): $e', - ); - // Continue - the critical auth data is already cleared + debugPrint('logoutPod() cache callback failed (non-critical): $e'); } - } else { - debugPrint('logoutPod() => No application cache callback registered'); } - // Step 4: Attempt OAuth2 logout - // This is OPTIONAL - should not block if it fails - if (logoutUrl != null && logoutUrl.isNotEmpty) { - try { - // Call the OAuth2 logout endpoint - // On web, this may fail with platform-related exceptions, but we continue anyway - await logout(logoutUrl); - debugPrint('logoutPod() => OAuth2 logout endpoint called successfully'); - } on Object catch (e) { - // On Flutter Web, platform-related exceptions might occur - // This is NOT a critical failure - the local session is already cleared - debugPrint('logoutPod() => OAuth2 logout warning (non-critical): $e'); - // Continue - local data is already cleared which is most important - } - } else { - debugPrint( - 'logoutPod() => No logout URL available, skipping OAuth2 logout', - ); + // Contact the OIDC logout endpoint and rotate the DPoP key. + // Must be called before removeAuthData() clears _authManager. + try { + await AuthDataManager.getAuthManager()?.logout(); + } on Object catch (e) { + debugPrint('logoutPod() OAuth2 logout warning (non-critical): $e'); } - // Success if we cleared the local data (most important part) + final authDataRemoved = await AuthDataManager.removeAuthData(); return authDataRemoved; } on Object catch (e) { - // Catch any remaining exceptions - debugPrint('logoutPod() => CRITICAL ERROR: $e'); - // Even if we reach here, attempt to clear auth data as fallback + debugPrint('logoutPod() CRITICAL ERROR: $e'); try { await AuthDataManager.removeAuthData(); await KeyManager.clear(); - } catch (fallbackError) { - debugPrint('logoutPod() => Fallback cleanup also failed: $fallbackError'); - } + } on Object catch (_) {} return false; } } @@ -530,7 +498,15 @@ Future silentLogout() async { try { await KeyManager.clear(); - final logoutUrl = await AuthDataManager.getLogoutUrl(); + // Get logout URL from discovery doc before clearing the manager. + String? logoutUrl; + try { + logoutUrl = await AuthDataManager.getLogoutUrl(); + } on Object catch (_) {} + + // Clear local token state only — no browser redirect. + await AuthDataManager.getAuthManager()?.forgetUser(); + final authDataRemoved = await AuthDataManager.removeAuthData(); if (_onLogoutClearCaches != null) { @@ -542,7 +518,6 @@ Future silentLogout() async { } // Best-effort IdP session invalidation via headless HTTP GET. - if (logoutUrl != null && logoutUrl.isNotEmpty) { try { await http.get(Uri.parse(logoutUrl)); diff --git a/pubspec.yaml b/pubspec.yaml index a2631aad..8e38020e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,7 +33,11 @@ dependencies: petitparser: ^6.1.0 pointycastle: ^4.0.0 rdflib: ^0.2.12 - solid_auth: ^0.1.29 + # solid_auth: ^0.1.29 + solid_auth: + git: + url: https://github.com/anusii/solid_auth.git + ref: av/session-persistence universal_io: ^2.3.1 dev_dependencies: @@ -43,6 +47,12 @@ dev_dependencies: flutter_lints: ^6.0.0 import_order_lint: ^0.2.2 +dependency_overrides: + solid_auth: + git: + url: https://github.com/anusii/solid_auth.git + ref: av/session-persistence + flutter: assets: - assets/images/default_image.jpg