diff --git a/CHANGELOG.md b/CHANGELOG.md index a62a5d0..5ec2fe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,14 @@ description of the update. Updates in the 0.1.n series are heading toward a 0.2 release. The `[version timestamp user]` string is utilised by the flutter version_widget package. +## 0.3 + ++ Implementing Authorization Code + PKCE, DPoP key binding (RFC 9449), and WebID-based issuer discovery using OpenID-certified [`package:oidc`](https://pub.dev/packages/oidc) [0.2.0 20260521 anushkavidanage] + ## 0.2 Stability ++ Update Try Another WebID workflow [0.1.30 20260520 tonypioneer] ++ Update jose dependency [0.1.29 20260415 jesscmoore] + Review and cleanup for publication [0.1.28 20250925 gjw] + Remove jwt, update openid, use encrypt_plus [0.1.28 20250923 anushkavidanage] + Export openid_client [0.1.28 20250917 anushkavidanage] diff --git a/README.md b/README.md index ba17f74..7f318c7 100644 --- a/README.md +++ b/README.md @@ -1,115 +1,200 @@ -# solid_auth (restructured) +# solid_auth -Solid-OIDC authentication for Flutter, now built on the -[OpenID-certified `oidc` package](https://pub.dev/packages/oidc). +[![Flutter](https://img.shields.io/badge/Flutter-%2302569B.svg?style=for-the-badge&logo=Flutter&logoColor=white)](https://flutter.dev) +[![Dart](https://img.shields.io/badge/dart-%230175C2.svg?style=for-the-badge&logo=dart&logoColor=white)](https://dart.dev) + +[![GitHub License](https://img.shields.io/github/license/anusii/solid_auth)](https://raw.githubusercontent.com/anusii/solid_auth/dev/LICENSE) +[![GitHub Version](https://img.shields.io/badge/dynamic/yaml?url=https://raw.githubusercontent.com/anusii/solid_auth/master/pubspec.yaml&query=$.version&label=version&logo=github)](https://github.com/anusii/solid_auth/blob/dev/CHANGELOG.md) +[![Pub Version](https://img.shields.io/pub/v/solid_auth?label=pub.dev&labelColor=333940&logo=flutter)](https://pub.dev/packages/solid_auth) +[![GitHub Last Updated](https://img.shields.io/github/last-commit/anusii/solid_auth?label=last%20updated)](https://github.com/anusii/solid_auth/commits/dev/) +[![GitHub Commit Activity (main)](https://img.shields.io/github/commit-activity/w/anusii/solid_auth/dev)](https://github.com/anusii/solid_auth/commits/dev/) +[![GitHub Issues](https://img.shields.io/github/issues/anusii/solid_auth)](https://github.com/anusii/solid_auth/issues) + +Solid-OIDC authentication package for Flutter apps. Handles Authorization Code + PKCE, DPoP key binding (RFC 9449), and WebID-based issuer discovery. This package is built on the OpenID-certified [`package:oidc`](https://pub.dev/packages/oidc). Implemented by the [ANU Software Innovation +Institute](https://sii.anu.edu.au) supporting the [Australian Solid +Community](https://solidcommunity.au). --- -## Architecture overview +## What is Solid? -``` -solid_auth (public API) -│ -├── SolidAuthManager ← main facade (replaces authenticate()) -│ ├── loginFromWebId() ← resolves issuer, then logs in -│ ├── login() ← direct login given issuer URI -│ ├── currentAuthData ← typed SolidAuthData (not a raw Map) -│ ├── authChanges ← Stream (like Firebase Auth) -│ └── logout() / dispose() -│ -├── SolidOidcManagerFactory ← wires SolidOidcConfig → OidcUserManager -│ └── create() -│ -├── DpopTokenGenerator ← DPoP proof JWT generation (unchanged logic) -│ ├── generateForRequest() ← new: auto-fetches key from DpopKeyManager -│ └── generate() ← legacy-compatible static method -│ -├── DpopKeyManager ← RSA key-pair lifecycle -├── ProfileFetcher ← replaces fetchProfileData() -│ └── fetchProfile() → SolidProfile -│ -└── WebIdUtils ← replaces getIssuer() - ├── getIssuer() - └── getProviderMetadata() → SolidProviderMetadata -``` +Solid () is an open standard for a server +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). -### Dependency map +To know more about our work related to Solid Pods +visit -``` -solid_auth - └── package:oidc (OidcUserManager, OidcUserManagerSettings, etc.) - └── oidc_core (OidcProviderMetadata, OidcToken, etc.) - └── oidc_default_store (secure token persistence) - └── dart_jsonwebtoken (DPoP JWT signing — kept) - └── fast_rsa (RSA key generation — kept) -``` +--- + +## Features -The entire forked `openid_client` code is **removed**. All OIDC discovery, -PKCE, token exchange and refresh is delegated to `package:oidc`. +- **Solid-OIDC login** via Authorization Code + PKCE on all platforms (Android, iOS, Web, Windows, macOS, Linux) +- **DPoP key binding** - generates and manages RSA-2048 key pairs; injects DPoP proof headers automatically at every token request +- **Session persistence** - saves tokens and DPoP keys to secure storage; silently restores sessions on app restart +- **WebID issuer discovery** - resolves an OIDC issuer from any WebID profile URL +- **Typed auth result** (`SolidAuthData`) - replaces the old raw `Map` --- -## Migration guide — 0.1.x → 0.2.x +## Installation + +```yaml +dependencies: + solid_auth: ^0.2.0 +``` + + --- -## Quick start +## Quick Start ```dart import 'package:solid_auth/solid_auth.dart'; -// 1. Create the manager (once, at app level) +// 1. Create the manager once (e.g. at widget level or in a provider). final auth = SolidAuthManager( config: SolidOidcConfig( - clientId: 'my_client_id', - redirectUri: Uri.parse('com.example.app://callback'), - scopes: SolidScopes.defaultScopes, // includes webid automatically + clientId: 'https://your-domain/client-profile.jsonld', + redirectUri: Uri.parse('https://your-domain/redirect.html'), + postLogoutRedirectUri: Uri.parse('https://your-domain/redirect.html'), + scopes: SolidScopes.defaultScopes, // includes `webid` automatically ), ); -// 2. Login — resolves issuer from WebID, then runs Authorization Code + PKCE -final authData = await auth.loginFromWebId( - 'https://charlieb.solidcommunity.net/profile/card#me', +// 2. Login — resolves issuer from WebID, then runs Authorization Code + PKCE. +final authData = await auth.authenticate( + 'https://pods.solidcommunity.au/alice-barnes/profile/card#me', ); -print(authData.webId); // https://charlieb.solidcommunity.net/profile/card#me +print(authData.webId); // https://pods.solidcommunity.au/alice-barnes/profile/card#me print(authData.accessToken); -// 3. Generate a DPoP proof for a resource request +// 3. Generate a DPoP proof for a protected resource request. final dpop = await DpopTokenGenerator.generateForRequest( - endpointUrl: 'https://charlieb.solidcommunity.net/private/notes.ttl', + endpointUrl: 'https://pods.solidcommunity.au/alice-barnes/notepod/data/notes.ttl', httpMethod: 'GET', accessToken: authData.accessToken, + keyManager: auth.keyManager, // must be the same key bound to the access token ); // Use in HTTP headers: -// 'Authorization': 'DPoP ${authData.accessToken}' -// 'DPoP': dpop - -// 4. Fetch public profile -final profile = await ProfileFetcher().fetchProfile(authData.webId); -print(profile.name); -print(profile.storage); +// 'Authorization': 'DPoP ${authData.accessToken}' +// 'DPoP': dpop -// 5. Logout +// 4. Logout. await auth.logout(); ``` --- -## Platform setup +## Session Restore + +After a successful login, `solid_auth` automatically saves the session (OIDC tokens + DPoP key pair) to platform-native secure storage. On the next app launch you can resume without requiring the user to log in again: + +```dart +// Call this in initState before showing the login UI. +final auth = SolidAuthManager(config: SolidOidcConfig(...)); + +final data = await auth.tryRestoreSession(); +if (data != null) { + // Valid session found — navigate directly to the authenticated screen. + print('Welcome back, ${data.webId}'); +} else { + // No stored session — show the login screen. +} +``` + +`tryRestoreSession()` returns `null` if no session exists, if the refresh token has expired, or if any storage error occurs (in which case the stored session is cleared so the next login starts clean). + +Calling `logout()` or `forgetUser()` always clears the stored session. + +--- + +## DPoP for Resource Requests + +Every request to a Solid server protected resource needs both an `Authorization` header and a fresh DPoP proof. The proof must be signed by the **same** RSA key that was active during login (the access token's `cnf.jkt` claim is bound to it): + +```dart +Future getPrivateResource( + SolidAuthManager auth, + String resourceUrl, +) async { + final authData = auth.currentAuthData!; + + final dpop = await DpopTokenGenerator.generateForRequest( + endpointUrl: resourceUrl, + httpMethod: 'GET', + accessToken: authData.accessToken, + keyManager: auth.keyManager, + ); + + return http.get( + Uri.parse(resourceUrl), + headers: { + 'Authorization': 'DPoP ${authData.accessToken}', + 'DPoP': dpop, + }, + ); +} +``` + +> **Important:** Always pass `keyManager: auth.keyManager`. The default (`DpopKeyManager.getInstance()`) returns the current singleton, which may be a freshly-generated key after an app restart, producing a thumbprint that the server will reject. + +--- + +## Platform Setup + +`redirectUri` and `postLogoutRedirectUri` must be registered in your [client ID document](https://solid.github.io/solid-oidc/#clientids-document) (`client-profile.jsonld`) 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" + ] +} +``` + +For Android, iOS, and other platform-specific setup steps (manifest entries, URL schemes, etc.), follow the [`package:oidc` Getting Started guide](https://bdaya-dev.github.io/oidc/oidc-getting-started/). + +--- + +## Migration Guide - 0.1.x → 0.2.x -Platform-specific setup (Android `build.gradle`, iOS `Info.plist`, -web `redirect.html`, etc.) follows `package:oidc` requirements exactly. -See the [oidc Getting Started guide](https://bdaya-dev.github.io/oidc/oidc-getting-started/). +> [!IMPORTANT] +> Upgrading from `0.1.x` to `0.2.x` is a **breaking change**. The underlying architecture has been re-engineered. The forked `openid_client` is replaced by the OpenID-certified `package:oidc`, and the top-level `authenticate()` function is replaced by `SolidAuthManager`. Please review the full README and use the table below to update your call sites. -The old `callback.html` for web should be replaced by the -[`redirect.html`](https://github.com/Bdaya-Dev/oidc/blob/main/packages/oidc/example/web/redirect.html) -from `package:oidc`. +| Old (0.1.x) | New (0.2.x) | +|---|---| +| `String issuer = await getIssuer(webId)` | `WebIdUtils.getIssuer(webId)` (same signature) | +| `var data = await authenticate(issuerUri, scopes)` | `await SolidAuthManager.authenticate(webIdOrIssuer)` - returns `SolidAuthData` | +| `data['accessToken']` | `authData.accessToken` | +| `data['idToken']` | `authData.idToken` | +| `genDpopToken(url, keyPair, jwk, method)` | `DpopTokenGenerator.generateForRequest(endpointUrl:, httpMethod:, accessToken:, keyManager: auth.keyManager)` | +| `fetchProfileData(webId)` | Removed - use `http` + parse the Turtle response directly | +| *(new)* | `auth.tryRestoreSession()` - silent session restore on app startup | diff --git a/example/.metadata b/example/.metadata index 9b9642c..87ba2b3 100644 --- a/example/.metadata +++ b/example/.metadata @@ -15,7 +15,7 @@ migration: - platform: root create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - - platform: windows + - platform: web create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 diff --git a/example/lib/components/Header.dart b/example/lib/components/Header.dart index e079ad4..b1b721b 100644 --- a/example/lib/components/Header.dart +++ b/example/lib/components/Header.dart @@ -85,12 +85,17 @@ class Header extends StatelessWidget { color: Colors.black, ), ), - onPressed: () { - authManager.logout(); - Navigator.pushReplacement( - context, - MaterialPageRoute(builder: (context) => LoginScreen()), - ); + onPressed: () async { + // Await logout so the browser completes the end-session + // redirect before we navigate away. + await authManager.logout(); + if (context.mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => LoginScreen()), + ); + } }, ) : IconButton( diff --git a/example/lib/screens/LoginScreen.dart b/example/lib/screens/LoginScreen.dart index 609fa5e..e0ca0d8 100644 --- a/example/lib/screens/LoginScreen.dart +++ b/example/lib/screens/LoginScreen.dart @@ -45,14 +45,88 @@ import 'package:solid_auth_example/models/Constants.dart'; import 'package:solid_auth_example/screens/PrivateScreen.dart'; import 'package:solid_auth_example/screens/PublicScreen.dart'; -// ignore: must_be_immutable -class LoginScreen extends StatelessWidget { +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { // Sample web ID to check the functionality - var webIdController = TextEditingController() + final _webIdController = TextEditingController() ..text = 'https://pods.solidcommunity.au/'; + /// Whether the app is currently checking for a previously saved session. + bool _isRestoringSession = true; + + /// The [SolidAuthManager] is created once and reused for both the + /// automatic session-restore check and the manual login button tap. + late final SolidAuthManager _authManager = SolidAuthManager( + config: SolidOidcConfig( + /// Client ID document hosted on web. Having a separate document for + /// a client app will prevent the app from requiring dynamic client + /// registration on every login. + /// See: https://anushkavidanage.github.io/solid_auth/example_app/client-profile.jsonld + clientId: + 'https://anushkavidanage.github.io/solid_auth/example_app/client-profile.jsonld', + + /// Redirect URIs vary by platform: + /// Mobile: custom-scheme URI (e.g. com.example.solid.auth.example://redirect) + /// Web: redirect.html URL (e.g. https://anushkavidanage.github.io/.../redirect.html) + /// Desktop: fixed-port localhost (e.g. http://localhost:4400/redirect) + redirectUri: Uri.parse('http://localhost:4400/redirect'), + + /// Must match the redirectUri for the current platform. + postLogoutRedirectUri: Uri.parse('http://localhost:4400/redirect'), + + /// Solid-OIDC scopes. The `webid` scope is always added automatically. + scopes: SolidScopes.defaultScopes, + ), + ); + + @override + void initState() { + super.initState(); + _tryRestoreSession(); + } + + /// Checks for a previously saved session on startup. + /// + /// If valid tokens are found in secure storage, navigates directly to + /// [PrivateScreen] without requiring the user to log in again. + Future _tryRestoreSession() async { + final data = await _authManager.tryRestoreSession(); + if (!mounted) return; + if (data != null) { + // Session restored — go straight to the authenticated screen. + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => PrivateScreen(authManager: _authManager), + ), + ); + } else { + // No valid session — show the login UI. + setState(() => _isRestoringSession = false); + } + } + + @override + void dispose() { + _webIdController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + // Show a loading indicator while checking for a persisted session. + if (_isRestoringSession) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + return Scaffold( body: SafeArea( child: Container( @@ -116,7 +190,7 @@ class LoginScreen extends StatelessWidget { height: 20.0, ), TextFormField( - controller: webIdController, + controller: _webIdController, decoration: InputDecoration( border: UnderlineInputBorder(), ), @@ -124,7 +198,7 @@ class LoginScreen extends StatelessWidget { SizedBox( height: 20.0, ), - createSolidLoginRow(context, webIdController), + _buildLoginRow(context), SizedBox( height: 20.0, ), @@ -154,7 +228,7 @@ class LoginScreen extends StatelessWidget { context, MaterialPageRoute( builder: (context) => PublicScreen( - webId: webIdController.text, + webId: _webIdController.text, )), ); }, @@ -183,9 +257,8 @@ class LoginScreen extends StatelessWidget { } // POD issuer registration page launch - launchIssuerReg(String _issuerUri) async { - var url = '$_issuerUri/register'; - + Future _launchIssuerReg(String issuerUri) async { + final url = '$issuerUri/register'; if (await canLaunchUrl(Uri.parse(url))) { await launchUrl(Uri.parse(url)); } else { @@ -194,8 +267,7 @@ class LoginScreen extends StatelessWidget { } // Create login row for SOLID POD issuer - Row createSolidLoginRow( - BuildContext context, TextEditingController _webIdTextController) { + Row _buildLoginRow(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -208,9 +280,8 @@ class LoginScreen extends StatelessWidget { borderRadius: BorderRadius.circular(10), ), ), - onPressed: () async => launchIssuerReg( - (await WebIdUtils.getIssuer(_webIdTextController.text)) - .toString()), + onPressed: () async => _launchIssuerReg( + (await WebIdUtils.getIssuer(_webIdController.text)).toString()), child: Text( 'GET A POD', style: TextStyle( @@ -234,53 +305,20 @@ class LoginScreen extends StatelessWidget { ), ), onPressed: () async { - // Define Solid Auth Manager - final authManager = SolidAuthManager( - config: SolidOidcConfig( - /// Custom URI schemes defined depending on the platform - /// [clientId] parameter should point to a `jsonld` document - /// containing the required authentication details. - /// For example see: https://anushkavidanage.github.io/solid_auth/example_app/client-profile.jsonld - /// - /// redirectUris for each platform defined below should match - /// the redirect uris defined on the clientId document above - /// - /// Client ID document hosted on web. Having a separate document for a client app - /// will prevent the app from requiring to do dynamic client registration everytime - /// app logs in - clientId: - 'https://anushkavidanage.github.io/solid_auth/example_app/client-profile.jsonld', - - /// Use the following schemes for defining redirect uris - /// Also refer to the oidc documentation - /// at: https://bdaya-dev.github.io/oidc/oidc-getting-started/ - /// On mobile: a custom-scheme URI registered with the OS (eg: com.example.solid.auth.example://redirect) - /// On web: the path to your redirect.html (eg: https://anushkavidanage.github.io/solid_auth/example_app/redirect.html) - /// On desktop: localhost as per oidc documentation (eg: http://localhost:0/redirect) - redirectUri: Uri.parse('http://localhost:0/redirect'), - - /// Use the same redirect uris used above for corresponding plaform - postLogoutRedirectUri: Uri.parse( - 'http://localhost:0/redirect'), //Uri.parse('${appUrlScheme}://logout'), - - /// Solid-OIDC scopes. Webid is always added automatically - scopes: SolidScopes.defaultScopes, - ), - ); - - // Authentication process for the POD issuer + // Authentication process for the POD issuer. + // getIssuer() + OidcUserManager.init() + loginAuthorizationCodeFlow() + // are all handled internally by authManager.authenticate(). try { - // getIssuer() + OidcUserManager.init() + loginAuthorizationCodeFlow() - // are all handled internally. - await authManager.authenticate(webIdController.text); + await _authManager.authenticate(_webIdController.text); + + if (!mounted) return; - if (authManager.authData != null) { - // Navigate to the profile through main screen + if (_authManager.authData != null) { Navigator.pushReplacement( context, MaterialPageRoute( builder: (context) => PrivateScreen( - authManager: authManager, + authManager: _authManager, )), ); } else { @@ -289,9 +327,10 @@ class LoginScreen extends StatelessWidget { duration: const Duration(milliseconds: 3000), )); } - } on SolidAuthException catch (e) { + } catch (e) { + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Login failed! \n ${e.message})'), + content: Text('Login failed! \n $e'), duration: const Duration(milliseconds: 3000), )); } diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart new file mode 100644 index 0000000..cc12cb7 --- /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:solid_auth_example/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/auth/solid_auth_manager.dart b/lib/src/auth/solid_auth_manager.dart index c1a71c8..39594fd 100644 --- a/lib/src/auth/solid_auth_manager.dart +++ b/lib/src/auth/solid_auth_manager.dart @@ -28,15 +28,16 @@ library; import 'package:http/http.dart' as http; -import 'package:oidc/oidc.dart'; import 'package:logging/logging.dart'; +import 'package:oidc/oidc.dart'; +import 'package:solid_auth/src/auth/solid_auth_session_store.dart'; +import 'package:solid_auth/src/auth/solid_oidc_manager_factory.dart'; import 'package:solid_auth/src/dpop/dpop_key_manager.dart'; import 'package:solid_auth/src/models/solid_auth_data.dart'; import 'package:solid_auth/src/models/solid_provider_metadata.dart'; import 'package:solid_auth/src/utils/solid_scopes.dart'; import 'package:solid_auth/src/utils/webid_utils.dart'; -import 'package:solid_auth/src/auth/solid_oidc_manager_factory.dart'; final _log = Logger('solid_auth.SolidAuthManager'); @@ -106,6 +107,8 @@ class SolidAuthManager { OidcUserManager? _oidcManager; + final _sessionStore = SolidAuthSessionStore(); + /// The [DpopKeyManager] created during [initForIssuer]. /// /// **Always pass this to [DpopTokenGenerator.generateForRequest]** when @@ -190,7 +193,21 @@ class SolidAuthManager { throw const SolidAuthTokenException('Login cancelled or failed.'); } - return _mapUserToAuthData(user, issuerUri); + final authResult = _mapUserToAuthData(user, issuerUri); + + // Persist issuer, scopes, and DPoP keys so tryRestoreSession() can + // silently resume this session on the next app launch. + final effectiveScopes = scopeOverride != null + ? _configWithScopes(scopeOverride).scopes + : config.scopes; + await _sessionStore.saveSession( + issuerUri: issuerUri, + scopes: effectiveScopes, + privateKeyPem: _keyManager!.keyPair.privateKey, + publicKeyPem: _keyManager!.keyPair.publicKey, + ); + + return authResult; } // ── Lifecycle ───────────────────────────────────────────────────────────── @@ -244,10 +261,100 @@ class SolidAuthManager { _log.fine('OidcUserManager ready!'); } + /// Attempts to restore a previously saved authentication session without + /// user interaction. + /// + /// Call this on app startup before showing the login UI: + /// + /// ```dart + /// final auth = SolidAuthManager(config: SolidOidcConfig(...)); + /// final data = await auth.tryRestoreSession(); + /// if (data != null) { + /// // Session restored — navigate directly to the authenticated screen. + /// } else { + /// // No valid session found — show the login screen. + /// } + /// ``` + /// + /// ## How restoration works + /// + /// 1. Loads the previously saved issuer URI, scopes, and DPoP RSA key pair + /// from secure storage. + /// 2. Calls [DpopKeyManager.restoreFromPem] to reinstate the original key + /// pair **before** [OidcUserManager] is created. This ensures the DPoP + /// hook signs proofs with the key whose thumbprint (`cnf.jkt`) is + /// embedded in the persisted access token. + /// 3. Calls [initForIssuer], which internally calls + /// `OidcUserManager.init()`. That triggers `loadCachedTokens()` in + /// `package:oidc`, which restores the OIDC tokens from secure storage + /// and transparently refreshes them if they have expired (provided a + /// refresh token is available). + /// 4. Returns [currentAuthData] — non-null means the session is live. + /// + /// Returns `null` if no stored session exists, if the stored tokens have + /// expired and cannot be refreshed, or if any error occurs during restore + /// (in which case the stored session is cleared to avoid repeated failures). + Future tryRestoreSession() async { + _log.info('Attempting to restore previous session'); + + final session = await _sessionStore.loadSession(); + if (session == null) { + _log.fine('No stored session found'); + return null; + } + + try { + // 1. Restore the DPoP key pair BEFORE creating OidcUserManager. + // If we let getInstance() run first it generates a fresh key whose + // thumbprint won't match the cnf.jkt in the persisted access token. + await DpopKeyManager.restoreFromPem( + privateKeyPem: session.privateKeyPem, + publicKeyPem: session.publicKeyPem, + ); + + // 2. Initialise OidcUserManager for the stored issuer. + // OidcUserManager.init() automatically calls loadCachedTokens(), + // restoring OIDC tokens and refreshing them if expired. + await initForIssuer(session.issuerUri, scopeOverride: session.scopes); + + // 3. Return current auth data — non-null means restore succeeded. + final data = currentAuthData; + if (data != null) { + authData = data; + _log.info('Session restored for: ${data.webId}'); + } else { + _log.fine('Stored tokens not found or could not be refreshed - ' + 'clearing session state'); + // Clear the persisted session so subsequent tryRestoreSession() calls + // don't attempt (and fail) again. Also reset the OIDC manager so that + // the next login() call creates a completely fresh OidcUserManager with + // no stale currentUser - this prevents an outdated id_token_hint from + // being sent in the authorization request, which can cause CSS to + // throw an error when its oidc-provider session has expired. + // (e.g. after a server restart). + await _sessionStore.clearSession(); + DpopKeyManager.clear(); + _oidcManager = null; + _keyManager = null; + } + return data; + } catch (e, stack) { + _log.warning( + 'Session restore failed — clearing stored session', + e, + stack, + ); + await _sessionStore.clearSession(); + DpopKeyManager.clear(); + return null; + } + } + /// Logs out the user from the identity provider and clears local tokens. Future logout() async { _log.info('Logging out'); await _oidcManager?.logout(); + await _sessionStore.clearSession(); DpopKeyManager.clear(); // rotate key on logout for forward secrecy _keyManager = null; } @@ -255,6 +362,7 @@ class SolidAuthManager { /// Clears local token state without contacting the identity provider. Future forgetUser() async { await _oidcManager?.forgetUser(); + await _sessionStore.clearSession(); } /// Disposes the underlying [OidcUserManager]. Call this when the auth diff --git a/lib/src/auth/solid_auth_session_store.dart b/lib/src/auth/solid_auth_session_store.dart new file mode 100644 index 0000000..b4d375e --- /dev/null +++ b/lib/src/auth/solid_auth_session_store.dart @@ -0,0 +1,164 @@ +/// Support for flutter apps authenticating to a Solid server. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Anushka Vidanage +library; + +import 'dart:convert'; + +import 'package:logging/logging.dart'; +import 'package:oidc_core/oidc_core.dart'; +import 'package:oidc_default_store/oidc_default_store.dart'; + +final _log = Logger('solid_auth.SolidAuthSessionStore'); + +/// Holds the Solid-specific parameters needed to restore a previous +/// authentication session on app restart. +/// +/// Returned by [SolidAuthSessionStore.loadSession]. +class SolidAuthSessionData { + const SolidAuthSessionData({ + required this.issuerUri, + required this.scopes, + required this.privateKeyPem, + required this.publicKeyPem, + }); + + /// The OIDC issuer URI (e.g. `https://solidcommunity.net`). + final String issuerUri; + + /// The OAuth scopes that were requested during the original login. + final List scopes; + + /// PEM-encoded RSA-2048 private key used for DPoP proofs. + final String privateKeyPem; + + /// PEM-encoded RSA-2048 public key paired with [privateKeyPem]. + final String publicKeyPem; +} + +/// Persists and retrieves the Solid-specific authentication parameters that +/// are needed to restore a session across app restarts. +/// +/// Uses [OidcDefaultStore] (`OidcStoreNamespace.secureTokens`) with +/// `managerId: null` so the keys do not collide with the OIDC manager's own +/// namespaced token entries. +/// +/// ## What is stored +/// +/// | Key | Value | +/// |-----|-------| +/// | `solid_auth_issuer_uri` | Resolved OIDC issuer URI | +/// | `solid_auth_scopes` | JSON-encoded scope list | +/// | `solid_auth_rsa_private` | PEM-encoded RSA private key | +/// | `solid_auth_rsa_public` | PEM-encoded RSA public key | +/// +/// The OIDC access/refresh tokens themselves are stored by `package:oidc` +/// automatically in the same underlying secure storage — this class only +/// tracks the Solid-specific extras needed to reconstruct the manager. +class SolidAuthSessionStore { + static const _issuerUriKey = 'solid_auth_issuer_uri'; + static const _scopesKey = 'solid_auth_scopes'; + static const _privateKeyKey = 'solid_auth_rsa_private'; + static const _publicKeyKey = 'solid_auth_rsa_public'; + + final _store = OidcDefaultStore(); + + /// Persists all parameters required to restore this session later. + /// + /// Should be called immediately after a successful login. + Future saveSession({ + required String issuerUri, + required List scopes, + required String privateKeyPem, + required String publicKeyPem, + }) async { + if (!_store.didInit) await _store.init(); + _log.fine('Saving session for issuer: $issuerUri'); + await _store.setMany( + OidcStoreNamespace.secureTokens, + values: { + _issuerUriKey: issuerUri, + _scopesKey: jsonEncode(scopes), + _privateKeyKey: privateKeyPem, + _publicKeyKey: publicKeyPem, + }, + managerId: null, + ); + } + + /// Loads previously saved session parameters. + /// + /// Returns `null` if no session has been saved (e.g. first launch or after + /// logout). + Future loadSession() async { + await _store.init(); + final map = await _store.getMany( + OidcStoreNamespace.secureTokens, + keys: {_issuerUriKey, _scopesKey, _privateKeyKey, _publicKeyKey}, + managerId: null, + ); + + final issuerUri = map[_issuerUriKey]; + final privateKeyPem = map[_privateKeyKey]; + final publicKeyPem = map[_publicKeyKey]; + + if (issuerUri == null || + privateKeyPem == null || + publicKeyPem == null || + issuerUri.isEmpty || + privateKeyPem.isEmpty || + publicKeyPem.isEmpty) { + _log.fine('No stored session found'); + return null; + } + + final rawScopes = map[_scopesKey]; + final scopes = rawScopes != null + ? (jsonDecode(rawScopes) as List).cast() + : []; + + return SolidAuthSessionData( + issuerUri: issuerUri, + scopes: scopes, + privateKeyPem: privateKeyPem, + publicKeyPem: publicKeyPem, + ); + } + + /// Removes all stored session parameters. + /// + /// Should be called on logout or when the session is no longer valid. + Future clearSession() async { + if (!_store.didInit) await _store.init(); + _log.fine('Clearing stored session'); + await _store.removeMany( + OidcStoreNamespace.secureTokens, + keys: {_issuerUriKey, _scopesKey, _privateKeyKey, _publicKeyKey}, + managerId: null, + ); + } +} diff --git a/lib/src/auth/solid_oidc_manager_factory.dart b/lib/src/auth/solid_oidc_manager_factory.dart index 6a71c6f..ae5b846 100644 --- a/lib/src/auth/solid_oidc_manager_factory.dart +++ b/lib/src/auth/solid_oidc_manager_factory.dart @@ -28,9 +28,9 @@ library; import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; import 'package:oidc/oidc.dart'; import 'package:oidc_default_store/oidc_default_store.dart'; -import 'package:logging/logging.dart'; import 'package:solid_auth/src/dpop/dpop_key_manager.dart'; import 'package:solid_auth/src/dpop/dpop_token_generator.dart'; diff --git a/lib/src/dpop/dpop_token_generator.dart b/lib/src/dpop/dpop_token_generator.dart index 673aba1..81d400c 100644 --- a/lib/src/dpop/dpop_token_generator.dart +++ b/lib/src/dpop/dpop_token_generator.dart @@ -32,8 +32,8 @@ import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:fast_rsa/fast_rsa.dart'; -import 'package:uuid/uuid.dart'; import 'package:logging/logging.dart'; +import 'package:uuid/uuid.dart'; import 'package:solid_auth/src/dpop/dpop_key_manager.dart'; diff --git a/lib/src/utils/webid_utils.dart b/lib/src/utils/webid_utils.dart index 6e1fe9d..4f0fa41 100644 --- a/lib/src/utils/webid_utils.dart +++ b/lib/src/utils/webid_utils.dart @@ -28,6 +28,7 @@ library; import 'dart:convert'; + import 'package:http/http.dart' as http; import 'package:logging/logging.dart';