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/README.md b/README.md index 8c42e8de..4e95ff83 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. @@ -96,8 +95,78 @@ 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. 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`: @@ -138,12 +207,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 @@ -186,17 +379,95 @@ 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, - ); +final result = await solidAuthenticate( + 'https://pods.solidcommunity.au/alice/profile/card#me', // WebID or issuer URI + context, + clientId: 'https://your-domain/client-profile.jsonld', + 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) { + 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** + +`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 | +|---|---|---| +| 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 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 +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" + ] +} +``` + +### 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`. @@ -261,7 +532,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 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..7b60fb81 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -93,6 +93,24 @@ 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) + 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/example/pubspec.yaml b/example/pubspec.yaml index 1f7e392b..ee86d9e9 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -22,6 +22,11 @@ dependencies: dependency_overrides: solidpod: path: .. + solidui: + # path: ../../solidui + git: + url: https://github.com/anusii/solidui.git + ref: av/40_migrate_oidc_implementation_revert 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 aa9f01f8..efae2943 100644 --- a/lib/solidpod.dart +++ b/lib/solidpod.dart @@ -58,7 +58,12 @@ export 'src/solid/constants/predicates.dart'; /// Solid authentication function export 'src/solid/authenticate.dart' - show cancelSolidAuthenticate, isSolidAuthenticatePending, solidAuthenticate; + show + solidAuthenticate, + tryRestoreSession, + pickRedirectUri, + cancelSolidAuthenticate, + isSolidAuthenticatePending; /// Status class to represent different function outputs diff --git a/lib/src/solid/authenticate.dart b/lib/src/solid/authenticate.dart index be90e05a..5a7ab1c8 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,18 +26,19 @@ // 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:async' show unawaited; import 'dart:convert'; -import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart' + show debugPrint, defaultTargetPlatform, kIsWeb, TargetPlatform; +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' @@ -47,19 +48,48 @@ import 'package:solidpod/src/solid/utils/exceptions.dart' 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 -]; +/// 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, + ); +} /// Returns true while [solidAuthenticate] is awaiting the browser-based OAuth /// flow. -bool isSolidAuthenticatePending() => isAuthenticatePending(); +// bool isSolidAuthenticatePending() => isAuthenticatePending(); /// Aborts any in-flight [solidAuthenticate] call. Delegates to /// `solid_auth.cancelAuthenticate()` which closes the local OAuth callback @@ -67,91 +97,141 @@ bool isSolidAuthenticatePending() => isAuthenticatePending(); /// [SolidAuthCancelledException]. void cancelSolidAuthenticate() { - unawaited(cancelAuthenticate()); + // unawaited(cancelAuthenticate()); } /// 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. - +/// [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. +/// +/// +/// Returns `[SolidAuthData, webId, profileTurtle]` on success, null on failure. Future?> solidAuthenticate( String serverId, - BuildContext context, -) async { + BuildContext context, { + required String clientId, + required List redirectUris, + List postLogoutRedirectUris = const [], +}) 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; - } + if (clientId.isEmpty) { + throw Exception( + 'oidcClientId is required for Solid-OIDC authentication. ' + 'Provide the URL of your app\'s client profile JSON-LD document.', + ); + } - // Proceed to fetch profile data with the authenticated credentials - final profCardUrl = webId.replaceAll('#me', ''); - final profData = utf8.decode(await getResource(profCardUrl)); + // Resolve the effective redirect URI. + final effectiveRedirectUri = pickRedirectUri(redirectUris); - return [authData, webId, profData]; + if (effectiveRedirectUri.isEmpty) { + throw ArgumentError( + 'A redirect URI is required. Provide at least one URI via redirectUris.', + ); } - // Already logged in successfully - fetch profile data - final webId = await AuthDataManager.getWebId(); - if (webId == null || webId.isEmpty) { - await logoutPod(); - return null; - } + // 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(effectiveRedirectUri), + postLogoutRedirectUri: Uri.parse(effectivePostLogoutUri), + ), + ); + + final solidAuthData = await authManager.authenticate(serverId); + if (solidAuthData == null) return null; + + await AuthDataManager.saveAuthData( + solidAuthData, + authManager, + oidcClientId: clientId, + redirectUri: effectiveRedirectUri, + ); + + final profData = utf8.decode( + await getResource(solidAuthData.webId.replaceAll('#me', '')), + ); + + return [solidAuthData, solidAuthData.webId, profData]; + // return [authData, webId, profData]; + // } on AuthCancelledException catch (e) { + // throw SolidAuthCancelledException(e.message); + } 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]; - } on AuthCancelledException catch (e) { - throw SolidAuthCancelledException(e.message); + 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 8dba0687..64cd3b3f 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.30 + # 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