From 4f893252e112cf2092c80de47452bda2d76f04fd Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Wed, 20 May 2026 13:05:00 +1000 Subject: [PATCH 01/14] changes to gitignore --- .gitignore | 7 +++++++ 1 file changed, 7 insertions(+) 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 From 070049bc8d2ccd3fc05f2244b831a62ea38e882b Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Wed, 20 May 2026 21:45:19 +1000 Subject: [PATCH 02/14] change authentication handling methods to work with solid_auth --- example/.metadata | 12 +- example/lib/main.dart | 4 + example/pubspec.yaml | 2 + example/test/widget_test.dart | 30 ++ lib/src/solid/authenticate.dart | 160 ++++++----- lib/src/solid/utils/authdata_manager.dart | 329 +++++++++------------- lib/src/solid/utils/misc.dart | 101 +++---- pubspec.yaml | 8 +- 8 files changed, 299 insertions(+), 347 deletions(-) create mode 100644 example/test/widget_test.dart 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..cb033546 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -93,6 +93,10 @@ class DemoPod extends StatelessWidget { infoButtonStyle: InfoButtonStyle( tooltip: 'Visit the DemoPod documentation.', ), + clientId: + 'https://anushkavidanage.github.io/solidpod/example/client-profile.jsonld', + redirectUri: 'http://localhost:4400/redirect', + postLogoutRedirectUri: 'http://localhost:4400/redirect', child: Home(), ), ); diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 9eca0ad2..f5c400fe 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -22,6 +22,8 @@ dependencies: dependency_overrides: solidpod: path: .. + solidui: + path: ../../solidui 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/src/solid/authenticate.dart b/lib/src/solid/authenticate.dart index ef23b4c5..09d0710b 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,110 @@ // 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 - } - } - - // 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; + // 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 (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]; } - // Already logged in successfully - fetch profile data - final webId = await AuthDataManager.getWebId(); - if (webId == null || webId.isEmpty) { - await logoutPod(); - return null; + if (clientId.isEmpty) { + throw Exception( + 'oidcClientId is required for Solid-OIDC authentication. ' + 'Provide the URL of your app\'s client profile JSON-LD document.', + ); } - final profCardUrl = webId.replaceAll('#me', ''); - final profData = utf8.decode(await getResource(profCardUrl)); - - return [authData, webId, profData]; + // 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; } } + +// /// 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..eba96ff3 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 DpopKeyManager, 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,184 @@ 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 the [authManager] in memory, and persists + /// [oidcClientId] + [redirectUri] + issuer to secure storage so the session + /// can be restored on app restart via [initForIssuer]. + static Future saveAuthData( + SolidAuthData authData, + SolidAuthManager authManager, { + String? oidcClientId, + String? redirectUri, + }) async { + _authManager = authManager; + _webId = authData.webId; + + final keyPair = authManager.keyManager.keyPair; 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, + 'issuer': authData.issuer, + 'oidc_client_id': oidcClientId ?? '', + 'redirect_uri': redirectUri ?? '', + 'dpop_private_key': keyPair.privateKey, + 'dpop_public_key': keyPair.publicKey, }), ); - // 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? ?? ''; + final storedIssuer = dataMap['issuer'] as String? ?? ''; + _webId = dataMap['web_id'] as String?; + + if (storedClientId.isEmpty || + storedRedirectUri.isEmpty || + storedIssuer.isEmpty) { + return null; + } + + // Restore the DPoP key pair that was active when the access token was + // issued. DpopKeyManager is in-memory only, so without this the manager + // would generate a new key pair whose thumbprint doesn't match cnf.jkt + // in the stored access token, causing the server to reject every request. + final dpopPrivateKey = dataMap['dpop_private_key'] as String? ?? ''; + final dpopPublicKey = dataMap['dpop_public_key'] as String? ?? ''; + if (dpopPrivateKey.isNotEmpty && dpopPublicKey.isNotEmpty) { + await DpopKeyManager.restoreFromPem( + privateKeyPem: dpopPrivateKey, + publicKeyPem: dpopPublicKey, + ); + } + + final restoredManager = SolidAuthManager( + config: SolidOidcConfig( + clientId: storedClientId, + redirectUri: Uri.parse(storedRedirectUri), + ), + ); + + // Restores the OIDC session from the oidc package's own secure storage. + await restoredManager.initForIssuer(storedIssuer); + + final authData = await _getRefreshedAuthData(restoredManager); + if (authData != null) { + _authManager = restoredManager; + 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; + final authData = await loadAuthData(); + return authData?.accessToken; } - /// 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; - } - - /// 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); + /// Exposes the live [SolidAuthManager] for DPoP proof generation and logout. + static SolidAuthManager? getAuthManager() => _authManager; - 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(), - ); - - 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..1080f030 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,7 +33,9 @@ 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: + path: ../solid_auth universal_io: ^2.3.1 dev_dependencies: @@ -43,6 +45,10 @@ dev_dependencies: flutter_lints: ^6.0.0 import_order_lint: ^0.2.2 +dependency_overrides: + solid_auth: + path: ../solid_auth + flutter: assets: - assets/images/default_image.jpg From 3b3192d2a2bfd68ad5bd341c348248bda5638a26 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Wed, 20 May 2026 22:10:35 +1000 Subject: [PATCH 03/14] auto logging session restore --- example/lib/main.dart | 1 + lib/solidpod.dart | 2 +- lib/src/solid/authenticate.dart | 23 +++++++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index cb033546..6fd12c7e 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -97,6 +97,7 @@ class DemoPod extends StatelessWidget { 'https://anushkavidanage.github.io/solidpod/example/client-profile.jsonld', redirectUri: 'http://localhost:4400/redirect', postLogoutRedirectUri: 'http://localhost:4400/redirect', + autoLogin: true, child: Home(), ), ); 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 09d0710b..f51bc498 100644 --- a/lib/src/solid/authenticate.dart +++ b/lib/src/solid/authenticate.dart @@ -120,6 +120,29 @@ Future?> solidAuthenticate( } } +/// 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; + + final profData = utf8.decode( + await getResource(authData.webId.replaceAll('#me', '')), + ); + return [authData, authData.webId, profData]; + } on Object catch (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) { From 06bc554e7de717bea47d7d5d6c26938febbe91c7 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Thu, 21 May 2026 11:38:05 +1000 Subject: [PATCH 04/14] use solid_auth restore session --- lib/src/solid/utils/authdata_manager.dart | 47 +++++++++-------------- 1 file changed, 18 insertions(+), 29 deletions(-) diff --git a/lib/src/solid/utils/authdata_manager.dart b/lib/src/solid/utils/authdata_manager.dart index eba96ff3..9a76ce46 100644 --- a/lib/src/solid/utils/authdata_manager.dart +++ b/lib/src/solid/utils/authdata_manager.dart @@ -33,7 +33,7 @@ import 'dart:convert' show jsonEncode, jsonDecode; import 'package:flutter/foundation.dart' show ValueNotifier; import 'package:solid_auth/solid_auth.dart' - show DpopKeyManager, SolidAuthData, SolidAuthManager, SolidOidcConfig; + show SolidAuthData, SolidAuthManager, SolidOidcConfig; import 'package:solidpod/src/solid/constants/common.dart' show secureStorage; import 'package:solidpod/src/solid/utils/misc.dart' show writeToSecureStorage; @@ -59,9 +59,10 @@ class AuthDataManager { /// Save auth data after a successful login. /// - /// Stores [authData] and the [authManager] in memory, and persists - /// [oidcClientId] + [redirectUri] + issuer to secure storage so the session - /// can be restored on app restart via [initForIssuer]. + /// 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, { @@ -71,16 +72,15 @@ class AuthDataManager { _authManager = authManager; _webId = authData.webId; - final keyPair = authManager.keyManager.keyPair; + // 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': authData.webId, - 'issuer': authData.issuer, 'oidc_client_id': oidcClientId ?? '', 'redirect_uri': redirectUri ?? '', - 'dpop_private_key': keyPair.privateKey, - 'dpop_public_key': keyPair.publicKey, }), ); @@ -106,28 +106,12 @@ class AuthDataManager { final dataMap = jsonDecode(dataStr) as Map; final storedClientId = dataMap['oidc_client_id'] as String? ?? ''; final storedRedirectUri = dataMap['redirect_uri'] as String? ?? ''; - final storedIssuer = dataMap['issuer'] as String? ?? ''; _webId = dataMap['web_id'] as String?; - if (storedClientId.isEmpty || - storedRedirectUri.isEmpty || - storedIssuer.isEmpty) { + if (storedClientId.isEmpty || storedRedirectUri.isEmpty) { return null; } - // Restore the DPoP key pair that was active when the access token was - // issued. DpopKeyManager is in-memory only, so without this the manager - // would generate a new key pair whose thumbprint doesn't match cnf.jkt - // in the stored access token, causing the server to reject every request. - final dpopPrivateKey = dataMap['dpop_private_key'] as String? ?? ''; - final dpopPublicKey = dataMap['dpop_public_key'] as String? ?? ''; - if (dpopPrivateKey.isNotEmpty && dpopPublicKey.isNotEmpty) { - await DpopKeyManager.restoreFromPem( - privateKeyPem: dpopPrivateKey, - publicKeyPem: dpopPublicKey, - ); - } - final restoredManager = SolidAuthManager( config: SolidOidcConfig( clientId: storedClientId, @@ -135,12 +119,17 @@ class AuthDataManager { ), ); - // Restores the OIDC session from the oidc package's own secure storage. - await restoredManager.initForIssuer(storedIssuer); - - final authData = await _getRefreshedAuthData(restoredManager); + // 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; } From 30fb6a02323102419e12b857862dd5e7c3b2b11d Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Thu, 21 May 2026 11:51:46 +1000 Subject: [PATCH 05/14] add redirect uris --- example/lib/main.dart | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 6fd12c7e..c04d2b7b 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -95,8 +95,13 @@ class DemoPod extends StatelessWidget { ), clientId: 'https://anushkavidanage.github.io/solidpod/example/client-profile.jsonld', - redirectUri: 'http://localhost:4400/redirect', - postLogoutRedirectUri: 'http://localhost:4400/redirect', + // 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(), ), From f78e13fd9dd77df32d726679b51b7886d6898293 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Thu, 21 May 2026 17:28:17 +1000 Subject: [PATCH 06/14] point to git branches --- example/pubspec.yaml | 4 +++- pubspec.yaml | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/example/pubspec.yaml b/example/pubspec.yaml index f5c400fe..e7c73121 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -23,7 +23,9 @@ dependency_overrides: solidpod: path: .. solidui: - 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/pubspec.yaml b/pubspec.yaml index 1080f030..8e38020e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,7 +35,9 @@ dependencies: rdflib: ^0.2.12 # solid_auth: ^0.1.29 solid_auth: - path: ../solid_auth + git: + url: https://github.com/anusii/solid_auth.git + ref: av/session-persistence universal_io: ^2.3.1 dev_dependencies: @@ -47,7 +49,9 @@ dev_dependencies: dependency_overrides: solid_auth: - path: ../solid_auth + git: + url: https://github.com/anusii/solid_auth.git + ref: av/session-persistence flutter: assets: From 8182a8ce0099a57fba76adcb01fef817833c92f0 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Mon, 25 May 2026 09:23:56 +1000 Subject: [PATCH 07/14] update readme --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index 8c42e8de..fb1d4d20 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,18 @@ If the package is being used to build either a `macos` or `web` app, the following changes are required in order to make the package fully functional. +## General + +`solidpod` delegates authentication to [`package:solid_auth`](https://pub.dev/packages/solid_auth), +which is built on the OpenID-certified [`package:oidc`](https://pub.dev/packages/oidc) and +implements the Solid-OIDC protocol. + +Authentication requires a client ID document, which is a publicly hosted JSON-LD file +that identifies your app to the Solid identity provider. Pass its URL as the clientId +parameter to solidAuthenticate(). See the +[Solid-OIDC client identifiers spec](https://solid.github.io/solid-oidc/#clientids-document) +for how to create and host one. + ## Android For a release be sure to update @@ -194,6 +206,9 @@ A function to authenticate a user against a given Solid server final authData = await solidAuthenticate( 'https://pods.solidcommunity.au/', context, + clientId: clientId, + redirectUri: redirectUri, + postLogoutRedirectUri: postLogoutRedirectUri, ); ``` From 4811f952aa50515b6ad7e161eb12ec480a3438de Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Mon, 25 May 2026 10:05:40 +1000 Subject: [PATCH 08/14] update readme --- README.md | 231 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 224 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index fb1d4d20..b3325e49 100644 --- a/README.md +++ b/README.md @@ -106,10 +106,68 @@ Authentication requires a client ID document, which is a publicly hosted JSON-LD that identifies your app to the Solid identity provider. Pass its URL as the clientId parameter to solidAuthenticate(). See the [Solid-OIDC client identifiers spec](https://solid.github.io/solid-oidc/#clientids-document) -for how to create and host one. +for how to create and host one. For an example client ID document refer to [here](https://anushkavidanage.github.io/solidpod/example/client-profile.jsonld). ## Android +As per [OIDC getting started guide](https://bdaya-dev.github.io/oidc/oidc-getting-started/) update the following. + +Go to `android/app/build.gradle`, and add the following line under `defaultConfig:` + +```gradle + defaultConfig { + ... + manifestPlaceholders += [ + 'appAuthRedirectScheme': 'com.my.app' + ] +} +``` + +Replace `com.my.app` with your `applicationId`. If you have a `build.gradle.kts` file upgrade in the following way + +```gradle + defaultConfig { + ... + manifestPlaceholders.putAll(mapOf( + "appAuthRedirectScheme" to "com.my.app" + )) +} +``` + +Go to `android/app/src/main/AndroidManifest.xml`, and add the following under `application` tag: + +```xml + +``` + +Also under `activity` tab change the following: +- Remove the line `android:taskAffinity=""` +- Change `android:launchMode="singleTop"` to `android:launchMode="singleTask"` + +Now create the following file in `android\app\src\main\res\xml\backup_rules.xml` + +```xml + + + + +``` + +Also create the following file in `android\app\src\main\res\xml\data_extraction_rules.xml` + +```xml + + + + + + +``` + For a release be sure to update `android/app/src/main/AndroidManifest.xml` to include within the `queries` section of the `manifest`: @@ -150,12 +208,136 @@ so fill the missing.* ### web -Inside the app directory go to the directory `/web/`. Inside create a + + +In the same location where your client ID document is hosted, create +a file called `redirect.html`. Add the following piece of `html` code into +that file. ```html + + + + + Flutter Oidc Redirect + + + + + +

Authentication completed! Please close this page.

+ + + +``` + + + ## Usage @@ -206,12 +388,47 @@ A function to authenticate a user against a given Solid server final authData = await solidAuthenticate( 'https://pods.solidcommunity.au/', context, - clientId: clientId, - redirectUri: redirectUri, - postLogoutRedirectUri: postLogoutRedirectUri, + clientId: "https://your-domain/client-profile.jsonld", + redirectUri: "https://your-domain/redirect.html", + postLogoutRedirectUri: "https://your-domain/redirect.html", \\ optional ); ``` +**IMPORTANT** + +`redirectUri` and `postLogoutRedirectUri` must be registered in your client +ID document and match the correct format for each platform: + +| Platform | URI format | Notes | +|---|---|---| +| Web | `https://your-domain/redirect.html` | Must be same origin as the app - `oidc` uses `BroadcastChannel` (same-origin only) | +| Android / iOS | `com.example.app://redirect` | Custom URI scheme registered with the OS | +| Windows / Linux / macOS | `http://localhost:4400/redirect` | **Fixed port required** - see below | + +### Desktop: use a fixed port + +`oidc_desktop` binds a loopback HTTP server to the port in your +`redirectUri`. If you use port `0`, the OS assigns a random port +that is never registered in the client document, causing the Solid +server to reject logout with `post_logout_redirect_uri not registered`. +Use a fixed port (e.g. `4400`) in both the app and the client document. + +Both `redirect_uris` and `post_logout_redirect_uris` in the client ID +document must list every URI used across platforms: + +```json +{ + "redirect_uris": [ + "https://your-domain/redirect.html", + "http://localhost:4400/redirect" + ], + "post_logout_redirect_uris": [ + "https://your-domain/redirect.html", + "http://localhost:4400/redirect" + ] +} +``` + ### Read Pod File Example Read data from the file `data/myfiles/my-data-file.ttl`. From 69135fc086f96e98908014b9a6021d1a1a90c495 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Mon, 25 May 2026 10:31:24 +1000 Subject: [PATCH 09/14] update readme --- README.md | 82 +++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 61 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index b3325e49..eade6865 100644 --- a/README.md +++ b/README.md @@ -49,23 +49,22 @@ to host personal online data stores (Pods). Numerous providers of Solid Server hosting are emerging allowing users to host and migrate their Pods on any such servers (or to run their own server). -To know more about our work relatd to Solid Pods +To know more about our work related to Solid Pods visit ## Features -- [Authenticate](#authenticate-example) a user against a given Solid server. -- [Read](#read-pod-file-example) and [write](#write-to-pod-file-example) data files -in a POD. +- [Authenticate](#authenticate-example) a user against a given Solid server (WebID or issuer URI). +- [Silent session restore](#session-restore-example) on app startup — no browser required. +- [Read](#read-pod-file-example) and [write](#write-to-pod-file-example) data files in a POD. +- [Delete files and containers](#delete-a-file-from-the-pod) from a POD. - [Read, write and delete](#large-file-manager-example) large data files. +- Grant and revoke access permissions between users. For UI components such as login screens, security key management, permission granting/revoking, and shared resource views, see the [solidui](https://pub.dev/packages/solidui) package. -[Solid](https://solidproject.org/) is an open standard for a server -providing Data Vaults hosting personal online data stores -(Pods). Numerous providers of Solid Server [hosts](https://solidproject.org/get_a_pod) support users host and migrate their Pods. Anyone can also host their own [Community Solid Server](https://communitysolidserver.github.io/CommunitySolidServer/latest/). @@ -84,8 +83,8 @@ dependencies: ``` An example project that uses `solidpod` can be found -in the [example](https://github.com/anusii/solidui/tree/dev/example) -folder of the [SolidUI](https://github.com/anusii/solidui) repository. +in the [example](https://github.com/anusii/solidpod/tree/dev/example) +folder of the [solidpod](https://github.com/anusii/solidpod) repository. @@ -380,23 +379,29 @@ by the package. ### Authenticate Example -A function to authenticate a user against a given Solid server -`https://pods.solidcommunity.au/`. Return a list containing - authentication data. +Authenticates a user against a Solid server. The first argument can be +either the user's **WebID** (preferred) or a bare issuer URI. Returns +`[SolidAuthData, webId, profileTurtle]` on success, or `null` on failure. ```dart -final authData = await solidAuthenticate( - 'https://pods.solidcommunity.au/', - context, - clientId: "https://your-domain/client-profile.jsonld", - redirectUri: "https://your-domain/redirect.html", - postLogoutRedirectUri: "https://your-domain/redirect.html", \\ optional - ); +final result = await solidAuthenticate( + 'https://pods.solidcommunity.au/alice/profile/card#me', // WebID or issuer URI + context, + clientId: 'https://your-domain/client-profile.jsonld', + redirectUri: 'https://your-domain/redirect.html', + postLogoutRedirectUri: 'https://your-domain/redirect.html', // optional +); + +if (result != null) { + final authData = result[0] as SolidAuthData; // access token, id token, etc. + final webId = result[1] as String; // user's WebID + final profile = result[2] as String; // Turtle-encoded profile document +} ``` **IMPORTANT** -`redirectUri` and `postLogoutRedirectUri` must be registered in your client +`redirectUri` and `postLogoutRedirectUri` must be registered in your client ID document and match the correct format for each platform: | Platform | URI format | Notes | @@ -429,6 +434,30 @@ document must list every URI used across platforms: } ``` +### Session Restore Example + +On app startup, call `tryRestoreSession()` to silently resume a previous +session without opening a browser. It returns `[SolidAuthData, webId, profileTurtle]` +if a valid persisted session exists, or `null` if the user needs to log in. + +```dart +// In initState() or app startup code — before showing the login UI. +final result = await tryRestoreSession(); +if (result != null) { + final authData = result[0] as SolidAuthData; + final webId = result[1] as String; + final profile = result[2] as String; + // Navigate directly to the authenticated screen. +} else { + // No valid session — show the login screen. +} +``` + +`tryRestoreSession()` never opens a browser. It silently refreshes expired +access tokens if a refresh token is available, and returns `null` if the +session cannot be restored (in which case the stored session is cleared +automatically so the next `solidAuthenticate()` starts clean). + ### Read Pod File Example Read data from the file `data/myfiles/my-data-file.ttl`. @@ -493,7 +522,18 @@ the directory `parentDir` and encrypt both files using that key. ### Delete a File from the Pod ```dart -deleteFile() +// Obtain the full URL for the file first. +final fileUrl = await getFileUrl('myfiles/my-data-file.ttl'); + +// Delete the file, its ACL, and its encryption key (if any). +// Also revokes any permissions previously granted to other users. +await deleteFile(fileUrl: fileUrl); +``` + +To delete an entire directory and all of its contents recursively: + +```dart +await deleteContainer('myapp/data', 'myfiles'); ``` ### Large File Manager Example From 3a2d3a40ce4114012cbf926da5bb4e349ba82dbe Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Thu, 28 May 2026 21:27:17 +1000 Subject: [PATCH 10/14] define a list for redirect Uris --- example/lib/main.dart | 12 ++++- lib/solidpod.dart | 3 +- lib/src/solid/authenticate.dart | 84 ++++++++++++++++++++++++++++----- 3 files changed, 83 insertions(+), 16 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index c04d2b7b..7b60fb81 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -100,8 +100,16 @@ class DemoPod extends StatelessWidget { // 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', + redirectUris: [ + 'https://anushkavidanage.github.io/solidpod/example/redirect.html', + 'http://localhost:4400/redirect', + 'com.example.demopod://redirect' + ], + postLogoutRedirectUris: [ + 'https://anushkavidanage.github.io/solidpod/example/redirect.html', + 'http://localhost:4400/redirect', + 'com.example.demopod://redirect' + ], autoLogin: true, child: Home(), ), diff --git a/lib/solidpod.dart b/lib/solidpod.dart index 9c062c6b..c126d612 100644 --- a/lib/solidpod.dart +++ b/lib/solidpod.dart @@ -49,7 +49,8 @@ export 'src/solid/constants/predicates.dart'; /// Solid authentication function -export 'src/solid/authenticate.dart' show solidAuthenticate, tryRestoreSession; +export 'src/solid/authenticate.dart' + show solidAuthenticate, tryRestoreSession, pickRedirectUri; /// Status class to represent different function outputs diff --git a/lib/src/solid/authenticate.dart b/lib/src/solid/authenticate.dart index f51bc498..e26f9f36 100644 --- a/lib/src/solid/authenticate.dart +++ b/lib/src/solid/authenticate.dart @@ -32,7 +32,8 @@ library; import 'dart:convert'; -import 'package:flutter/foundation.dart' show debugPrint; +import 'package:flutter/foundation.dart' + show debugPrint, defaultTargetPlatform, kIsWeb, TargetPlatform; import 'package:flutter/material.dart' show BuildContext; import 'package:solid_auth/solid_auth.dart' @@ -43,6 +44,44 @@ import 'package:solidpod/src/solid/utils/authdata_manager.dart' show AuthDataManager; import 'package:solidpod/src/solid/utils/misc.dart' show isUserLoggedIn; +/// Selects the appropriate redirect URI from [uris] based on the runtime +/// platform, using the URI format as the discriminator: +/// +/// | Platform | Matched format | +/// |---|---| +/// | Web | `https://` URI (same-origin BroadcastChannel requirement) | +/// | Android / iOS | Custom scheme URI (not `http://` or `https://`) | +/// | Desktop (Windows / macOS / Linux) | `http://localhost` loopback URI | +/// +/// Falls back to the first element when no format-matched entry is found, so +/// a single-element list always returns that element unchanged. +/// +/// Throws [ArgumentError] if [uris] is empty. +String pickRedirectUri(List uris) { + if (uris.isEmpty) throw ArgumentError('redirectUris must not be empty'); + if (uris.length == 1) return uris.first; + + if (kIsWeb) { + return uris.firstWhere( + (u) => u.startsWith('https://'), + orElse: () => uris.first, + ); + } + if (defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS) { + // Mobile platforms use a custom URI scheme (not http or https). + return uris.firstWhere( + (u) => !u.startsWith('http://') && !u.startsWith('https://'), + orElse: () => uris.first, + ); + } + // Desktop: Windows / macOS / Linux use a localhost loopback URL. + return uris.firstWhere( + (u) => u.startsWith('http://localhost'), + orElse: () => uris.first, + ); +} + /// Asynchronously authenticate a user against a Solid server [serverId]. /// /// [serverId] is the user's WebID or an issuer URI. Issuer resolution is @@ -54,19 +93,28 @@ import 'package:solidpod/src/solid/utils/misc.dart' show isUserLoggedIn; /// [clientId] must be the URL of the app's client profile JSON-LD /// document (or a pre-registered client ID). Required. /// -/// [redirectUri] is the custom URL scheme for the OAuth to redirect to -/// after authentication. +/// [redirectUris] is the preferred parameter: provide one URI per platform +/// and [pickRedirectUri] selects the correct one automatically at runtime. +/// For example: +/// ```dart +/// redirectUris: [ +/// 'https://your-domain/redirect.html', // web +/// 'com.example.app://redirect', // android / ios +/// 'http://localhost:4400/redirect', // desktop +/// ] +/// ``` +/// +/// [postLogoutRedirectUris] works the same way for the post-logout redirect. +/// Defaults to the same selection as [redirectUris] when omitted. /// -/// [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, { required String clientId, - required String redirectUri, - String? postLogoutRedirectUri, + required List redirectUris, + List postLogoutRedirectUris = const [], }) async { try { // Return existing session without re-authenticating. @@ -87,15 +135,25 @@ Future?> solidAuthenticate( ); } - // Check if post logout redirect URI is set. If not use the - // same value of redirect URI. - postLogoutRedirectUri ??= redirectUri; + // Resolve the effective redirect URI. + final effectiveRedirectUri = pickRedirectUri(redirectUris); + + if (effectiveRedirectUri.isEmpty) { + throw ArgumentError( + 'A redirect URI is required. Provide at least one URI via redirectUris.', + ); + } + + // Resolve the post-logout URI the same way; default to the redirect URI. + final effectivePostLogoutUri = postLogoutRedirectUris.isNotEmpty + ? pickRedirectUri(postLogoutRedirectUris) + : effectiveRedirectUri; final authManager = SolidAuthManager( config: SolidOidcConfig( clientId: clientId, - redirectUri: Uri.parse(redirectUri), - postLogoutRedirectUri: Uri.parse(postLogoutRedirectUri), + redirectUri: Uri.parse(effectiveRedirectUri), + postLogoutRedirectUri: Uri.parse(effectivePostLogoutUri), ), ); @@ -106,7 +164,7 @@ Future?> solidAuthenticate( solidAuthData, authManager, oidcClientId: clientId, - redirectUri: redirectUri.toString(), + redirectUri: effectiveRedirectUri, ); final profData = utf8.decode( From beec6842542f824d6e952886d5b5581f89a6f6b6 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Thu, 28 May 2026 21:28:00 +1000 Subject: [PATCH 11/14] update readme --- README.md | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index eade6865..4e95ff83 100644 --- a/README.md +++ b/README.md @@ -388,8 +388,16 @@ final result = await solidAuthenticate( 'https://pods.solidcommunity.au/alice/profile/card#me', // WebID or issuer URI context, clientId: 'https://your-domain/client-profile.jsonld', - redirectUri: 'https://your-domain/redirect.html', - postLogoutRedirectUri: 'https://your-domain/redirect.html', // optional + redirectUris: [ + 'https://your-domain/redirect.html', // web + 'com.example.app://redirect', // Android / iOS + 'http://localhost:4400/redirect', // Windows / Linux / macOS + ], + postLogoutRedirectUris: [ // optional, defaults to redirectUris selection + 'https://your-domain/redirect.html', + 'com.example.app://redirect', + 'http://localhost:4400/redirect', + ], ); if (result != null) { @@ -401,7 +409,9 @@ if (result != null) { **IMPORTANT** -`redirectUri` and `postLogoutRedirectUri` must be registered in your client +`redirectUris` and `postLogoutRedirectUris` take a list of URIs, one per +platform. At runtime `solidAuthenticate()` picks the entry that matches the +current platform. Every URI in the list must be registered in your client ID document and match the correct format for each platform: | Platform | URI format | Notes | @@ -412,10 +422,10 @@ ID document and match the correct format for each platform: ### Desktop: use a fixed port -`oidc_desktop` binds a loopback HTTP server to the port in your -`redirectUri`. If you use port `0`, the OS assigns a random port -that is never registered in the client document, causing the Solid -server to reject logout with `post_logout_redirect_uri not registered`. +`oidc_desktop` binds a loopback HTTP server to the port in the desktop +entry of `redirectUris`. If you use port `0`, the OS assigns a random port +that is never registered in the client document, causing the Solid server +to reject logout with `post_logout_redirect_uri not registered`. Use a fixed port (e.g. `4400`) in both the app and the client document. Both `redirect_uris` and `post_logout_redirect_uris` in the client ID From 411932d0ae7252dd54e40a2db65ef87e988e2d45 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Fri, 29 May 2026 11:26:30 +1000 Subject: [PATCH 12/14] comment out solid_auth new code --- lib/src/solid/authenticate.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/src/solid/authenticate.dart b/lib/src/solid/authenticate.dart index 5337b334..5a7ab1c8 100644 --- a/lib/src/solid/authenticate.dart +++ b/lib/src/solid/authenticate.dart @@ -96,9 +96,9 @@ String pickRedirectUri(List uris) { /// server and errors the pending awaiter, so this caller unwinds with a /// [SolidAuthCancelledException]. -// void cancelSolidAuthenticate() { -// unawaited(cancelAuthenticate()); -// } +void cancelSolidAuthenticate() { + // unawaited(cancelAuthenticate()); +} /// Asynchronously authenticate a user against a Solid server [serverId]. /// From 9f64bb4421569d45ae189fce65920830decc9663 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Fri, 29 May 2026 14:48:22 +1000 Subject: [PATCH 13/14] update pubspec to local --- example/pubspec.yaml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 223f4720..0d3e247f 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -23,9 +23,10 @@ dependency_overrides: solidpod: path: .. solidui: - git: - url: https://github.com/anusii/solidui.git - ref: av/40_migrate_oidc_implementation + path: ../../solidui + # git: + # url: https://github.com/anusii/solidui.git + # ref: av/40_migrate_oidc_implementation dev_dependencies: dependency_validator: ^5.0.4 From 053fc22112b7c1c2001d26f6db453efdfbeb4b16 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Fri, 29 May 2026 14:58:24 +1000 Subject: [PATCH 14/14] point to git branch --- example/pubspec.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 0d3e247f..ee86d9e9 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -23,10 +23,10 @@ dependency_overrides: solidpod: path: .. solidui: - path: ../../solidui - # git: - # url: https://github.com/anusii/solidui.git - # ref: av/40_migrate_oidc_implementation + # path: ../../solidui + git: + url: https://github.com/anusii/solidui.git + ref: av/40_migrate_oidc_implementation_revert dev_dependencies: dependency_validator: ^5.0.4