From 4375724ad99b045ed68801aa340d04aaad7894be Mon Sep 17 00:00:00 2001 From: Klas Kalass Date: Sun, 3 Aug 2025 20:56:17 +0200 Subject: [PATCH 01/11] feat: migrate from custom OpenID implementation to oidc library This PR migrates the authentication system from the custom OpenID Connect implementation to the well-maintained oidc library, addressing several security and maintainability concerns. Key changes: - Replace custom OpenID client with oidc library for better security - Add reactive authentication state management with ValueNotifier - Implement proper DPoP token handling through oidc hooks - Refactor example app to demonstrate new authentication patterns - Add client-profile.jsonld for Solid OIDC compliance - Update dependencies and remove unnused packages Breaking changes: - New SolidAuth class replaces previous authenticate() function - Authentication state is now reactive via isAuthenticatedNotifier - DPoP token generation integrated into authentication flow This migration improves security, reduces maintenance burden, and provides a more modern Flutter-friendly API while maintaining full Solid OIDC compatibility. The changes have been tested with the example application. Resolves authentication reliability issues and provides a foundation for future enhancements to the library. --- .github/workflows/deploy-web.yml | 91 +++ example/client-profile.jsonld | 23 + example/lib/components/Header.dart | 105 ++- example/lib/main.dart | 144 +++- example/lib/models/GetRdfData.dart | 40 +- example/lib/models/SolidApi.dart | 11 +- example/lib/screens/EditProfile.dart | 62 +- example/lib/screens/LoginScreen.dart | 405 ++++++----- example/lib/screens/PrivateProfile.dart | 35 +- example/lib/screens/PrivateScreen.dart | 12 +- example/lib/screens/ProfileInfo.dart | 24 +- example/lib/screens/PublicProfile.dart | 45 +- example/lib/screens/PublicScreen.dart | 13 +- example/pubspec.lock | 513 ++++++++++---- example/pubspec.yaml | 21 +- example/redirect.html | 122 ++++ example/web/callback.html | 30 - lib/solid_auth.dart | 30 +- lib/solid_auth_client.dart | 263 -------- .../auth_manager/auth_manager_abstract.dart | 25 - lib/src/auth_manager/auth_manager_stub.dart | 4 - lib/src/auth_manager/web_auth_manager.dart | 48 -- lib/src/oidc/solid_oidc_user_manager.dart | 613 +++++++++++++++++ lib/src/openid/openid_client.dart | 6 - lib/src/openid/openid_client_browser.dart | 78 --- lib/src/openid/openid_client_io.dart | 124 ---- lib/src/openid/src/http_util.dart | 95 --- lib/src/openid/src/model.dart | 12 - lib/src/openid/src/model/claims.dart | 179 ----- lib/src/openid/src/model/metadata.dart | 230 ------- lib/src/openid/src/model/token.dart | 8 - lib/src/openid/src/model/token_response.dart | 41 -- lib/src/openid/src/openid.dart | 633 ------------------ lib/src/solid_auth.dart | 456 +++++++++++++ lib/src/solid_auth_client.dart | 56 ++ lib/{ => src}/solid_auth_issuer.dart | 5 +- pubspec.yaml | 22 +- 37 files changed, 2292 insertions(+), 2332 deletions(-) create mode 100644 .github/workflows/deploy-web.yml create mode 100644 example/client-profile.jsonld create mode 100644 example/redirect.html delete mode 100644 example/web/callback.html delete mode 100644 lib/solid_auth_client.dart delete mode 100644 lib/src/auth_manager/auth_manager_abstract.dart delete mode 100644 lib/src/auth_manager/auth_manager_stub.dart delete mode 100644 lib/src/auth_manager/web_auth_manager.dart create mode 100644 lib/src/oidc/solid_oidc_user_manager.dart delete mode 100644 lib/src/openid/openid_client.dart delete mode 100644 lib/src/openid/openid_client_browser.dart delete mode 100644 lib/src/openid/openid_client_io.dart delete mode 100644 lib/src/openid/src/http_util.dart delete mode 100644 lib/src/openid/src/model.dart delete mode 100644 lib/src/openid/src/model/claims.dart delete mode 100644 lib/src/openid/src/model/metadata.dart delete mode 100644 lib/src/openid/src/model/token.dart delete mode 100644 lib/src/openid/src/model/token_response.dart delete mode 100644 lib/src/openid/src/openid.dart create mode 100644 lib/src/solid_auth.dart create mode 100644 lib/src/solid_auth_client.dart rename lib/{ => src}/solid_auth_issuer.dart (95%) diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml new file mode 100644 index 0000000..7694ebd --- /dev/null +++ b/.github/workflows/deploy-web.yml @@ -0,0 +1,91 @@ +name: Deploy Flutter Web to GitHub Pages + +on: + push: + # FIXME: Change to [ main ] when merging to upstream - this is currently set for fork development + branches: [ feat/migrate-to-bdaya-oidc-security-fix ] + # FIXME: Add back pull_request trigger when merging to upstream: + # pull_request: + # branches: [ main ] + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: 'stable' + cache: true + + - name: Disable Flutter analytics + run: flutter config --no-analytics + + - name: Cache pub dependencies + uses: actions/cache@v4 + with: + path: ~/.pub-cache + key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }} + restore-keys: ${{ runner.os }}-pub- + + - name: Install dependencies (root) + run: flutter pub get + + - name: Install dependencies (example) + run: | + cd example + flutter pub get + + - name: Build web app + run: | + cd example + flutter build web --release --base-href /solid_auth/example/ + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Prepare deployment directory + run: | + # Create a deployment directory + mkdir -p deploy/example + + # Copy Flutter web build (built in example directory) + cp -r example/build/web/* deploy/example/ + + # Copy client profile and redirect files + cp example/client-profile.jsonld deploy/example/ + cp example/redirect.html deploy/example/ + + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ./deploy + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + # FIXME: Change to `if: github.ref == 'refs/heads/main'` when merging to upstream + if: github.ref == 'refs/heads/feat/migrate-to-bdaya-oidc-security-fix' + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/example/client-profile.jsonld b/example/client-profile.jsonld new file mode 100644 index 0000000..a7cd6d3 --- /dev/null +++ b/example/client-profile.jsonld @@ -0,0 +1,23 @@ +{ + "@context": "https://www.w3.org/ns/solid/oidc-context.jsonld", + "client_id": "https://kkalass.github.io/solid_auth/example/client-profile.jsonld", + "client_name": "Solid Auth Example App", + "application_type": "native", + "redirect_uris": [ + "https://kkalass.github.io/solid_auth/example/redirect.html", + "de.kalass.solidauth.example://redirect" + ], + "post_logout_redirect_uris": [ + "https://kkalass.github.io/solid_auth/example/redirect.html", + "de.kalass.solidauth.example://logout" + ], + "scope": "openid profile offline_access webid", + "grant_types": [ + "authorization_code", + "refresh_token" + ], + "response_types": [ + "code" + ], + "token_endpoint_auth_method": "none" +} \ No newline at end of file diff --git a/example/lib/components/Header.dart b/example/lib/components/Header.dart index ebae029..099e8b1 100644 --- a/example/lib/components/Header.dart +++ b/example/lib/components/Header.dart @@ -7,72 +7,71 @@ import 'package:solid_auth/solid_auth.dart'; // Project imports: import 'package:solid_auth_example/models/Constants.dart'; import 'package:solid_auth_example/models/Responsive.dart'; -import 'package:solid_auth_example/screens/LoginScreen.dart'; // Widget for the top horizontal bar // ignore: must_be_immutable class Header extends StatelessWidget { var mainDrawer; - String logoutUrl; + final SolidAuth solidAuth; Header({ Key? key, required this.mainDrawer, - required this.logoutUrl, + required this.solidAuth, }) : super(key: key); @override Widget build(BuildContext context) { - return Container( - color: lightGold, - child: Padding( - padding: const EdgeInsets.all(kDefaultPadding / 1.5), - child: Row( - children: [ - if (Responsive.isMobile(context) & (logoutUrl != 'none')) - IconButton(onPressed: () {}, icon: Icon(Icons.menu)), - if (!Responsive.isDesktop(context)) SizedBox(width: 5), - Spacer(), - if (!Responsive.isDesktop(context)) SizedBox(width: 5), - SizedBox(width: kDefaultPadding / 4), - if (logoutUrl != 'none') SizedBox(width: kDefaultPadding / 4), - (logoutUrl != 'none') - ? TextButton.icon( - icon: Icon( - Icons.logout, - color: Colors.black, - size: 24.0, - ), - label: Text( - 'LOGOUT', - style: TextStyle( - fontWeight: FontWeight.bold, - color: Colors.black, + return ValueListenableBuilder( + valueListenable: solidAuth.isAuthenticatedNotifier, + builder: (context, isAuthenticated, child) { + return Container( + color: lightGold, + child: Padding( + padding: const EdgeInsets.all(kDefaultPadding / 1.5), + child: Row( + children: [ + if (Responsive.isMobile(context) & (isAuthenticated)) + IconButton(onPressed: () {}, icon: Icon(Icons.menu)), + if (!Responsive.isDesktop(context)) SizedBox(width: 5), + Spacer(), + if (!Responsive.isDesktop(context)) SizedBox(width: 5), + SizedBox(width: kDefaultPadding / 4), + if (isAuthenticated) SizedBox(width: kDefaultPadding / 4), + (isAuthenticated) + ? TextButton.icon( + icon: Icon( + Icons.logout, + color: Colors.black, + size: 24.0, + ), + label: Text( + 'LOGOUT', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + onPressed: () { + // Logout and let reactive main app handle screen transition + solidAuth.logout(); + }, + ) + : IconButton( + icon: Icon( + Icons.arrow_back, + size: 24.0, + ), + onPressed: () { + // Navigate back in the stack (e.g., from profile to main screen) + Navigator.of(context).pop(); + }, ), - ), - onPressed: () { - logout(logoutUrl); - Navigator.pushReplacement( - context, - MaterialPageRoute(builder: (context) => LoginScreen()), - ); - }, - ) - : IconButton( - icon: Icon( - Icons.arrow_back, - size: 24.0, - ), - onPressed: () { - Navigator.pushReplacement( - context, - MaterialPageRoute(builder: (context) => LoginScreen()), - ); - }, - ), - SizedBox(width: kDefaultPadding / 4), - ], - ), - ), + SizedBox(width: kDefaultPadding / 4), + ], + ), + ), + ); + }, ); } } diff --git a/example/lib/main.dart b/example/lib/main.dart index a582486..59fc494 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,22 +1,160 @@ // Flutter imports: import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:solid_auth/solid_auth.dart'; // Project imports: import 'package:solid_auth_example/screens/LoginScreen.dart'; +import 'package:solid_auth_example/screens/PrivateScreen.dart'; void main() { + // Ensure Flutter bindings are initialized before any async operations + WidgetsFlutterBinding.ensureInitialized(); + + _setupConsoleLogging(); + runApp(MyApp()); } -class MyApp extends StatelessWidget { - // This widget is the root of the application. +class MyApp extends StatefulWidget { + const MyApp({Key? key}) : super(key: key); + + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + late final SolidAuth solidAuth; + late final Future _initFuture; + + @override + void initState() { + super.initState(); + + // Initialize SolidAuth with OIDC client configuration + // + // Security Model: + // - For web: Relies on DNS security and browser Same-Origin Policy for redirect validation + // - For mobile/desktop: Uses platform-specific URL schemes with security enforced by app stores + // and platform policies to ensure scheme uniqueness and prevent hijacking + // - The client-profile.jsonld must be hosted on a trusted domain and contain matching redirect URIs + // + // CRITICAL WARNING: As of this writing, the OIDC library uses localhost loopback with random ports + // for Windows and Linux desktop applications. This approach is NOT well supported with + // client-profile.jsonld static configuration. Windows and Linux desktop apps are NOT ADVISED + // until further research determines how to support this securely with pre-defined redirect URIs. + solidAuth = SolidAuth( + // OIDC Client ID: URL pointing to the client profile document (client-profile.jsonld) + // In Solid OIDC, this URL itself serves as the client_id and must be used in two places: + // 1. Here as the oidcClientId parameter + // 2. As the "client_id" field value inside the client-profile.jsonld document + // + // CRITICAL: The URL provided here MUST exactly match the "client_id" field in the JSON document. + // + // The client-profile.jsonld document contains the OAuth2/OIDC client metadata including: + // - client_id: Must be identical to this URL (REQUIRED) + // - redirect_uris: List of allowed redirect URIs after authentication + // - client_name: Human-readable name of the application + // - grant_types: Supported OAuth2 grant types (typically "authorization_code") + // - scope: Requested scopes (typically "openid profile webid") + // + // Security: The hosting domain must be trusted as this document defines the security + // boundaries of the OAuth2 client. Tampering with this document could compromise security. + // + // This example app hosts the client-profile.jsonld on GitHub Pages, which provides: + // - HTTPS encryption for secure document delivery + // - Reliable availability through GitHub's CDN infrastructure + // - Version-controlled configuration management + // Production apps should similarly host this document on a trusted, reliable platform. + // FIXME: update when merging to upstream! + oidcClientId: + 'https://kkalass.github.io/solid_auth/example/client-profile.jsonld', + + // App URL Scheme: Custom URI scheme for mobile/desktop platforms (ios/android/macos) + // SolidAuth will automatically construct redirect and logout URIs using this scheme: + // - '${appUrlScheme}://redirect' for authentication redirects + // - '${appUrlScheme}://logout' for logout redirects + // These constructed URIs must match entries in the client-profile.jsonld redirect_uris array + // + // Security: Platform-specific URL schemes provide security through: + // - iOS: App Store review process ensures scheme uniqueness + // - Android: Package name-based scheme prevents hijacking by other apps + // - macOS: Bundle identifier-based validation + // + // Note: For web-only applications, this parameter is not strictly required + // but should be set if you plan to support mobile/desktop platforms + // FIXME: update when merging to upstream! + appUrlScheme: 'de.kalass.solidauth.example', + + // Frontend Redirect URL: Web-specific redirect URI for browser-based authentication + // This URL is used for both authentication redirects and logout redirects on web platforms + // This URL must be: + // 1. Listed in the redirect_uris array of the client-profile.jsonld + // 2. Served over HTTPS + // 3. Hosted on the same domain as your web application for security + // + // Security: Browser Same-Origin Policy prevents malicious sites from intercepting + // the authorization code. DNS security ensures the redirect goes to the intended domain. + // FIXME: update when merging to upstream! + frontendRedirectUrl: Uri.parse( + 'https://kkalass.github.io/solid_auth/example/redirect.html', + ), + ); + + // Initialize SolidAuth and prepare for reactive authentication state changes + _initFuture = solidAuth.init(); + } + + @override + void dispose() { + // Properly dispose of SolidAuth resources when the app shuts down + solidAuth.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, title: 'Flutter Solid Authentication', theme: ThemeData(), - home: LoginScreen(), + home: FutureBuilder( + // Wait for SolidAuth initialization to complete + future: _initFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + // After initialization, use reactive authentication state + return ValueListenableBuilder( + valueListenable: solidAuth.isAuthenticatedNotifier, + builder: (context, isAuthenticated, child) { + return isAuthenticated + ? PrivateScreen(solidAuth: solidAuth) + : LoginScreen(solidAuth: solidAuth); + }, + ); + }, + ), ); } } + +void _setupConsoleLogging() { + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((record) { + // ignore: avoid_print + print('${record.level.name}: ${record.time}: ${record.message}'); + if (record.error != null) { + // ignore: avoid_print + print('Error: ${record.error}'); + } + if (record.stackTrace != null) { + // ignore: avoid_print + print('Stack trace:\n${record.stackTrace}'); + } + }); +} diff --git a/example/lib/models/GetRdfData.dart b/example/lib/models/GetRdfData.dart index 9570f84..73b5cdc 100644 --- a/example/lib/models/GetRdfData.dart +++ b/example/lib/models/GetRdfData.dart @@ -2,11 +2,11 @@ class PodProfile { String profileRdfStr = ''; - PodProfile(String profileRdfStr){ + PodProfile(String profileRdfStr) { this.profileRdfStr = profileRdfStr; } - List divideRdfData(String profileRdfStr){ + List divideRdfData(String profileRdfStr) { List rdfDataList = []; String vcardPrefix = ''; String foafPrefix = ''; @@ -20,8 +20,7 @@ class PodProfile { String item = itemList[j]; rdfDataList.add(item); } - } - else{ + } else { rdfDataList.add(dataItem); } @@ -38,7 +37,7 @@ class PodProfile { return [rdfDataList, vcardPrefix, foafPrefix]; } - List dividePrvRdfData(){ + List dividePrvRdfData() { List rdfDataList = []; final Map prefixList = {}; @@ -51,8 +50,7 @@ class PodProfile { String item = itemList[j]; rdfDataList.add(item); } - } - else{ + } else { rdfDataList.add(dataItem); } @@ -60,13 +58,11 @@ class PodProfile { var itemList = dataItem.split(' '); prefixList[itemList[1]] = itemList[2]; } - } return [rdfDataList, prefixList]; } - - String getProfPicture(){ + String getProfPicture() { var rdfRes = divideRdfData(profileRdfStr); List rdfDataList = rdfRes[0]; String vcardPrefix = rdfRes[1]; @@ -75,47 +71,47 @@ class PodProfile { String optionalPictureUrl = ''; for (var i = 0; i < rdfDataList.length; i++) { String dataItem = rdfDataList[i]; - if (dataItem.contains(vcardPrefix+'hasPhoto')) { + if (dataItem.contains(vcardPrefix + 'hasPhoto')) { var itemList = dataItem.split('<'); pictureUrl = itemList[1].replaceAll('>', ''); } - if(dataItem.contains(foafPrefix+'img')){ + if (dataItem.contains(foafPrefix + 'img')) { var itemList = dataItem.split('<'); optionalPictureUrl = itemList[1].replaceAll('>', ''); } } - if(pictureUrl.isEmpty & optionalPictureUrl.isNotEmpty){ + if (pictureUrl.isEmpty & optionalPictureUrl.isNotEmpty) { pictureUrl = optionalPictureUrl; } return pictureUrl; } - - String getProfName(){ + + String getProfName() { String profName = ''; var rdfRes = divideRdfData(profileRdfStr); List rdfDataList = rdfRes[0]; String vcardPrefix = rdfRes[1]; for (var i = 0; i < rdfDataList.length; i++) { String dataItem = rdfDataList[i]; - if(dataItem.contains(vcardPrefix+'fn')){ + if (dataItem.contains(vcardPrefix + 'fn')) { var itemList = dataItem.split('"'); profName = itemList[1]; } } if (profName.isEmpty) { - profName = 'John Doe'; + profName = ''; } return profName; } - String getPersonalInfo(String infoLabel){ + String getPersonalInfo(String infoLabel) { String personalInfo = ''; var rdfRes = divideRdfData(profileRdfStr); List rdfDataList = rdfRes[0]; String vcardPrefix = rdfRes[1]; for (var i = 0; i < rdfDataList.length; i++) { String dataItem = rdfDataList[i]; - if (dataItem.contains(vcardPrefix+infoLabel)) { + if (dataItem.contains(vcardPrefix + infoLabel)) { var itemList = dataItem.split('"'); personalInfo = itemList[1]; } @@ -123,18 +119,18 @@ class PodProfile { return personalInfo; } - String getAddressId(String infoLabel){ + String getAddressId(String infoLabel) { String personalInfo = ''; var rdfRes = divideRdfData(profileRdfStr); List rdfDataList = rdfRes[0]; String vcardPrefix = rdfRes[1]; for (var i = 0; i < rdfDataList.length; i++) { String dataItem = rdfDataList[i]; - if (dataItem.contains(vcardPrefix+infoLabel)) { + if (dataItem.contains(vcardPrefix + infoLabel)) { var itemList = dataItem.split(':'); personalInfo = itemList[2]; } } return personalInfo; } -} \ No newline at end of file +} diff --git a/example/lib/models/SolidApi.dart b/example/lib/models/SolidApi.dart index ee84bab..1cdca9f 100644 --- a/example/lib/models/SolidApi.dart +++ b/example/lib/models/SolidApi.dart @@ -4,6 +4,7 @@ import 'dart:math'; // Package imports: import 'package:http/http.dart' as http; +import 'package:solid_auth/solid_auth.dart'; const _chars = 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890_-'; @@ -41,17 +42,19 @@ Future fetchPrvProfile( } // Update profile information -Future updateProfile(String profCardUrl, String accessToken, - String dPopToken, String query) async { +Future updateProfile( + String profCardUrl, SolidAuth solidAuth, String query) async { + // Generate DPoP token + final dPopToken = solidAuth.genDpopToken(profCardUrl, 'PATCH'); + final editResponse = await http.patch( Uri.parse(profCardUrl), headers: { 'Accept': '*/*', - 'Authorization': 'DPoP $accessToken', 'Connection': 'keep-alive', 'Content-Type': 'application/sparql-update', 'Content-Length': query.length.toString(), - 'DPoP': dPopToken, + ...dPopToken.httpHeaders(), }, body: query, ); diff --git a/example/lib/screens/EditProfile.dart b/example/lib/screens/EditProfile.dart index 4bada2e..a68d829 100644 --- a/example/lib/screens/EditProfile.dart +++ b/example/lib/screens/EditProfile.dart @@ -9,17 +9,15 @@ import 'package:solid_auth/solid_auth.dart'; import 'package:solid_auth_example/models/Constants.dart'; import 'package:solid_auth_example/components/Header.dart'; import 'package:solid_auth_example/models/SolidApi.dart'; -import 'package:solid_auth_example/screens/PrivateScreen.dart'; class EditProfile extends StatefulWidget { - final Map authData; - final String webId; final Map profData; + final SolidAuth solidAuth; + const EditProfile({ Key? key, - required this.authData, - required this.webId, required this.profData, + required this.solidAuth, }) : super(key: key); @override @@ -46,14 +44,12 @@ class _EditProfileState extends State { @override Widget build(BuildContext context) { - String logoutUrl = widget.authData['logoutUrl']; - return Scaffold( key: _scaffoldKey, body: SafeArea( child: Column( children: [ - Header(mainDrawer: _scaffoldKey, logoutUrl: logoutUrl), + Header(mainDrawer: _scaffoldKey, solidAuth: widget.solidAuth), Divider(thickness: 1), Expanded( child: SingleChildScrollView( @@ -98,14 +94,7 @@ class _EditProfileState extends State { children: [ OutlinedButton( onPressed: () { - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => PrivateScreen( - authData: widget.authData, - webId: widget.webId, - )), - ); + Navigator.pop(context); }, style: OutlinedButton.styleFrom( padding: @@ -128,26 +117,11 @@ class _EditProfileState extends State { ), ElevatedButton( onPressed: () async { - var rsaInfo = widget.authData['rsaInfo']; - - // Get access token - String accessToken = - widget.authData['accessToken']; - // Map decodedToken = - // JwtDecoder.decode(accessToken); - - // Get RSA public/private key pair - var rsaKeyPair = rsaInfo['rsa']; - var publicKeyJwk = rsaInfo['pubKeyJwk']; - // Get profile URI + final webId = + widget.solidAuth.currentWebId!; String profCardUrl = - widget.webId.replaceAll('#me', ''); - - // Generate DPoP token - String dPopToken = genDpopToken(profCardUrl, - rsaKeyPair, publicKeyJwk, 'PATCH'); - ; + webId.replaceAll('#me', ''); List attrList = [ 'name', @@ -210,7 +184,7 @@ class _EditProfileState extends State { if (attr == 'dob') { updateQuery = genSparqlQuery( 'UPDATE_DATE', - widget.webId, + webId, 'http://www.w3.org/2006/vcard/ns#' + predicateMap[attr], newVal, @@ -220,7 +194,7 @@ class _EditProfileState extends State { } else { updateQuery = genSparqlQuery( 'UPDATE', - widget.webId, + webId, 'http://www.w3.org/2006/vcard/ns#' + predicateMap[attr], newVal, @@ -229,11 +203,8 @@ class _EditProfileState extends State { // Update profile using the generated query String updateResponse = - await updateProfile( - profCardUrl, - accessToken, - dPopToken, - updateQuery); + await updateProfile(profCardUrl, + widget.solidAuth, updateQuery); numOfUpdates += 1; assert(updateResponse != ''); } @@ -243,14 +214,7 @@ class _EditProfileState extends State { 'Number of updates conducted: $numOfUpdates'); // Print number of updates conducted // Going back to profile page - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => PrivateScreen( - authData: widget.authData, - webId: widget.webId, - )), - ); + Navigator.pop(context); }, style: ElevatedButton.styleFrom( foregroundColor: darkGold, diff --git a/example/lib/screens/LoginScreen.dart b/example/lib/screens/LoginScreen.dart index cafed3c..dbb5cd6 100644 --- a/example/lib/screens/LoginScreen.dart +++ b/example/lib/screens/LoginScreen.dart @@ -1,159 +1,220 @@ // Flutter imports: import 'package:flutter/material.dart'; - -// Package imports: -import 'package:url_launcher/url_launcher.dart'; -import 'package:jwt_decoder/jwt_decoder.dart'; - -// Project imports: -import 'package:solid_auth_example/models/Constants.dart'; -import 'package:solid_auth_example/screens/PrivateScreen.dart'; -import 'package:solid_auth_example/screens/PublicScreen.dart'; +import 'package:logging/logging.dart'; //import 'package:solid_auth_example/models/RestAPI.dart'; //import 'package:solid_auth/solid_auth.dart'; import 'package:solid_auth/solid_auth.dart'; +// Project imports: +import 'package:solid_auth_example/models/Constants.dart'; +import 'package:solid_auth_example/screens/PublicScreen.dart'; +// Package imports: +import 'package:url_launcher/url_launcher.dart'; + +final _log = Logger('LoginScreen'); +const String defaultIssuer = 'https://pods.solidcommunity.au/'; +const String defaultIssuerRegister = + 'https://pods.solidcommunity.au/.account/login/password/register/'; + +class LoginScreen extends StatefulWidget { + final SolidAuth solidAuth; + + LoginScreen({Key? key, required this.solidAuth}) : super(key: key); -// ignore: must_be_immutable -class LoginScreen extends StatelessWidget { + @override + _LoginScreenState createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { // Sample web ID to check the functionality - var webIdController = TextEditingController() - ..text = 'https://pods.solidcommunity.au/'; + late TextEditingController webIdController; + bool isValidWebId = false; + + @override + void initState() { + super.initState(); + webIdController = TextEditingController(text: defaultIssuer); + webIdController.addListener(_validateWebId); + _validateWebId(); // Initial validation + } + + @override + void dispose() { + webIdController.removeListener(_validateWebId); + webIdController.dispose(); + super.dispose(); + } + + /// Validates if the current text is a valid WebID + /// WebID should end with '/profile/card#me' + void _validateWebId() { + setState(() { + isValidWebId = webIdController.text.trim().endsWith('/profile/card#me'); + }); + } @override Widget build(BuildContext context) { return Scaffold( - body: SafeArea( - child: Container( - decoration: screenWidth(context) < 1175 - ? BoxDecoration( - image: DecorationImage( - image: AssetImage('assets/images/background.jpg'), - fit: BoxFit.cover)) - : null, - child: Row( - children: [ - screenWidth(context) < 1175 - ? Container() - : Expanded( - flex: 7, - child: Container( - decoration: BoxDecoration( - image: DecorationImage( + body: SafeArea( + child: Container( + decoration: screenWidth(context) < 1175 + ? BoxDecoration( + image: DecorationImage( + image: AssetImage('assets/images/background.jpg'), + fit: BoxFit.cover, + ), + ) + : null, + child: Row( + children: [ + screenWidth(context) < 1175 + ? Container() + : Expanded( + flex: 7, + child: Container( + decoration: BoxDecoration( + image: DecorationImage( image: AssetImage('assets/images/background.jpg'), - fit: BoxFit.cover)), - )), - Expanded( - flex: 5, - child: Container( - margin: EdgeInsets.symmetric( + fit: BoxFit.cover, + ), + ), + ), + ), + Expanded( + flex: 5, + child: Container( + margin: EdgeInsets.symmetric( horizontal: screenWidth(context) < 1175 ? screenWidth(context) < 750 - ? screenWidth(context) * 0.05 - : screenWidth(context) * 0.25 - : screenWidth(context) * 0.05), - child: SingleChildScrollView( - child: Card( - elevation: 5, - color: bgOffWhite, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15)), - child: Container( - height: 910, - padding: EdgeInsets.all(30), - child: Column( - children: [ - Image.asset( - "assets/images/authentication-logo.png", - width: 400, - ), - SizedBox( - height: 0.0, - ), - Divider(height: 15, thickness: 2), - SizedBox( - height: 60.0, - ), - Text('FLUTTER SOID AUTHENTICATION', + ? screenWidth(context) * 0.05 + : screenWidth(context) * 0.25 + : screenWidth(context) * 0.05, + ), + child: SingleChildScrollView( + child: Card( + elevation: 5, + color: bgOffWhite, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15), + ), + child: Container( + height: 910, + padding: EdgeInsets.all(30), + child: Column( + children: [ + Image.asset( + "assets/images/authentication-logo.png", + width: 400, + ), + SizedBox(height: 0.0), + Divider(height: 15, thickness: 2), + SizedBox(height: 60.0), + Text( + 'FLUTTER SOLID AUTHENTICATION', textAlign: TextAlign.center, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 20, color: Colors.black, - )), - SizedBox( - height: 20.0, - ), - TextFormField( - controller: webIdController, - decoration: InputDecoration( - border: UnderlineInputBorder(), + ), ), - ), - SizedBox( - height: 20.0, - ), - createSolidLoginRow(context, webIdController), - SizedBox( - height: 20.0, - ), - Text('OR', + SizedBox(height: 20.0), + TextFormField( + controller: webIdController, + decoration: InputDecoration( + border: UnderlineInputBorder(), + ), + ), + SizedBox(height: 8.0), + Text( + 'Enter an Issuer URL or WebID to log in', + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + fontStyle: FontStyle.italic, + ), + ), + SizedBox(height: 20.0), + createSolidLoginRow(context, webIdController), + SizedBox(height: 20.0), + Text( + 'OR', style: TextStyle( fontWeight: FontWeight.bold, fontSize: 18, color: Colors.black, - )), - SizedBox( - height: 20.0, - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: TextButton( - style: TextButton.styleFrom( - padding: EdgeInsets.all(20), - backgroundColor: lightGold, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - onPressed: () { - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => PublicScreen( - webId: webIdController.text, - )), - ); - }, - child: Text( - 'READ PUBLIC INFO', - style: TextStyle( - color: Colors.white, - letterSpacing: 2.0, - fontSize: 15.0, - fontWeight: FontWeight.bold, - fontFamily: 'Poppins', + ), + ), + SizedBox(height: 20.0), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Tooltip( + message: isValidWebId + ? 'Read public profile information' + : 'Enter a WebID (ending with /profile/card#me) to read public profiles', + child: TextButton( + style: TextButton.styleFrom( + padding: EdgeInsets.all(20), + backgroundColor: isValidWebId + ? lightGold + : Colors.grey, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + 10, + ), + ), + ), + onPressed: isValidWebId + ? () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + PublicScreen( + solidAuth: + widget.solidAuth, + webId: webIdController + .text, + ), + ), + ); + } + : null, + child: Text( + 'READ PUBLIC INFO', + style: TextStyle( + color: isValidWebId + ? Colors.white + : Colors.grey[600], + letterSpacing: 2.0, + fontSize: 15.0, + fontWeight: FontWeight.bold, + fontFamily: 'Poppins', + ), + ), + ), ), ), - )), - ], - ), - ], + ], + ), + ], + ), ), ), ), ), - )), - ], + ), + ], + ), + ), ), - ))); + ); } // POD issuer registration page launch - launchIssuerReg(String _issuerUri) async { - var url = '$_issuerUri/register'; - + launchIssuerReg(String url) async { if (await canLaunchUrl(Uri.parse(url))) { await launchUrl(Uri.parse(url)); } else { @@ -163,34 +224,35 @@ class LoginScreen extends StatelessWidget { // Create login row for SOLID POD issuer Row createSolidLoginRow( - BuildContext context, TextEditingController _webIdTextController) { + BuildContext context, + TextEditingController _webIdTextController, + ) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Expanded( - child: TextButton( - style: TextButton.styleFrom( - padding: EdgeInsets.all(20), - backgroundColor: exLightBlue, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + child: TextButton( + style: TextButton.styleFrom( + padding: EdgeInsets.all(20), + backgroundColor: exLightBlue, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), ), - ), - onPressed: () async => launchIssuerReg( - (await getIssuer(_webIdTextController.text)).toString()), - child: Text( - 'GET A POD', - style: TextStyle( - color: titleAsh, - letterSpacing: 2.0, - fontSize: 15.0, - fontWeight: FontWeight.bold, + // FIXME: I simplified this to always use solidcommunity.au - I hope this is fine? + onPressed: () async => launchIssuerReg(defaultIssuerRegister), + child: Text( + 'GET A POD', + style: TextStyle( + color: titleAsh, + letterSpacing: 2.0, + fontSize: 15.0, + fontWeight: FontWeight.bold, + ), ), ), - )), - SizedBox( - width: 15.0, ), + SizedBox(width: 15.0), Expanded( child: TextButton( style: TextButton.styleFrom( @@ -201,43 +263,30 @@ class LoginScreen extends StatelessWidget { ), ), onPressed: () async { - // Get issuer URI - String _issuerUri = await getIssuer(_webIdTextController.text); - - // Define scopes. Also possible scopes -> webid, email, api - final List _scopes = [ - 'openid', - 'profile', - 'offline_access', - 'webid', - ]; - // Authentication process for the POD issuer - var authData = - await authenticate(Uri.parse(_issuerUri), _scopes, context); - - if (authData.containsKey('error')) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('You cancelled the login!'), - duration: const Duration(milliseconds: 3000), - )); - } else { - // Decode access token to get the correct webId - String accessToken = authData['accessToken']; - Map decodedToken = - JwtDecoder.decode(accessToken); - String webId = decodedToken.containsKey('webid') - ? decodedToken['webid'] - : decodedToken['sub']; - - // Navigate to the profile through main screen - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => PrivateScreen( - authData: authData, - webId: webId, - )), + try { + await widget.solidAuth.authenticate( + _webIdTextController.text, + scopes: ['profile'], + ); + // Authentication successful - the ValueListenableBuilder will automatically + // detect the state change and show PrivateScreen + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Login Successful!'), + duration: const Duration(milliseconds: 2000), + backgroundColor: Colors.green, + ), + ); + } catch (e, stackTrace) { + // Log the actual error for debugging + _log.severe('Authentication error: $e', e, stackTrace); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Login Failed: ${e.toString()}'), + duration: const Duration(milliseconds: 5000), + backgroundColor: Colors.red, + ), ); } }, diff --git a/example/lib/screens/PrivateProfile.dart b/example/lib/screens/PrivateProfile.dart index 31926b8..10d5419 100644 --- a/example/lib/screens/PrivateProfile.dart +++ b/example/lib/screens/PrivateProfile.dart @@ -14,11 +14,9 @@ import 'package:solid_auth/solid_auth.dart'; import 'package:solid_auth_example/models/GetRdfData.dart'; class PrivateProfile extends StatefulWidget { - final Map authData; // Authentication data - final String webId; // User WebId + final SolidAuth solidAuth; // SolidAuth instance - const PrivateProfile({Key? key, required this.authData, required this.webId}) - : super(key: key); + const PrivateProfile({Key? key, required this.solidAuth}) : super(key: key); @override State createState() => _PrivateProfileState(); @@ -77,8 +75,7 @@ class _PrivateProfileState extends State { ); } - Widget _loadedScreen( - Object profInfo, String webId, String logoutUrl, Map authData) { + Widget _loadedScreen(Object profInfo, String webId, SolidAuth solidAuth) { // Read profile info from the turtle file PodProfile podProfile = PodProfile(profInfo.toString()); @@ -122,7 +119,7 @@ class _PrivateProfileState extends State { color: Colors.white, child: Column( children: [ - Header(mainDrawer: _scaffoldKey, logoutUrl: logoutUrl), + Header(mainDrawer: _scaffoldKey, solidAuth: solidAuth), Divider(thickness: 1), Expanded( child: SingleChildScrollView( @@ -131,8 +128,7 @@ class _PrivateProfileState extends State { child: ProfileInfo( profData: profData, profType: 'private', - webId: webId, - authData: authData)), + solidAuth: solidAuth)), ) ], ), @@ -141,21 +137,10 @@ class _PrivateProfileState extends State { @override Widget build(BuildContext context) { - Map authData = widget.authData; - String webId = widget.webId; - String logoutUrl = authData['logoutUrl']; - - var rsaInfo = authData['rsaInfo']; - var rsaKeyPair = rsaInfo['rsa']; - var publicKeyJwk = rsaInfo['pubKeyJwk']; - - String accessToken = authData['accessToken']; - //Map decodedToken = JwtDecoder.decode(accessToken); - // Get profile + String webId = widget.solidAuth.currentWebId!; String profCardUrl = webId.replaceAll('#me', ''); - String dPopToken = - genDpopToken(profCardUrl, rsaKeyPair, publicKeyJwk, 'GET'); + final dPopToken = widget.solidAuth.genDpopToken(profCardUrl, 'GET'); return Scaffold( key: _scaffoldKey, @@ -169,13 +154,13 @@ class _PrivateProfileState extends State { // ), body: SafeArea( child: FutureBuilder( - future: - rest_api.fetchPrvProfile(profCardUrl, accessToken, dPopToken), + future: rest_api.fetchPrvProfile( + profCardUrl, dPopToken.accessToken, dPopToken.dpopToken), builder: (context, snapshot) { Widget returnVal; if (snapshot.connectionState == ConnectionState.done) { returnVal = - _loadedScreen(snapshot.data!, webId, logoutUrl, authData); + _loadedScreen(snapshot.data!, webId, widget.solidAuth); } else { returnVal = _loadingScreen(); } diff --git a/example/lib/screens/PrivateScreen.dart b/example/lib/screens/PrivateScreen.dart index 16c5707..1187c3a 100644 --- a/example/lib/screens/PrivateScreen.dart +++ b/example/lib/screens/PrivateScreen.dart @@ -1,5 +1,6 @@ // Flutter imports: import 'package:flutter/material.dart'; +import 'package:solid_auth/solid_auth.dart'; // Project imports: import 'package:solid_auth_example/models/Responsive.dart'; @@ -8,15 +9,16 @@ import 'package:solid_auth_example/models/Constants.dart'; // ignore: must_be_immutable class PrivateScreen extends StatelessWidget { - Map authData; // Authentication data - String webId; // User WebId - PrivateScreen({Key? key, required this.authData, required this.webId}) - : super(key: key); + SolidAuth solidAuth; // Authentication data + + PrivateScreen({Key? key, required this.solidAuth}) : super(key: key); @override Widget build(BuildContext context) { // Assign loading screen - var loadingScreen = PrivateProfile(authData: authData, webId: webId); + var loadingScreen = PrivateProfile( + solidAuth: solidAuth, + ); // Setup Scaffold to be responsive return Scaffold( diff --git a/example/lib/screens/ProfileInfo.dart b/example/lib/screens/ProfileInfo.dart index 444ca40..816940c 100644 --- a/example/lib/screens/ProfileInfo.dart +++ b/example/lib/screens/ProfileInfo.dart @@ -1,23 +1,20 @@ // Flutter imports: import 'package:flutter/material.dart'; - +import 'package:solid_auth/solid_auth.dart'; // Project imports: import 'package:solid_auth_example/models/Constants.dart'; import 'package:solid_auth_example/screens/EditProfile.dart'; class ProfileInfo extends StatelessWidget { final Map profData; // Profile data - final Map? authData; // Authentication related data final String profType; // Public or private - final String? webId; // WebId of the user - - const ProfileInfo( - {Key? key, - required this.profData, - required this.profType, - this.authData, - this.webId}) - : super(key: key); + final SolidAuth solidAuth; // SolidAuth instance + const ProfileInfo({ + Key? key, + required this.profData, + required this.profType, + required this.solidAuth, + }) : super(key: key); @override Widget build(BuildContext context) { @@ -69,13 +66,12 @@ class ProfileInfo extends StatelessWidget { color: Colors.white, onPressed: () { // Navigate to the profile edit function - Navigator.pushReplacement( + Navigator.push( context, MaterialPageRoute( builder: (context) => EditProfile( - authData: authData!, - webId: webId!, profData: profData, + solidAuth: solidAuth, )), ); }, diff --git a/example/lib/screens/PublicProfile.dart b/example/lib/screens/PublicProfile.dart index 9cde51e..9b9c4b0 100644 --- a/example/lib/screens/PublicProfile.dart +++ b/example/lib/screens/PublicProfile.dart @@ -5,19 +5,41 @@ import 'package:flutter/material.dart'; import 'package:solid_auth_example/models/Constants.dart'; import 'package:solid_auth_example/components/Header.dart'; import 'package:solid_auth_example/screens/ProfileInfo.dart'; -//import 'package:solid_auth_example/models/RestAPI.dart'; import 'package:solid_auth/solid_auth.dart'; import 'package:solid_auth_example/models/GetRdfData.dart'; +import 'package:http/http.dart' as http; class PublicProfile extends StatefulWidget { - final String webId; + final SolidAuth solidAuth; // SolidAuth instance + final String webId; // Web ID for public profile - const PublicProfile({Key? key, required this.webId}) : super(key: key); + const PublicProfile({Key? key, required this.solidAuth, required this.webId}) + : super(key: key); @override State createState() => _PublicProfileState(); } +/// Get public profile information from webId +Future _fetchProfileData(String profUrl) async { + final response = await http.get( + Uri.parse(profUrl), + headers: { + 'Content-Type': 'text/turtle', + }, + ); + + if (response.statusCode == 200) { + /// If the server did return a 200 OK response, + /// then parse the JSON. + return response.body; + } else { + /// If the server did not return a 200 OK response, + /// then throw an exception. + throw Exception('Failed to load data! Try again in a while.'); + } +} + class _PublicProfileState extends State { final GlobalKey _scaffoldKey = GlobalKey(); @@ -110,12 +132,19 @@ class _PublicProfileState extends State { color: Colors.white, child: Column( children: [ - Header(mainDrawer: _scaffoldKey, logoutUrl: 'none'), + Header( + mainDrawer: _scaffoldKey, + solidAuth: widget.solidAuth, + ), Divider(thickness: 1), Expanded( child: SingleChildScrollView( padding: EdgeInsets.all(kDefaultPadding * 1.5), - child: ProfileInfo(profData: profData, profType: 'public')), + child: ProfileInfo( + profData: profData, + profType: 'public', + solidAuth: widget.solidAuth, + )), ) ], ), @@ -124,14 +153,14 @@ class _PublicProfileState extends State { @override Widget build(BuildContext context) { - String webId = widget.webId; + String webId = widget.webId; // Get the webId from the widget return Scaffold( key: _scaffoldKey, body: SafeArea( child: FutureBuilder( - future: fetchProfileData( - webId), // Get profile data (.ttl file) from the webId + // Get profile data (.ttl file) from the webId + future: _fetchProfileData(webId), builder: (context, snapshot) { Widget returnVal; if (snapshot.connectionState == ConnectionState.done) { diff --git a/example/lib/screens/PublicScreen.dart b/example/lib/screens/PublicScreen.dart index 5677b52..7030447 100644 --- a/example/lib/screens/PublicScreen.dart +++ b/example/lib/screens/PublicScreen.dart @@ -1,20 +1,25 @@ // Flutter imports: import 'package:flutter/material.dart'; +import 'package:solid_auth/solid_auth.dart'; // Project imports: import 'package:solid_auth_example/models/Responsive.dart'; import 'package:solid_auth_example/screens/PublicProfile.dart'; -// ignore: must_be_immutable class PublicScreen extends StatelessWidget { - String webId; + final SolidAuth solidAuth; + final String webId; // Web ID for public profile - PublicScreen({Key? key, required this.webId}) : super(key: key); + PublicScreen({Key? key, required this.solidAuth, required this.webId}) + : super(key: key); @override Widget build(BuildContext context) { // Navigate to public profile with a loading screen - var loadingScreen = PublicProfile(webId: webId); + var loadingScreen = PublicProfile( + solidAuth: solidAuth, + webId: webId, + ); return Scaffold( body: Responsive( mobile: loadingScreen, diff --git a/example/pubspec.lock b/example/pubspec.lock index e443938..12802c8 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -1,22 +1,6 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - adaptive_number: - dependency: transitive - description: - name: adaptive_number - sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" - source: hosted - version: "2.7.0" asn1lib: dependency: transitive description: @@ -33,14 +17,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" - auth_header: - dependency: transitive - description: - name: auth_header - sha256: "0a3938128b6124530de93ce1a20ccb58639195fe7952f638248ea1bc0e5408eb" - url: "https://pub.dev" - source: hosted - version: "3.0.1" boolean_selector: dependency: transitive description: @@ -57,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" clock: dependency: transitive description: @@ -81,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + copy_with_extension: + dependency: transitive + description: + name: copy_with_extension + sha256: "0447e5ea09845b275fbeaa7605bc85e74da759788678760b2a6c4e06ca622410" + url: "https://pub.dev" + source: hosted + version: "6.0.1" crypto: dependency: transitive description: @@ -89,38 +81,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.6" - crypto_keys: + crypto_keys_plus: dependency: transitive description: - name: crypto_keys - sha256: "2ed305a11a3e5d16dd7f489121c956fd19b9816938bb68bc7ed3a379827a304e" - url: "https://pub.dev" - source: hosted - version: "0.3.0+2" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + name: crypto_keys_plus + sha256: a64dc8b3555e730aa183067fbb666efd9996bc2d0cfda4517c77607d9e2adf91 url: "https://pub.dev" source: hosted - version: "1.0.8" - dart_jsonwebtoken: - dependency: "direct main" - description: - name: dart_jsonwebtoken - sha256: "21ce9f8a8712f741e8d6876a9c82c0f8a257fe928c4378a91d8527b92a3fd413" - url: "https://pub.dev" - source: hosted - version: "3.2.0" - ed25519_edwards: + version: "0.5.0" + csslib: dependency: transitive description: - name: ed25519_edwards - sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd" + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" url: "https://pub.dev" source: hosted - version: "0.3.1" + version: "1.0.2" fake_async: dependency: transitive description: @@ -130,13 +106,13 @@ packages: source: hosted version: "1.3.3" fast_rsa: - dependency: "direct main" + dependency: transitive description: name: fast_rsa - sha256: "73c3b34f19aafd8a2377611eba74d7bc02f5ae242a7014b9330d2c0150abdf47" + sha256: e410cf3bc1e5034a79ff11a1d5a0b9928e4d5c3f9e861b0b7d3a1b16833585a5 url: "https://pub.dev" source: hosted - version: "3.8.4" + version: "3.8.5" ffi: dependency: transitive description: @@ -174,27 +150,75 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_appauth: + dependency: transitive + description: + name: flutter_appauth + sha256: b09fa8e3eaba12ec341c69ec45063e06eb565304e24cc35caaf105bbae2e955c + url: "https://pub.dev" + source: hosted + version: "9.0.1" + flutter_appauth_platform_interface: + dependency: transitive + description: + name: flutter_appauth_platform_interface + sha256: fd2920b853d09741aff2e1178e044ea2ade0c87799cd8e63f094ab35b00fdf70 + url: "https://pub.dev" + source: hosted + version: "9.0.0" flutter_driver: dependency: transitive description: flutter source: sdk version: "0.0.0" - flutter_staggered_grid_view: - dependency: "direct main" + flutter_secure_storage: + dependency: transitive description: - name: flutter_staggered_grid_view - sha256: "19e7abb550c96fbfeb546b23f3ff356ee7c59a019a651f8f102a4ba9b7349395" + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" url: "https://pub.dev" source: hosted - version: "0.7.0" - flutter_svg: - dependency: "direct main" + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive description: - name: flutter_svg - sha256: cd57f7969b4679317c17af6fd16ee233c1e60a82ed209d8a475c54fd6fd6f845 + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" flutter_test: dependency: "direct dev" description: flutter @@ -210,6 +234,14 @@ packages: description: flutter source: sdk version: "0.0.0" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: dependency: transitive description: @@ -231,38 +263,30 @@ packages: description: flutter source: sdk version: "0.0.0" - intl: - dependency: "direct main" - description: - name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" - url: "https://pub.dev" - source: hosted - version: "0.20.2" - jaguar_jwt: - dependency: "direct main" + jose_plus: + dependency: transitive description: - name: jaguar_jwt - sha256: c3ab24be5ba5f736f93eacfc94c4381bd93551a351ab5687e70c8d71a9916e8d + name: jose_plus + sha256: c262694c2f8e74825a70d0b7339c24a285fd8856f9b35b0848aa23fa1d26e724 url: "https://pub.dev" source: hosted - version: "3.0.0" - jose: + version: "0.4.7" + js: dependency: transitive description: - name: jose - sha256: "7955ec5d131960104e81fbf151abacb9d835c16c9e793ed394b2809f28b2198d" + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 url: "https://pub.dev" source: hosted - version: "0.3.4" - jwt_decoder: - dependency: "direct main" + version: "0.6.7" + json_annotation: + dependency: transitive description: - name: jwt_decoder - sha256: "54774aebf83f2923b99e6416b4ea915d47af3bde56884eb622de85feabbc559f" + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "4.9.0" leak_tracker: dependency: transitive description: @@ -319,22 +343,127 @@ packages: url: "https://pub.dev" source: hosted version: "1.16.0" - openidconnect_platform_interface: + nonce: + dependency: transitive + description: + name: nonce + sha256: bd279f698c9f641a64375a17040bd899061e46bdb9aa65bf846838bd3b8b437f + url: "https://pub.dev" + source: hosted + version: "1.2.0" + oidc: dependency: transitive description: - name: openidconnect_platform_interface - sha256: "923907ebf22a9d9ddc4f5df9a13853788c030a0e87deb2e343474a7ab628f0c9" + name: oidc + sha256: "4ae699ec07966845cb397dd593583669281d992cebf9972fcffc306f4ddbf7d2" url: "https://pub.dev" source: hosted - version: "1.0.17" - openidconnect_web: + version: "0.12.1+2" + oidc_android: dependency: transitive description: - name: openidconnect_web - sha256: "0100bbc472724212d135c12ccdd170563ca62a80cb45efbf777a96984bddaed6" + name: oidc_android + sha256: "7888bf47a879e69edce5185a15a6ac34d2b3bda03c91ed8f16abbdaa2d6fa6c9" url: "https://pub.dev" source: hosted - version: "1.0.26" + version: "0.7.0+5" + oidc_core: + dependency: "direct overridden" + description: + path: "packages/oidc_core" + ref: "fix/oidc-hook-execute-stack-overflow" + resolved-ref: "1c9fd5bedcbb729fc0e6285ed5f086eb851e5938" + url: "https://github.com/kkalass/oidc.git" + source: git + version: "0.14.2+1" + oidc_default_store: + dependency: transitive + description: + name: oidc_default_store + sha256: b522f6970e49aae4aa21bf6232000a760f143d2a998455f9caaffb251cdc9cff + url: "https://pub.dev" + source: hosted + version: "0.4.0+3" + oidc_desktop: + dependency: transitive + description: + name: oidc_desktop + sha256: "921ea658799c41fb1b46238bc3dd1b908ae7298937c643221806a88aad403893" + url: "https://pub.dev" + source: hosted + version: "0.6.1+2" + oidc_flutter_appauth: + dependency: transitive + description: + name: oidc_flutter_appauth + sha256: "7e3d73fa480bfc3a8a5186cfbfcaf7ea68e8764603bddd2e40125938f17f4e28" + url: "https://pub.dev" + source: hosted + version: "0.6.0+5" + oidc_ios: + dependency: transitive + description: + name: oidc_ios + sha256: "50e5255276bfb200354ddc99c8e69ddf972b161ae8f35d8eb8ce5b9fe773559b" + url: "https://pub.dev" + source: hosted + version: "0.7.0+5" + oidc_linux: + dependency: transitive + description: + name: oidc_linux + sha256: cb7fa8da9ea5d107271aba08f362c69e6b8cb4a66778aca3e44aa0470b6fa3aa + url: "https://pub.dev" + source: hosted + version: "0.4.1+2" + oidc_loopback_listener: + dependency: transitive + description: + name: oidc_loopback_listener + sha256: a198a11dab1d800a6f6818c155dd808ee3e6d44534a28d793eafca9a7fcc8b87 + url: "https://pub.dev" + source: hosted + version: "0.2.0+1" + oidc_macos: + dependency: transitive + description: + name: oidc_macos + sha256: fd11487e4a2a6c4bd914e3fb72038b94905c57493f48326ba9e1123d480bb240 + url: "https://pub.dev" + source: hosted + version: "0.7.0+5" + oidc_platform_interface: + dependency: transitive + description: + name: oidc_platform_interface + sha256: "6bbf02dde677326242795da24a67960435b3457335e16fc712d45da9342b226a" + url: "https://pub.dev" + source: hosted + version: "0.6.0+9" + oidc_web: + dependency: transitive + description: + name: oidc_web + sha256: "76b6905b5614bdb169e7554e4799e80be83d7e9c213146c328238a8c1cc6f4f0" + url: "https://pub.dev" + source: hosted + version: "0.6.0+9" + oidc_web_core: + dependency: transitive + description: + name: oidc_web_core + sha256: "05b11538a384cc5891de44a98a50e38202aa3514f204b4261bd5621732838291" + url: "https://pub.dev" + source: hosted + version: "0.3.1+3" + oidc_windows: + dependency: transitive + description: + name: oidc_windows + sha256: "2947212de3bf4cc9f191531c049b602fff9cd57e1a8b61b20f7316b98ab01f56" + url: "https://pub.dev" + source: hosted + version: "0.3.1+14" path: dependency: transitive description: @@ -343,22 +472,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" - path_parsing: + path_provider: dependency: transitive description: - name: path_parsing - sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" url: "https://pub.dev" source: hosted - version: "1.1.0" - petitparser: + version: "2.1.5" + path_provider_android: dependency: transitive description: - name: petitparser - sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" + name: path_provider_android + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "2.2.17" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" platform: dependency: transitive description: @@ -391,14 +552,78 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.3" - quiver: + retry: dependency: transitive description: - name: quiver - sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 + name: retry + sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc" url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.1.2" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac" + url: "https://pub.dev" + source: hosted + version: "2.4.10" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -410,7 +635,7 @@ packages: path: ".." relative: true source: path - version: "0.1.25" + version: "0.2.0-dev.1" source_span: dependency: transitive description: @@ -483,14 +708,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + universal_html: + dependency: transitive + description: + name: universal_html + sha256: "56536254004e24d9d8cfdb7dbbf09b74cf8df96729f38a2f5c238163e3d58971" + url: "https://pub.dev" + source: hosted + version: "2.2.4" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + url: "https://pub.dev" + source: hosted + version: "2.2.2" url_launcher: - dependency: "direct main" + dependency: transitive description: name: url_launcher - sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 url: "https://pub.dev" source: hosted - version: "6.3.1" + version: "6.3.2" url_launcher_android: dependency: transitive description: @@ -555,30 +796,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.1" - vector_graphics: - dependency: transitive - description: - name: vector_graphics - sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 - url: "https://pub.dev" - source: hosted - version: "1.1.19" - vector_graphics_codec: - dependency: transitive - description: - name: vector_graphics_codec - sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" - url: "https://pub.dev" - source: hosted - version: "1.1.13" - vector_graphics_compiler: - dependency: transitive - description: - name: vector_graphics_compiler - sha256: "557a315b7d2a6dbb0aaaff84d857967ce6bdc96a63dc6ee2a57ce5a6ee5d3331" - url: "https://pub.dev" - source: hosted - version: "1.1.17" vector_math: dependency: transitive description: @@ -611,22 +828,38 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" - x509: + win32: dependency: transitive description: - name: x509 - sha256: cbd1a63846884afd273cda247b0365284c8d85a365ca98e110413f93d105b935 + name: win32 + sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" url: "https://pub.dev" source: hosted - version: "0.2.4+3" - xml: + version: "5.14.0" + window_to_front: dependency: transitive description: - name: xml - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + name: window_to_front + sha256: "7aef379752b7190c10479e12b5fd7c0b9d92adc96817d9e96c59937929512aee" url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "0.0.3" + x509_plus: + dependency: transitive + description: + name: x509_plus + sha256: f9f5caade6077c9a605125428d0bf79c002afa35a475d400849adb1a25ebeee6 + url: "https://pub.dev" + source: hosted + version: "0.3.3" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" sdks: - dart: ">=3.7.0 <4.0.0" + dart: ">=3.8.0 <4.0.0" flutter: ">=3.27.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 9a46fb1..b28eeb7 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -10,17 +10,8 @@ dependencies: flutter: sdk: flutter - cupertino_icons: ^1.0.1 - dart_jsonwebtoken: ^3.2.0 - fast_rsa: ^3.8.1 - flutter_staggered_grid_view: ^0.7.0 - flutter_svg: ^2.2.0 - intl: ^0.20.2 - jaguar_jwt: ^3.0.0 - jwt_decoder: ^2.0.1 - url_launcher: ^6.3.1 - - #solid_auth: ^0.1.6 + + solid_auth: path: .. @@ -37,3 +28,11 @@ flutter: assets: - assets/images/ + +dependency_overrides: + oidc_core: + git: + url: https://github.com/kkalass/oidc.git + ref: fix/oidc-hook-execute-stack-overflow + path: packages/oidc_core + \ No newline at end of file diff --git a/example/redirect.html b/example/redirect.html new file mode 100644 index 0000000..ad9a9ae --- /dev/null +++ b/example/redirect.html @@ -0,0 +1,122 @@ + + + + + + + Flutter Oidc Redirect + + + + + +

Operation Successful! Please close this page.

+ + + \ No newline at end of file diff --git a/example/web/callback.html b/example/web/callback.html deleted file mode 100644 index 5738d63..0000000 --- a/example/web/callback.html +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/lib/solid_auth.dart b/lib/solid_auth.dart index c731be7..3aac39d 100644 --- a/lib/solid_auth.dart +++ b/lib/solid_auth.dart @@ -1,31 +1,3 @@ library solid_auth; -/// Dart imports: -import 'dart:async'; -import 'dart:convert'; - -/// Package imports: -import 'package:http/http.dart' as http; -import 'package:flutter/widgets.dart'; -import 'package:solid_auth/src/openid/openid_client.dart'; -import 'package:solid_auth/src/jwt/dart_jsonwebtoken.dart'; -import 'package:uuid/uuid.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:fast_rsa/fast_rsa.dart'; - -/// Package imports: -import 'package:solid_auth/platform_info.dart'; -import 'package:solid_auth/src/openid/openid_client_io.dart' as oidc_mobile; -import 'package:solid_auth/src/auth_manager/auth_manager_abstract.dart'; - -part 'solid_auth_client.dart'; -part 'solid_auth_issuer.dart'; - -/// Set port number to be used in localhost -const int _port = 4400; - -/// To get platform information -PlatformInfo currPlatform = PlatformInfo(); - -/// Initialise authentication manager -AuthManager authManager = AuthManager(); +export 'src/solid_auth.dart'; diff --git a/lib/solid_auth_client.dart b/lib/solid_auth_client.dart deleted file mode 100644 index 1785243..0000000 --- a/lib/solid_auth_client.dart +++ /dev/null @@ -1,263 +0,0 @@ -part of 'solid_auth.dart'; - -/// Dynamically register the user in the POD server -Future clientDynamicReg(String regEndpoint, List reidirUrlList, - String authMethod, List scopes) async { - final response = await http.post(Uri.parse(regEndpoint), - headers: { - 'Accept': '*/*', - 'Content-Type': 'application/json', - 'Connection': 'keep-alive', - 'Accept-Encoding': 'gzip, deflate, br', - // 'Sec-Fetch-Dest': 'empty', - // 'Sec-Fetch-Mode': 'cors', - // 'Sec-Fetch-Site': 'cross-site', - }, - body: json.encode({ - "application_type": "web", - "scope": scopes.join(' '), - "grant_types": ["authorization_code", "refresh_token"], - "redirect_uris": reidirUrlList, - "token_endpoint_auth_method": authMethod, - //"client_name": "fluttersolidauth", - //"id_token_signed_response_alg": "RS256", - //"subject_type": "pairwise", - //"userinfo_encrypted_response_alg": "RSA1_5", - //"userinfo_encrypted_response_enc": "A128CBC-HS256", - })); - - if (response.statusCode == 201) { - /// If the server did return a 200 OK response, - /// then parse the JSON. - return response.body; - } else { - /// If the server did not return a 200 OK response, - /// then throw an exception. - throw Exception('Failed to load data! Try again in a while.'); - } -} - -/// Generate RSA key pair for the authentication -Future genRsaKeyPair() async { - /// Generate a key pair - var rsaKeyPair = await RSA.generate(2048); - - /// JWK conversion of private and public keys - var publicKeyJwk = await RSA.convertPublicKeyToJWK(rsaKeyPair.publicKey); - var privateKeyJwk = await RSA.convertPrivateKeyToJWK(rsaKeyPair.privateKey); - - publicKeyJwk['alg'] = "RS256"; - return { - 'rsa': rsaKeyPair, - 'privKeyJwk': privateKeyJwk, - 'pubKeyJwk': publicKeyJwk - }; -} - -/// Generate dPoP token for the authentication -String genDpopToken(String endPointUrl, KeyPair rsaKeyPair, - dynamic publicKeyJwk, String httpMethod) { - /// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop-03 - /// Unique identifier for DPoP proof JWT - /// Here we are using a version 4 UUID according to https://datatracker.ietf.org/doc/html/rfc4122 - var uuid = const Uuid(); - final String tokenId = uuid.v4(); - - /// Initialising token head and body (payload) - /// https://solid.github.io/solid-oidc/primer/#authorization-code-pkce-flow - /// https://datatracker.ietf.org/doc/html/rfc7519 - var tokenHead = {"alg": "RS256", "typ": "dpop+jwt", "jwk": publicKeyJwk}; - - var tokenBody = { - "htu": endPointUrl, - "htm": httpMethod, - "jti": tokenId, - "iat": (DateTime.now().millisecondsSinceEpoch / 1000).round() - }; - - /// Create a json web token - final jwt = JWT( - tokenBody, - header: tokenHead, - ); - - /// Sign the JWT using private key - var dpopToken = jwt.sign(RSAPrivateKey(rsaKeyPair.privateKey), - algorithm: JWTAlgorithm.RS256); - - return dpopToken; -} - -/// The authentication function -Future authenticate( - Uri issuerUri, List scopes, BuildContext context) async { - /// Platform type parameter - String platformType; - - /// Re-direct URIs - String redirUrl; - List redirUriList; - - /// Authentication method - String authMethod; - - /// Authentication response - Credential authResponse; - - /// Output data from the authentication - Map authData; - - /// Check the platform - if (currPlatform.isWeb()) { - platformType = 'web'; - } else if (currPlatform.isAppOS()) { - platformType = 'mobile'; - } else { - platformType = 'desktop'; - } - - /// Get issuer metatada - Issuer issuer = await Issuer.discover(issuerUri); - - /// Get end point URIs - String regEndpoint = issuer.metadata['registration_endpoint']; - String tokenEndpoint = issuer.metadata['token_endpoint']; - var authMethods = issuer.metadata['token_endpoint_auth_methods_supported']; - - if (authMethods is String) { - authMethod = authMethods; - } else { - if (authMethods.contains('client_secret_basic')) { - authMethod = 'client_secret_basic'; - } else { - authMethod = authMethods[1]; - } - } - - if (platformType == 'web') { - redirUrl = authManager.getWebUrl(); - redirUriList = [redirUrl]; - } else { - redirUrl = 'http://localhost:$_port/'; - redirUriList = ['http://localhost:$_port/']; - } - - /// Dynamic registration of the client (our app) - var regResponse = - await clientDynamicReg(regEndpoint, redirUriList, authMethod, scopes); - - /// Decode the registration details - var regResJson = jsonDecode(regResponse); - - /// Generating the RSA key pair - Map rsaResults = await genRsaKeyPair(); - var rsaKeyPair = rsaResults['rsa']; - var publicKeyJwk = rsaResults['pubKeyJwk']; - - ///Generate DPoP token using the RSA private key - String dPopToken = - genDpopToken(tokenEndpoint, rsaKeyPair, publicKeyJwk, "POST"); - - final String _clientId = regResJson['client_id']; - final String _clientSecret = regResJson['client_secret']; - - var client = Client(issuer, _clientId, clientSecret: _clientSecret); - - if (platformType != 'web') { - /// Create a function to open a browser with an url - Future urlLauncher(String url) async { - if (!await launchUrl(Uri.parse(url))) { - throw Exception('Could not launch $url'); - } - } - - /// create an authenticator - var authenticator = oidc_mobile.Authenticator( - client, - scopes: scopes, - port: _port, - urlLancher: urlLauncher, - redirectUri: Uri.parse(redirUrl), - popToken: dPopToken, - ); - - /// starts the authentication + authorisation process - authResponse = await authenticator.authorize(); - - /// close the webview when finished - /// closing web view function does not work in Windows applications - if (platformType == 'mobile') { - //closeWebView(); - closeInAppWebView(); - } - } else { - ///create an authenticator - var authenticator = - authManager.createAuthenticator(client, scopes, dPopToken); - - var oidc = authManager.getOidcWeb(); - var callbackUri = await oidc.authorizeInteractive( - context: context, - title: 'authProcess', - authorizationUrl: authenticator.flow.authenticationUri.toString(), - redirectUrl: redirUrl, - popupWidth: 700, - popupHeight: 500); - - var regResponse = Uri.parse(callbackUri).queryParameters; - authResponse = await authenticator.flow.callback(regResponse); - } - - /// Check if user cancelled the interaction or there was another unexpected - /// error authenticating to the server - if ((authResponse.response as Map).containsKey('error')) { - authData = authResponse.response as Map; - } else { - /// The following function call first check if the existing access token - /// is expired or not. - /// If its not expired then returns the token data as a token object - /// If expired then run the refresh token and get a new token and - /// returns the new token data as a token object - - var tokenResponse = await authResponse.getTokenResponse(); - String? accessToken = tokenResponse.accessToken; - - /// Generate the logout URL - final _logoutUrl = authResponse.generateLogoutUrl().toString(); - - /// Store authentication data - authData = { - 'client': client, - 'rsaInfo': rsaResults, - 'authResponse': authResponse, - 'tokenResponse': tokenResponse, - 'accessToken': accessToken, - 'idToken': tokenResponse.idToken, - 'refreshToken': tokenResponse.refreshToken, - 'expiresIn': tokenResponse.expiresIn, - 'logoutUrl': _logoutUrl - }; - } - - return authData; -} - -Future logout(_logoutUrl) async { - Uri url = Uri.parse(_logoutUrl); - - if (await canLaunchUrl(url)) { - //await launch(_logoutUrl, forceWebView: true); - await launchUrl(url); - } else { - throw 'Could not launch $url'; - } - - await Future.delayed(Duration(seconds: 4)); - - /// closing web view function does not work in Windows applications - if (currPlatform.isAppOS()) { - //closeWebView(); - closeInAppWebView(); - } - return true; -} diff --git a/lib/src/auth_manager/auth_manager_abstract.dart b/lib/src/auth_manager/auth_manager_abstract.dart deleted file mode 100644 index fb3e5d6..0000000 --- a/lib/src/auth_manager/auth_manager_abstract.dart +++ /dev/null @@ -1,25 +0,0 @@ -// import just for the client class. Not used anywhere else. -import 'package:solid_auth/src/openid/src/openid.dart'; - -import 'auth_manager_stub.dart' - // ignore: uri_does_not_exist - // if (dart.library.io) 'MobileOpenId.dart' - // ignore: uri_does_not_exist - if (dart.library.html) 'web_auth_manager.dart'; - -abstract class AuthManager { - // some generic methods to be exposed. - - // returns a value based on the key - String getKeyValue(String key) { - return "I am from the interface"; - } - - getWebUrl() {} - createAuthenticator(Client client, List scopes, String dPopToken) {} - getOidcWeb() {} - userLogout(String logoutUrl) {} - - // factory constructor to return the correct implementation. - factory AuthManager() => getAuthManager(); -} diff --git a/lib/src/auth_manager/auth_manager_stub.dart b/lib/src/auth_manager/auth_manager_stub.dart deleted file mode 100644 index d57bd01..0000000 --- a/lib/src/auth_manager/auth_manager_stub.dart +++ /dev/null @@ -1,4 +0,0 @@ -import 'auth_manager_abstract.dart'; - -AuthManager getAuthManager() => throw UnsupportedError( - 'Cannot create a keyfinder without the packages dart:html or package:shared_preferences'); diff --git a/lib/src/auth_manager/web_auth_manager.dart b/lib/src/auth_manager/web_auth_manager.dart deleted file mode 100644 index a51c237..0000000 --- a/lib/src/auth_manager/web_auth_manager.dart +++ /dev/null @@ -1,48 +0,0 @@ -// Dart imports: -import 'dart:html'; - -// Project imports: -import 'auth_manager_abstract.dart'; -import 'package:solid_auth/src/openid/openid_client_browser.dart'; -import 'package:openidconnect_web/openidconnect_web.dart'; - -late Window windowLoc; - -class WebAuthManager implements AuthManager { - WebAuthManager() { - windowLoc = window; - // storing something initially just to make sure it works. - windowLoc.localStorage["MyKey"] = "I am from web local storage"; - } - - String getWebUrl() { - if (window.location.href.contains('#/')) { - return window.location.href.replaceAll('#/', 'callback.html'); - } else { - return (window.location.href + 'callback.html'); - } - } - - Authenticator createAuthenticator( - Client client, List scopes, String dPopToken) { - var authenticator = - new Authenticator(client, scopes: scopes, popToken: dPopToken); - return authenticator; - } - - OpenIdConnectWeb getOidcWeb() { - OpenIdConnectWeb oidc = OpenIdConnectWeb(); - return oidc; - } - - String getKeyValue(String key) { - return windowLoc.localStorage[key]!; - } - - userLogout(String logoutUrl) { - final child = window.open(logoutUrl, "user_logout"); - child.close(); - } -} - -AuthManager getAuthManager() => WebAuthManager(); diff --git a/lib/src/oidc/solid_oidc_user_manager.dart b/lib/src/oidc/solid_oidc_user_manager.dart new file mode 100644 index 0000000..5cd44d4 --- /dev/null +++ b/lib/src/oidc/solid_oidc_user_manager.dart @@ -0,0 +1,613 @@ +import 'dart:convert'; + +import 'package:fast_rsa/fast_rsa.dart'; +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; +import 'package:oidc/oidc.dart'; +import 'package:solid_auth/src/solid_auth_client.dart' as solid_auth_client; +import 'package:solid_auth/src/solid_auth_issuer.dart' as solid_auth_issuer; + +final _log = Logger("solid_authentication_oidc"); + +class DPoP { + final String dpopToken; + final String accessToken; + + DPoP({required this.dpopToken, required this.accessToken}); + + Map httpHeaders() => { + 'Authorization': 'DPoP $accessToken', + 'DPoP': dpopToken, + }; +} + +class UserAndWebId { + final OidcUser user; + final String webId; + + UserAndWebId({required this.user, required this.webId}); +} + +typedef GetIssuers = Future> Function(String webIdOrIssuer); + +Future> _getIssuersDefault(String webIdOrIssuer) async { + try { + return [Uri.parse(await solid_auth_issuer.getIssuer(webIdOrIssuer))]; + } catch (e) { + // If loading the profile fails, return the input as is + return [Uri.parse(webIdOrIssuer)]; + } +} + +class _RsaInfo { + final String pubKey; + final String privKey; + final dynamic pubKeyJwk; + + _RsaInfo({ + required this.pubKey, + required this.privKey, + required this.pubKeyJwk, + }); +} + +class SolidOidcUserManagerSettings { + /// + const SolidOidcUserManagerSettings({ + required this.redirectUri, + this.uiLocales, + this.extraTokenHeaders, + this.extraScopes = defaultScopes, + this.prompt = const [], + this.display, + this.acrValues, + this.maxAge, + this.extraAuthenticationParameters, + this.expiryTolerance = const Duration(minutes: 1), + this.extraTokenParameters, + this.postLogoutRedirectUri, + this.options, + this.frontChannelLogoutUri, + this.userInfoSettings = const OidcUserInfoSettings(), + this.frontChannelRequestListeningOptions = + const OidcFrontChannelRequestListeningOptions(), + this.refreshBefore = defaultRefreshBefore, + this.strictJwtVerification = false, + this.getExpiresIn, + this.sessionManagementSettings = const OidcSessionManagementSettings(), + this.getIdToken, + this.supportOfflineAuth = false, + this.hooks, + this.extraRevocationParameters, + this.extraRevocationHeaders, + this.getIssuers, + }); + + /// The default scopes + static const defaultScopes = ['openid', 'webid', 'offline_access']; + + /// Settings to control using the user_info endpoint. + final OidcUserInfoSettings userInfoSettings; + + /// whether JWTs are strictly verified. + /// + /// If set to true, the library will throw an exception if a JWT is invalid. + final bool strictJwtVerification; + + /// Whether to support offline authentication or not. + /// + /// When this option is enabled, expired tokens will NOT be removed if the + /// server can't be contacted + /// + /// This parameter is disabled by default due to security concerns. + final bool supportOfflineAuth; + + /// see [OidcAuthorizeRequest.redirectUri]. + final Uri redirectUri; + + /// see [OidcEndSessionRequest.postLogoutRedirectUri]. + final Uri? postLogoutRedirectUri; + + /// the uri of the front channel logout flow. + /// this Uri MUST be registered with the OP first. + /// the OP will call this Uri when it wants to logout the user. + final Uri? frontChannelLogoutUri; + + /// The options to use when listening to platform channels. + /// + /// [frontChannelLogoutUri] must be set for this to work. + final OidcFrontChannelRequestListeningOptions + frontChannelRequestListeningOptions; + + /// see [OidcAuthorizeRequest.scope]. + final List extraScopes; + + /// see [OidcAuthorizeRequest.prompt]. + final List prompt; + + /// see [OidcAuthorizeRequest.display]. + final String? display; + + /// see [OidcAuthorizeRequest.uiLocales]. + final List? uiLocales; + + /// see [OidcAuthorizeRequest.acrValues]. + final List? acrValues; + + /// see [OidcAuthorizeRequest.maxAge] + final Duration? maxAge; + + /// see [OidcAuthorizeRequest.extra] + final Map? extraAuthenticationParameters; + + /// see [OidcTokenRequest.extra] + final Map? extraTokenHeaders; + + /// see [OidcTokenRequest.extra] + final Map? extraTokenParameters; + + /// see [OidcRevocationRequest.extra] + final Map? extraRevocationParameters; + + /// Extra headers to send with the revocation request. + final Map? extraRevocationHeaders; + + /// see [OidcIdTokenVerificationOptions.expiryTolerance]. + final Duration expiryTolerance; + + /// Settings related to the session management spec. + final OidcSessionManagementSettings sessionManagementSettings; + + /// How early the token gets refreshed. + /// + /// for example: + /// + /// - if `Duration.zero` is returned, the token gets refreshed once it's expired. + /// - (default) if `Duration(minutes: 1)` is returned, it will refresh the token 1 minute before it expires. + /// - if `null` is returned, automatic refresh is disabled. + final OidcRefreshBeforeCallback? refreshBefore; + + /// overrides a token's expires_in value. + final Duration? Function(OidcTokenResponse tokenResponse)? getExpiresIn; + + /// pass this function to control how a webIdOrIssuer is resoled to the issuer URI. + final GetIssuers? getIssuers; + + /// pass this function to control how an `id_token` is fetched from a + /// token response. + /// + /// This can be used to trick the user manager into using a JWT `access_token` + /// as an `id_token` for example. + final Future Function(OidcToken token)? getIdToken; + + /// platform-specific options. + final OidcPlatformSpecificOptions? options; + + /// Customized hooks to modify the user manager behavior. + final OidcUserManagerHooks? hooks; + + /// Creates a copy of this [SolidOidcUserManagerSettings] with the given fields replaced with new values. + SolidOidcUserManagerSettings copyWith({ + Uri? redirectUri, + List? uiLocales, + Map? extraTokenHeaders, + List? extraScopes, + List? prompt, + String? display, + List? acrValues, + Duration? maxAge, + Map? extraAuthenticationParameters, + Duration? expiryTolerance, + Map? extraTokenParameters, + Uri? postLogoutRedirectUri, + OidcPlatformSpecificOptions? options, + Uri? frontChannelLogoutUri, + OidcUserInfoSettings? userInfoSettings, + OidcFrontChannelRequestListeningOptions? + frontChannelRequestListeningOptions, + OidcRefreshBeforeCallback? refreshBefore, + bool? strictJwtVerification, + Duration? Function(OidcTokenResponse tokenResponse)? getExpiresIn, + OidcSessionManagementSettings? sessionManagementSettings, + Future Function(OidcToken token)? getIdToken, + bool? supportOfflineAuth, + OidcUserManagerHooks? hooks, + Map? extraRevocationParameters, + Map? extraRevocationHeaders, + GetIssuers? getIssuers, + }) { + return SolidOidcUserManagerSettings( + redirectUri: redirectUri ?? this.redirectUri, + uiLocales: uiLocales ?? this.uiLocales, + extraTokenHeaders: extraTokenHeaders ?? this.extraTokenHeaders, + extraScopes: extraScopes ?? this.extraScopes, + prompt: prompt ?? this.prompt, + display: display ?? this.display, + acrValues: acrValues ?? this.acrValues, + maxAge: maxAge ?? this.maxAge, + extraAuthenticationParameters: + extraAuthenticationParameters ?? this.extraAuthenticationParameters, + expiryTolerance: expiryTolerance ?? this.expiryTolerance, + extraTokenParameters: extraTokenParameters ?? this.extraTokenParameters, + postLogoutRedirectUri: + postLogoutRedirectUri ?? this.postLogoutRedirectUri, + options: options ?? this.options, + frontChannelLogoutUri: + frontChannelLogoutUri ?? this.frontChannelLogoutUri, + userInfoSettings: userInfoSettings ?? this.userInfoSettings, + frontChannelRequestListeningOptions: + frontChannelRequestListeningOptions ?? + this.frontChannelRequestListeningOptions, + refreshBefore: refreshBefore ?? this.refreshBefore, + strictJwtVerification: + strictJwtVerification ?? this.strictJwtVerification, + getExpiresIn: getExpiresIn ?? this.getExpiresIn, + sessionManagementSettings: + sessionManagementSettings ?? this.sessionManagementSettings, + getIdToken: getIdToken ?? this.getIdToken, + supportOfflineAuth: supportOfflineAuth ?? this.supportOfflineAuth, + hooks: hooks ?? this.hooks, + extraRevocationParameters: + extraRevocationParameters ?? this.extraRevocationParameters, + extraRevocationHeaders: + extraRevocationHeaders ?? this.extraRevocationHeaders, + getIssuers: getIssuers ?? this.getIssuers, + ); + } +} + +class SolidOidcUserManager { + Uri? _issuerUri; + OidcUserManager? _manager; + + /// The WebID or issuer URL. + final String _webIdOrIssuer; + + /// The store responsible for setting/getting cached values. + final OidcStore store; + + final String? _id; + + /// The http client to use when sending requests + final http.Client? _httpClient; + final String _clientId; + + /// The id_token verification options. + final JsonWebKeyStore? _keyStore; + final SolidOidcUserManagerSettings _settings; + + // DPoP key pair management - using solid_auth generated keys + _RsaInfo? _rsaInfo; + + String? _currentWebId; + + // Storage keys for persisting SOLID-specific data + static const String _rsaInfoKey = 'solid_rsa_info'; + + SolidOidcUserManager({ + required String clientId, + required String webIdOrIssuer, + required this.store, + required SolidOidcUserManagerSettings settings, + String? id, + http.Client? httpClient, + JsonWebKeyStore? keyStore, + }) : _settings = settings, + _webIdOrIssuer = webIdOrIssuer, + _id = id, + _keyStore = keyStore, + _httpClient = httpClient, + _clientId = clientId; + + /// The current authenticated user, if any + OidcUser? get currentUser => _manager?.currentUser; + String? get currentWebId => _currentWebId; + + Future init() async { + if (_manager != null) { + await logout(); + } + + final issuerUris = + await (_settings.getIssuers ?? _getIssuersDefault)(_webIdOrIssuer); + _issuerUri = issuerUris.first; + Uri wellKnownUri = OidcUtils.getOpenIdConfigWellKnownUri(_issuerUri!); + + // Use static client ID pointing to our Public Client Identifier Document + final clientCredentials = OidcClientAuthentication.none( + clientId: _clientId, + ); + + // Try to restore persisted RSA info first + await _loadPersistedRsaInfo(); + + // Generate RSA key pair for DPoP token generation if not already available + if (_rsaInfo == null) { + await _generateAndPersistRsaKeyPair(); + } else { + _log.info('DPoP RSA key pair restored from storage'); + } + _log.info('Using Public Client Identifier: $_clientId'); + final hooks = _settings.hooks ?? OidcUserManagerHooks(); + final dpopHookTokenHook = OidcHook( + modifyRequest: (request) async { + if (_rsaInfo == null) { + // user has logged out in the mean time and now wants to log in again, + // we need to generate a new key pair + await _generateAndPersistRsaKeyPair(); + } + + ///Generate DPoP token using the RSA private key + String dPopToken = _genDpopToken( + request.tokenEndpoint.toString(), + "POST", + ); + + request.headers!['DPoP'] = dPopToken; + + return Future.value(request); + }, + ); + hooks.token = OidcHookGroup( + hooks: [if (hooks.token != null) hooks.token!, dpopHookTokenHook], + executionHook: (hooks.token is OidcExecutionHookMixin< + OidcTokenHookRequest, OidcTokenResponse>) + ? hooks.token + as OidcExecutionHookMixin + : dpopHookTokenHook, + ); + _manager = OidcUserManager.lazy( + discoveryDocumentUri: wellKnownUri, + clientCredentials: clientCredentials, + store: store, + settings: OidcUserManagerSettings( + strictJwtVerification: _settings.strictJwtVerification, + scope: { + // we are more aggressive with our scopes - those scopes simply + // are needed for solid-oidc. + ...SolidOidcUserManagerSettings.defaultScopes, + ..._settings.extraScopes, + }.toList(), + frontChannelLogoutUri: _settings.frontChannelLogoutUri, + redirectUri: _settings.redirectUri, + postLogoutRedirectUri: _settings.postLogoutRedirectUri, + hooks: hooks, + acrValues: _settings.acrValues, + display: _settings.display, + expiryTolerance: _settings.expiryTolerance, + extraAuthenticationParameters: _settings.extraAuthenticationParameters, + extraTokenHeaders: _settings.extraTokenHeaders, + extraTokenParameters: _settings.extraTokenParameters, + uiLocales: _settings.uiLocales, + prompt: _settings.prompt, + maxAge: _settings.maxAge, + extraRevocationHeaders: _settings.extraRevocationHeaders, + extraRevocationParameters: _settings.extraRevocationParameters, + options: _settings.options, + frontChannelRequestListeningOptions: + _settings.frontChannelRequestListeningOptions, + refreshBefore: _settings.refreshBefore, + getExpiresIn: _settings.getExpiresIn, + sessionManagementSettings: _settings.sessionManagementSettings, + getIdToken: _settings.getIdToken, + supportOfflineAuth: _settings.supportOfflineAuth, + userInfoSettings: _settings.userInfoSettings, + ), + keyStore: _keyStore, + id: _id, + httpClient: _httpClient, + ); + + await _manager!.init(); + if (_manager!.currentUser != null) { + _log.info( + 'SolidOidcUserManager initialized with existing user: ${_manager!.currentUser!.claims.subject}', + ); + // Extract WebID from the OIDC token using the Solid-OIDC spec methods + String webId = await _extractAndValidateWebId(_manager!.currentUser!); + _currentWebId = webId; + } else { + _log.info('SolidOidcUserManager initialized without existing user'); + } + } + + Future _generateAndPersistRsaKeyPair() async { + final rsaInfo = await solid_auth_client.genRsaKeyPair(); + final rsa = rsaInfo['rsa'] as KeyPair; + _rsaInfo = _RsaInfo( + pubKey: rsa.publicKey, + privKey: rsa.privateKey, + pubKeyJwk: rsaInfo['pubKeyJwk'], + ); + await _persistRsaInfo(); + _log.info('DPoP RSA key pair generated and persisted'); + } + + Future loginAuthorizationCodeFlow() async { + final oidcUser = await _manager!.loginAuthorizationCodeFlow(); + if (oidcUser == null) { + throw Exception('OIDC authentication failed: no user returned'); + } + + // Extract WebID from the OIDC token using the Solid-OIDC spec methods + String webId = await _extractAndValidateWebId(oidcUser); + _currentWebId = webId; + return UserAndWebId(user: oidcUser, webId: webId); + } + + Future _extractAndValidateWebId(OidcUser oidcUser) async { + // Extract WebID from the OIDC token using the Solid-OIDC spec methods + final webId = _extractWebIdFromOidcUser(oidcUser); + + // extra security check: retrieve the profile and ensure that the + // issuer really is allowed by this webID + final issuerUris = (await (_settings.getIssuers ?? _getIssuersDefault)( + webId, + )) + .map(_normalizeUri) + .toSet(); + final normalizedIssuerUri = _normalizeUri(_issuerUri!); + if (!issuerUris.contains(normalizedIssuerUri)) { + throw Exception( + 'No valid issuer found for WebID: $webId . Expected: $normalizedIssuerUri but got: $issuerUris', + ); + } + return webId; + } + + /// Normalizes a URI by removing trailing slashes and converting to lowercase for comparison. + Uri _normalizeUri(Uri uri) { + final pathWithoutTrailingSlash = uri.path.endsWith('/') + ? uri.path.substring(0, uri.path.length - 1) + : uri.path; + + return uri.replace( + scheme: uri.scheme.toLowerCase(), + host: uri.host.toLowerCase(), + path: pathWithoutTrailingSlash, + ); + } + + String _genDpopToken(String url, String method) { + if (_rsaInfo == null) { + throw Exception('RSA key pair not generated. Call authenticate first.'); + } + + final rsaKeyPair = KeyPair(_rsaInfo!.pubKey, _rsaInfo!.privKey); + final publicKeyJwk = _rsaInfo!.pubKeyJwk; + + return solid_auth_client.genDpopToken( + url, rsaKeyPair, publicKeyJwk, method); + } + + DPoP genDpopToken(String url, String method) { + if (_manager?.currentUser?.token.accessToken == null) { + throw Exception('No access token available for DPoP generation'); + } + + final dpopToken = _genDpopToken(url, method); + + // Get the access token from the current user + final accessToken = _manager!.currentUser!.token.accessToken!; + + return DPoP(dpopToken: dpopToken, accessToken: accessToken); + } + + Future logout() async { + await _manager?.logout(); + _currentWebId = null; + + // Clear persisted RSA key pair info + _rsaInfo = null; + await _clearPersistedRsaInfo(); + } + + Future dispose() async { + await _manager?.dispose(); + _manager = null; + _issuerUri = null; + } + + /// Persists the RSA key pair info to the store for session continuity + Future _persistRsaInfo() async { + if (_rsaInfo != null) { + final serializableData = { + 'pubKey': _rsaInfo!.pubKey, + 'privKey': _rsaInfo!.privKey, + 'pubKeyJwk': _rsaInfo!.pubKeyJwk, + }; + + await store.set( + OidcStoreNamespace.secureTokens, + key: _rsaInfoKey, + value: jsonEncode(serializableData), + managerId: _id, + ); + } + } + + /// Loads the RSA key pair info from the store + Future _loadPersistedRsaInfo() async { + try { + final rsaInfoStr = await store.get( + OidcStoreNamespace.secureTokens, + key: _rsaInfoKey, + managerId: _id, + ); + if (rsaInfoStr != null) { + final data = Map.from(jsonDecode(rsaInfoStr)); + _rsaInfo = _RsaInfo( + pubKey: data['pubKey'] as String, + privKey: data['privKey'] as String, + pubKeyJwk: data['pubKeyJwk'], + ); + } + } catch (e) { + _log.warning('Failed to load persisted RSA info: $e'); + } + } + + /// Clears the persisted RSA key pair info + Future _clearPersistedRsaInfo() async { + try { + await store.remove( + OidcStoreNamespace.secureTokens, + key: _rsaInfoKey, + managerId: _id, + ); + } catch (e) { + _log.warning('Failed to clear persisted RSA info: $e'); + } + } + + /// Extracts the WebID URI from the OIDC user according to the Solid-OIDC specification. + /// + /// The spec defines three methods in order of preference: + /// 1. Custom 'webid' claim in the ID token + /// 2. 'sub' claim contains a valid HTTP(S) URI + /// 3. UserInfo request + 'website' claim + String _extractWebIdFromOidcUser(OidcUser oidcUser) { + // Method 1: Check for custom 'webid' claim in ID token + final webidClaim = oidcUser.claims['webid']; + if (webidClaim != null && + webidClaim is String && + _isValidHttpUri(webidClaim)) { + _log.fine('WebID extracted from webid claim: $webidClaim'); + return webidClaim; + } + + // Method 2: Check if 'sub' claim contains a valid HTTP(S) URI + final subClaim = oidcUser.claims.subject; + if (subClaim != null && _isValidHttpUri(subClaim)) { + _log.fine('WebID extracted from sub claim: $subClaim'); + return subClaim; + } + + // Method 3: Check userInfo for 'website' claim + final websiteClaim = oidcUser.userInfo['website']; + if (websiteClaim != null && + websiteClaim is String && + _isValidHttpUri(websiteClaim)) { + _log.fine('WebID extracted from website claim: $websiteClaim'); + return websiteClaim; + } + + // If no WebID found, throw an exception + throw Exception( + 'No valid WebID found in OIDC token. ' + 'Checked webid claim, sub claim, and website claim. ' + 'The OIDC provider must support Solid-OIDC specification.', + ); + } + + /// Validates if a string is a valid HTTP or HTTPS URI. + bool _isValidHttpUri(String uriString) { + try { + final uri = Uri.parse(uriString); + return (uri.scheme == 'http' || uri.scheme == 'https') && + uri.host.isNotEmpty; + } catch (e) { + return false; + } + } +} diff --git a/lib/src/openid/openid_client.dart b/lib/src/openid/openid_client.dart deleted file mode 100644 index f0c8446..0000000 --- a/lib/src/openid/openid_client.dart +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright (c) 2017, rbellens. All rights reserved. Use of this source code -// is governed by a BSD-style license that can be found in the LICENSE file. - -library openid_client; - -export 'src/openid.dart'; diff --git a/lib/src/openid/openid_client_browser.dart b/lib/src/openid/openid_client_browser.dart deleted file mode 100644 index bcbd87e..0000000 --- a/lib/src/openid/openid_client_browser.dart +++ /dev/null @@ -1,78 +0,0 @@ -// ignore_for_file: avoid_web_libraries_in_flutter - -import 'openid_client.dart'; -import 'dart:html' hide Credential, Client; -import 'dart:async'; -import 'dart:convert'; -export 'openid_client.dart'; - -class Authenticator { - final Flow flow; - - final Future credential; - - Authenticator._(this.flow) : credential = _credentialFromUri(flow); - - // With PKCE flow - Authenticator( - Client client, { - Iterable scopes = const [], - popToken = '', - }) : this._(Flow.authorizationCodeWithPKCE(client, - state: window.localStorage['openid_client:state']) - ..scopes.addAll(scopes) - ..redirectUri = Uri.parse(window.location.href.contains('#/') - ? window.location.href.replaceAll('#/', 'callback.html') - : window.location.href + 'callback.html') - .removeFragment() - ..dPoPToken = popToken); - - void authorize() { - _forgetCredentials(); - window.localStorage['openid_client:state'] = flow.state; - window.location.href = flow.authenticationUri.toString(); - } - - // ignore: unused_field - static final Map>> _requestsByState = - {}; - - void logout() async { - _forgetCredentials(); - var c = await credential; - if (c == null) return; - var uri = c.generateLogoutUrl( - redirectUri: Uri.parse(window.location.href).removeFragment()); - if (uri != null) { - window.location.href = uri.toString(); - } - } - - void _forgetCredentials() { - window.localStorage.remove('openid_client:state'); - window.localStorage.remove('openid_client:auth'); - } - - static Future _credentialFromUri(Flow flow) async { - Map? q; - if (window.localStorage.containsKey('openid_client:auth')) { - q = json.decode(window.localStorage['openid_client:auth']!); - } else { - var uri = Uri(query: Uri.parse(window.location.href).fragment); - q = uri.queryParameters; - if (q.containsKey('access_token') || - q.containsKey('code') || - q.containsKey('id_token')) { - window.localStorage['openid_client:auth'] = json.encode(q); - window.location.href = - Uri.parse(window.location.href).removeFragment().toString(); - } - } - if (q!.containsKey('access_token') || - q.containsKey('code') || - q.containsKey('id_token')) { - return await flow.callback(q.cast()); - } - return null; - } -} diff --git a/lib/src/openid/openid_client_io.dart b/lib/src/openid/openid_client_io.dart deleted file mode 100644 index d6df537..0000000 --- a/lib/src/openid/openid_client_io.dart +++ /dev/null @@ -1,124 +0,0 @@ -library openid_client.io; - -import 'openid_client.dart'; -import 'dart:async'; -import 'dart:io'; -import 'package:flutter/foundation.dart'; - -export 'openid_client.dart'; - -class Authenticator { - final Flow flow; - - final Function(String url) urlLancher; - - final int port; - - String popToken; - - Authenticator.fromFlow( - this.flow, { - Function(String url)? urlLancher, - this.popToken = '', - }) : port = flow.redirectUri.port, - urlLancher = urlLancher ?? _runBrowser; - - Authenticator(Client client, - {this.port = 4000, - this.urlLancher = _runBrowser, - this.popToken = '', - Iterable scopes = const [], - Uri? redirectUri}) - : flow = redirectUri == null - ? Flow.authorizationCode(client) - : Flow.authorizationCodeWithPKCE(client) - ..scopes.addAll(scopes) - ..redirectUri = redirectUri ?? Uri.parse('http://localhost:$port/') - ..dPoPToken = popToken; - - Future authorize() async { - var state = flow.authenticationUri.queryParameters['state']!; - - _requestsByState[state] = Completer(); - await _startServer(port); - urlLancher(flow.authenticationUri.toString()); - - var response = await _requestsByState[state]!.future; - - return flow.callback(response); - } - - /// cancel the ongoing auth flow, i.e. when the user closed the webview/browser without a successful login - Future cancel() async { - final state = flow.authenticationUri.queryParameters['state']; - _requestsByState[state!]?.completeError(Exception('Flow was cancelled')); - final server = await _requestServers.remove(port)!; - await server.close(); - } - - static final Map> _requestServers = {}; - static final Map>> _requestsByState = - {}; - - static Future _startServer(int port) { - return _requestServers[port] ??= - (HttpServer.bind(InternetAddress.anyIPv4, port) - ..then((requestServer) async { - print('server started $port'); - await for (var request in requestServer) { - request.response.statusCode = 200; - request.response.headers.set('Content-type', 'text/html'); - request.response.writeln('' - '

Authentication process completed. You can now close this window!

' - '' - ''); - await request.response.close(); - var result = request.requestedUri.queryParameters; - - if (!result.containsKey('state')) continue; - await processResult(result); - } - - await _requestServers.remove(port); - })); - } - - /// Process the Result from a auth Request - /// You can call this manually if you are redirected to the app by an external browser - static Future processResult(Map result) async { - var r = _requestsByState.remove(result['state'])!; - r.complete(result); - if (_requestsByState.isEmpty) { - for (var s in _requestServers.values) { - await (await s).close(); - } - _requestServers.clear(); - } - } -} - -void _runBrowser(String url) { - if ((defaultTargetPlatform == TargetPlatform.linux) || - (defaultTargetPlatform == TargetPlatform.macOS) || - (defaultTargetPlatform == TargetPlatform.windows)) { - switch (defaultTargetPlatform) { - case TargetPlatform.linux: - Process.run('x-www-browser', [url]); - break; - case TargetPlatform.macOS: - Process.run('open', [url]); - break; - case TargetPlatform.windows: - Process.run('chrome', [url]); - break; - default: - throw UnsupportedError('Unsupported platform: $defaultTargetPlatform'); - } - } -} - -extension FlowX on Flow { - Future authorize({Function(String url)? urlLauncher}) { - return Authenticator.fromFlow(this, urlLancher: urlLauncher).authorize(); - } -} diff --git a/lib/src/openid/src/http_util.dart b/lib/src/openid/src/http_util.dart deleted file mode 100644 index 566b305..0000000 --- a/lib/src/openid/src/http_util.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:http/http.dart' as http; -import 'package:logging/logging.dart'; - -import '../openid_client.dart'; - -export 'package:http/http.dart' show Client; - -final _logger = Logger('openid_client'); - -typedef ClientFactory = http.Client Function(); - -Future get(Uri url, - {Map? headers, required http.Client? client}) async { - return _processResponse( - await _withClient((client) => client.get(url, headers: headers), client)); -} - -Future post(Uri url, - {Map? headers, - body, - Encoding? encoding, - required http.Client? client}) async { - return _processResponse(await _withClient( - (client) => - client.post(url, headers: headers, body: body, encoding: encoding), - client)); -} - -dynamic _processResponse(http.Response response) { - _logger.fine( - '${response.request!.method} ${response.request!.url}: ${response.body}'); - var contentType = response.headers.entries - .firstWhere((v) => v.key.toLowerCase() == 'content-type', - orElse: () => MapEntry('', '')) - .value; - var isJson = contentType.split(';').first == 'application/json'; - - var body = isJson ? json.decode(response.body) : response.body; - if (body is Map && body['error'] is String) { - throw OpenIdException( - body['error'], body['error_description'], body['error_uri']); - } - if (response.statusCode < 200 || response.statusCode >= 300) { - throw HttpRequestException(statusCode: response.statusCode, body: body); - } - return body; -} - -Future _withClient(Future Function(http.Client client) fn, - [http.Client? client0]) async { - var client = client0 ?? http.Client(); - try { - return await fn(client); - } finally { - if (client != client0) client.close(); - } -} - -class AuthorizedClient extends http.BaseClient { - final http.Client baseClient; - - final Credential credential; - - AuthorizedClient(this.baseClient, this.credential); - - @override - Future send(http.BaseRequest request) async { - var token = await credential.getTokenResponse(); - if (token.tokenType != null && token.tokenType!.toLowerCase() != 'bearer') { - throw UnsupportedError('Unknown token type: ${token.tokenType}'); - } - - request.headers['Authorization'] = 'Bearer ${token.accessToken}'; - - return baseClient.send(request); - } -} - -/// An exception thrown when a http request responds with a status code other -/// than successful (2xx) and the response is not in the openid error format. -class HttpRequestException implements Exception { - final int statusCode; - - final dynamic body; - - HttpRequestException({required this.statusCode, this.body}); - - @override - String toString() { - return 'HttpRequestException($statusCode): $body'; - } -} diff --git a/lib/src/openid/src/model.dart b/lib/src/openid/src/model.dart deleted file mode 100644 index 7bf2394..0000000 --- a/lib/src/openid/src/model.dart +++ /dev/null @@ -1,12 +0,0 @@ -library openid.model; - -import 'package:jose/src/util.dart'; -import 'package:jose/jose.dart'; -import 'package:clock/clock.dart'; - -part 'model/metadata.dart'; - -part 'model/token_response.dart'; - -part 'model/claims.dart'; -part 'model/token.dart'; diff --git a/lib/src/openid/src/model/claims.dart b/lib/src/openid/src/model/claims.dart deleted file mode 100644 index 91f9534..0000000 --- a/lib/src/openid/src/model/claims.dart +++ /dev/null @@ -1,179 +0,0 @@ -part of '../model.dart'; - -abstract class UserInfo implements JsonObject { - /// Identifier for the End-User at the Issuer. - String get subject => this['sub']; - - /// End-User's full name in displayable form including all name parts, - /// possibly including titles and suffixes, ordered according to the - /// End-User's locale and preferences. - String? get name => this['name']; - - /// Given name(s) or first name(s) of the End-User. - /// - /// Note that in some cultures, people can have multiple given names; all can - /// be present, with the names being separated by space characters. - String? get givenName => this['given_name']; - - /// Surname(s) or last name(s) of the End-User. - /// - /// Note that in some cultures, people can have multiple family names or no - /// family name; all can be present, with the names being separated by space - /// characters. - String? get familyName => this['family_name']; - - /// Middle name(s) of the End-User. - /// - /// Note that in some cultures, people can have multiple middle names; all can - /// be present, with the names being separated by space characters. Also note - /// that in some cultures, middle names are not used. - String? get middleName => this['middle_name']; - - /// Casual name of the End-User that may or may not be the same as the - /// given name. - String? get nickname => this['nickname']; - - /// Shorthand name by which the End-User wishes to be referred to at the RP, - /// such as janedoe or j.doe. T - String? get preferredUsername => this['preferred_username']; - - /// URL of the End-User's profile page. - Uri? get profile => - this['profile'] == null ? null : Uri.parse(this['profile']); - - /// URL of the End-User's profile picture. - Uri? get picture => - this['picture'] == null ? null : Uri.parse(this['picture']); - - /// URL of the End-User's Web page or blog. - Uri? get website => - this['website'] == null ? null : Uri.parse(this['website']); - - /// End-User's preferred e-mail address. - String? get email => this['email']; - - /// `true` if the End-User's e-mail address has been verified. - bool? get emailVerified => this['email_verified']; - - /// End-User's gender. - /// - /// Values defined by the specification are `female` and `male`. Other values - /// MAY be used when neither of the defined values are applicable. - String? get gender => this['gender']; - - /// End-User's birthday. - /// - /// Date represented as an ISO 8601:2004 [ISO8601‑2004] YYYY-MM-DD format. - /// The year MAY be 0000, indicating that it is omitted. To represent only the - /// year, YYYY format is allowed. - String? get birthdate => this['birthdate']; - - /// The End-User's time zone. - /// - /// For example, Europe/Paris or America/Los_Angeles. - String? get zoneinfo => this['zoneinfo']; - - /// End-User's locale. - String? get locale => this['locale']; - - /// End-User's preferred telephone number. - String? get phoneNumber => this['phone_number']; - - /// `true if the End-User's phone number has been verified` - bool? get phoneNumberVerified => this['phone_number_verified']; - - /// End-User's preferred postal address. - Address? get address => - this['address'] == null ? null : Address.fromJson(this['address']); - - /// Time the End-User's information was last updated. - DateTime? get updatedAt => this['updated_at'] == null - ? null - : DateTime.fromMillisecondsSinceEpoch(this['updated_at'] * 1000); - - factory UserInfo.fromJson(Map json) = _UserInfoImpl.fromJson; -} - -class _UserInfoImpl extends JsonObject with UserInfo { - _UserInfoImpl.fromJson(Map json) : super.from(json); -} - -class Address extends JsonObject { - /// Full mailing address, formatted for display or use on a mailing label. - String? get formatted => this['formatted']; - - /// Full street address component. - String? get streetAddress => this['street_address']; - - /// City or locality component. - String? get locality => this['locality']; - - /// State, province, prefecture, or region component. - String? get region => this['region']; - - /// Zip code or postal code component. - String? get postalCode => this['postal_code']; - - /// Country name component. - String? get country => this['country']; - - Address.fromJson(Map json) : super.from(json); -} - -class OpenIdClaims extends JsonWebTokenClaims with UserInfo { - /// Time when the End-User authentication occurred. - DateTime? get authTime => this['auth_time'] == null - ? null - : DateTime.fromMillisecondsSinceEpoch(this['auth_time'] * 1000); - - /// String value used to associate a Client session with an ID Token, and to - /// mitigate replay attacks. - String? get nonce => this['nonce']; - - /// Identifies the Authentication Context Class that the authentication - /// performed satisfied. - String? get authenticationContextClassReference => this['acr']; - - /// List of strings that are identifiers for authentication methods used in - /// the authentication. - List? get authenticationMethodsReferences => - (this['amr'] as List?)?.cast(); - - /// The party to which the ID Token was issued. - String? get authorizedParty => this['azp']; - - @override - Uri get issuer => super.issuer!; - - @override - List get audience => super.audience!; - - @override - DateTime get expiry => super.expiry!; - - @override - DateTime get issuedAt => super.issuedAt!; - - OpenIdClaims.fromJson(Map json) : super.fromJson(json); - - @override - Iterable validate( - {Duration expiryTolerance = const Duration(), - Uri? issuer, - String? clientId, - String? nonce}) sync* { - yield* super.validate( - expiryTolerance: expiryTolerance, issuer: issuer, clientId: clientId); - if (audience.length > 1 && authorizedParty == null) { - yield JoseException('No authorized party claim present.'); - } - - if (authorizedParty != null && authorizedParty != clientId) { - yield JoseException('Invalid authorized party claim.'); - } - - if (nonce != null && nonce != this.nonce) { - yield JoseException('Nonce does not match.'); - } - } -} diff --git a/lib/src/openid/src/model/metadata.dart b/lib/src/openid/src/model/metadata.dart deleted file mode 100644 index 1eeea47..0000000 --- a/lib/src/openid/src/model/metadata.dart +++ /dev/null @@ -1,230 +0,0 @@ -part of '../model.dart'; - -/// OpenID Provider Metadata -class OpenIdProviderMetadata extends JsonObject { - /// URL that the OP asserts as its OpenIdProviderMetadata Identifier. - Uri get issuer => getTyped('issuer')!; - - /// URL of the OP's OAuth 2.0 Authorization Endpoint. - Uri get authorizationEndpoint => getTyped('authorization_endpoint')!; - - /// URL of the OP's OAuth 2.0 Token Endpoint. - Uri? get tokenEndpoint => getTyped('token_endpoint'); - - /// URL of the OP's UserInfo Endpoint. - Uri? get userinfoEndpoint => getTyped('userinfo_endpoint'); - - /// URL of the OP's JSON Web Key Set document. - /// - /// This contains the signing key(s) the RP uses to validate signatures from the OP. - Uri? get jwksUri => getTyped('jwks_uri'); - - /// URL of the OP's Dynamic Client Registration Endpoint. - Uri? get registrationEndpoint => getTyped('registration_endpoint'); - - /// A list of the OAuth 2.0 scope values that this server supports. - List? get scopesSupported => getTypedList('scopes_supported'); - - /// A list of the OAuth 2.0 `response_type` values that this OP supports. - List get responseTypesSupported => - getTypedList('response_types_supported')!; - - /// A list of the OAuth 2.0 `response_mode` values that this OP supports. - List? get responseModesSupported => - getTypedList('response_modes_supported'); - - /// A list of the OAuth 2.0 Grant Type values that this OP supports. - List? get grantTypesSupported => - getTypedList('grant_types_supported'); - - /// A list of the Authentication Context Class References that this OP supports. - List? get acrValuesSupported => getTypedList('acr_values_supported'); - - /// A list of the Subject Identifier types that this OP supports. - /// - /// Valid types include `pairwise` and `public`. - List get subjectTypesSupported => - getTypedList('subject_types_supported')!; - - /// A list of the JWS signing algorithms (`alg` values) supported by the OP for - /// the ID Token to encode the Claims in a JWT. - /// - /// The algorithm `RS256` MUST be included. The value `none` MAY be supported, - /// but MUST NOT be used unless the Response Type used returns no ID Token - /// from the Authorization Endpoint (such as when using the Authorization Code - /// Flow). - List get idTokenSigningAlgValuesSupported => - getTypedList('id_token_signing_alg_values_supported')!; - - /// A list of the JWE encryption algorithms (`alg` values) supported by the OP - /// for the ID Token to encode the Claims in a JWT. - List? get idTokenEncryptionAlgValuesSupported => - getTypedList('id_token_encryption_alg_values_supported'); - - /// A list of the JWE encryption algorithms (`enc` values) supported by the OP - /// for the ID Token to encode the Claims in a JWT. - List? get idTokenEncryptionEncValuesSupported => - getTypedList('id_token_encryption_enc_values_supported'); - - /// A list of the JWS signing algorithms (`alg` values) supported by the - /// UserInfo Endpoint to encode the Claims in a JWT. - List? get userinfoSigningAlgValuesSupported => - getTypedList('userinfo_signing_alg_values_supported'); - - /// A list of the JWE encryption algorithms (`alg` values) supported by the - /// UserInfo Endpoint to encode the Claims in a JWT. - List? get userinfoEncryptionAlgValuesSupported => - getTypedList('userinfo_encryption_alg_values_supported'); - - /// A list of the JWE encryption algorithms (`enc` values) supported by the - /// UserInfo Endpoint to encode the Claims in a JWT. - List? get userinfoEncryptionEncValuesSupported => - getTypedList('userinfo_encryption_enc_values_supported'); - - /// A list of the JWS signing algorithms (`alg` values) supported by the OP - /// for Request Objects. - /// - /// These algorithms are used both when the Request Object is passed by value - /// (using the request parameter) and when it is passed by reference (using - /// the request_uri parameter). - List? get requestObjectSigningAlgValuesSupported => - getTypedList('request_object_signing_alg_values_supported'); - - /// A list of the JWE encryption algorithms (`alg` values) supported by the OP - /// for Request Objects. - /// - /// These algorithms are used both when the Request Object is passed by value - /// and when it is passed by reference. - List? get requestObjectEncryptionAlgValuesSupported => - getTypedList('request_object_encryption_alg_values_supported'); - - /// A list of the JWE encryption algorithms (`enc` values) supported by the OP - /// for Request Objects. - /// - /// These algorithms are used both when the Request Object is passed by value - /// and when it is passed by reference. - List? get requestObjectEncryptionEncValuesSupported => - getTypedList('request_object_encryption_enc_values_supported'); - - /// A list of Client Authentication methods supported by this Token Endpoint. - /// - /// The options are `client_secret_post`, `client_secret_basic`, - /// `client_secret_jwt`, and `private_key_jwt`. Other authentication methods - /// MAY be defined by extensions. - List? get tokenEndpointAuthMethodsSupported => - getTypedList('token_endpoint_auth_methods_supported'); - - /// A list of the JWS signing algorithms (`alg` values) supported by the Token - /// Endpoint for the signature on the JWT used to authenticate the Client at - /// the Token Endpoint for the `private_key_jwt` and `client_secret_jwt` - /// authentication methods. - List? get tokenEndpointAuthSigningAlgValuesSupported => - getTypedList('token_endpoint_auth_signing_alg_values_supported'); - - /// A list of the display parameter values that the OpenID Provider supports. - List? get displayValuesSupported => - getTypedList('display_values_supported'); - - /// A list of the Claim Types that the OpenID Provider supports. - /// - /// Values defined by the specification are `normal`, `aggregated`, and - /// `distributed`. If omitted, the implementation supports only `normal` Claims. - List? get claimTypesSupported => - getTypedList('claim_types_supported'); - - /// A list of the Claim Names of the Claims that the OpenID Provider MAY be - /// able to supply values for. - /// - /// Note that for privacy or other reasons, this might not be an exhaustive - /// list. - List? get claimsSupported => getTypedList('claims_supported'); - - /// URL of a page containing human-readable information that developers might - /// want or need to know when using the OpenID Provider. - Uri? get serviceDocumentation => getTyped('service_documentation'); - - /// Languages and scripts supported for values in Claims being returned. - /// - /// Not all languages and scripts are necessarily supported for all Claim values. - List? get claimsLocalesSupported => - getTypedList('claims_locales_supported'); - - /// Languages and scripts supported for the user interface. - List? get uiLocalesSupported => getTypedList('ui_locales_supported'); - - /// `true` when the OP supports use of the `claims` parameter. - bool get claimsParameterSupported => - this['claims_parameter_supported'] ?? false; - - /// `true` when the OP supports use of the `request` parameter. - bool get requestParameterSupported => - this['request_parameter_supported'] ?? false; - - /// `true` when the OP supports use of the `request_uri` parameter. - bool get requestUriParameterSupported => - this['request_uri_parameter_supported'] ?? true; - - /// `true` when the OP requires any `request_uri` values used to be - /// pre-registered using the request_uris registration parameter. - bool get requireRequestUriRegistration => - this['require_request_uri_registration'] ?? false; - - /// URL that the OpenID Provider provides to the person registering the Client - /// to read about the OP's requirements on how the Relying Party can use the - /// data provided by the OP. - Uri? get opPolicyUri => getTyped('op_policy_uri'); - - /// URL that the OpenID Provider provides to the person registering the Client - /// to read about OpenID Provider's terms of service. - Uri? get opTosUri => getTyped('op_tos_uri'); - - /// URL of an OP iframe that supports cross-origin communications for session - /// state information with the RP Client, using the HTML5 postMessage API. - /// - /// The page is loaded from an invisible iframe embedded in an RP page so that - /// it can run in the OP's security context. It accepts postMessage requests - /// from the relevant RP iframe and uses postMessage to post back the login - /// status of the End-User at the OP. - Uri? get checkSessionIframe => getTyped('check_session_iframe'); - - /// URL at the OP to which an RP can perform a redirect to request that the - /// End-User be logged out at the OP. - Uri? get endSessionEndpoint => getTyped('end_session_endpoint'); - - /// URL of the authorization server's OAuth 2.0 revocation endpoint. - Uri? get revocationEndpoint => getTyped('revocation_endpoint'); - - /// A list of client authentication methods supported by this revocation - /// endpoint. - List? get revocationEndpointAuthMethodsSupported => - getTypedList('revocation_endpoint_auth_methods_supported'); - - /// A list of the JWS signing algorithms (`alg` values) supported by the - /// revocation endpoint for the signature on the JWT used to authenticate the - /// client at the revocation endpoint for the `private_key_jwt` and - /// `client_secret_jwt` authentication methods. - List? get revocationEndpointAuthSigningAlgValuesSupported => - getTypedList('revocation_endpoint_auth_signing_alg_values_supported'); - - /// URL of the authorization server's OAuth 2.0 introspection endpoint. - Uri? get introspectionEndpoint => getTyped('introspection_endpoint'); - - /// A list of client authentication methods supported by this introspection - /// endpoint. - List? get introspectionEndpointAuthMethodsSupported => - getTypedList('introspection_endpoint_auth_methods_supported'); - - /// A list of the JWS signing algorithms (`alg` values) supported by the - /// introspection endpoint for the signature on the JWT used to authenticate - /// the client at the introspection endpoint for the `private_key_jwt` and - /// `client_secret_jwt` authentication methods. - List? get introspectionEndpointAuthSigningAlgValuesSupported => - getTypedList('introspection_endpoint_auth_signing_alg_values_supported'); - - /// A list of PKCE code challenge methods supported by this authorization - /// server. - List? get codeChallengeMethodsSupported => - getTypedList('code_challenge_methods_supported'); - - OpenIdProviderMetadata.fromJson(Map json) : super.from(json); -} diff --git a/lib/src/openid/src/model/token.dart b/lib/src/openid/src/model/token.dart deleted file mode 100644 index eb7fe6f..0000000 --- a/lib/src/openid/src/model/token.dart +++ /dev/null @@ -1,8 +0,0 @@ -part of '../model.dart'; - -class IdToken extends JsonWebToken { - IdToken.unverified(String serialization) : super.unverified(serialization); - - @override - OpenIdClaims get claims => OpenIdClaims.fromJson(super.claims.toJson()); -} diff --git a/lib/src/openid/src/model/token_response.dart b/lib/src/openid/src/model/token_response.dart deleted file mode 100644 index 978fb1f..0000000 --- a/lib/src/openid/src/model/token_response.dart +++ /dev/null @@ -1,41 +0,0 @@ -part of '../model.dart'; - -class TokenResponse extends JsonObject { - /// OAuth 2.0 Access Token - /// - /// This is returned unless the response_type value used is `id_token`. - String? get accessToken => this['access_token']; - - /// OAuth 2.0 Token Type value - /// - /// The value MUST be Bearer or another token_type value that the Client has - /// negotiated with the Authorization Server. - String? get tokenType => this['token_type']; - - /// Refresh token - String? get refreshToken => this['refresh_token']; - - /// Expiration time of the Access Token since the response was generated. - Duration? get expiresIn => expiresAt == null - ? getTyped('expires_in') - : expiresAt!.difference(clock.now()); - - /// ID Token - IdToken get idToken => - getTyped('id_token', factory: (v) => IdToken.unverified(v))!; - - DateTime? get expiresAt => getTyped('expires_at'); - - TokenResponse.fromJson(Map json) - : super.from({ - if (json['expires_in'] != null && json['expires_at'] == null) - 'expires_at': DateTime.now() - .add(Duration( - seconds: json['expires_in'] is String - ? int.parse(json['expires_in']) - : json['expires_in'])) - .millisecondsSinceEpoch ~/ - 1000, - ...json, - }); -} diff --git a/lib/src/openid/src/openid.dart b/lib/src/openid/src/openid.dart deleted file mode 100644 index 317e522..0000000 --- a/lib/src/openid/src/openid.dart +++ /dev/null @@ -1,633 +0,0 @@ -library openid_client.openid; - -import 'dart:async'; -import 'dart:convert'; -import 'dart:math'; -import 'dart:typed_data'; - -import 'package:jose/jose.dart'; -import 'package:pointycastle/digests/sha256.dart'; - -import 'http_util.dart' as http; -import 'model.dart'; - -export 'model.dart'; -export 'http_util.dart' show HttpRequestException; - -/// Represents an OpenId Provider -class Issuer { - /// The OpenId Provider's metadata - final OpenIdProviderMetadata metadata; - - final Map claimsMap; - - final JsonWebKeyStore _keyStore; - - /// Creates an issuer from its metadata. - Issuer(this.metadata, {this.claimsMap = const {}}) - : _keyStore = metadata.jwksUri == null - ? JsonWebKeyStore() - : (JsonWebKeyStore()..addKeySetUrl(metadata.jwksUri!)); - - /// Url of the facebook issuer. - /// - /// Note: facebook does not support OpenID Connect, but the authentication - /// works. - static final Uri facebook = Uri.parse('https://www.facebook.com'); - - /// Url of the google issuer. - static final Uri google = Uri.parse('https://accounts.google.com'); - - /// Url of the yahoo issuer. - static final Uri yahoo = Uri.parse('https://api.login.yahoo.com'); - - /// Url of the microsoft issuer. - static final Uri microsoft = - Uri.parse('https://login.microsoftonline.com/common'); - - /// Url of the salesforce issuer. - static final Uri salesforce = Uri.parse('https://login.salesforce.com'); - - static Uri firebase(String id) => - Uri.parse('https://securetoken.google.com/$id'); - - static final Map _discoveries = { - facebook: Issuer(OpenIdProviderMetadata.fromJson({ - 'issuer': facebook.toString(), - 'authorization_endpoint': 'https://www.facebook.com/v2.8/dialog/oauth', - 'token_endpoint': 'https://graph.facebook.com/v2.8/oauth/access_token', - 'userinfo_endpoint': 'https://graph.facebook.com/v2.8/879023912133394', - 'response_types_supported': ['token', 'code', 'code token'], - 'token_endpoint_auth_methods_supported': ['client_secret_post'], - 'scopes_supported': [ - 'public_profile', - 'user_friends', - 'email', - 'user_about_me', - 'user_actions.books', - 'user_actions.fitness', - 'user_actions.music', - 'user_actions.news', - 'user_actions.video', - 'user_birthday', - 'user_education_history', - 'user_events', - 'user_games_activity', - 'user_hometown', - 'user_likes', - 'user_location', - 'user_managed_groups', - 'user_photos', - 'user_posts', - 'user_relationships', - 'user_relationship_details', - 'user_religion_politics', - 'user_tagged_places', - 'user_videos', - 'user_website', - 'user_work_history', - 'read_custom_friendlists', - 'read_insights', - 'read_audience_network_insights', - 'read_page_mailboxes', - 'manage_pages', - 'publish_pages', - 'publish_actions', - 'rsvp_event', - 'pages_show_list', - 'pages_manage_cta', - 'pages_manage_instant_articles', - 'ads_read', - 'ads_management', - 'business_management', - 'pages_messaging', - 'pages_messaging_subscriptions', - 'pages_messaging_phone_number' - ] - })), - google: null, - yahoo: null, - microsoft: null, - salesforce: null - }; - - static Iterable get knownIssuers => _discoveries.keys; - - /// Discovers the OpenId Provider's metadata based on its uri. - static Future discover(Uri uri, {http.Client? httpClient}) async { - if (_discoveries[uri] != null) return _discoveries[uri]!; - - var segments = uri.pathSegments.toList(); - if (segments.isNotEmpty && segments.last.isEmpty) { - segments.removeLast(); - } - segments.addAll(['.well-known', 'openid-configuration']); - uri = uri.replace(pathSegments: segments); - - var json = await http.get(uri, client: httpClient); - return _discoveries[uri] = Issuer(OpenIdProviderMetadata.fromJson(json)); - } -} - -/// Represents the client application. -class Client { - /// The id of the client. - final String clientId; - - /// A secret for authenticating the client to the OP. - final String? clientSecret; - - /// The [Issuer] representing the OP. - final Issuer issuer; - - final http.Client? httpClient; - - Client(this.issuer, this.clientId, {this.clientSecret, this.httpClient}); - - static Future forIdToken(String idToken, - {http.Client? httpClient}) async { - var token = JsonWebToken.unverified(idToken); - var claims = OpenIdClaims.fromJson(token.claims.toJson()); - var issuer = await Issuer.discover(claims.issuer, httpClient: httpClient); - if (!await token.verify(issuer._keyStore)) { - throw ArgumentError('Unable to verify token'); - } - var clientId = claims.authorizedParty ?? claims.audience.single; - return Client(issuer, clientId, httpClient: httpClient); - } - - /// Creates a [Credential] for this client. - Credential createCredential( - {String? accessToken, - String? tokenType, - String? refreshToken, - Duration? expiresIn, - DateTime? expiresAt, - String? idToken}) => - Credential._( - this, - TokenResponse.fromJson({ - 'access_token': accessToken, - 'token_type': tokenType, - 'refresh_token': refreshToken, - 'id_token': idToken, - if (expiresIn != null) 'expires_in': expiresIn.inSeconds, - if (expiresAt != null) - 'expires_at': expiresAt.millisecondsSinceEpoch ~/ 1000 - }), - null); -} - -class Credential { - TokenResponse _token; - final Client client; - final String? nonce; - - final StreamController _onTokenChanged = - StreamController.broadcast(); - - Credential._(this.client, this._token, this.nonce); - - Map? get response => _token.toJson(); - - Future getUserInfo() async { - var uri = client.issuer.metadata.userinfoEndpoint; - if (uri == null) { - throw UnsupportedError('Issuer does not support userinfo endpoint.'); - } - return UserInfo.fromJson(await _get(uri)); - } - - /// Emits a new [TokenResponse] every time the token is refreshed - Stream get onTokenChanged => _onTokenChanged.stream; - - /// Allows clients to notify the authorization server that a previously - /// obtained refresh or access token is no longer needed - /// - /// See https://tools.ietf.org/html/rfc7009 - Future revoke() async { - var uri = client.issuer.metadata.revocationEndpoint; - if (uri == null) { - throw UnsupportedError('Issuer does not support revocation endpoint.'); - } - var request = _token.refreshToken != null - ? {'token': _token.refreshToken, 'token_type_hint': 'refresh_token'} - : {'token': _token.accessToken, 'token_type_hint': 'access_token'}; - await _post(uri, body: request); - } - - /// Returns an url to redirect to for a Relying Party to request that an - /// OpenID Provider log out the End-User. - /// - /// [redirectUri] is an url to which the Relying Party is requesting that the - /// End-User's User Agent be redirected after a logout has been performed. - /// - /// [state] is an opaque value used by the Relying Party to maintain state - /// between the logout request and the callback to [redirectUri]. - /// - /// See https://openid.net/specs/openid-connect-rpinitiated-1_0.html - Uri? generateLogoutUrl({Uri? redirectUri, String? state}) { - return client.issuer.metadata.endSessionEndpoint?.replace(queryParameters: { - 'id_token_hint': _token.idToken.toCompactSerialization(), - if (redirectUri != null) - 'post_logout_redirect_uri': redirectUri.toString(), - if (state != null) 'state': state - }); - } - - http.Client createHttpClient([http.Client? baseClient]) => - http.AuthorizedClient( - baseClient ?? client.httpClient ?? http.Client(), this); - - Future _get(uri) async { - return http.get(uri, client: createHttpClient()); - } - - Future _post(uri, {dynamic body}) async { - return http.post(uri, client: createHttpClient(), body: body); - } - - IdToken get idToken => _token.idToken; - - Stream validateToken( - {bool validateClaims = true, bool validateExpiry = true}) async* { - var keyStore = JsonWebKeyStore(); - var jwksUri = client.issuer.metadata.jwksUri; - if (jwksUri != null) { - keyStore.addKeySetUrl(jwksUri); - } - if (!await idToken.verify(keyStore, - allowedArguments: - client.issuer.metadata.idTokenSigningAlgValuesSupported)) { - yield JoseException('Could not verify token signature'); - } - - yield* Stream.fromIterable(idToken.claims - .validate( - expiryTolerance: const Duration(seconds: 30), - issuer: client.issuer.metadata.issuer, - clientId: client.clientId, - nonce: nonce) - .where((e) => - validateExpiry || - !(e is JoseException && e.message.startsWith('JWT expired.')))); - } - - String? get refreshToken => _token.refreshToken; - - Future getTokenResponse( - {bool forceRefresh = false, String dPoPToken = ''}) async { - if (!forceRefresh && - _token.accessToken != null && - (_token.expiresAt == null || - _token.expiresAt!.isAfter(DateTime.now()))) { - return _token; - } - if (_token.accessToken == null && _token.refreshToken == null) { - return _token; - } - - var h = - base64.encode('${client.clientId}:${client.clientSecret}'.codeUnits); - - ///Generate DPoP token using the RSA private key - var json = await http.post(client.issuer.tokenEndpoint, - headers: { - 'Accept': '*/*', - 'Accept-Encoding': 'gzip, deflate, br', - 'content-type': 'application/x-www-form-urlencoded', - 'DPoP': dPoPToken, - 'Authorization': 'Basic $h', - }, - body: { - 'grant_type': 'refresh_token', - 'token_type': 'DPoP', - 'refresh_token': _token.refreshToken, - }, - client: client.httpClient); - if (json['error'] != null) { - throw OpenIdException( - json['error'], json['error_description'], json['error_uri']); - } - - //return _token = TokenResponse.fromJson(json); - _token = - TokenResponse.fromJson({'refresh_token': _token.refreshToken, ...json}); - _onTokenChanged.add(_token); - return _token; - } - - Credential.fromJson(Map json, {http.Client? httpClient}) - : this._( - Client( - Issuer(OpenIdProviderMetadata.fromJson( - (json['issuer'] as Map).cast())), - json['client_id'], - clientSecret: json['client_secret'], - httpClient: httpClient), - TokenResponse.fromJson((json['token'] as Map).cast()), - json['nonce']); - - Map toJson() => { - 'issuer': client.issuer.metadata.toJson(), - 'client_id': client.clientId, - 'client_secret': client.clientSecret, - 'token': _token.toJson(), - 'nonce': nonce - }; -} - -extension _IssuerX on Issuer { - Uri get tokenEndpoint { - var endpoint = metadata.tokenEndpoint; - if (endpoint == null) { - throw OpenIdException.missingTokenEndpoint(); - } - return endpoint; - } -} - -enum FlowType { - implicit, - authorizationCode, - proofKeyForCodeExchange, - jwtBearer -} - -class Flow { - final FlowType type; - - final String? responseType; - - final Client client; - - final List scopes = []; - - final String state; - - final Map _additionalParameters; - - String dPoPToken = ''; - - //Uri redirectUri = Uri.parse('http://localhost'); - Uri redirectUri; - - // Flow._(this.type, this.responseType, this.client, {String? state}) - // : state = state ?? _randomString(20) { - // //var scopes = client!.issuer!.metadata.scopesSupported; // There is no 'scopes_supported' in SOLID issuer response - // List scopes = []; - // for (var s in const ['openid', 'profile', 'offline_access']) { - // if (scopes.contains(s)) { - // this.scopes.add(s); - // break; - // } - // } - - Flow._(this.type, this.responseType, this.client, - {String? state, - Map? additionalParameters, - Uri? redirectUri, - List scopes = const ['openid', 'profile', 'offline_access']}) - : state = state ?? _randomString(20), - _additionalParameters = {...?additionalParameters}, - redirectUri = redirectUri ?? Uri.parse('http://localhost') { - var supportedScopes = client.issuer.metadata.scopesSupported ?? []; - for (var s in scopes) { - if (!supportedScopes.contains(s)) { - this.scopes.remove(s); - } - } - - var verifier = _randomString(96); - var challenge = base64Url - .encode(SHA256Digest().process(Uint8List.fromList(verifier.codeUnits))) - .replaceAll('=', ''); - _proofKeyForCodeExchange = { - 'code_verifier': verifier, - 'code_challenge': challenge - }; - } - - Flow.authorizationCode(Client client, - {String? state, - String? prompt, - String? accessType, - Uri? redirectUri, - List scopes = const ['openid', 'profile', 'offline_access']}) - : this._(FlowType.authorizationCode, 'code', client, - state: state, - additionalParameters: { - if (prompt != null) 'prompt': prompt, - if (accessType != null) 'access_type': accessType, - }, - scopes: scopes, - redirectUri: redirectUri); - - Flow.authorizationCodeWithPKCE(Client client, {String? state}) - : this._(FlowType.proofKeyForCodeExchange, 'code', client, state: state); - - Flow.implicit(Client client, {String? state}) - : this._( - FlowType.implicit, - ['token id_token', 'id_token token', 'id_token', 'token'] - .firstWhere((v) => - client.issuer.metadata.responseTypesSupported.contains(v)), - client, - state: state); - - Flow.jwtBearer(Client client) : this._(FlowType.jwtBearer, null, client); - - Uri get authenticationUri => client.issuer.metadata.authorizationEndpoint - .replace(queryParameters: _authenticationUriParameters); - - late Map _proofKeyForCodeExchange; - - final String _nonce = _randomString(16); - - Map get _authenticationUriParameters { - var v = { - ..._additionalParameters, - 'response_type': responseType, - 'scope': scopes.join(' '), - 'client_id': client.clientId, - 'redirect_uri': redirectUri.toString(), - 'prompt': 'consent', - 'state': state - }..addAll( - responseType!.split(' ').contains('id_token') ? {'nonce': _nonce} : {}); - - if (type == FlowType.proofKeyForCodeExchange) { - v.addAll({ - 'code_challenge_method': 'S256', - 'code_challenge': _proofKeyForCodeExchange['code_challenge'] - }); - } - return v; - } - - Future _getToken(String? code) async { - var methods = client.issuer.metadata.tokenEndpointAuthMethodsSupported; - Map json; - if (type == FlowType.jwtBearer) { - json = await http.post(client.issuer.tokenEndpoint, - body: { - 'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer', - 'assertion': code, - }, - client: client.httpClient); - } else if (type == FlowType.proofKeyForCodeExchange) { - var h = - base64.encode('${client.clientId}:${client.clientSecret}'.codeUnits); - json = await http.post(client.issuer.tokenEndpoint, - headers: { - 'Accept': '*/*', - 'Accept-Encoding': 'gzip, deflate, br', - 'DPoP': dPoPToken, - 'content-type': 'application/x-www-form-urlencoded', - 'Authorization': 'Basic $h', - //'Connection': 'keep-alive', - }, - body: { - 'grant_type': 'authorization_code', - 'code': code, - 'redirect_uri': redirectUri.toString(), - //'client_id': client.clientId, - // if (client.clientSecret != null) - // 'client_secret': client.clientSecret, - 'code_verifier': _proofKeyForCodeExchange['code_verifier'] - }, - client: client.httpClient); - } else if (methods!.contains('client_secret_post')) { - json = await http.post(client.issuer.tokenEndpoint, - body: { - 'grant_type': 'authorization_code', - 'code': code, - 'redirect_uri': redirectUri.toString(), - 'client_id': client.clientId, - 'client_secret': client.clientSecret - }, - client: client.httpClient); - } else if (methods.contains('client_secret_basic')) { - var h = - base64.encode('${client.clientId}:${client.clientSecret}'.codeUnits); - json = await http.post(client.issuer.tokenEndpoint, - headers: {'authorization': 'Basic $h'}, - body: { - 'grant_type': 'authorization_code', - 'code': code, - 'redirect_uri': redirectUri.toString() - }, - client: client.httpClient); - } else { - throw UnsupportedError('Unknown auth methods: $methods'); - } - if (json['error'] != null) { - throw OpenIdException( - json['error'], json['error_description'], json['error_uri']); - } - return TokenResponse.fromJson(json); - } - - Future callback(Map response) async { - if (response['state'] != state) { - throw ArgumentError('State does not match'); - } - if (type == FlowType.jwtBearer) { - var code = response['jwt']; - return Credential._(client, await _getToken(code), null); - } else if (response.containsKey('code') && - (type == FlowType.proofKeyForCodeExchange || - client.clientSecret != null)) { - var code = response['code']; - return Credential._(client, await _getToken(code), null); - } else if (response.containsKey('access_token') || - response.containsKey('id_token')) { - return Credential._(client, TokenResponse.fromJson(response), _nonce); - } else { - return Credential._(client, TokenResponse.fromJson(response), _nonce); - } - } -} - -String _randomString(int length) { - var r = Random.secure(); - var chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; - return Iterable.generate(length, (_) => chars[r.nextInt(chars.length)]) - .join(); -} - -class OpenIdException implements Exception { - /// An error code - final String? code; - - /// Human-readable text description of the error. - final String? message; - - /// A URI identifying a human-readable web page with information about the - /// error, used to provide the client developer with additional information - /// about the error. - final String? uri; - - static const _defaultMessages = { - 'duplicate_requests': - 'The Client sent simultaneous requests to the User Questioning Polling Endpoint for the same question_id. This error is responded to oldest requests. The last request is processed normally.', - 'forbidden': - 'The Client sent a request to the User Questioning Polling Endpoint whereas it is configured with a client_notification_endpoint.', - 'high_rate_client': - 'The Client sent requests at a too high rate, amongst all question_id. Information about the allowed and recommended rates can be included in the error_description.', - 'high_rate_question': - 'The Client sent requests at a too high rate for a given question_id. Information about the allowed and recommended rates can be included in the error_description.', - 'invalid_question_id': - 'The Client sent a request to the User Questioning Polling Endpoint for a question_id that does not exist or is not valid for the requesting Client.', - 'invalid_request': - 'The User Questioning Request is not valid. The request is missing a required parameter, includes an unsupported parameter value (other than grant type), repeats a parameter, includes multiple credentials, utilizes more than one mechanism for authenticating the client, or is otherwise malformed.', - 'no_suitable_method': - 'There is no Questioning Method suitable with the User Questioning Request. The OP can use this error code when it does not implement mechanisms suitable for the wished AMR or ACR.', - 'timeout': - 'The Questioned User did not answer in the allowed period of time.', - 'unauthorized': - 'The Client is not authorized to use the User Questioning API or did not send a valid Access Token.', - 'unknown_user': - 'The Questioned User mentioned in the user_id attribute of the User Questioning Request is unknown.', - 'unreachable_user': - 'The Questioned User mentioned in the User Questioning Request (either in the Access Token or in the user_id attribute) is unreachable. The OP can use this error when it does not have a reachability identifier (e.g. MSISDN) for the Question User or when the reachability identifier is not operational (e.g. unsubscribed MSISDN).', - 'user_refused_to_answer': - 'The Questioned User refused to make a statement to the question.', - 'interaction_required': - 'The Authorization Server requires End-User interaction of some form to proceed. This error MAY be returned when the prompt parameter value in the Authentication Request is none, but the Authentication Request cannot be completed without displaying a user interface for End-User interaction.', - 'login_required': - 'The Authorization Server requires End-User authentication. This error MAY be returned when the prompt parameter value in the Authentication Request is none, but the Authentication Request cannot be completed without displaying a user interface for End-User authentication.', - 'account_selection_required': - 'The End-User is REQUIRED to select a session at the Authorization Server. The End-User MAY be authenticated at the Authorization Server with different associated accounts, but the End-User did not select a session. This error MAY be returned when the prompt parameter value in the Authentication Request is none, but the Authentication Request cannot be completed without displaying a user interface to prompt for a session to use.', - 'consent_required': - 'The Authorization Server requires End-User consent. This error MAY be returned when the prompt parameter value in the Authentication Request is none, but the Authentication Request cannot be completed without displaying a user interface for End-User consent.', - 'invalid_request_uri': - 'The request_uri in the Authorization Request returns an error or contains invalid data.', - 'invalid_request_object': - 'The request parameter contains an invalid Request Object.', - 'request_not_supported': - 'The OP does not support use of the request parameter', - 'request_uri_not_supported': - 'The OP does not support use of the request_uri parameter', - 'registration_not_supported': - 'The OP does not support use of the registration parameter', - 'invalid_redirect_uri': - 'The value of one or more redirect_uris is invalid.', - 'invalid_client_metadata': - 'The value of one of the Client Metadata fields is invalid and the server has rejected this request. Note that an Authorization Server MAY choose to substitute a valid value for any requested parameter of a Client\'s Metadata.', - }; - - /// Thrown when trying to get a token, but the token endpoint is missing from - /// the issuer metadata - const OpenIdException.missingTokenEndpoint() - : this._('missing_token_endpoint', - 'The issuer metadata does not contain a token endpoint.'); - - const OpenIdException._(this.code, this.message) : uri = null; - - OpenIdException(this.code, String? message, [this.uri]) - : message = message ?? _defaultMessages[code]; - - @override - String toString() => 'OpenIdException($code): $message'; -} diff --git a/lib/src/solid_auth.dart b/lib/src/solid_auth.dart new file mode 100644 index 0000000..f418d5f --- /dev/null +++ b/lib/src/solid_auth.dart @@ -0,0 +1,456 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; +import 'package:oidc/oidc.dart'; +import 'package:oidc_default_store/oidc_default_store.dart'; +import 'package:solid_auth/src/oidc/solid_oidc_user_manager.dart'; +export 'package:solid_auth/src/oidc/solid_oidc_user_manager.dart' + show DPoP, UserAndWebId; + +final _log = Logger("solid_authentication_oidc"); + +class SolidAuthSettings { + /// + const SolidAuthSettings({ + this.uiLocales, + this.extraTokenHeaders, + this.prompt = const [], + this.display, + this.acrValues, + this.maxAge, + this.extraAuthenticationParameters, + this.expiryTolerance = const Duration(minutes: 1), + this.extraTokenParameters, + this.options, + this.userInfoSettings = const OidcUserInfoSettings(), + this.refreshBefore = defaultRefreshBefore, + this.strictJwtVerification = false, + this.getExpiresIn, + this.sessionManagementSettings = const OidcSessionManagementSettings(), + this.getIdToken, + this.supportOfflineAuth = false, + this.hooks, + this.extraRevocationParameters, + this.extraRevocationHeaders, + this.getIssuers, + }); + + /// Settings to control using the user_info endpoint. + final OidcUserInfoSettings userInfoSettings; + + /// whether JWTs are strictly verified. + /// + /// If set to true, the library will throw an exception if a JWT is invalid. + final bool strictJwtVerification; + + /// Whether to support offline authentication or not. + /// + /// When this option is enabled, expired tokens will NOT be removed if the + /// server can't be contacted + /// + /// This parameter is disabled by default due to security concerns. + final bool supportOfflineAuth; + + /// see [OidcAuthorizeRequest.prompt]. + final List prompt; + + /// see [OidcAuthorizeRequest.display]. + final String? display; + + /// see [OidcAuthorizeRequest.uiLocales]. + final List? uiLocales; + + /// see [OidcAuthorizeRequest.acrValues]. + final List? acrValues; + + /// see [OidcAuthorizeRequest.maxAge] + final Duration? maxAge; + + /// see [OidcAuthorizeRequest.extra] + final Map? extraAuthenticationParameters; + + /// see [OidcTokenRequest.extra] + final Map? extraTokenHeaders; + + /// see [OidcTokenRequest.extra] + final Map? extraTokenParameters; + + /// see [OidcRevocationRequest.extra] + final Map? extraRevocationParameters; + + /// Extra headers to send with the revocation request. + final Map? extraRevocationHeaders; + + /// see [OidcIdTokenVerificationOptions.expiryTolerance]. + final Duration expiryTolerance; + + /// Settings related to the session management spec. + final OidcSessionManagementSettings sessionManagementSettings; + + /// How early the token gets refreshed. + /// + /// for example: + /// + /// - if `Duration.zero` is returned, the token gets refreshed once it's expired. + /// - (default) if `Duration(minutes: 1)` is returned, it will refresh the token 1 minute before it expires. + /// - if `null` is returned, automatic refresh is disabled. + final OidcRefreshBeforeCallback? refreshBefore; + + /// overrides a token's expires_in value. + final Duration? Function(OidcTokenResponse tokenResponse)? getExpiresIn; + + /// pass this function to control how a webIdOrIssuer is resoled to the issuer URI. + final GetIssuers? getIssuers; + + /// pass this function to control how an `id_token` is fetched from a + /// token response. + /// + /// This can be used to trick the user manager into using a JWT `access_token` + /// as an `id_token` for example. + final Future Function(OidcToken token)? getIdToken; + + /// platform-specific options. + final OidcPlatformSpecificOptions? options; + + /// Customized hooks to modify the user manager behavior. + final OidcUserManagerHooks? hooks; + + /// Creates a copy of this [SolidOidcUserManagerSettings] with the given fields replaced with new values. + SolidAuthSettings copyWith({ + List? uiLocales, + Map? extraTokenHeaders, + List? prompt, + String? display, + List? acrValues, + Duration? maxAge, + Map? extraAuthenticationParameters, + Duration? expiryTolerance, + Map? extraTokenParameters, + OidcPlatformSpecificOptions? options, + OidcUserInfoSettings? userInfoSettings, + OidcRefreshBeforeCallback? refreshBefore, + bool? strictJwtVerification, + Duration? Function(OidcTokenResponse tokenResponse)? getExpiresIn, + OidcSessionManagementSettings? sessionManagementSettings, + Future Function(OidcToken token)? getIdToken, + bool? supportOfflineAuth, + OidcUserManagerHooks? hooks, + Map? extraRevocationParameters, + Map? extraRevocationHeaders, + GetIssuers? getIssuers, + }) { + return SolidAuthSettings( + uiLocales: uiLocales ?? this.uiLocales, + extraTokenHeaders: extraTokenHeaders ?? this.extraTokenHeaders, + prompt: prompt ?? this.prompt, + display: display ?? this.display, + acrValues: acrValues ?? this.acrValues, + maxAge: maxAge ?? this.maxAge, + extraAuthenticationParameters: + extraAuthenticationParameters ?? this.extraAuthenticationParameters, + expiryTolerance: expiryTolerance ?? this.expiryTolerance, + extraTokenParameters: extraTokenParameters ?? this.extraTokenParameters, + options: options ?? this.options, + userInfoSettings: userInfoSettings ?? this.userInfoSettings, + refreshBefore: refreshBefore ?? this.refreshBefore, + strictJwtVerification: + strictJwtVerification ?? this.strictJwtVerification, + getExpiresIn: getExpiresIn ?? this.getExpiresIn, + sessionManagementSettings: + sessionManagementSettings ?? this.sessionManagementSettings, + getIdToken: getIdToken ?? this.getIdToken, + supportOfflineAuth: supportOfflineAuth ?? this.supportOfflineAuth, + hooks: hooks ?? this.hooks, + extraRevocationParameters: + extraRevocationParameters ?? this.extraRevocationParameters, + extraRevocationHeaders: + extraRevocationHeaders ?? this.extraRevocationHeaders, + getIssuers: getIssuers ?? this.getIssuers, + ); + } +} + +class SolidAuthUriSettings { + final Uri redirectUri; + final Uri postLogoutRedirectUri; + final Uri frontChannelLogoutUri; + final OidcFrontChannelRequestListeningOptions + frontChannelRequestListeningOptions; + SolidAuthUriSettings({ + required this.redirectUri, + required this.postLogoutRedirectUri, + required this.frontChannelLogoutUri, + this.frontChannelRequestListeningOptions = + const OidcFrontChannelRequestListeningOptions(), + }); +} + +class SolidAuth { + SolidOidcUserManager? _manager; + final ValueNotifier _isAuthenticatedNotifier = + ValueNotifier(false); + + final OidcStore _store; + final String _oidcClientId; + final SolidAuthSettings _settings; + final SolidAuthUriSettings _uriSettings; + // Storage keys for persisting authentication parameters + static const String _webIdOrIssuerKey = 'solid_auth_webid_or_issuer'; + static const String _scopesKey = 'solid_auth_scopes'; + + SolidAuth({ + required String oidcClientId, + required String appUrlScheme, + required Uri frontendRedirectUrl, + SolidAuthSettings? settings, + OidcStore? store, + }) : _oidcClientId = oidcClientId, + _settings = settings ?? const SolidAuthSettings(), + _uriSettings = SolidAuth.createUriSettings( + appUrlScheme: appUrlScheme, + frontendRedirectUrl: frontendRedirectUrl, + ), + _store = store ?? OidcDefaultStore(); + + SolidAuth.forRedirects({ + required String oidcClientId, + required SolidAuthUriSettings uriSettings, + SolidAuthSettings? settings, + OidcStore? store, + }) : _oidcClientId = oidcClientId, + _settings = settings ?? const SolidAuthSettings(), + _store = store ?? OidcDefaultStore(), + _uriSettings = uriSettings; + + String? get currentWebId => _manager?.currentWebId; + + /// ValueListenable that notifies when authentication state changes + ValueListenable get isAuthenticatedNotifier => _isAuthenticatedNotifier; + + /// Updates the authentication state and notifies listeners + void _updateAuthenticationState() { + final newState = _manager != null && _manager!.currentUser != null; + if (_isAuthenticatedNotifier.value != newState) { + _log.fine( + 'Authentication state changed: ${_isAuthenticatedNotifier.value} => $newState'); + _isAuthenticatedNotifier.value = newState; + } + } + + Future init() async { + await _store.init(); + + // Try to restore authentication parameters from storage + final webIdOrIssuer = await _store.get( + OidcStoreNamespace.secureTokens, + key: _webIdOrIssuerKey, + ); + + final scopesJson = await _store.get( + OidcStoreNamespace.secureTokens, + key: _scopesKey, + ); + + if (webIdOrIssuer != null && scopesJson != null) { + try { + final scopes = List.from(jsonDecode(scopesJson)); + _manager = + await _createAndInitializeManager(webIdOrIssuer, scopes: scopes); + + // Verify the manager actually has a valid session + if (_manager?.currentUser != null) { + _log.info( + 'Successfully restored session for webIdOrIssuer: $webIdOrIssuer', + ); + _updateAuthenticationState(); + return isAuthenticated; + } else { + _log.info('Stored parameters found but no valid session exists'); + await _clearStoredParameters(); + } + } catch (e) { + _log.warning('Failed to restore session with stored parameters: $e'); + await _clearStoredParameters(); + } + } + + _log.info('No valid session found during initialization'); + _updateAuthenticationState(); + return false; + } + + /// Clears stored authentication parameters + Future _clearStoredParameters() async { + await _store.remove( + OidcStoreNamespace.secureTokens, + key: _webIdOrIssuerKey, + ); + await _store.remove(OidcStoreNamespace.secureTokens, key: _scopesKey); + } + + /// Persists authentication parameters for session restoration + Future _persistAuthParameters( + String webIdOrIssuer, + List scopes, + ) async { + await _store.set( + OidcStoreNamespace.secureTokens, + key: _webIdOrIssuerKey, + value: webIdOrIssuer, + ); + await _store.set( + OidcStoreNamespace.secureTokens, + key: _scopesKey, + value: jsonEncode(scopes), + ); + } + + Future authenticate(String webIdOrIssuerUri, + {List scopes = const []}) async { + // Clean up any existing manager + if (_manager != null) { + await logout(); + } + + // Create and initialize manager with new parameters + _manager = + await _createAndInitializeManager(webIdOrIssuerUri, scopes: scopes); + + // Check if there's already a valid session (from cached tokens) + if (_manager!.currentUser != null && _manager!.currentWebId != null) { + final webId = _manager!.currentWebId!; + // Persist the parameters for future restoration + await _persistAuthParameters(webIdOrIssuerUri, scopes); + + _log.info('Using restored session for WebID: $webId'); + _updateAuthenticationState(); + return UserAndWebId(user: _manager!.currentUser!, webId: webId); + } + + _log.info( + "Beginning full authentication flow for WebID: $webIdOrIssuerUri"); + // No existing session, perform full authentication flow + final authResult = await _manager!.loginAuthorizationCodeFlow(); + if (authResult == null) { + throw Exception('OIDC authentication failed: no user returned'); + } + + final oidcUser = authResult.user; + final webId = authResult.webId; + + // Persist authentication parameters for session restoration + await _persistAuthParameters(webIdOrIssuerUri, scopes); + + _log.info( + 'OIDC User authenticated: ${oidcUser.uid ?? 'unknown'} for webId: $webId', + ); + + _updateAuthenticationState(); + return authResult; + } + + static SolidAuthUriSettings createUriSettings({ + required String appUrlScheme, + required Uri frontendRedirectUrl, + }) { + if (kIsWeb) { + // Web platform uses HTML redirect page + final htmlPageLink = frontendRedirectUrl; + + return SolidAuthUriSettings( + redirectUri: htmlPageLink, + postLogoutRedirectUri: htmlPageLink, + frontChannelLogoutUri: htmlPageLink.replace( + queryParameters: { + ...htmlPageLink.queryParameters, + 'requestType': 'front-channel-logout', + }, + )); + } else { + return SolidAuthUriSettings( + redirectUri: Uri.parse('${appUrlScheme}://redirect'), + postLogoutRedirectUri: Uri.parse('${appUrlScheme}://logout'), + frontChannelLogoutUri: Uri.parse('${appUrlScheme}://logout'), + ); + } + } + + Future _createAndInitializeManager( + String webIdOrIssuerUri, + {List scopes = const []}) async { + var manager = SolidOidcUserManager( + clientId: _oidcClientId, + webIdOrIssuer: webIdOrIssuerUri, + store: _store, + settings: SolidOidcUserManagerSettings( + redirectUri: _uriSettings.redirectUri, + postLogoutRedirectUri: _uriSettings.postLogoutRedirectUri, + frontChannelLogoutUri: _uriSettings.frontChannelLogoutUri, + frontChannelRequestListeningOptions: + _uriSettings.frontChannelRequestListeningOptions, + acrValues: _settings.acrValues, + display: _settings.display, + expiryTolerance: _settings.expiryTolerance, + extraAuthenticationParameters: + _settings.extraAuthenticationParameters, + extraRevocationHeaders: _settings.extraRevocationHeaders, + extraRevocationParameters: _settings.extraRevocationParameters, + extraScopes: scopes, + extraTokenHeaders: _settings.extraTokenHeaders, + extraTokenParameters: _settings.extraTokenParameters, + getExpiresIn: _settings.getExpiresIn, + getIdToken: _settings.getIdToken, + getIssuers: _settings.getIssuers, + hooks: _settings.hooks, + maxAge: _settings.maxAge, + options: _settings.options, + prompt: _settings.prompt, + refreshBefore: _settings.refreshBefore, + sessionManagementSettings: _settings.sessionManagementSettings, + strictJwtVerification: _settings.strictJwtVerification, + supportOfflineAuth: _settings.supportOfflineAuth, + userInfoSettings: _settings.userInfoSettings, + uiLocales: _settings.uiLocales, + )); + + await manager.init(); + return manager; + } + + DPoP genDpopToken(String url, String method) { + return _manager!.genDpopToken(url, method); + } + + bool get isAuthenticated { + return _manager != null && _manager!.currentUser != null; + } + + Future logout() async { + await _manager?.logout(); + await _manager?.dispose(); + _manager = null; + + // Clear stored authentication parameters + await _clearStoredParameters(); + + _updateAuthenticationState(); + } + + /// Dispose of resources when SolidAuth is no longer needed. + /// + /// This method cleans up internal resources (ValueNotifier) but does NOT + /// clear stored authentication data or logout the user. + /// + /// Use cases: + /// - App shutdown or widget disposal + /// - Switching to a different authentication provider + /// + /// If you want to log out the user and clear stored data, call logout() first. + /// This method is safe to call multiple times. + Future dispose() async { + _isAuthenticatedNotifier.dispose(); + await _manager?.dispose(); + _manager = null; + } +} diff --git a/lib/src/solid_auth_client.dart b/lib/src/solid_auth_client.dart new file mode 100644 index 0000000..dfd8ccb --- /dev/null +++ b/lib/src/solid_auth_client.dart @@ -0,0 +1,56 @@ +import 'dart:async'; + +import 'package:fast_rsa/fast_rsa.dart'; +import 'package:solid_auth/src/jwt/dart_jsonwebtoken.dart'; +import 'package:uuid/uuid.dart'; + +/// Generate RSA key pair for the authentication +Future genRsaKeyPair() async { + /// Generate a key pair + var rsaKeyPair = await RSA.generate(2048); + + /// JWK conversion of private and public keys + var publicKeyJwk = await RSA.convertPublicKeyToJWK(rsaKeyPair.publicKey); + var privateKeyJwk = await RSA.convertPrivateKeyToJWK(rsaKeyPair.privateKey); + + publicKeyJwk['alg'] = "RS256"; + return { + 'rsa': rsaKeyPair, + 'privKeyJwk': privateKeyJwk, + 'pubKeyJwk': publicKeyJwk + }; +} + +/// Generate dPoP token for the authentication +String genDpopToken(String endPointUrl, KeyPair rsaKeyPair, + dynamic publicKeyJwk, String httpMethod) { + /// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop-03 + /// Unique identifier for DPoP proof JWT + /// Here we are using a version 4 UUID according to https://datatracker.ietf.org/doc/html/rfc4122 + var uuid = const Uuid(); + final String tokenId = uuid.v4(); + + /// Initialising token head and body (payload) + /// https://solid.github.io/solid-oidc/primer/#authorization-code-pkce-flow + /// https://datatracker.ietf.org/doc/html/rfc7519 + var tokenHead = {"alg": "RS256", "typ": "dpop+jwt", "jwk": publicKeyJwk}; + + var tokenBody = { + "htu": endPointUrl, + "htm": httpMethod, + "jti": tokenId, + "iat": (DateTime.now().millisecondsSinceEpoch / 1000).round() + }; + + /// Create a json web token + final jwt = JWT( + tokenBody, + header: tokenHead, + ); + + /// Sign the JWT using private key + var dpopToken = jwt.sign(RSAPrivateKey(rsaKeyPair.privateKey), + algorithm: JWTAlgorithm.RS256); + + return dpopToken; +} diff --git a/lib/solid_auth_issuer.dart b/lib/src/solid_auth_issuer.dart similarity index 95% rename from lib/solid_auth_issuer.dart rename to lib/src/solid_auth_issuer.dart index 8842823..d7c83e1 100644 --- a/lib/solid_auth_issuer.dart +++ b/lib/src/solid_auth_issuer.dart @@ -1,4 +1,7 @@ -part of 'solid_auth.dart'; +import 'dart:async'; + +/// Package imports: +import 'package:http/http.dart' as http; /// Get POD issuer URI Future getIssuer(String textUrl) async { diff --git a/pubspec.yaml b/pubspec.yaml index 75ef053..d19161f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: solid_auth description: Authentication library to connect to a Solid server from solidproject.org -version: 0.1.27 +version: 0.2.0-dev.1 homepage: https://github.com/anusii/solid_auth environment: @@ -11,22 +11,17 @@ dependencies: flutter: sdk: flutter - clock: ^1.1.2 collection: ^1.19.1 convert: ^3.1.2 crypto: ^3.0.6 fast_rsa: ^3.8.1 http: ^1.3.0 - intl: ^0.20.2 - jose: ^0.3.4 - jwt_decoder: ^2.0.1 logging: ^1.3.0 - openidconnect_platform_interface: ^1.0.17 - openidconnect_web: ^1.0.26 - pointycastle: ^3.9.1 - url_launcher: ^6.3.1 - url_launcher_android: ^6.3.15 + # FIXME: why was pointycastle downgraded to ^3.9.1 before? + pointycastle: '>=3.9.1 <5.0.0' uuid: ^4.5.1 + oidc: ^0.12.1+2 + oidc_default_store: ^0.4.0+3 dev_dependencies: flutter_test: @@ -47,3 +42,10 @@ flutter: - packages/fast_rsa/web/assets/worker.js - packages/fast_rsa/web/assets/wasm_exec.js - packages/fast_rsa/web/assets/rsa.wasm + +dependency_overrides: + oidc_core: + git: + url: https://github.com/kkalass/oidc.git + ref: fix/oidc-hook-execute-stack-overflow + path: packages/oidc_core From 4c398cd02007adfe4f2029e960c553bb804dc3ef Mon Sep 17 00:00:00 2001 From: Klas Kalass Date: Sun, 3 Aug 2025 23:00:35 +0200 Subject: [PATCH 02/11] Added API Documentation --- README.md | 12 + lib/solid_auth.dart | 157 +++++ lib/src/oidc/solid_oidc_user_manager.dart | 531 ++++++++++++++++- lib/src/solid_auth.dart | 687 ++++++++++++++++++++-- 4 files changed, 1337 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 6f2a599..0a6444f 100644 --- a/README.md +++ b/README.md @@ -131,3 +131,15 @@ In order to successfully run `solid auth` in a web application you also need to ``` + +## Roadmap + +### Offline-First Support +Currently, `solid_auth` requires network connectivity during initialization to: +- Discover identity providers from WebID profiles +- Fetch OIDC provider configurations +- Validate authentication sessions + +**Future Goal**: Enable fully offline-first applications that can start and function without network connectivity, using cached authentication data and provider configurations. + +This is essential for truly offline-capable Solid applications, but requires careful consideration of security trade-offs and cache management strategies. \ No newline at end of file diff --git a/lib/solid_auth.dart b/lib/solid_auth.dart index 3aac39d..e1fc433 100644 --- a/lib/solid_auth.dart +++ b/lib/solid_auth.dart @@ -1,3 +1,160 @@ +/// A Flutter library for authenticating with Solid pods using OpenID Connect. +/// +/// This library provides a simple, reactive interface for Solid authentication +/// that handles the complexity of Solid-OIDC flows, token management, WebID discovery, +/// and DPoP (Demonstration of Proof-of-Possession) tokens required by Solid servers. +/// +/// ## What is Solid? +/// +/// Solid is a web decentralization project that gives users control over their +/// data by storing it in personal data pods. Users authenticate with identity +/// providers and grant applications access to specific data in their pods. +/// +/// ## Key Features +/// +/// - **Reactive Authentication State**: Use `ValueListenable` to update UI when +/// authentication status changes +/// - **Automatic Session Restoration**: Persists login across app restarts +/// - **Multi-Platform Support**: Works on web, mobile, and macOS (Windows/Linux have redirect URI limitations) +/// - **DPoP Token Support**: Handles security tokens required by Solid servers +/// - **WebID Discovery**: Automatically finds identity providers from WebIDs +/// - **Secure Token Storage**: Uses platform-appropriate secure storage +/// +/// ## Quick Start +/// +/// ```dart +/// import 'package:solid_auth/solid_auth.dart'; +/// +/// // 1. Initialize SolidAuth with your client configuration +/// final solidAuth = SolidAuth( +/// oidcClientId: 'https://myapp.com/client-profile.jsonld', +/// appUrlScheme: 'com.mycompany.myapp', +/// frontendRedirectUrl: Uri.parse('https://myapp.com/auth/callback.html'), +/// ); +/// +/// // 2. Initialize and check for existing session +/// await solidAuth.init(); +/// +/// // 3. Build reactive UI +/// ValueListenableBuilder( +/// valueListenable: solidAuth.isAuthenticatedNotifier, +/// builder: (context, isAuthenticated, child) { +/// if (isAuthenticated) { +/// return Text('Welcome, ${solidAuth.currentWebId}!'); +/// } else { +/// return ElevatedButton( +/// onPressed: () => authenticate(), +/// child: Text('Login with Solid'), +/// ); +/// } +/// }, +/// ); +/// +/// // 4. Authenticate user +/// Future authenticate() async { +/// try { +/// final result = await solidAuth.authenticate( +/// 'https://alice.solidcommunity.net/profile/card#me' +/// ); +/// print('Authenticated as: ${result.webId}'); +/// } catch (e) { +/// print('Authentication failed: $e'); +/// } +/// } +/// +/// // 5. Make authenticated API requests +/// Future fetchData() async { +/// final dpop = solidAuth.genDpopToken( +/// 'https://alice.solidcommunity.net/private/data.ttl', +/// 'GET' +/// ); +/// +/// final response = await http.get( +/// Uri.parse('https://alice.solidcommunity.net/private/data.ttl'), +/// headers: { +/// ...dpop.httpHeaders(), // DPoP authentication headers +/// 'Accept': 'text/turtle', // Specify desired RDF format +/// 'User-Agent': 'MyApp/1.0', +/// }, +/// ); +/// } +/// ``` +/// +/// ## Client Configuration +/// +/// Your application needs a public client profile document (also called a client +/// identifier document or client configuration) typically named `client-profile.jsonld` +/// that describes your app to Solid identity providers. +/// +/// ### Redirect URI Construction +/// +/// The redirect URIs must match the patterns used by SolidAuth based on your platform: +/// +/// **For `redirect_uris`:** +/// - Web: The exact `frontendRedirectUrl` you provide to SolidAuth +/// - Mobile/Desktop: `{appUrlScheme}://redirect` +/// +/// **For `post_logout_redirect_uris`:** +/// - Web: The exact `frontendRedirectUrl` you provide to SolidAuth +/// - Mobile/Desktop: `{appUrlScheme}://logout` +/// +/// ### Required Scopes +/// +/// The `scope` field **must** include these mandatory scopes: +/// - `openid`: Required for OpenID Connect authentication +/// - `webid`: Required for Solid WebID functionality +/// - `offline_access`: Required for token refresh capability +/// +/// Additional scopes like `profile` can be included as needed. +/// +/// ### Example Configuration +/// +/// ```json +/// { +/// "@context": "https://www.w3.org/ns/solid/oidc-context.jsonld", +/// "client_id": "https://myapp.com/client-profile.jsonld", +/// "client_name": "My Solid App", +/// "application_type": "native", +/// "redirect_uris": [ +/// "https://myapp.com/auth/callback.html", +/// "com.mycompany.myapp://redirect" +/// ], +/// "post_logout_redirect_uris": [ +/// "https://myapp.com/auth/callback.html", +/// "com.mycompany.myapp://logout" +/// ], +/// "scope": "openid webid offline_access profile", +/// "grant_types": ["authorization_code", "refresh_token"], +/// "response_types": ["code"], +/// "token_endpoint_auth_method": "none" +/// } +/// ``` +/// +/// ## Security Considerations +/// +/// - **HTTPS Required**: All redirect URIs must use HTTPS in production +/// - **Client Registration**: All redirect URIs must be pre-registered in your +/// client profile document +/// - **DPoP Tokens**: Generate fresh DPoP tokens for each API request +/// - **Token Storage**: The library uses secure platform storage for tokens +/// - **WebID Validation**: WebIDs are validated by fetching their profile documents +/// and verifying the declared identity providers match the authentication source +/// +/// ## Platform-Specific Setup +/// +/// This library builds on the `oidc` package. For detailed platform-specific +/// setup instructions, see the [OIDC Getting Started Guide](https://bdaya-dev.github.io/oidc/oidc-getting-started/). +/// +/// ### All Platforms +/// - Create and host your `client-profile.jsonld` on HTTPS +/// - Ensure all redirect URIs are declared in your client profile document +/// - Set the `client_id` field to the URL where your client profile document is hosted +/// +/// ## Learn More +/// +/// - [Solid Project](https://solidproject.org/) +/// - [Solid OIDC Specification](https://solid.github.io/solid-oidc/) +/// - [WebID Specification](https://www.w3.org/2005/Incubator/webid/spec/identity/) library solid_auth; export 'src/solid_auth.dart'; diff --git a/lib/src/oidc/solid_oidc_user_manager.dart b/lib/src/oidc/solid_oidc_user_manager.dart index 5cd44d4..ac72fc8 100644 --- a/lib/src/oidc/solid_oidc_user_manager.dart +++ b/lib/src/oidc/solid_oidc_user_manager.dart @@ -9,25 +9,174 @@ import 'package:solid_auth/src/solid_auth_issuer.dart' as solid_auth_issuer; final _log = Logger("solid_authentication_oidc"); +/// Contains DPoP token and access token for authenticated API requests to Solid servers. +/// +/// DPoP (Demonstration of Proof-of-Possession) is a security mechanism required +/// by Solid servers to prove that the client making an API request is the same +/// client to which the access token was issued. This prevents token theft and replay attacks. +/// +/// ## Usage +/// +/// Typically obtained from [SolidAuth.genDpopToken] and used to make authenticated +/// requests to Solid pod resources. +/// +/// ```dart +/// final dpop = solidAuth.genDpopToken('https://alice.pod.com/data/', 'GET'); +/// +/// // Use convenience method with additional headers +/// final response = await http.get( +/// Uri.parse('https://alice.pod.com/data/'), +/// headers: { +/// ...dpop.httpHeaders(), +/// 'Accept': 'text/turtle', +/// }, +/// ); +/// +/// // Or construct headers manually +/// final response = await http.get( +/// Uri.parse('https://alice.pod.com/data/'), +/// headers: { +/// 'Authorization': 'DPoP ${dpop.accessToken}', +/// 'DPoP': dpop.dpopToken, +/// 'Accept': 'text/turtle', +/// }, +/// ); +/// ``` class DPoP { + /// The DPoP JWT token that proves possession of the access token. + /// + /// This is a signed JWT that includes: + /// - The HTTP method and URL being accessed + /// - A unique nonce to prevent replay attacks + /// - A timestamp showing when the token was created + /// - The public key corresponding to the private key used for signing final String dpopToken; + + /// The OAuth2 access token for the authenticated user. + /// + /// This token grants access to resources but must be accompanied by the + /// [dpopToken] to prove possession when making requests to Solid servers. final String accessToken; DPoP({required this.dpopToken, required this.accessToken}); + /// Returns HTTP headers formatted for Solid API requests. + /// + /// This is the recommended way to use DPoP tokens with HTTP clients. + /// The returned map contains: + /// - `Authorization`: 'DPoP {accessToken}' + /// - `DPoP`: The DPoP JWT token + /// + /// ## Example + /// ```dart + /// final dpop = solidAuth.genDpopToken('https://alice.pod.com/data/', 'GET'); + /// final response = await http.get( + /// Uri.parse('https://alice.pod.com/data/'), + /// headers: { + /// ...dpop.httpHeaders(), + /// 'Accept': 'text/turtle', + /// }, + /// ); + /// ``` Map httpHeaders() => { 'Authorization': 'DPoP $accessToken', 'DPoP': dpopToken, }; } +/// Contains the authentication result with both OIDC user data and validated WebID. +/// +/// This class is returned by [SolidAuth.authenticate] and contains all the +/// information needed to work with the authenticated user in the Solid ecosystem. +/// +/// ## WebID vs User Data +/// +/// - **WebID**: A unique identifier for the user in the Solid ecosystem, typically +/// an HTTPS URL pointing to their profile document (e.g., 'https://alice.solidcommunity.net/profile/card#me') +/// +/// - **User Data**: Standard OIDC user information including tokens, claims, and +/// profile information from the identity provider +/// +/// ## Example +/// ```dart +/// final result = await solidAuth.authenticate('https://alice.solidcommunity.net/profile/card#me'); +/// +/// print('WebID: ${result.webId}'); +/// print('Provider: ${result.oidcUser.claims.issuer}'); +/// print('Subject: ${result.oidcUser.claims.subject}'); +/// print('Access token expires: ${result.oidcUser.token.expiresAt}'); +/// ``` class UserAndWebId { - final OidcUser user; + /// The OIDC user object containing tokens, claims, and profile information. + /// + /// This object provides access to: + /// - Access tokens for API requests + /// - ID token with user claims + /// - Refresh tokens for maintaining the session + /// - User profile information from the identity provider + /// + /// Use this primarily for accessing tokens and provider-specific user data. + final OidcUser oidcUser; + + /// The validated WebID of the authenticated user. + /// + /// A WebID is a globally unique identifier for a person or agent in the + /// Solid ecosystem. It's an HTTPS URL that points to the user's profile + /// document, which contains information about the user and their data. + /// + /// This WebID has been validated to ensure: + /// 1. It's properly formatted as an HTTPS URL + /// 2. The associated profile is accessible + /// 3. The profile confirms the identity provider is authorized for this WebID + /// + /// Use this for identifying the user across different Solid applications + /// and for constructing URLs to the user's pod resources. final String webId; - UserAndWebId({required this.user, required this.webId}); + UserAndWebId({required this.oidcUser, required this.webId}); } +/// Function type for customizing how WebID or issuer strings are resolved to issuer URIs. +/// +/// This function is called when the library needs to determine the OIDC issuer +/// (identity provider) for a given WebID or issuer string. +/// +/// ## Parameters +/// +/// - The input string, which could be: +/// - A WebID URL (e.g., 'https://alice.solidcommunity.net/profile/card#me') +/// - An issuer URL (e.g., 'https://solidcommunity.net') +/// - Any other string that might identify an identity provider +/// +/// ## Return Value +/// +/// Should return a list of possible issuer URIs to try, in order of preference. +/// The library will currently use the first one. +/// +/// ## Default Behavior +/// +/// If not provided, the library will: +/// 1. If the input looks like a WebID, fetch the profile document and extract +/// the `solid:oidcIssuer` property +/// 2. Otherwise, treat the input as an issuer URI directly +/// +/// ## Custom Implementation Example +/// +/// ```dart +/// Future> customIssuerResolver(String webIdOrIssuer) async { +/// if (webIdOrIssuer.contains('example.com')) { +/// // Custom logic for example.com domains +/// return [Uri.parse('https://auth.example.com')]; +/// } +/// +/// // Fall back to default behavior +/// return [Uri.parse(webIdOrIssuer)]; +/// } +/// +/// final settings = SolidAuthSettings( +/// getIssuers: customIssuerResolver, +/// ); +/// ``` typedef GetIssuers = Future> Function(String webIdOrIssuer); Future> _getIssuersDefault(String webIdOrIssuer) async { @@ -51,8 +200,47 @@ class _RsaInfo { }); } +/// Advanced configuration settings for the OIDC authentication flow in Solid applications. +/// +/// This class provides fine-grained control over the OpenID Connect authentication +/// process, including security settings, token management, and platform-specific +/// behaviors. It extends the standard OIDC configuration with Solid-specific +/// requirements and optimizations. +/// +/// ## Usage +/// +/// Typically used internally by [SolidAuth], but may be exposed for advanced +/// use cases requiring custom OIDC flow configuration: +/// +/// ```dart +/// final settings = SolidOidcUserManagerSettings( +/// redirectUri: Uri.parse('https://myapp.com/callback'), +/// extraScopes: ['profile', 'email'], +/// strictJwtVerification: true, +/// supportOfflineAuth: false, +/// refreshBefore: Duration(minutes: 5), +/// ); +/// ``` +/// +/// ## Security Considerations +/// +/// - **JWT Verification**: Enable [strictJwtVerification] in production +/// - **Offline Auth**: Disable [supportOfflineAuth] unless specifically needed +/// - **Token Refresh**: Configure [refreshBefore] to prevent token expiration +/// - **Redirect URIs**: Ensure all URIs are registered with your identity provider +/// +/// ## Solid-Specific Defaults +/// +/// This class provides sensible defaults for Solid OIDC: +/// - Default scopes: `['openid', 'webid', 'offline_access']` +/// - WebID discovery integration via [getIssuers] +/// - DPoP token support for enhanced security +/// - Automatic session restoration capabilities class SolidOidcUserManagerSettings { + /// Creates a new instance of [SolidOidcUserManagerSettings]. /// + /// [redirectUri] is required and must be registered with your identity provider. + /// All other parameters have sensible defaults for Solid OIDC authentication. const SolidOidcUserManagerSettings({ required this.redirectUri, this.uiLocales, @@ -71,7 +259,7 @@ class SolidOidcUserManagerSettings { this.userInfoSettings = const OidcUserInfoSettings(), this.frontChannelRequestListeningOptions = const OidcFrontChannelRequestListeningOptions(), - this.refreshBefore = defaultRefreshBefore, + this.refreshBefore, this.strictJwtVerification = false, this.getExpiresIn, this.sessionManagementSettings = const OidcSessionManagementSettings(), @@ -83,7 +271,12 @@ class SolidOidcUserManagerSettings { this.getIssuers, }); - /// The default scopes + /// The default scopes required for Solid OIDC authentication. + /// + /// These scopes provide: + /// - `openid`: Basic OpenID Connect functionality + /// - `webid`: Access to the user's WebID (Solid-specific) + /// - `offline_access`: Ability to refresh tokens when the user is offline static const defaultScopes = ['openid', 'webid', 'offline_access']; /// Settings to control using the user_info endpoint. @@ -170,7 +363,15 @@ class SolidOidcUserManagerSettings { /// overrides a token's expires_in value. final Duration? Function(OidcTokenResponse tokenResponse)? getExpiresIn; - /// pass this function to control how a webIdOrIssuer is resoled to the issuer URI. + /// Custom function for resolving WebIDs or issuer strings to identity provider URIs. + /// + /// This function overrides the default WebID-to-issuer discovery process. + /// See [GetIssuers] typedef for detailed documentation and examples. + /// + /// When `null`, the library uses the standard Solid WebID discovery: + /// 1. Fetch the WebID profile document + /// 2. Extract the `solid:oidcIssuer` property + /// 3. Use that as the identity provider URI final GetIssuers? getIssuers; /// pass this function to control how an `id_token` is fetched from a @@ -256,24 +457,88 @@ class SolidOidcUserManagerSettings { } } +/// Low-level OIDC user manager with Solid-specific enhancements. +/// +/// This class provides direct access to the underlying OIDC authentication +/// mechanisms with Solid pod integration. It handles WebID discovery, DPoP +/// token generation, and secure session management. +/// +/// ## Internal Implementation +/// +/// This class is typically used internally by [SolidAuth] but may be exposed +/// for advanced use cases requiring fine-grained control over the authentication +/// flow, such as: +/// +/// - Custom identity provider discovery logic +/// - Advanced token lifecycle management +/// - Integration with non-standard OIDC providers +/// +/// ## Key Features +/// +/// - **WebID Integration**: Automatically resolves WebIDs to identity providers +/// - **DPoP Support**: Generates and manages DPoP tokens for enhanced security +/// - **Session Persistence**: Maintains authentication state across app restarts +/// - **Flexible Configuration**: Supports extensive OIDC customization options +/// +/// ## Usage Example +/// +/// ```dart +/// final manager = SolidOidcUserManager( +/// clientId: 'https://myapp.com/client-profile.jsonld', +/// webIdOrIssuer: 'https://alice.solidcommunity.net/profile/card#me', +/// store: OidcMemoryStore(), // or OidcDefaultStore() for persistence +/// settings: SolidOidcUserManagerSettings( +/// redirectUri: Uri.parse('https://myapp.com/callback'), +/// ), +/// ); +/// +/// await manager.init(); +/// final result = await manager.loginAuthorizationCodeFlow(); +/// final dpop = manager.genDpopToken('https://alice.pod.com/data', 'GET'); +/// ``` +/// +/// ## Security Considerations +/// +/// - DPoP keys are automatically generated and securely stored +/// - WebID validation ensures identity provider authorization +/// - Token refresh is handled automatically with configurable timing +/// - All tokens are stored using platform-appropriate secure storage class SolidOidcUserManager { Uri? _issuerUri; OidcUserManager? _manager; - /// The WebID or issuer URL. + /// The WebID or issuer URL used for authentication discovery. final String _webIdOrIssuer; - /// The store responsible for setting/getting cached values. + /// The persistent store for caching tokens, keys, and session data. + /// + /// This store handles secure persistence of: + /// - Access and refresh tokens + /// - DPoP cryptographic key pairs + /// - User session information + /// - OIDC discovery metadata + /// + /// Use [OidcDefaultStore] for production apps with persistent storage, + /// or [OidcMemoryStore] for testing or non-persistent scenarios. final OidcStore store; final String? _id; - /// The http client to use when sending requests + /// The HTTP client used for making authentication and API requests. + /// + /// If not provided, a default HTTP client will be used. Custom clients + /// can be provided for proxy support, custom headers, or request monitoring. final http.Client? _httpClient; + final String _clientId; - /// The id_token verification options. + /// The cryptographic key store for JWT token verification. + /// + /// Contains public keys from identity providers used to verify + /// the authenticity and integrity of JWT tokens. Keys are typically + /// fetched automatically from the provider's JWKS endpoint. final JsonWebKeyStore? _keyStore; + final SolidOidcUserManagerSettings _settings; // DPoP key pair management - using solid_auth generated keys @@ -284,6 +549,31 @@ class SolidOidcUserManager { // Storage keys for persisting SOLID-specific data static const String _rsaInfoKey = 'solid_rsa_info'; + /// Creates a new [SolidOidcUserManager] instance. + /// + /// ## Parameters + /// + /// - [clientId]: Your application's client identifier (typically a URL to your client profile document) + /// - [webIdOrIssuer]: The user's WebID or the identity provider's issuer URI + /// - [store]: Persistent storage for tokens and session data + /// - [settings]: Configuration options for the OIDC flow + /// - [id]: Optional identifier for this manager instance (useful for multiple accounts) + /// - [httpClient]: Optional custom HTTP client for requests + /// - [keyStore]: Optional custom key store for JWT verification + /// + /// ## Example + /// + /// ```dart + /// final manager = SolidOidcUserManager( + /// clientId: 'https://myapp.com/client-profile.jsonld', + /// webIdOrIssuer: 'https://alice.solidcommunity.net/profile/card#me', + /// store: OidcDefaultStore(), + /// settings: SolidOidcUserManagerSettings( + /// redirectUri: Uri.parse('com.myapp://callback'), + /// strictJwtVerification: true, + /// ), + /// ); + /// ``` SolidOidcUserManager({ required String clientId, required String webIdOrIssuer, @@ -299,10 +589,65 @@ class SolidOidcUserManager { _httpClient = httpClient, _clientId = clientId; - /// The current authenticated user, if any + /// The currently authenticated OIDC user, or `null` if not authenticated. + /// + /// This object contains: + /// - Access and refresh tokens + /// - User claims from the ID token + /// - Profile information from UserInfo endpoint + /// - Token expiration and refresh status + /// + /// Use this to access OIDC-specific user information and tokens. OidcUser? get currentUser => _manager?.currentUser; + + /// The currently authenticated user's WebID, or `null` if not authenticated. + /// + /// The WebID is the Solid-specific user identifier that was validated + /// during authentication. This is the primary identifier for the user + /// in the Solid ecosystem and should be used for: + /// - Identifying the user across Solid applications + /// - Constructing URLs to the user's pod resources + /// - Access control and authorization decisions + /// + /// Example: `'https://alice.solidcommunity.net/profile/card#me'` String? get currentWebId => _currentWebId; + /// Initializes the user manager and attempts to restore any existing session. + /// + /// This method must be called before any other authentication operations. + /// It performs the following steps: + /// + /// 1. **Identity Provider Discovery**: Resolves the WebID or issuer to determine + /// the appropriate OIDC identity provider + /// 2. **OIDC Configuration**: Fetches the provider's OpenID configuration + /// 3. **Key Pair Management**: Restores or generates DPoP cryptographic keys + /// 4. **Session Restoration**: Attempts to restore any existing authentication session + /// 5. **WebID Validation**: If a session exists, validates the WebID against the provider + /// + /// ## Authentication State + /// + /// After initialization, check [currentUser] and [currentWebId] to determine + /// if the user is already authenticated from a previous session. + /// + /// ## Error Handling + /// + /// ```dart + /// try { + /// await manager.init(); + /// if (manager.currentUser != null) { + /// print('Restored session for: ${manager.currentWebId}'); + /// } + /// } catch (e) { + /// print('Initialization failed: $e'); + /// // Handle provider discovery or configuration errors + /// } + /// ``` + /// + /// ## Performance Notes + /// + /// - Network requests are made to discover provider configuration + /// - Cryptographic key generation may occur on first use + /// - Existing sessions are validated against current provider settings Future init() async { if (_manager != null) { await logout(); @@ -423,6 +768,50 @@ class SolidOidcUserManager { _log.info('DPoP RSA key pair generated and persisted'); } + /// Initiates the OAuth2 Authorization Code Flow for user authentication. + /// + /// This method starts the standard OIDC authentication process: + /// + /// 1. **Redirect to Provider**: Opens the identity provider's login page + /// 2. **User Authentication**: User enters credentials and grants consent + /// 3. **Authorization Code**: Provider redirects back with authorization code + /// 4. **Token Exchange**: Code is exchanged for access and ID tokens + /// 5. **WebID Extraction**: WebID is extracted and validated from tokens + /// 6. **DPoP Integration**: Tokens are enhanced with DPoP proof-of-possession + /// + /// ## Platform Behavior + /// + /// - **Web**: Opens provider login in the same window or popup + /// - **Mobile**: Launches system browser or in-app WebView + /// - **Desktop**: Opens default system browser + /// + /// ## Return Value + /// + /// Returns [UserAndWebId] containing both the OIDC user data and the + /// validated Solid WebID, or `null` if the user cancels authentication. + /// + /// ## Error Handling + /// + /// ```dart + /// try { + /// final result = await manager.loginAuthorizationCodeFlow(); + /// if (result != null) { + /// print('Authenticated as: ${result.webId}'); + /// print('Provider: ${result.user.claims.issuer}'); + /// } + /// } on OidcException catch (e) { + /// // Handle OIDC-specific errors (network, configuration, etc.) + /// } on Exception catch (e) { + /// // Handle WebID validation or other authentication errors + /// } + /// ``` + /// + /// ## Security Notes + /// + /// - WebID profile document is fetched to validate the identity provider is authorized for this WebID + /// - RSA key pairs for DPoP token generation are automatically created and securely stored + /// - All tokens are stored using platform-appropriate secure storage + /// - Session state is automatically persisted for future app launches Future loginAuthorizationCodeFlow() async { final oidcUser = await _manager!.loginAuthorizationCodeFlow(); if (oidcUser == null) { @@ -432,7 +821,7 @@ class SolidOidcUserManager { // Extract WebID from the OIDC token using the Solid-OIDC spec methods String webId = await _extractAndValidateWebId(oidcUser); _currentWebId = webId; - return UserAndWebId(user: oidcUser, webId: webId); + return UserAndWebId(oidcUser: oidcUser, webId: webId); } Future _extractAndValidateWebId(OidcUser oidcUser) async { @@ -480,6 +869,61 @@ class SolidOidcUserManager { url, rsaKeyPair, publicKeyJwk, method); } + /// Generates a DPoP (Demonstration of Proof-of-Possession) token for API requests. + /// + /// DPoP tokens are required by Solid servers to prove that the client making + /// an API request is the same client that was issued the access token. This + /// prevents token theft and replay attacks. + /// + /// ## Parameters + /// + /// - [url]: The complete URL of the API endpoint being accessed + /// - [method]: The HTTP method being used ('GET', 'POST', 'PUT', 'DELETE', etc.) + /// + /// ## Return Value + /// + /// Returns a [DPoP] object containing both the DPoP proof token and the + /// access token, ready for use in HTTP requests. + /// + /// ## Usage Example + /// + /// ```dart + /// // Generate DPoP token for a specific request + /// final dpop = manager.genDpopToken( + /// 'https://alice.solidcommunity.net/profile/card', + /// 'GET' + /// ); + /// + /// // Use with HTTP client + /// final response = await http.get( + /// Uri.parse('https://alice.solidcommunity.net/profile/card'), + /// headers: dpop.httpHeaders(), + /// ); + /// ``` + /// + /// ## Security Requirements + /// + /// - Must be called only after successful authentication + /// - Each DPoP token is tied to a specific URL and HTTP method + /// - Tokens should be generated fresh for each API request + /// - The underlying RSA key pair is automatically managed and persisted + /// + /// ## Error Conditions + /// + /// Throws [Exception] if: + /// - No user is currently authenticated + /// - Access token is not available or expired + /// - DPoP key pair generation failed + /// + /// ```dart + /// try { + /// final dpop = manager.genDpopToken(url, 'GET'); + /// // Use dpop for API request + /// } catch (e) { + /// // Handle authentication or key generation errors + /// print('DPoP generation failed: $e'); + /// } + /// ``` DPoP genDpopToken(String url, String method) { if (_manager?.currentUser?.token.accessToken == null) { throw Exception('No access token available for DPoP generation'); @@ -493,6 +937,43 @@ class SolidOidcUserManager { return DPoP(dpopToken: dpopToken, accessToken: accessToken); } + /// Logs out the current user and clears all authentication data. + /// + /// This method performs a complete logout process: + /// + /// 1. **Provider Logout**: Notifies the identity provider of the logout (if supported) + /// 2. **Local Cleanup**: Clears all cached authentication data + /// 3. **Key Cleanup**: Removes DPoP cryptographic key pairs + /// 4. **Session Termination**: Ensures no residual authentication state + /// + /// **Note**: Token revocation depends on the underlying OIDC library implementation + /// and identity provider support. Check your provider's documentation for revocation capabilities. + /// + /// ## Post-Logout State + /// + /// After logout: + /// - [currentUser] returns `null` + /// - [currentWebId] returns `null` + /// - All tokens and keys are securely erased + /// - A new authentication flow is required for future API access + /// + /// ## Usage Example + /// + /// ```dart + /// await manager.logout(); + /// print('User logged out successfully'); + /// + /// // Verify logout state + /// assert(manager.currentUser == null); + /// assert(manager.currentWebId == null); + /// ``` + /// + /// ## Security Notes + /// + /// - Logout is performed securely with the identity provider when possible + /// - All cryptographic material is securely erased from local storage + /// - Network failures during provider logout don't prevent local cleanup + /// - Multiple logout calls are safe and idempotent Future logout() async { await _manager?.logout(); _currentWebId = null; @@ -502,6 +983,34 @@ class SolidOidcUserManager { await _clearPersistedRsaInfo(); } + /// Disposes of all resources and cleans up the user manager. + /// + /// This method should be called when the user manager is no longer needed, + /// typically when the application is shutting down or switching user contexts. + /// + /// ## Cleanup Operations + /// + /// - Releases HTTP client resources + /// - Closes any open authentication flows + /// - Disposes of the underlying OIDC manager + /// - Clears all internal state references + /// + /// ## Usage + /// + /// ```dart + /// // Clean shutdown + /// await manager.logout(); // Optional: logout first + /// await manager.dispose(); // Required: dispose resources + /// + /// // Manager is no longer usable after dispose + /// ``` + /// + /// ## Important Notes + /// + /// - Call [logout] first if you want to perform a clean logout + /// - The manager cannot be used after disposal + /// - This method does not automatically logout the user + /// - Multiple dispose calls are safe and idempotent Future dispose() async { await _manager?.dispose(); _manager = null; diff --git a/lib/src/solid_auth.dart b/lib/src/solid_auth.dart index f418d5f..13a1102 100644 --- a/lib/src/solid_auth.dart +++ b/lib/src/solid_auth.dart @@ -8,10 +8,41 @@ import 'package:solid_auth/src/oidc/solid_oidc_user_manager.dart'; export 'package:solid_auth/src/oidc/solid_oidc_user_manager.dart' show DPoP, UserAndWebId; +/// The default refresh behavior: refresh tokens 1 minute before they expire. +/// +/// This matches the default behavior from the underlying OIDC library. +Duration? defaultRefreshBefore(OidcToken token) { + return const Duration(minutes: 1); +} + final _log = Logger("solid_authentication_oidc"); +/// Configuration settings for Solid authentication. +/// +/// This class wraps and extends [OidcUserManagerSettings] with Solid-specific +/// functionality. Most fields correspond directly to properties in the underlying +/// OIDC library - see the [OIDC package documentation](https://bdaya-dev.github.io/oidc/oidc-usage/) +/// for detailed descriptions of these settings. +/// +/// Most applications can use the default constructor without parameters, +/// which provides sensible defaults for typical Solid authentication scenarios. +/// +/// ## Example +/// ```dart +/// // Use default settings +/// final settings = SolidAuthSettings(); +/// +/// // Customize specific settings +/// final customSettings = SolidAuthSettings( +/// strictJwtVerification: true, +/// expiryTolerance: Duration(minutes: 2), +/// prompt: ['consent'], +/// ); +/// ``` class SolidAuthSettings { + /// Creates authentication settings with the specified options. /// + /// All parameters are optional and have sensible defaults for most use cases. const SolidAuthSettings({ this.uiLocales, this.extraTokenHeaders, @@ -36,87 +67,127 @@ class SolidAuthSettings { this.getIssuers, }); - /// Settings to control using the user_info endpoint. + /// Settings for requesting user information from the OIDC provider. + /// + /// See [OidcUserInfoSettings] in the OIDC package documentation. final OidcUserInfoSettings userInfoSettings; - /// whether JWTs are strictly verified. + /// Whether JSON Web Tokens (JWTs) should be strictly verified. /// - /// If set to true, the library will throw an exception if a JWT is invalid. + /// See [OidcUserManagerSettings.strictJwtVerification] in the OIDC package documentation. final bool strictJwtVerification; - /// Whether to support offline authentication or not. + /// Whether to support offline authentication when the network is unavailable. /// - /// When this option is enabled, expired tokens will NOT be removed if the - /// server can't be contacted - /// - /// This parameter is disabled by default due to security concerns. + /// See [OidcUserManagerSettings.supportOfflineAuth] in the OIDC package documentation. final bool supportOfflineAuth; - /// see [OidcAuthorizeRequest.prompt]. + /// Controls what prompts the user sees during authentication. + /// + /// See [OidcUserManagerSettings.prompt] in the OIDC package documentation. final List prompt; - /// see [OidcAuthorizeRequest.display]. + /// How the authentication user interface should be displayed. + /// + /// See [OidcUserManagerSettings.display] in the OIDC package documentation. final String? display; - /// see [OidcAuthorizeRequest.uiLocales]. + /// Preferred languages for the authentication user interface. + /// + /// See [OidcUserManagerSettings.uiLocales] in the OIDC package documentation. final List? uiLocales; - /// see [OidcAuthorizeRequest.acrValues]. + /// Authentication Context Class Reference values that the client is requesting. + /// + /// See [OidcUserManagerSettings.acrValues] in the OIDC package documentation. final List? acrValues; - /// see [OidcAuthorizeRequest.maxAge] + /// Maximum authentication age allowed. + /// + /// See [OidcUserManagerSettings.maxAge] in the OIDC package documentation. final Duration? maxAge; - /// see [OidcAuthorizeRequest.extra] + /// Additional parameters to include in authentication requests. + /// + /// See [OidcUserManagerSettings.extraAuthenticationParameters] in the OIDC package documentation. final Map? extraAuthenticationParameters; - /// see [OidcTokenRequest.extra] + /// Additional HTTP headers to include in token requests. + /// + /// See [OidcUserManagerSettings.extraTokenHeaders] in the OIDC package documentation. final Map? extraTokenHeaders; - /// see [OidcTokenRequest.extra] + /// Additional parameters to include in token requests. + /// + /// See [OidcUserManagerSettings.extraTokenParameters] in the OIDC package documentation. final Map? extraTokenParameters; - /// see [OidcRevocationRequest.extra] + /// Additional parameters to include in token revocation requests. + /// + /// See [OidcUserManagerSettings.extraRevocationParameters] in the OIDC package documentation. final Map? extraRevocationParameters; - /// Extra headers to send with the revocation request. + /// Additional HTTP headers to include in token revocation requests. + /// + /// See [OidcUserManagerSettings.extraRevocationHeaders] in the OIDC package documentation. final Map? extraRevocationHeaders; - /// see [OidcIdTokenVerificationOptions.expiryTolerance]. + /// Time buffer for token expiry validation. + /// + /// See [OidcUserManagerSettings.expiryTolerance] in the OIDC package documentation. final Duration expiryTolerance; - /// Settings related to the session management spec. + /// Configuration for OIDC session management features. + /// + /// See [OidcUserManagerSettings.sessionManagementSettings] in the OIDC package documentation. final OidcSessionManagementSettings sessionManagementSettings; - /// How early the token gets refreshed. - /// - /// for example: + /// Controls when access tokens are automatically refreshed. /// - /// - if `Duration.zero` is returned, the token gets refreshed once it's expired. - /// - (default) if `Duration(minutes: 1)` is returned, it will refresh the token 1 minute before it expires. - /// - if `null` is returned, automatic refresh is disabled. + /// See [OidcUserManagerSettings.refreshBefore] in the OIDC package documentation. final OidcRefreshBeforeCallback? refreshBefore; - /// overrides a token's expires_in value. + /// Custom function to override token expiration times. + /// + /// See [OidcUserManagerSettings.getExpiresIn] in the OIDC package documentation. final Duration? Function(OidcTokenResponse tokenResponse)? getExpiresIn; - /// pass this function to control how a webIdOrIssuer is resoled to the issuer URI. + /// Custom function to resolve WebID or issuer strings to issuer URIs. + /// + /// This function overrides the default WebID-to-issuer discovery process. + /// See [GetIssuers] typedef for detailed documentation and examples. + /// + /// When `null`, the library uses the standard Solid WebID discovery process. final GetIssuers? getIssuers; - /// pass this function to control how an `id_token` is fetched from a - /// token response. + /// Custom function to extract ID tokens from token responses. /// - /// This can be used to trick the user manager into using a JWT `access_token` - /// as an `id_token` for example. + /// See [OidcUserManagerSettings.getIdToken] in the OIDC package documentation. final Future Function(OidcToken token)? getIdToken; - /// platform-specific options. + /// Platform-specific configuration options. + /// + /// See [OidcUserManagerSettings.options] in the OIDC package documentation. final OidcPlatformSpecificOptions? options; - /// Customized hooks to modify the user manager behavior. + /// Custom hooks to modify authentication behavior. + /// + /// See [OidcUserManagerSettings.hooks] in the OIDC package documentation. final OidcUserManagerHooks? hooks; - /// Creates a copy of this [SolidOidcUserManagerSettings] with the given fields replaced with new values. + /// Creates a copy of this settings object with the given fields replaced. + /// + /// This is useful for creating variations of settings without modifying + /// the original object. + /// + /// ## Example + /// ```dart + /// final baseSettings = SolidAuthSettings(); + /// final strictSettings = baseSettings.copyWith( + /// strictJwtVerification: true, + /// expiryTolerance: Duration(seconds: 30), + /// ); + /// ``` SolidAuthSettings copyWith({ List? uiLocales, Map? extraTokenHeaders, @@ -171,12 +242,55 @@ class SolidAuthSettings { } } +/// URI configuration for Solid authentication redirects. +/// +/// This class configures the various redirect URIs used during the OIDC +/// authentication flow. Different URIs are used for different purposes: +/// +/// - **redirectUri**: Where users are sent after successful authentication +/// - **postLogoutRedirectUri**: Where users are sent after logging out +/// - **frontChannelLogoutUri**: Used for single sign-out notifications +/// +/// For most applications, you should use [SolidAuth.createUriSettings] to +/// generate appropriate settings automatically based on your platform. class SolidAuthUriSettings { + /// The URI where users will be redirected after successful authentication. + /// + /// This URI must be registered with your OIDC client configuration + /// (e.g., in your client-profile.jsonld file for Solid). final Uri redirectUri; + + /// The URI where users will be redirected after logging out. + /// + /// This should typically be your app's main page or login screen. final Uri postLogoutRedirectUri; + + /// The URI used for front-channel logout notifications. + /// + /// When single sign-out is triggered from another application, the identity + /// provider will make a request to this URI to notify your application + /// that the user has been logged out. final Uri frontChannelLogoutUri; + + /// Configuration for listening to front-channel logout requests. + /// + /// Controls how the library listens for and handles front-channel logout + /// notifications from the identity provider. final OidcFrontChannelRequestListeningOptions frontChannelRequestListeningOptions; + + /// Creates URI settings with the specified redirect URIs. + /// + /// All URIs must be properly registered with your OIDC client configuration. + /// + /// ## Example + /// ```dart + /// final uriSettings = SolidAuthUriSettings( + /// redirectUri: Uri.parse('https://myapp.com/auth/callback'), + /// postLogoutRedirectUri: Uri.parse('https://myapp.com/'), + /// frontChannelLogoutUri: Uri.parse('https://myapp.com/auth/logout'), + /// ); + /// ``` SolidAuthUriSettings({ required this.redirectUri, required this.postLogoutRedirectUri, @@ -186,6 +300,72 @@ class SolidAuthUriSettings { }); } +/// Main class for authenticating with Solid pods using OpenID Connect. +/// +/// [SolidAuth] provides a simplified, reactive interface for Solid authentication +/// that handles the complexity of OIDC flows, token management, and WebID discovery. +/// +/// ## Key Features +/// +/// - **Reactive Authentication State**: Use [isAuthenticatedNotifier] to reactively +/// update your UI based on authentication status +/// - **Automatic Session Restoration**: Persists authentication state across app restarts +/// - **DPoP Token Support**: Handles Demonstration of Proof-of-Possession tokens +/// required by Solid servers +/// - **Cross-Platform**: Works on web, mobile, and desktop with appropriate redirect handling +/// +/// ## Basic Usage +/// +/// ```dart +/// // Initialize SolidAuth +/// final solidAuth = SolidAuth( +/// oidcClientId: 'https://myapp.com/client-profile.jsonld', +/// appUrlScheme: 'myapp', +/// frontendRedirectUrl: Uri.parse('https://myapp.com/redirect.html'), +/// ); +/// +/// // Initialize and check for existing session +/// await solidAuth.init(); +/// +/// // Listen to authentication state changes +/// ValueListenableBuilder( +/// valueListenable: solidAuth.isAuthenticatedNotifier, +/// builder: (context, isAuthenticated, child) { +/// return isAuthenticated ? AuthenticatedView() : LoginView(); +/// }, +/// ); +/// +/// // Authenticate with a WebID or issuer +/// try { +/// final result = await solidAuth.authenticate('https://alice.solidcommunity.net/profile/card#me'); +/// print('Authenticated as: ${result.webId}'); +/// } catch (e) { +/// print('Authentication failed: $e'); +/// } +/// ``` +/// +/// ## Authentication Parameters +/// +/// The [oidcClientId] should point to a publicly accessible JSON-LD document +/// that describes your application according to the Solid OIDC specification. +/// This document must include: +/// +/// - `client_id`: The same URL as the document location +/// - `redirect_uris`: List of allowed redirect URIs +/// - `grant_types`: Typically `["authorization_code", "refresh_token"]` +/// - `scope`: Required scopes like `"openid profile webid"` +/// +/// ## Platform-Specific Behavior +/// +/// - **Web**: Uses HTML redirect pages for authentication callbacks +/// - **Mobile/Desktop**: Uses custom URL schemes for deep linking +/// +/// ## Security Considerations +/// +/// - All redirect URIs must be registered in your client configuration +/// - The client configuration document must be served over HTTPS +/// - DPoP tokens are automatically generated to prevent token replay attacks +/// - Session data is stored securely using platform-appropriate mechanisms class SolidAuth { SolidOidcUserManager? _manager; final ValueNotifier _isAuthenticatedNotifier = @@ -199,6 +379,81 @@ class SolidAuth { static const String _webIdOrIssuerKey = 'solid_auth_webid_or_issuer'; static const String _scopesKey = 'solid_auth_scopes'; + /// Creates a new SolidAuth instance with automatic redirect URI configuration. + /// + /// This is the recommended constructor for most applications as it automatically + /// configures appropriate redirect URIs based on your platform and parameters. + /// + /// ## Parameters + /// + /// - [oidcClientId]: URL pointing to your public client identifier document + /// (client-profile.jsonld). This document must be accessible via HTTPS and + /// contain your OIDC client configuration including allowed redirect URIs. + /// + /// - [appUrlScheme]: Custom URL scheme for your application, used on mobile + /// and desktop platforms for deep linking. Should be unique to your app + /// (e.g., 'com.mycompany.myapp'). Not used on web platforms. + /// + /// - [frontendRedirectUrl]: The redirect URL for web browsers. This should + /// point to an HTML page that handles the OIDC callback. Must be registered + /// in your client configuration. + /// + /// - [settings]: Optional advanced configuration settings. Most apps can + /// use the defaults. + /// + /// - [store]: Optional custom storage implementation for tokens and session data. + /// Defaults to platform-appropriate secure storage. + /// + /// ## Redirect URI Registration + /// + /// The following URIs must be registered in your client-profile.jsonld: + /// + /// **For `redirect_uris`:** + /// - Web: The exact [frontendRedirectUrl] you provide + /// - Mobile/Desktop: `{appUrlScheme}://redirect` + /// + /// **For `post_logout_redirect_uris`:** + /// - Web: The exact [frontendRedirectUrl] you provide + /// - Mobile/Desktop: `{appUrlScheme}://logout` + /// + /// ## Example + /// ```dart + /// final solidAuth = SolidAuth( + /// oidcClientId: 'https://myapp.example.com/client-profile.jsonld', + /// appUrlScheme: 'com.mycompany.myapp', + /// frontendRedirectUrl: Uri.parse('https://myapp.example.com/auth/callback.html'), + /// ); + /// ``` + /// + /// ## Client Configuration Example + /// Your client-profile.jsonld should look like: + /// ```json + /// { + /// "@context": "https://www.w3.org/ns/solid/oidc-context.jsonld", + /// "client_id": "https://myapp.example.com/client-profile.jsonld", + /// "client_name": "My Solid App", + /// "redirect_uris": [ + /// "https://myapp.example.com/auth/callback.html", + /// "com.mycompany.myapp://redirect" + /// ], + /// "post_logout_redirect_uris": [ + /// "https://myapp.example.com/auth/callback.html", + /// "com.mycompany.myapp://logout" + /// ], + /// "grant_types": ["authorization_code", "refresh_token"], + /// "scope": "openid webid offline_access profile" + /// } + /// ``` + /// + /// ## Required Scopes + /// + /// The `scope` field in your client-profile.jsonld **must** include these required scopes: + /// - `openid`: Required for OpenID Connect authentication + /// - `webid`: Required for Solid WebID functionality + /// - `offline_access`: Required for token refresh capability + /// + /// Additional scopes (like `profile`, `email`, etc.) can be included in the client + /// profile and requested during authentication via the `scopes` parameter in [authenticate]. SolidAuth({ required String oidcClientId, required String appUrlScheme, @@ -213,6 +468,32 @@ class SolidAuth { ), _store = store ?? OidcDefaultStore(); + /// Creates a SolidAuth instance with explicit redirect URI configuration. + /// + /// Use this constructor when you need full control over redirect URI configuration + /// or when the automatic configuration from the main constructor doesn't meet + /// your needs. + /// + /// ## Parameters + /// + /// - [oidcClientId]: URL pointing to your public client identifier document + /// - [uriSettings]: Explicit configuration of all redirect URIs + /// - [settings]: Optional advanced configuration settings + /// - [store]: Optional custom storage implementation + /// + /// ## Example + /// ```dart + /// final uriSettings = SolidAuthUriSettings( + /// redirectUri: Uri.parse('https://myapp.com/auth/callback'), + /// postLogoutRedirectUri: Uri.parse('https://myapp.com/'), + /// frontChannelLogoutUri: Uri.parse('https://myapp.com/auth/logout'), + /// ); + /// + /// final solidAuth = SolidAuth.forRedirects( + /// oidcClientId: 'https://myapp.com/client-profile.jsonld', + /// uriSettings: uriSettings, + /// ); + /// ``` SolidAuth.forRedirects({ required String oidcClientId, required SolidAuthUriSettings uriSettings, @@ -223,9 +504,44 @@ class SolidAuth { _store = store ?? OidcDefaultStore(), _uriSettings = uriSettings; + /// The WebID of the currently authenticated user, if any. + /// + /// A WebID is a unique identifier for a person or agent in the Solid ecosystem. + /// It's typically an HTTPS URL that points to the user's profile document. + /// + /// Returns `null` if no user is currently authenticated. + /// + /// ## Example + /// ```dart + /// print('Current user: ${solidAuth.currentWebId ?? 'Not authenticated'}'); + /// ``` String? get currentWebId => _manager?.currentWebId; - /// ValueListenable that notifies when authentication state changes + /// A [ValueListenable] that notifies when authentication state changes. + /// + /// This is the recommended way to reactively update your UI based on + /// authentication status. The value is `true` when a user is authenticated + /// and `false` otherwise. + /// + /// ## Example + /// ```dart + /// ValueListenableBuilder( + /// valueListenable: solidAuth.isAuthenticatedNotifier, + /// builder: (context, isAuthenticated, child) { + /// if (isAuthenticated) { + /// return Text('Welcome, ${solidAuth.currentWebId}!'); + /// } else { + /// return LoginButton(); + /// } + /// }, + /// ); + /// ``` + /// + /// The notifier automatically updates when: + /// - [authenticate] completes successfully + /// - [logout] is called + /// - [init] restores an existing session + /// - Token refresh fails and the session becomes invalid ValueListenable get isAuthenticatedNotifier => _isAuthenticatedNotifier; /// Updates the authentication state and notifies listeners @@ -238,6 +554,45 @@ class SolidAuth { } } + /// Initializes the SolidAuth instance and attempts to restore any existing session. + /// + /// This method must be called before using any other authentication methods. + /// It performs the following operations: + /// + /// 1. Initializes the secure storage system + /// 2. Attempts to restore authentication parameters from previous sessions + /// 3. Validates any existing tokens and session data + /// 4. Updates the authentication state accordingly + /// + /// ## Return Value + /// + /// Returns `true` if an existing valid session was restored, `false` if no + /// valid session exists and the user needs to authenticate. + /// + /// ## Example + /// ```dart + /// final solidAuth = SolidAuth(/* ... */); + /// + /// // Initialize and check for existing session + /// final hasExistingSession = await solidAuth.init(); + /// + /// if (hasExistingSession) { + /// print('User already authenticated: ${solidAuth.currentWebId}'); + /// } else { + /// print('User needs to log in'); + /// } + /// ``` + /// + /// ## Error Handling + /// + /// This method handles errors gracefully. If stored session data is corrupted + /// or invalid, it will be cleared and the method will return `false` rather + /// than throwing an exception. + /// + /// ## Thread Safety + /// + /// This method is safe to call multiple times, though subsequent calls after + /// the first will have no effect. Future init() async { await _store.init(); @@ -306,6 +661,70 @@ class SolidAuth { ); } + /// Authenticates a user with their WebID or identity provider. + /// + /// This method handles the complete OIDC authentication flow, including: + /// - WebID discovery (if a WebID is provided) + /// - Identity provider discovery and configuration + /// - Browser-based authorization flow + /// - Token exchange and validation + /// - Session persistence for future use + /// + /// ## Parameters + /// + /// - [webIdOrIssuerUri]: Either a WebID (e.g., 'https://alice.solidcommunity.net/profile/card#me') + /// or an identity provider URI (e.g., 'https://solidcommunity.net'). + /// If a WebID is provided, the library will automatically discover the + /// associated identity provider. + /// + /// - [scopes]: Additional OAuth2 scopes to request beyond the default Solid + /// scopes ('openid', 'webid', 'offline_access'). These additional scopes must + /// also be declared in your client-profile.jsonld. Common additional scopes + /// include 'profile' for extended profile information. + /// + /// ## Return Value + /// + /// Returns a [UserAndWebId] object containing: + /// - `user`: The OIDC user information including tokens and claims + /// - `webId`: The validated WebID of the authenticated user + /// + /// ## Examples + /// + /// ```dart + /// // Authenticate with a WebID + /// try { + /// final result = await solidAuth.authenticate( + /// 'https://alice.solidcommunity.net/profile/card#me' + /// ); + /// print('Authenticated as: ${result.webId}'); + /// print('Access token expires: ${result.user.token.expiresAt}'); + /// } catch (e) { + /// print('Authentication failed: $e'); + /// } + /// + /// // Authenticate with additional scopes + /// final result = await solidAuth.authenticate( + /// 'https://solidcommunity.net', + /// scopes: ['profile', 'email'], + /// ); + /// ``` + /// + /// ## Error Handling + /// + /// This method may throw various exceptions: + /// - Network errors if the identity provider is unreachable + /// - Authentication errors if the user cancels or credentials are invalid + /// - Configuration errors if redirect URIs are not properly registered + /// - Security errors if token validation fails + /// + /// ## Session Management + /// + /// Upon successful authentication, the session is automatically persisted + /// and will be restored on subsequent app launches via [init]. + /// + /// If a user is already authenticated, calling this method will first log + /// out the current user before beginning the new authentication flow. + /// Future authenticate(String webIdOrIssuerUri, {List scopes = const []}) async { // Clean up any existing manager @@ -325,7 +744,7 @@ class SolidAuth { _log.info('Using restored session for WebID: $webId'); _updateAuthenticationState(); - return UserAndWebId(user: _manager!.currentUser!, webId: webId); + return UserAndWebId(oidcUser: _manager!.currentUser!, webId: webId); } _log.info( @@ -336,7 +755,7 @@ class SolidAuth { throw Exception('OIDC authentication failed: no user returned'); } - final oidcUser = authResult.user; + final oidcUser = authResult.oidcUser; final webId = authResult.webId; // Persist authentication parameters for session restoration @@ -350,6 +769,69 @@ class SolidAuth { return authResult; } + /// Creates appropriate URI settings for the current platform. + /// + /// This static method automatically configures redirect URIs based on the + /// platform your app is running on: + /// + /// - **Web Platform**: Uses the provided [frontendRedirectUrl] for all redirects + /// - **Mobile/Desktop**: Creates custom URL scheme redirects using [appUrlScheme] + /// + /// ## Parameters + /// + /// - [appUrlScheme]: The custom URL scheme for your app (e.g., 'com.mycompany.myapp'). + /// Used only on mobile and desktop platforms. Should be unique and registered + /// with your app's platform configuration. + /// + /// - [frontendRedirectUrl]: The web URL for authentication callbacks. Used only + /// on web platforms. Should point to an HTML page that handles OIDC redirects. + /// + /// ## Generated URIs + /// + /// For **web platforms**: + /// - `redirectUri`: Same as frontendRedirectUrl + /// - `postLogoutRedirectUri`: Same as frontendRedirectUrl + /// - `frontChannelLogoutUri`: frontendRedirectUrl with '?requestType=front-channel-logout' + /// + /// For **mobile/desktop platforms**: + /// - `redirectUri`: `{appUrlScheme}://redirect` + /// - `postLogoutRedirectUri`: `{appUrlScheme}://logout` + /// - `frontChannelLogoutUri`: `{appUrlScheme}://logout` + /// + /// ## Client Registration + /// + /// All generated URIs must be registered in your OIDC client configuration: + /// + /// ```json + /// { + /// "redirect_uris": [ + /// "https://myapp.com/auth/callback.html", + /// "com.mycompany.myapp://redirect" + /// ], + /// "post_logout_redirect_uris": [ + /// "https://myapp.com/auth/callback.html", + /// "com.mycompany.myapp://logout" + /// ] + /// } + /// ``` + /// + /// ## Return Value + /// + /// Returns a [SolidAuthUriSettings] object with platform-appropriate redirect URIs. + /// + /// ## Example + /// ```dart + /// final uriSettings = SolidAuth.createUriSettings( + /// appUrlScheme: 'com.mycompany.myapp', + /// frontendRedirectUrl: Uri.parse('https://myapp.com/auth/callback.html'), + /// ); + /// + /// // Use with explicit constructor + /// final solidAuth = SolidAuth.forRedirects( + /// oidcClientId: 'https://myapp.com/client-profile.jsonld', + /// uriSettings: uriSettings, + /// ); + /// ``` static SolidAuthUriSettings createUriSettings({ required String appUrlScheme, required Uri frontendRedirectUrl, @@ -418,14 +900,141 @@ class SolidAuth { return manager; } + /// Generates a DPoP (Demonstration of Proof-of-Possession) token for API requests. + /// + /// DPoP tokens are required by Solid servers to prove that the client making + /// an API request is the same client that was issued the access token. This + /// prevents token theft and replay attacks. + /// + /// ## Parameters + /// + /// - [url]: The complete URL of the API endpoint you're about to call + /// - [method]: The HTTP method ('GET', 'POST', 'PUT', 'DELETE', etc.) + /// + /// ## Return Value + /// + /// Returns a [DPoP] object containing: + /// - `dpopToken`: The DPoP JWT token + /// - `accessToken`: The OAuth2 access token + /// - `httpHeaders()`: Convenience method to get properly formatted HTTP headers + /// + /// ## Example + /// ```dart + /// // Generate DPoP token for a GET request + /// final dpop = solidAuth.genDpopToken( + /// 'https://alice.solidcommunity.net/profile/card', + /// 'GET' + /// ); + /// + /// // Use with HTTP client + /// final response = await http.get( + /// Uri.parse('https://alice.solidcommunity.net/profile/card'), + /// headers: { + /// ...dpop.httpHeaders(), + /// 'Content-Type': 'text/turtle', + /// }, + /// ); + /// + /// // Or set headers manually + /// final response = await http.get( + /// Uri.parse('https://alice.solidcommunity.net/profile/card'), + /// headers: { + /// 'Authorization': 'DPoP ${dpop.accessToken}', + /// 'DPoP': dpop.dpopToken, + /// 'Content-Type': 'text/turtle', + /// }, + /// ); + /// ``` + /// + /// ## Requirements + /// + /// - User must be authenticated (call [authenticate] first) + /// - The URL must be the exact URL you're going to call + /// - The method must match the actual HTTP method used + /// - Each DPoP token can only be used once for the specific URL/method combination + /// + /// ## Security Notes + /// + /// - DPoP tokens are tied to the specific URL and HTTP method + /// - Each token includes a unique nonce and timestamp + /// - Tokens should be generated immediately before making the API call + /// - Never reuse DPoP tokens across different requests + /// + /// ## Throws + /// + /// Throws an exception if no user is currently authenticated. DPoP genDpopToken(String url, String method) { return _manager!.genDpopToken(url, method); } + /// Checks if a user is currently authenticated. + /// + /// This is a synchronous check of the current authentication state. + /// For reactive UI updates, prefer using [isAuthenticatedNotifier]. + /// + /// ## Return Value + /// + /// Returns `true` if a user is authenticated and has valid tokens, + /// `false` otherwise. + /// + /// ## Example + /// ```dart + /// if (solidAuth.isAuthenticated) { + /// print('User is logged in as: ${solidAuth.currentWebId}'); + /// } else { + /// print('Please log in'); + /// } + /// ``` + /// + /// ## Note + /// + /// This method only checks if authentication data exists, not whether + /// the tokens are still valid or if the server is reachable. Token + /// validation happens automatically during API calls. bool get isAuthenticated { return _manager != null && _manager!.currentUser != null; } + /// Logs out the current user and clears all authentication data. + /// + /// This method performs a complete logout process: + /// 1. Notifying the identity provider of the logout (if supported and reachable) + /// 2. Clearing all stored authentication data locally + /// 3. Updating the authentication state + /// + /// **Note**: Token revocation depends on the underlying OIDC library implementation + /// and identity provider support. The library will attempt to notify the provider + /// but cannot guarantee that tokens are revoked on the server side. + /// + /// ## Example + /// ```dart + /// await solidAuth.logout(); + /// print('User logged out successfully'); + /// ``` + /// + /// ## Behavior + /// + /// - If no user is currently authenticated, this method completes successfully + /// without error + /// - Network errors during logout (e.g., unable to reach identity provider) + /// are logged but don't prevent local cleanup + /// - The [isAuthenticatedNotifier] will be updated to reflect the logout + /// - All stored session data is permanently removed locally + /// + /// ## Post-Logout State + /// + /// After calling logout: + /// - [isAuthenticated] returns `false` + /// - [currentWebId] returns `null` + /// - [genDpopToken] will throw an exception + /// - [authenticate] can be called to log in a new user + /// + /// ## Platform Behavior + /// + /// On some platforms, logout may open a browser window to complete the + /// logout process with the identity provider. This ensures single sign-out + /// works correctly if the user has multiple applications authenticated + /// with the same provider. Future logout() async { await _manager?.logout(); await _manager?.dispose(); @@ -444,7 +1053,7 @@ class SolidAuth { /// /// Use cases: /// - App shutdown or widget disposal - /// - Switching to a different authentication provider + /// - Switching to a different authentication provider (after logout) /// /// If you want to log out the user and clear stored data, call logout() first. /// This method is safe to call multiple times. From b39c8b95521eda61002cb97a5daeba4a29a5a0e5 Mon Sep 17 00:00:00 2001 From: Klas Kalass Date: Sun, 3 Aug 2025 23:47:46 +0200 Subject: [PATCH 03/11] Updated README --- README.md | 299 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 220 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 0a6444f..9b3567f 100644 --- a/README.md +++ b/README.md @@ -13,124 +13,259 @@ and the Flutter guide for # Solid Auth -Solid Auth is an implementation of [Solid-OIDC flow](https://solid.github.io/solid-oidc/) which can be used to authenticate a client application to a Solid POD. Solid OIDC is built on top of OpenID Connect 1.0. +[![pub package](https://img.shields.io/pub/v/solid_auth.svg)](https://pub.dev/packages/solid_auth) -The authentication process works with both Android and Web based client applications. The package can also be used to create DPoP proof tokens for accessing private data inside PODs after the authentication. +A Flutter library for authenticating with [Solid pods](https://solidproject.org/) using OpenID Connect, implementing the [Solid-OIDC specification](https://solid.github.io/solid-oidc/). -This package includes the source code of two other packages, [openid_client](https://pub.dev/packages/openid_client) and [dart_jsonwebtoken](https://pub.dev/packages/dart_jsonwebtoken), with slight modifications done to those package files in order to be compatible with Solid-OIDC flow. +This library provides a simple, reactive interface for Solid authentication that handles the complexity of OIDC flows, token management, WebID discovery, and DPoP (Demonstration of Proof-of-Possession) tokens required by Solid servers. -## Features +Built on the robust foundation of [Bdaya-Dev/oidc](https://pub.dev/packages/oidc), this package focuses specifically on Solid pod authentication while leveraging excellent, well-maintained OpenID Connect functionality. -* Authenticate a client application to a Solid POD -* Create DPoP tokens for accessing data inside a POD -* Access public profile data of a POD using its WebID +This package includes an embedded copy of the [dart_jsonwebtoken](https://pub.dev/packages/dart_jsonwebtoken) package with modifications for Solid-OIDC compatibility. + +## ✨ Features + +- **🔐 Complete Solid Authentication**: Full implementation of Solid-OIDC specification +- **📱 Cross-Platform**: Works on web, mobile (iOS/Android), and desktop (macOS) +- **🔄 Reactive State Management**: Use `ValueListenable` to reactively update UI based on authentication status +- **💾 Automatic Session Restoration**: Persists authentication across app restarts +- **🛡️ DPoP Token Support**: Handles security tokens required by Solid servers +- **🌐 WebID Discovery**: Automatically discovers identity providers from WebIDs +- **🔒 Secure Token Storage**: Uses platform-appropriate secure storage mechanisms -## Usage +## 🚀 Quick Start + +### 1. Add to pubspec.yaml + +```sh +dart pub add solid_auth +``` + +### 2. Create Your Client Profile + +Create a `client-profile.jsonld` file and host it on HTTPS: + +💡 **Hosting Tip**: Don't have a server? You can easily host this file for free using [GitHub Pages](https://pages.github.com/), [Netlify](https://www.netlify.com/), or [Vercel](https://vercel.com/). Just commit the file to your repository and enable static hosting. + +```json +{ + "@context": "https://www.w3.org/ns/solid/oidc-context.jsonld", + "client_id": "https://myapp.com/client-profile.jsonld", + "client_name": "My Solid App", + "application_type": "native", + "redirect_uris": [ + "https://myapp.com/auth/callback.html", + "com.mycompany.myapp://redirect" + ], + "post_logout_redirect_uris": [ + "https://myapp.com/auth/callback.html", + "com.mycompany.myapp://logout" + ], + "scope": "openid webid offline_access profile", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none" +} +``` + +🚨 **CRITICAL REQUIREMENT**: The `client_id` field **must** be the exact URL where you host this document. -To use this package add `solid_auth` as a dependency in your `pubspec.yaml` file. An example project that uses `solid_auth` can be found [here](https://github.com/anusii/solid_auth/tree/main/example). +If you host this at `https://myapp.com/client-profile.jsonld`, then: +- The `client_id` field **must** be `"https://myapp.com/client-profile.jsonld"` +- The `oidcClientId` parameter **must** be `'https://myapp.com/client-profile.jsonld'` +- Both values **must** be identical -### Authentication Example +### 3. Initialize SolidAuth ```dart import 'package:solid_auth/solid_auth.dart'; -import 'package:jwt_decoder/jwt_decoder.dart'; -// Example WebID -String _myWebId = 'https://charlieb.solidcommunity.net/profile/card#me'; +// Initialize SolidAuth with your client configuration +final solidAuth = SolidAuth( + // This URL must exactly match the "client_id" field in your client-profile.jsonld + oidcClientId: 'https://myapp.com/client-profile.jsonld', + appUrlScheme: 'com.mycompany.myapp', + frontendRedirectUrl: Uri.parse('https://myapp.com/auth/callback.html'), +); -// Get issuer URI -String _issuerUri = await getIssuer(_myWebId); +// Initialize and check for existing session +await solidAuth.init(); +``` -// Define scopes. Also possible scopes -> webid, email, api -final List _scopes = [ - 'openid', - 'profile', - 'offline_access', -]; +### 4. Build Reactive UI -// Authentication process for the POD issuer -var authData = await authenticate(Uri.parse(_issuerUri), _scopes); +```dart +// Build reactive UI based on authentication state +ValueListenableBuilder( + valueListenable: solidAuth.isAuthenticatedNotifier, + builder: (context, isAuthenticated, child) { + if (isAuthenticated) { + return Text('Welcome, ${solidAuth.currentWebId}!'); + } else { + return ElevatedButton( + onPressed: () => authenticate(), + child: Text('Login with Solid'), + ); + } + }, +); +``` -// Decode access token to recheck the WebID -String accessToken = authData['accessToken']; -Map decodedToken = JwtDecoder.decode(accessToken); -String webId = decodedToken['webid']; +### 5. Authenticate Users +```dart +// Authenticate with a WebID or identity provider +Future authenticate() async { + try { + final result = await solidAuth.authenticate( + 'https://alice.solidcommunity.net/profile/card#me' + ); + print('Authenticated as: ${result.webId}'); + } catch (e) { + print('Authentication failed: $e'); + } +} ``` -### Accessing Public Data Example +### 6. Make Authenticated API Requests ```dart -import 'package:solid_auth/solid_auth.dart'; +// Generate DPoP token and make authenticated request +Future fetchPrivateData() async { + // Generate DPoP token for the specific request + final dpop = solidAuth.genDpopToken( + 'https://alice.solidcommunity.net/private/data.ttl', + 'GET' + ); + + final response = await http.get( + Uri.parse('https://alice.solidcommunity.net/private/data.ttl'), + headers: { + ...dpop.httpHeaders(), // Includes Authorization and DPoP headers + 'Accept': 'text/turtle', + }, + ); + + if (response.statusCode == 200) { + print('Private data: ${response.body}'); + } +} +``` -// Example WebID -String _myWebId = 'https://charlieb.solidcommunity.net/profile/card#me'; +## 📚 Comprehensive Examples -// Get issuer URI -Future profilePage = await fetchProfileData(_myWebId); +### Authentication with Additional Scopes +```dart +// Request additional scopes (must be declared in client-profile.jsonld) +final result = await solidAuth.authenticate( + 'https://alice.solidcommunity.net/profile/card#me', + scopes: ['profile', 'email'], // Additional to required: openid, webid, offline_access +); ``` -### Generating DPoP Token Example +### Authenticate with Identity Provider URL ```dart -import 'package:solid_auth/solid_auth.dart'; +// Authenticate directly with provider (skips WebID discovery) +final result = await solidAuth.authenticate( + 'https://solidcommunity.net' +); +``` -String endPointUrl; // The URL of the resource that is being requested -KeyPair rsaKeyPair; // Public/private key pair (RSA) -dynamic publicKeyJwk; // JSON web key of the public key -String httpMethod; // Http method to be used (eg: POST, PATCH) +### Session Management -// Generate DPoP token -String dPopToken = genDpopToken(endPointUrl, rsaKeyPair, publicKeyJwk, httpMethod); +```dart +// Check authentication status +if (solidAuth.isAuthenticated) { + print('User: ${solidAuth.currentWebId}'); +} +// Logout user +await solidAuth.logout(); + +// Clean up resources +await solidAuth.dispose(); ``` -## Additional information +## 🔐 Client Configuration Guide + +### Required Scopes + +Your `client-profile.jsonld` **must** include these mandatory scopes: +- `openid`: Required for OpenID Connect authentication +- `webid`: Required for Solid WebID functionality +- `offline_access`: Required for token refresh capability + +### Redirect URI Patterns + +The library automatically constructs redirect URIs based on your platform: + +**Web Platform:** +- `redirect_uris`: Your exact `frontendRedirectUrl` +- `post_logout_redirect_uris`: Your exact `frontendRedirectUrl` + +**Mobile/Desktop Platforms:** +- `redirect_uris`: `{appUrlScheme}://redirect` +- `post_logout_redirect_uris`: `{appUrlScheme}://logout` + +## 🔧 Platform Setup + +**📚 Important**: For complete platform-specific setup instructions (web, iOS, Android, macOS, Windows, Linux), see the comprehensive [OIDC Getting Started Guide](https://bdaya-dev.github.io/oidc/oidc-getting-started/). + +### Web Applications + +Create a redirect handler HTML page at your `frontendRedirectUrl` location. **Use the official redirect.html from the [OIDC Getting Started Guide](https://bdaya-dev.github.io/oidc/oidc-getting-started/)** to ensure compatibility with the latest OIDC package version. + +### Mobile & Desktop Applications + +Each platform requires specific configuration for URL schemes and redirect handling. + +See the [OIDC Getting Started Guide](https://bdaya-dev.github.io/oidc/oidc-getting-started/) for detailed, up-to-date instructions for each platform. + +## 🔒 Security Considerations + +- **HTTPS Required**: All redirect URIs must use HTTPS in production +- **Client Registration**: All redirect URIs must be listed in your client profile document +- **DPoP Tokens**: Generate fresh DPoP tokens for each API request - never reuse them +- **Token Storage**: The library uses secure platform storage for sensitive data +- **WebID Validation**: WebIDs are validated by fetching profile documents and verifying identity providers + +## 🌟 What is Solid? + +[Solid](https://solidproject.org/) is a web decentralization project that gives users control over their data by storing it in personal data pods. Users authenticate with identity providers and grant applications specific access to their data. + +## 📖 Additional Information The source code can be accessed via [GitHub repository](https://github.com/anusii/solid_auth). You can also file issues you face at [GitHub Issues](https://github.com/anusii/solid_auth/issues). -### Running Solid Auth in web applications - -In order to successfully run `solid auth` in a web application you also need to create a custom `callback.html` file inside the `web` directory. After created simply copy and paste the following code into that file. - -```html - - - - - - - - - - - -``` +An example project that demonstrates `solid_auth` usage can be found [here](https://github.com/anusii/solid_auth/tree/main/example). + +## 🙏 Acknowledgments + +This library builds upon the excellent work of the [Bdaya-Dev/oidc](https://github.com/Bdaya-Dev/oidc) team. We are standing on the shoulders of giants! + +Special thanks to: +- **[Bdaya-Dev/oidc](https://pub.dev/packages/oidc)** - The robust, well-maintained OpenID Connect implementation that powers this library +- **[oidc_default_store](https://pub.dev/packages/oidc_default_store)** - Secure, platform-appropriate token storage +- The broader Solid and OpenID Connect communities for their specifications and guidance + +The solid_auth library focuses specifically on Solid pod authentication while leveraging these excellent foundational libraries for the core OIDC functionality. + +## 🔗 Links + +- [Solid Project](https://solidproject.org/) +- [Solid OIDC Specification](https://solid.github.io/solid-oidc/) +- [WebID Specification](https://www.w3.org/2005/Incubator/webid/spec/identity/) +- [Example Application](https://github.com/anusii/solid_auth/tree/main/example) +- [Issue Tracker](https://github.com/anusii/solid_auth/issues) + +--- ## Roadmap @@ -142,4 +277,10 @@ Currently, `solid_auth` requires network connectivity during initialization to: **Future Goal**: Enable fully offline-first applications that can start and function without network connectivity, using cached authentication data and provider configurations. -This is essential for truly offline-capable Solid applications, but requires careful consideration of security trade-offs and cache management strategies. \ No newline at end of file +This is essential for truly offline-capable Solid applications, but requires careful consideration of security trade-offs and cache management strategies. + +### Windows/Linux Desktop Support +The OIDC library supports Windows and Linux via localhost loopback device with random ports. +Configuring `localhost:*` in the client profile probably is not a good idea for security reasons and possibly +disallowed by many Solid pod implementations, so we need to find out if this really is a problem or if it +does work after all, or if we find some way to make it work for those two platforms. \ No newline at end of file From 0f1acec4c3af74145cea7c726609e2d3e4deb046 Mon Sep 17 00:00:00 2001 From: Klas Kalass Date: Wed, 6 Aug 2025 13:46:49 +0200 Subject: [PATCH 04/11] Added some more entries to .gitignore --- example/.gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/example/.gitignore b/example/.gitignore index b30374f..4d8f710 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -12,9 +12,11 @@ windows/ *.swp .DS_Store .atom/ +.build/ .buildlog/ .history .svn/ +.swiftpm/ migrate_working_dir/ # IntelliJ related From 7a43e59e0adc8f5bfda8816d017aa8cace4c75c2 Mon Sep 17 00:00:00 2001 From: Klas Kalass Date: Wed, 6 Aug 2025 14:00:27 +0200 Subject: [PATCH 05/11] fix: enable refresh tokens by automatically adding consent prompt for offline_access Previously, Solid OIDC authentication failed to obtain refresh tokens because the required 'consent' prompt wasn't sent to identity providers. This change automatically adds the consent prompt when offline_access is in the requested scopes, enabling proper token refresh and persistent authentication. Includes configurable prompt calculation and comprehensive test coverage. --- lib/src/oidc/solid_oidc_user_manager.dart | 278 +++++++++++++- test/solid_oidc_user_manager_test.dart | 436 ++++++++++++++++++++++ 2 files changed, 698 insertions(+), 16 deletions(-) create mode 100644 test/solid_oidc_user_manager_test.dart diff --git a/lib/src/oidc/solid_oidc_user_manager.dart b/lib/src/oidc/solid_oidc_user_manager.dart index ac72fc8..ce0d53e 100644 --- a/lib/src/oidc/solid_oidc_user_manager.dart +++ b/lib/src/oidc/solid_oidc_user_manager.dart @@ -179,6 +179,56 @@ class UserAndWebId { /// ``` typedef GetIssuers = Future> Function(String webIdOrIssuer); +/// Function type for customizing prompt calculation based on scopes and configured prompts. +/// +/// This function is called when the library needs to determine the effective +/// prompts for the OIDC authorization request. +/// +/// ## Parameters +/// +/// - [configuredPrompts]: The prompts explicitly configured in [SolidOidcUserManagerSettings.prompt] +/// - [effectiveScopes]: The complete list of scopes that will be requested during authentication +/// +/// ## Return Value +/// +/// Should return a list of prompt values to be sent to the identity provider. +/// The library will use this list as-is, so ensure proper deduplication and validation. +/// +/// ## Default Behavior +/// +/// If not provided, the library uses the default Solid behavior: +/// - Includes all configured prompts +/// - Automatically adds `consent` when `offline_access` is in the effective scopes +/// +/// ## Custom Implementation Example +/// +/// ```dart +/// List customPromptCalculator(List configuredPrompts, List effectiveScopes) { +/// final prompts = {...configuredPrompts}; +/// +/// // Custom logic: only add consent for specific providers +/// if (effectiveScopes.contains('offline_access') && isSpecialProvider()) { +/// prompts.add('consent'); +/// } +/// +/// // Always force login for sensitive operations +/// if (effectiveScopes.contains('admin')) { +/// prompts.add('login'); +/// } +/// +/// return prompts.toList()..sort(); +/// } +/// +/// final settings = SolidOidcUserManagerSettings( +/// redirectUri: Uri.parse('https://myapp.com/callback'), +/// calculateEffectivePrompts: customPromptCalculator, +/// ); +/// ``` +typedef CalculateEffectivePrompts = List Function( + List configuredPrompts, + List effectiveScopes, +); + Future> _getIssuersDefault(String webIdOrIssuer) async { try { return [Uri.parse(await solid_auth_issuer.getIssuer(webIdOrIssuer))]; @@ -215,7 +265,6 @@ class _RsaInfo { /// ```dart /// final settings = SolidOidcUserManagerSettings( /// redirectUri: Uri.parse('https://myapp.com/callback'), -/// extraScopes: ['profile', 'email'], /// strictJwtVerification: true, /// supportOfflineAuth: false, /// refreshBefore: Duration(minutes: 5), @@ -232,10 +281,34 @@ class _RsaInfo { /// ## Solid-Specific Defaults /// /// This class provides sensible defaults for Solid OIDC: -/// - Default scopes: `['openid', 'webid', 'offline_access']` +/// - Default scopes: `['openid', 'webid', 'offline_access']` (recommended for Flutter apps) +/// - Automatic `consent` prompt when `offline_access` scope is requested /// - WebID discovery integration via [getIssuers] /// - DPoP token support for enhanced security /// - Automatic session restoration capabilities +/// +/// ## Scope Usage in Solid +/// +/// Unlike traditional OAuth2 APIs, Solid applications typically don't need +/// additional scopes beyond the defaults. Access control in Solid is handled +/// at the resource level through Web Access Control (WAC) or Access Control +/// Policies (ACP), not through OAuth2 scopes. +/// +/// The default scopes `['openid', 'webid', 'offline_access']` are sufficient +/// for virtually all Solid applications. Extra scopes are only needed for +/// specialized scenarios such as hybrid applications that integrate with +/// both Solid pods and traditional OAuth2 APIs. +/// +/// ## Prompt Handling +/// +/// The library automatically adds the `consent` prompt when the effective scopes +/// include `offline_access` (which is included by default). This ensures users +/// explicitly consent to refresh token capabilities, which is often required +/// by OIDC providers for security compliance. +/// +/// For advanced use cases, you can customize prompt calculation using the +/// [calculateEffectivePrompts] parameter to implement custom logic based on +/// scopes and application requirements. class SolidOidcUserManagerSettings { /// Creates a new instance of [SolidOidcUserManagerSettings]. /// @@ -245,7 +318,8 @@ class SolidOidcUserManagerSettings { required this.redirectUri, this.uiLocales, this.extraTokenHeaders, - this.extraScopes = defaultScopes, + this.defaultScopes = staticDefaultScopes, + this.extraScopes = const [], this.prompt = const [], this.display, this.acrValues, @@ -259,7 +333,7 @@ class SolidOidcUserManagerSettings { this.userInfoSettings = const OidcUserInfoSettings(), this.frontChannelRequestListeningOptions = const OidcFrontChannelRequestListeningOptions(), - this.refreshBefore, + this.refreshBefore = defaultRefreshBefore, this.strictJwtVerification = false, this.getExpiresIn, this.sessionManagementSettings = const OidcSessionManagementSettings(), @@ -269,15 +343,47 @@ class SolidOidcUserManagerSettings { this.extraRevocationParameters, this.extraRevocationHeaders, this.getIssuers, + this.calculateEffectivePrompts, }); - /// The default scopes required for Solid OIDC authentication. + /// The static default scopes required for Solid OIDC authentication. /// /// These scopes provide: /// - `openid`: Basic OpenID Connect functionality /// - `webid`: Access to the user's WebID (Solid-specific) /// - `offline_access`: Ability to refresh tokens when the user is offline - static const defaultScopes = ['openid', 'webid', 'offline_access']; + /// + /// This constant provides the recommended baseline scopes for Solid OIDC. + /// Individual instances can override these via the [defaultScopes] parameter. + static const staticDefaultScopes = ['openid', 'webid', 'offline_access']; + + /// The configurable default scopes for this instance. + /// + /// These scopes form the base set of scopes that will be requested during + /// authentication. For most Flutter applications, the default scopes + /// `['openid', 'webid', 'offline_access']` are recommended and should not + /// be changed. + /// + /// The `offline_access` scope is particularly important for Flutter apps as it + /// enables refresh tokens, allowing the app to maintain authentication across + /// app restarts and network interruptions without requiring re-authentication. + /// + /// **Note**: This parameter is primarily intended for advanced use cases or + /// specialized integrations. Most Solid applications should use the default + /// scopes without modification: + /// + /// ```dart + /// // Recommended: Use default scopes for typical Solid apps + /// final settings = SolidOidcUserManagerSettings( + /// redirectUri: Uri.parse('https://myapp.com/callback'), + /// // No scope configuration needed - defaults are ideal + /// ); + /// ``` + /// + /// **Security Note**: Removing `offline_access` from default scopes will + /// prevent refresh token functionality and require re-authentication when + /// access tokens expire, which is generally not suitable for Flutter Solid applications. + final List defaultScopes; /// Settings to control using the user_info endpoint. final OidcUserInfoSettings userInfoSettings; @@ -312,10 +418,45 @@ class SolidOidcUserManagerSettings { final OidcFrontChannelRequestListeningOptions frontChannelRequestListeningOptions; - /// see [OidcAuthorizeRequest.scope]. + /// Additional scopes to request beyond the default scopes. + /// + /// **Note**: Extra scopes are rarely needed for Solid applications. Access + /// control in Solid is typically handled at the resource level through Web + /// Access Control (WAC) or Access Control Policies (ACP), not through OAuth2 scopes. + /// + /// This parameter is primarily for specialized scenarios such as: + /// - Hybrid applications that integrate with both Solid pods and traditional OAuth2 APIs + /// - Identity providers that offer additional profile information beyond WebID + /// - Custom provider-specific functionality + /// + /// For pure Solid applications, the default scopes `['openid', 'webid', 'offline_access']` + /// are typically sufficient. + /// + /// ```dart + /// // Most Solid apps don't need extra scopes + /// final settings = SolidOidcUserManagerSettings( + /// redirectUri: Uri.parse('https://myapp.com/callback'), + /// // extraScopes typically not needed + /// ); + /// + /// // Only for specialized hybrid applications + /// final hybridSettings = SolidOidcUserManagerSettings( + /// redirectUri: Uri.parse('https://myapp.com/callback'), + /// extraScopes: ['profile'], // For non-Solid API integration + /// ); + /// ``` final List extraScopes; - /// see [OidcAuthorizeRequest.prompt]. + /// Custom prompts for the authorization request. + /// + /// These prompts control how the identity provider handles user interaction + /// during authentication. See [OidcAuthorizeRequest.prompt] for standard values. + /// + /// **Note**: The `consent` prompt is automatically added when the effective + /// scopes include `offline_access` (which is included by default). This ensures + /// users explicitly consent to refresh token capabilities required for offline access. + /// + /// Example: `['login', 'select_account']` - force re-authentication and account selection final List prompt; /// see [OidcAuthorizeRequest.display]. @@ -374,6 +515,37 @@ class SolidOidcUserManagerSettings { /// 3. Use that as the identity provider URI final GetIssuers? getIssuers; + /// Custom function for calculating effective prompts based on scopes and configured prompts. + /// + /// This function overrides the default prompt calculation behavior. + /// See [CalculateEffectivePrompts] typedef for detailed documentation and examples. + /// + /// When `null`, the library uses the default Solid behavior: + /// - Includes all configured prompts from [prompt] + /// - Automatically adds `consent` when `offline_access` is in the effective scopes + /// + /// When provided, gives full control over prompt calculation for advanced use cases: + /// + /// ```dart + /// final settings = SolidOidcUserManagerSettings( + /// redirectUri: Uri.parse('https://myapp.com/callback'), + /// calculateEffectivePrompts: (configuredPrompts, effectiveScopes) { + /// final prompts = {...configuredPrompts}; + /// + /// // Custom logic: only add consent for offline access in production + /// if (effectiveScopes.contains('offline_access') && kReleaseMode) { + /// prompts.add('consent'); + /// } + /// + /// return prompts.toList()..sort(); + /// }, + /// ); + /// ``` + /// + /// **Note**: This is an advanced feature primarily intended for specialized + /// integration scenarios. Most applications should rely on the default behavior. + final CalculateEffectivePrompts? calculateEffectivePrompts; + /// pass this function to control how an `id_token` is fetched from a /// token response. /// @@ -392,6 +564,7 @@ class SolidOidcUserManagerSettings { Uri? redirectUri, List? uiLocales, Map? extraTokenHeaders, + List? defaultScopes, List? extraScopes, List? prompt, String? display, @@ -416,11 +589,13 @@ class SolidOidcUserManagerSettings { Map? extraRevocationParameters, Map? extraRevocationHeaders, GetIssuers? getIssuers, + CalculateEffectivePrompts? calculateEffectivePrompts, }) { return SolidOidcUserManagerSettings( redirectUri: redirectUri ?? this.redirectUri, uiLocales: uiLocales ?? this.uiLocales, extraTokenHeaders: extraTokenHeaders ?? this.extraTokenHeaders, + defaultScopes: defaultScopes ?? this.defaultScopes, extraScopes: extraScopes ?? this.extraScopes, prompt: prompt ?? this.prompt, display: display ?? this.display, @@ -453,6 +628,8 @@ class SolidOidcUserManagerSettings { extraRevocationHeaders: extraRevocationHeaders ?? this.extraRevocationHeaders, getIssuers: getIssuers ?? this.getIssuers, + calculateEffectivePrompts: + calculateEffectivePrompts ?? this.calculateEffectivePrompts, ); } } @@ -689,7 +866,6 @@ class SolidOidcUserManager { ); request.headers!['DPoP'] = dPopToken; - return Future.value(request); }, ); @@ -701,18 +877,17 @@ class SolidOidcUserManager { as OidcExecutionHookMixin : dpopHookTokenHook, ); + + // Compute effective scopes once to avoid duplication + final effectiveScopes = getEffectiveScopes(); + _manager = OidcUserManager.lazy( discoveryDocumentUri: wellKnownUri, clientCredentials: clientCredentials, store: store, settings: OidcUserManagerSettings( strictJwtVerification: _settings.strictJwtVerification, - scope: { - // we are more aggressive with our scopes - those scopes simply - // are needed for solid-oidc. - ...SolidOidcUserManagerSettings.defaultScopes, - ..._settings.extraScopes, - }.toList(), + scope: effectiveScopes, frontChannelLogoutUri: _settings.frontChannelLogoutUri, redirectUri: _settings.redirectUri, postLogoutRedirectUri: _settings.postLogoutRedirectUri, @@ -724,7 +899,7 @@ class SolidOidcUserManager { extraTokenHeaders: _settings.extraTokenHeaders, extraTokenParameters: _settings.extraTokenParameters, uiLocales: _settings.uiLocales, - prompt: _settings.prompt, + prompt: getEffectivePrompts(effectiveScopes), maxAge: _settings.maxAge, extraRevocationHeaders: _settings.extraRevocationHeaders, extraRevocationParameters: _settings.extraRevocationParameters, @@ -756,6 +931,70 @@ class SolidOidcUserManager { } } + List getEffectiveScopes() { + return { + // Use the configurable default scopes for this instance + ..._settings.defaultScopes, + ..._settings.extraScopes, + }.toList() + // make sure the result is always the same + ..sort(); + } + + /// Calculates the effective prompts for the OIDC authorization request. + /// + /// This method combines the configured prompts with automatically added + /// prompts based on the requested scopes, or delegates to a custom function + /// if [SolidOidcUserManagerSettings.calculateEffectivePrompts] is provided. + /// + /// ## Parameters + /// + /// - [scopes]: The effective scopes that will be requested during authentication + /// + /// ## Default Behavior (when [calculateEffectivePrompts] is null) + /// + /// - Includes all configured prompts from [SolidOidcUserManagerSettings.prompt] + /// - Automatically adds `consent` when `offline_access` is in the provided scopes + /// - Custom prompts from [SolidOidcUserManagerSettings.prompt] are preserved + /// + /// ## Custom Behavior + /// + /// When [SolidOidcUserManagerSettings.calculateEffectivePrompts] is provided, + /// that function takes full control over prompt calculation and receives: + /// - The configured prompts from settings + /// - The effective scopes for the request + /// + /// ## Automatic Consent Prompt (Default Behavior) + /// + /// The `consent` prompt is required when requesting `offline_access` because: + /// - Refresh tokens allow long-term access without user interaction + /// - Users must explicitly consent to this enhanced access level + /// - Many OIDC providers require explicit consent for offline access + /// + /// ## Return Value + /// + /// Returns a list of prompt values to be sent to the identity provider + /// during the authorization request. + List getEffectivePrompts(List scopes) { + // Use custom function if provided + if (_settings.calculateEffectivePrompts != null) { + return _settings.calculateEffectivePrompts!(_settings.prompt, scopes); + } + + // Default behavior: include configured prompts and add consent for offline_access + final prompts = {..._settings.prompt}; + + // Automatically add 'consent' prompt when offline_access is requested + // This ensures users explicitly consent to refresh token capabilities + if (scopes.contains('offline_access')) { + prompts.add('consent'); + } + + return prompts.toList() + // Ensure consistent ordering + ..sort(); + } + Future _generateAndPersistRsaKeyPair() async { final rsaInfo = await solid_auth_client.genRsaKeyPair(); final rsa = rsaInfo['rsa'] as KeyPair; @@ -779,6 +1018,13 @@ class SolidOidcUserManager { /// 5. **WebID Extraction**: WebID is extracted and validated from tokens /// 6. **DPoP Integration**: Tokens are enhanced with DPoP proof-of-possession /// + /// ## Consent Requirements + /// + /// The authentication flow automatically includes a `consent` prompt when + /// `offline_access` is in the requested scopes (included by default). This + /// ensures users explicitly consent to refresh token capabilities, which + /// many identity providers require for security compliance. + /// /// ## Platform Behavior /// /// - **Web**: Opens provider login in the same window or popup diff --git a/test/solid_oidc_user_manager_test.dart b/test/solid_oidc_user_manager_test.dart new file mode 100644 index 0000000..ad1e475 --- /dev/null +++ b/test/solid_oidc_user_manager_test.dart @@ -0,0 +1,436 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:oidc/oidc.dart'; +import 'package:solid_auth/src/oidc/solid_oidc_user_manager.dart'; + +void main() { + group('SolidOidcUserManager', () { + late SolidOidcUserManager userManager; + late SolidOidcUserManagerSettings settings; + + setUp(() { + settings = SolidOidcUserManagerSettings( + redirectUri: Uri.parse('https://example.com/callback'), + ); + + userManager = SolidOidcUserManager( + clientId: 'https://example.com/client-profile.jsonld', + webIdOrIssuer: 'https://alice.solidcommunity.net/profile/card#me', + store: OidcMemoryStore(), + settings: settings, + ); + }); + + group('getEffectivePrompts', () { + test( + 'should automatically add consent prompt when offline_access is in default scopes', + () { + // Given: Default settings (which include offline_access by default) + final scopes = userManager.getEffectiveScopes(); + final prompts = userManager.getEffectivePrompts(scopes); + + // Then: consent should be automatically added + expect(prompts, contains('consent')); + }); + + test('should preserve custom prompts and add consent', () { + // Given: Custom prompts configured + final customSettings = SolidOidcUserManagerSettings( + redirectUri: Uri.parse('https://example.com/callback'), + prompt: ['login', 'select_account'], + ); + + final customUserManager = SolidOidcUserManager( + clientId: 'https://example.com/client-profile.jsonld', + webIdOrIssuer: 'https://alice.solidcommunity.net/profile/card#me', + store: OidcMemoryStore(), + settings: customSettings, + ); + + // When: Getting effective prompts + final scopes = customUserManager.getEffectiveScopes(); + final prompts = customUserManager.getEffectivePrompts(scopes); + + // Then: Should contain both custom prompts and consent + expect(prompts, containsAll(['login', 'select_account', 'consent'])); + }); + + test('should not duplicate consent prompt if already specified', () { + // Given: Consent already in custom prompts + final customSettings = SolidOidcUserManagerSettings( + redirectUri: Uri.parse('https://example.com/callback'), + prompt: ['consent', 'login'], + ); + + final customUserManager = SolidOidcUserManager( + clientId: 'https://example.com/client-profile.jsonld', + webIdOrIssuer: 'https://alice.solidcommunity.net/profile/card#me', + store: OidcMemoryStore(), + settings: customSettings, + ); + + // When: Getting effective prompts + final scopes = customUserManager.getEffectiveScopes(); + final prompts = customUserManager.getEffectivePrompts(scopes); + + // Then: Should contain consent only once + expect(prompts.where((p) => p == 'consent').length, equals(1)); + expect(prompts, containsAll(['consent', 'login'])); + }); + + test( + 'should not add consent prompt if offline_access is not in effective scopes', + () { + // Given: Settings without offline_access scope in default scopes + final customSettings = SolidOidcUserManagerSettings( + redirectUri: Uri.parse('https://example.com/callback'), + defaultScopes: ['openid', 'webid'], // No offline_access + prompt: ['login'], + ); + + final customUserManager = SolidOidcUserManager( + clientId: 'https://example.com/client-profile.jsonld', + webIdOrIssuer: 'https://alice.solidcommunity.net/profile/card#me', + store: OidcMemoryStore(), + settings: customSettings, + ); + + // When: Getting effective prompts + final scopes = customUserManager.getEffectiveScopes(); + final prompts = customUserManager.getEffectivePrompts(scopes); + + // Then: Should not contain consent since offline_access is not in scopes + expect(scopes, isNot(contains('offline_access'))); + expect(prompts, isNot(contains('consent'))); + expect(prompts, contains('login')); + }); + + test('should return sorted and deduplicated prompts', () { + // Given: Settings with duplicate and unsorted prompts + final customSettings = SolidOidcUserManagerSettings( + redirectUri: Uri.parse('https://example.com/callback'), + prompt: [ + 'select_account', + 'login', + 'select_account' + ], // Duplicates and unsorted + ); + + final customUserManager = SolidOidcUserManager( + clientId: 'https://example.com/client-profile.jsonld', + webIdOrIssuer: 'https://alice.solidcommunity.net/profile/card#me', + store: OidcMemoryStore(), + settings: customSettings, + ); + + // When: Getting effective prompts + final scopes = customUserManager.getEffectiveScopes(); + final prompts = customUserManager.getEffectivePrompts(scopes); + + // Then: Should be sorted and deduplicated + final expected = [ + 'consent', + 'login', + 'select_account' + ]; // Sorted alphabetically + expect(prompts, equals(expected)); + }); + }); + + group('getEffectiveScopes', () { + test('should include default scopes', () { + // When: Getting effective scopes + final scopes = userManager.getEffectiveScopes(); + + // Then: Should include all default scopes + expect(scopes, containsAll(['openid', 'webid', 'offline_access'])); + }); + + test('should use custom default scopes when specified', () { + // Given: Settings with custom default scopes + final customSettings = SolidOidcUserManagerSettings( + redirectUri: Uri.parse('https://example.com/callback'), + defaultScopes: ['openid', 'webid'], // No offline_access + ); + + final customUserManager = SolidOidcUserManager( + clientId: 'https://example.com/client-profile.jsonld', + webIdOrIssuer: 'https://alice.solidcommunity.net/profile/card#me', + store: OidcMemoryStore(), + settings: customSettings, + ); + + // When: Getting effective scopes + final scopes = customUserManager.getEffectiveScopes(); + + // Then: Should include only custom default scopes + expect(scopes, equals(['openid', 'webid'])); + expect(scopes, isNot(contains('offline_access'))); + }); + + test('should combine default and extra scopes', () { + // Given: Settings with extra scopes + final customSettings = SolidOidcUserManagerSettings( + redirectUri: Uri.parse('https://example.com/callback'), + extraScopes: ['profile', 'email'], + ); + + final customUserManager = SolidOidcUserManager( + clientId: 'https://example.com/client-profile.jsonld', + webIdOrIssuer: 'https://alice.solidcommunity.net/profile/card#me', + store: OidcMemoryStore(), + settings: customSettings, + ); + + // When: Getting effective scopes + final scopes = customUserManager.getEffectiveScopes(); + + // Then: Should include both default and extra scopes + expect( + scopes, + containsAll( + ['openid', 'webid', 'offline_access', 'profile', 'email'])); + }); + + test('should combine custom default scopes with extra scopes', () { + // Given: Settings with both custom default scopes and extra scopes + final customSettings = SolidOidcUserManagerSettings( + redirectUri: Uri.parse('https://example.com/callback'), + defaultScopes: [ + 'openid', + 'webid' + ], // Custom defaults without offline_access + extraScopes: ['profile', 'email'], // Extra scopes + ); + + final customUserManager = SolidOidcUserManager( + clientId: 'https://example.com/client-profile.jsonld', + webIdOrIssuer: 'https://alice.solidcommunity.net/profile/card#me', + store: OidcMemoryStore(), + settings: customSettings, + ); + + // When: Getting effective scopes + final scopes = customUserManager.getEffectiveScopes(); + + // Then: Should include both custom defaults and extra scopes + expect(scopes, containsAll(['openid', 'webid', 'profile', 'email'])); + expect(scopes, isNot(contains('offline_access'))); + expect(scopes.length, equals(4)); + }); + + test('should return sorted and deduplicated scopes', () { + // Given: Settings with duplicate scopes + final customSettings = SolidOidcUserManagerSettings( + redirectUri: Uri.parse('https://example.com/callback'), + extraScopes: ['openid', 'profile'], // openid is already in defaults + ); + + final customUserManager = SolidOidcUserManager( + clientId: 'https://example.com/client-profile.jsonld', + webIdOrIssuer: 'https://alice.solidcommunity.net/profile/card#me', + store: OidcMemoryStore(), + settings: customSettings, + ); + + // When: Getting effective scopes + final scopes = customUserManager.getEffectiveScopes(); + + // Then: Should be sorted and deduplicated + expect(scopes.where((s) => s == 'openid').length, equals(1)); + expect(scopes, equals(scopes.toList()..sort())); // Should be sorted + }); + }); + + group('calculateEffectivePrompts configuration', () { + test('should use custom prompt calculation function when provided', () { + // Given: Custom prompt calculation function + List customPromptCalculation( + List configuredPrompts, List effectiveScopes) { + final prompts = [...configuredPrompts]; + + // Custom logic: add 'login' for any scopes containing 'profile' + if (effectiveScopes.any((scope) => scope.contains('profile'))) { + prompts.add('login'); + } + + // Custom logic: add 'select_account' for offline_access + if (effectiveScopes.contains('offline_access')) { + prompts.add('select_account'); + } + + return prompts.toSet().toList()..sort(); + } + + final customSettings = SolidOidcUserManagerSettings( + redirectUri: Uri.parse('https://example.com/callback'), + extraScopes: ['profile', 'offline_access'], + prompt: ['consent'], + calculateEffectivePrompts: customPromptCalculation, + ); + + final customUserManager = SolidOidcUserManager( + clientId: 'https://example.com/client-profile.jsonld', + webIdOrIssuer: 'https://alice.solidcommunity.net/profile/card#me', + store: OidcMemoryStore(), + settings: customSettings, + ); + + // When: Getting effective prompts + final effectiveScopes = customUserManager.getEffectiveScopes(); + final prompts = customUserManager.getEffectivePrompts(effectiveScopes); + + // Then: Should use custom calculation (no default consent, but custom login and select_account) + expect(prompts, containsAll(['consent', 'login', 'select_account'])); + expect(prompts.length, equals(3)); + expect(prompts, equals(prompts.toList()..sort())); // Should be sorted + }); + + test( + 'should pass correct parameters to custom prompt calculation function', + () { + // Given: Mock function to capture parameters + List? capturedConfiguredPrompts; + List? capturedEffectiveScopes; + + List mockPromptCalculation( + List configuredPrompts, List effectiveScopes) { + capturedConfiguredPrompts = configuredPrompts; + capturedEffectiveScopes = effectiveScopes; + return ['mock_prompt']; + } + + final customSettings = SolidOidcUserManagerSettings( + redirectUri: Uri.parse('https://example.com/callback'), + extraScopes: ['profile', 'email'], + prompt: ['consent', 'login'], + calculateEffectivePrompts: mockPromptCalculation, + ); + + final customUserManager = SolidOidcUserManager( + clientId: 'https://example.com/client-profile.jsonld', + webIdOrIssuer: 'https://alice.solidcommunity.net/profile/card#me', + store: OidcMemoryStore(), + settings: customSettings, + ); + + // When: Getting effective prompts + final effectiveScopes = customUserManager.getEffectiveScopes(); + final prompts = customUserManager.getEffectivePrompts(effectiveScopes); + + // Then: Should pass correct parameters + expect(capturedConfiguredPrompts, equals(['consent', 'login'])); + expect(capturedEffectiveScopes, isNotNull); + expect( + capturedEffectiveScopes, + containsAll( + ['openid', 'webid', 'offline_access', 'profile', 'email'])); + expect(prompts, equals(['mock_prompt'])); + }); + + test( + 'should fall back to default behavior when no custom function provided', + () { + // Given: Settings without custom prompt calculation + final defaultSettings = SolidOidcUserManagerSettings( + redirectUri: Uri.parse('https://example.com/callback'), + extraScopes: ['offline_access'], + prompt: ['login'], + ); + + final defaultUserManager = SolidOidcUserManager( + clientId: 'https://example.com/client-profile.jsonld', + webIdOrIssuer: 'https://alice.solidcommunity.net/profile/card#me', + store: OidcMemoryStore(), + settings: defaultSettings, + ); + + // When: Getting effective prompts + final effectiveScopes = defaultUserManager.getEffectiveScopes(); + final prompts = defaultUserManager.getEffectivePrompts(effectiveScopes); + + // Then: Should use default behavior (automatic consent for offline_access) + expect(prompts, containsAll(['login', 'consent'])); + expect(prompts.length, equals(2)); + }); + + test('should handle custom function returning empty list', () { + // Given: Custom function that returns empty list + List emptyPromptCalculation( + List configuredPrompts, List effectiveScopes) { + return []; + } + + final customSettings = SolidOidcUserManagerSettings( + redirectUri: Uri.parse('https://example.com/callback'), + extraScopes: ['offline_access'], + prompt: ['consent', 'login'], + calculateEffectivePrompts: emptyPromptCalculation, + ); + + final customUserManager = SolidOidcUserManager( + clientId: 'https://example.com/client-profile.jsonld', + webIdOrIssuer: 'https://alice.solidcommunity.net/profile/card#me', + store: OidcMemoryStore(), + settings: customSettings, + ); + + // When: Getting effective prompts + final effectiveScopes = customUserManager.getEffectiveScopes(); + final prompts = customUserManager.getEffectivePrompts(effectiveScopes); + + // Then: Should return empty list as specified by custom function + expect(prompts, isEmpty); + }); + + test('should handle custom function with complex logic', () { + // Given: Custom function with conditional logic + List complexPromptCalculation( + List configuredPrompts, List effectiveScopes) { + final prompts = []; + + // Always include configured prompts + prompts.addAll(configuredPrompts); + + // Conditional logic based on scope combinations + if (effectiveScopes.contains('offline_access') && + effectiveScopes.contains('profile')) { + prompts.addAll(['consent', 'select_account']); + } else if (effectiveScopes.contains('offline_access')) { + prompts.add('consent'); + } + + // Add login prompt for webid scope + if (effectiveScopes.contains('webid')) { + prompts.add('login'); + } + + return prompts.toSet().toList()..sort(); + } + + final customSettings = SolidOidcUserManagerSettings( + redirectUri: Uri.parse('https://example.com/callback'), + extraScopes: ['profile', 'offline_access'], + prompt: ['none'], + calculateEffectivePrompts: complexPromptCalculation, + ); + + final customUserManager = SolidOidcUserManager( + clientId: 'https://example.com/client-profile.jsonld', + webIdOrIssuer: 'https://alice.solidcommunity.net/profile/card#me', + store: OidcMemoryStore(), + settings: customSettings, + ); + + // When: Getting effective prompts + final effectiveScopes = customUserManager.getEffectiveScopes(); + final prompts = customUserManager.getEffectivePrompts(effectiveScopes); + + // Then: Should follow custom logic + expect(prompts, + containsAll(['none', 'consent', 'select_account', 'login'])); + expect(prompts.length, equals(4)); + expect(prompts, equals(prompts.toList()..sort())); // Should be sorted + }); + }); + }); +} From 2b93ebae96327f8420d1401f91f67d9085b02cb5 Mon Sep 17 00:00:00 2001 From: Klas Kalass Date: Wed, 6 Aug 2025 14:08:06 +0200 Subject: [PATCH 06/11] feat: enable strict JWT verification by default for enhanced security - Change default value of strictJwtVerification from false to true - Update documentation to reflect security-first approach - Add security warnings for disabling strict verification - Remove redundant examples showing explicit true value --- lib/src/oidc/solid_oidc_user_manager.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/src/oidc/solid_oidc_user_manager.dart b/lib/src/oidc/solid_oidc_user_manager.dart index ce0d53e..3b59653 100644 --- a/lib/src/oidc/solid_oidc_user_manager.dart +++ b/lib/src/oidc/solid_oidc_user_manager.dart @@ -265,7 +265,6 @@ class _RsaInfo { /// ```dart /// final settings = SolidOidcUserManagerSettings( /// redirectUri: Uri.parse('https://myapp.com/callback'), -/// strictJwtVerification: true, /// supportOfflineAuth: false, /// refreshBefore: Duration(minutes: 5), /// ); @@ -273,7 +272,7 @@ class _RsaInfo { /// /// ## Security Considerations /// -/// - **JWT Verification**: Enable [strictJwtVerification] in production +/// - **JWT Verification**: [strictJwtVerification] is enabled by default for security /// - **Offline Auth**: Disable [supportOfflineAuth] unless specifically needed /// - **Token Refresh**: Configure [refreshBefore] to prevent token expiration /// - **Redirect URIs**: Ensure all URIs are registered with your identity provider @@ -334,7 +333,7 @@ class SolidOidcUserManagerSettings { this.frontChannelRequestListeningOptions = const OidcFrontChannelRequestListeningOptions(), this.refreshBefore = defaultRefreshBefore, - this.strictJwtVerification = false, + this.strictJwtVerification = true, this.getExpiresIn, this.sessionManagementSettings = const OidcSessionManagementSettings(), this.getIdToken, @@ -391,6 +390,9 @@ class SolidOidcUserManagerSettings { /// whether JWTs are strictly verified. /// /// If set to true, the library will throw an exception if a JWT is invalid. + /// + /// **Security Note**: This defaults to `true` for security. Only set to `false` + /// for development/testing or when working with non-compliant OIDC providers. final bool strictJwtVerification; /// Whether to support offline authentication or not. From 14b9eeffae54809b916b707a9b64f256eca814c2 Mon Sep 17 00:00:00 2001 From: Klas Kalass Date: Wed, 6 Aug 2025 18:11:54 +0200 Subject: [PATCH 07/11] Fix for refreshes: make sure we always have headers During refresh, apparently we do not have headers on the request, so we need to set it to an empty map --- lib/src/oidc/solid_oidc_user_manager.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/src/oidc/solid_oidc_user_manager.dart b/lib/src/oidc/solid_oidc_user_manager.dart index 3b59653..5b8a03b 100644 --- a/lib/src/oidc/solid_oidc_user_manager.dart +++ b/lib/src/oidc/solid_oidc_user_manager.dart @@ -866,7 +866,9 @@ class SolidOidcUserManager { request.tokenEndpoint.toString(), "POST", ); - + if (request.headers == null) { + request.headers = {}; + } request.headers!['DPoP'] = dPopToken; return Future.value(request); }, From 0333efd942985b3e5177122843b674744fbcfd8d Mon Sep 17 00:00:00 2001 From: Klas Kalass Date: Fri, 19 Sep 2025 09:25:08 +0200 Subject: [PATCH 08/11] docs: add network permissions setup for Android and macOS Added required network permissions documentation to README: - Android: INTERNET permission in AndroidManifest.xml - macOS: com.apple.security.network.client entitlement These permissions are essential for Solid authentication to work properly when connecting to identity providers and pod servers. Co-Authored-By: Claude --- README.md | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9b3567f..263fe0e 100644 --- a/README.md +++ b/README.md @@ -224,10 +224,43 @@ Create a redirect handler HTML page at your `frontendRedirectUrl` location. **Us ### Mobile & Desktop Applications -Each platform requires specific configuration for URL schemes and redirect handling. +Each platform requires specific configuration for URL schemes and redirect handling. **Additionally, you must configure network permissions for Solid pod authentication to work properly.** See the [OIDC Getting Started Guide](https://bdaya-dev.github.io/oidc/oidc-getting-started/) for detailed, up-to-date instructions for each platform. +#### 🌐 Required Network Permissions + +Since Solid authentication requires network access to communicate with identity providers and pod servers, you must configure the following platform-specific network permissions: + +**Android** - Add to `android/app/src/main/AndroidManifest.xml`: +```xml + + + + +``` + +**macOS** - Add to both `macos/Runner/DebugProfile.entitlements` and `macos/Runner/Release.entitlements`: +```xml +com.apple.security.network.client + +``` + +Example for `DebugProfile.entitlements`: +```xml + + com.apple.security.app-sandbox + + com.apple.security.network.server + + com.apple.security.network.client + + + +``` + +⚠️ **Note**: Without these network permissions, authentication will fail silently or with network-related errors. These permissions are essential for connecting to Solid identity providers. + ## 🔒 Security Considerations - **HTTPS Required**: All redirect URIs must use HTTPS in production From b0e291c9e1e54eb15ec21ac572692af5d23c31ba Mon Sep 17 00:00:00 2001 From: Klas Kalass Date: Wed, 29 Oct 2025 11:32:08 +0100 Subject: [PATCH 09/11] feat: add worker thread support for DPoP token generation Add DpopCredentials class for safe intra-process transfer of cryptographic material to Dart isolates and web workers. This enables offloading DPoP token generation to worker threads for improved performance in high- throughput scenarios. - DpopCredentials: Immutable, serializable class with RSA keys and access token - exportDpopCredentials(): Export credentials from SolidAuth/SolidOidcUserManager - generateDpopToken(): Instance method on DpopCredentials for token generation - Comprehensive security documentation explaining intra-process trust model - Complete isolate example (example/dpop_worker_example.dart) - Test coverage (7 tests for DpopCredentials) The security model is based on standard practice in multi-threaded crypto libraries: credentials stay within the OS-protected process boundary. --- doc/dpop_worker_threads.md | 170 ++++++++++++++++ example/dpop_worker_example.dart | 121 +++++++++++ lib/src/oidc/solid_oidc_user_manager.dart | 233 ++++++++++++++++++++++ lib/src/solid_auth.dart | 69 ++++++- test/dpop_credentials_test.dart | 125 ++++++++++++ 5 files changed, 717 insertions(+), 1 deletion(-) create mode 100644 doc/dpop_worker_threads.md create mode 100644 example/dpop_worker_example.dart create mode 100644 test/dpop_credentials_test.dart diff --git a/doc/dpop_worker_threads.md b/doc/dpop_worker_threads.md new file mode 100644 index 0000000..341670f --- /dev/null +++ b/doc/dpop_worker_threads.md @@ -0,0 +1,170 @@ +# DPoP Token Generation in Worker Threads + +This document describes the worker thread support for DPoP token generation in SolidAuth. + +## Overview + +SolidAuth supports generating DPoP tokens in Dart isolates or web workers through the `DpopCredentials` API. This enables: + +- Parallel DPoP token generation +- Offloading token generation from the main thread +- Better performance in high-throughput scenarios + +## API + +### Exporting Credentials + +```dart +final solidAuth = SolidAuth(/* ... */); +await solidAuth.authenticate('https://alice.pod.com/profile/card#me'); + +// Export credentials for worker thread +final credentials = solidAuth.exportDpopCredentials(); +``` + +### Generating DPoP Tokens in Workers + +```dart +// In worker thread/isolate +final credentials = DpopCredentials.fromJson(credentialsJson); +final dpop = credentials.generateDpopToken( + url: 'https://alice.pod.com/data/file.txt', + method: 'GET', +); + +// Use dpop.httpHeaders() for HTTP requests +``` + +## Security Model + +### What Gets Transferred + +`DpopCredentials` contains: +- RSA private key (PEM format) +- RSA public key (PEM and JWK format) +- OAuth2 access token + +### Security Boundary + +The private key is transferred between threads **within your application process**. This is secure because: + +1. **Process Isolation**: The OS protects your app's memory from other processes +2. **Same Security Context**: Workers run with the same permissions as the main thread +3. **Standard Practice**: Equivalent to multi-threaded crypto libraries (OpenSSL, BoringSSL) + +### Safe Usage + +✅ **SAFE - Intra-Process:** +```dart +// Dart isolate +await Isolate.spawn(worker, credentials.toJson()); + +// Flutter compute() - requires Map serialization +final result = await compute(workerFunc, { + 'credentials': credentials.toJson(), + 'url': url, + 'method': 'GET', +}); + +// Web worker (same-origin) +webWorker.postMessage(credentials.toJson()); +``` + +❌ **UNSAFE - External:** +```dart +// Network transfer +http.post(url, body: credentials.toJson()); // NEVER + +// Persistent storage +prefs.setString('creds', json); // NEVER + +// Logging +print(credentials.privateKey); // NEVER +``` + +## Example: Using with Dart Isolates + +See `example/dpop_worker_example.dart` for a complete implementation. + +```dart +// Main thread +final credentials = solidAuth.exportDpopCredentials(); +await Isolate.spawn(_workerFunction, { + 'sendPort': receivePort.sendPort, + 'credentials': credentials.toJson(), + 'url': url, +}); + +// Worker function +void _workerFunction(Map message) { + final credentials = DpopCredentials.fromJson(message['credentials']); + final dpop = credentials.generateDpopToken( + url: message['url'], + method: 'GET', + ); + // Use dpop for HTTP requests... +} +``` + +## Example: Using with compute() + +For simpler cases: + +```dart +import 'package:flutter/foundation.dart'; + +// Top-level or static function for compute() +Map _generateDpopInCompute(Map params) { + final credentials = DpopCredentials.fromJson(params['credentials']); + final dpop = credentials.generateDpopToken( + url: params['url'] as String, + method: params['method'] as String, + ); + // Return serializable Map + return { + 'dpopToken': dpop.dpopToken, + 'accessToken': dpop.accessToken, + }; +} + +// Usage +final credentials = solidAuth.exportDpopCredentials(); +final result = await compute( + _generateDpopInCompute, + { + 'credentials': credentials.toJson(), + 'url': 'https://alice.pod.com/data/', + 'method': 'GET', + }, +); + +// Reconstruct DPoP object on main thread +final dpop = DPoP( + dpopToken: result['dpopToken']!, + accessToken: result['accessToken']!, +); +``` + +## When to Use + +**Use worker threads when:** +- Generating many DPoP tokens concurrently +- Main thread responsiveness is critical +- Profiling shows DPoP generation is a bottleneck + +**Use main thread (simpler) when:** +- Generating a few tokens occasionally +- Simplicity is more important than performance + +```dart +// Main thread (simpler, sufficient for most apps) +final dpop = solidAuth.genDpopToken(url, method); +``` + +## Performance Considerations + +Worker threads add overhead (~10-50ms per isolate spawn). Benefits are only visible when: +- Generating multiple tokens in parallel +- Token generation would otherwise block the main thread + +Profile your specific use case to determine if workers provide benefits. diff --git a/example/dpop_worker_example.dart b/example/dpop_worker_example.dart new file mode 100644 index 0000000..945d5c9 --- /dev/null +++ b/example/dpop_worker_example.dart @@ -0,0 +1,121 @@ +/// Example: DPoP Token Generation in Dart Isolates +/// +/// Demonstrates offloading DPoP token generation to worker isolates. +/// +/// **Note:** This is a demonstration example showing the worker thread pattern. +/// It cannot be run standalone as it requires a complete Flutter application +/// with proper UI for OIDC authentication (browser redirects, etc.). +/// Use the patterns shown here in your own Flutter app after authentication +/// has been successfully completed on the main thread. + +import 'dart:isolate'; +import 'package:solid_auth/solid_auth.dart'; + +// Simple message passing between main and worker +class _WorkerMessage { + final SendPort sendPort; + final Map credentials; + final String url; + final String method; + + _WorkerMessage(this.sendPort, this.credentials, this.url, this.method); +} + +/// Worker function that generates DPoP token +void _workerFunction(_WorkerMessage msg) { + try { + final credentials = DpopCredentials.fromJson(msg.credentials); + final dpop = credentials.generateDpopToken( + url: msg.url, + method: msg.method, + ); + // Send back as Map (serializable) + msg.sendPort.send({ + 'success': true, + 'dpopToken': dpop.dpopToken, + 'accessToken': dpop.accessToken, + }); + } catch (e) { + msg.sendPort.send({'success': false, 'error': e.toString()}); + } +} + +/// Helper to generate DPoP in isolate +Future generateDpopInIsolate( + DpopCredentials credentials, + String url, + String method, +) async { + final receivePort = ReceivePort(); + await Isolate.spawn( + _workerFunction, + _WorkerMessage(receivePort.sendPort, credentials.toJson(), url, method), + ); + + final response = await receivePort.first as Map; + receivePort.close(); + + if (response['success'] == true) { + // Reconstruct DPoP object from Map + return DPoP( + dpopToken: response['dpopToken'] as String, + accessToken: response['accessToken'] as String, + ); + } else { + throw Exception('Worker error: ${response['error']}'); + } +} + +void main() async { + // Note: This example demonstrates the pattern but cannot run standalone. + // In a real app, authentication happens in the UI with browser redirects. + + // Initialize and authenticate (must be on main thread) + final solidAuth = SolidAuth( + oidcClientId: 'https://myapp.com/client-profile.jsonld', + appUrlScheme: 'myapp', + frontendRedirectUrl: Uri.parse('https://myapp.com/redirect.html'), + ); + + await solidAuth.init(); + + // In a real app, uncomment and ensure proper OIDC redirect handling: + // await solidAuth.authenticate('https://alice.pod.com/profile/card#me'); + + // Export credentials once (after successful authentication) + final credentials = solidAuth.exportDpopCredentials(); + + // Generate DPoP tokens in parallel + final urls = [ + 'https://alice.pod.com/profile/card', + 'https://alice.pod.com/public/', + 'https://alice.pod.com/private/', + ]; + + final dpopTokens = await Future.wait( + urls.map((url) => generateDpopInIsolate(credentials, url, 'GET')), + ); + + print('Generated ${dpopTokens.length} DPoP tokens in parallel'); + + // Alternative: Simple compute() approach + // Top-level function required for compute() + // Map _computeDpop(Map params) { + // final creds = DpopCredentials.fromJson(params['credentials']); + // final dpop = creds.generateDpopToken( + // url: params['url'] as String, + // method: params['method'] as String, + // ); + // return {'dpopToken': dpop.dpopToken, 'accessToken': dpop.accessToken}; + // } + // + // final result = await compute(_computeDpop, { + // 'credentials': credentials.toJson(), + // 'url': 'https://alice.pod.com/data/', + // 'method': 'GET', + // }); + // final dpop = DPoP( + // dpopToken: result['dpopToken']!, + // accessToken: result['accessToken']!, + // ); +} diff --git a/lib/src/oidc/solid_oidc_user_manager.dart b/lib/src/oidc/solid_oidc_user_manager.dart index 5b8a03b..aa4e629 100644 --- a/lib/src/oidc/solid_oidc_user_manager.dart +++ b/lib/src/oidc/solid_oidc_user_manager.dart @@ -9,6 +9,156 @@ import 'package:solid_auth/src/solid_auth_issuer.dart' as solid_auth_issuer; final _log = Logger("solid_authentication_oidc"); +/// Serializable credentials for generating DPoP tokens in worker threads/isolates. +/// +/// This class contains all the necessary data to generate DPoP tokens without +/// requiring access to the full [SolidAuth] instance. It's designed to be +/// safely transferred to Dart isolates or web workers. +/// +/// ## ⚠️ Contains Sensitive Cryptographic Material +/// +/// This class holds your **RSA private key** and **OAuth2 access token**. +/// These credentials enable secure intra-process transfer to worker threads +/// while maintaining the security boundary of your application. +/// +/// ## Quick Example +/// +/// ```dart +/// // Main thread +/// final credentials = solidAuth.exportDpopCredentials(); +/// await Isolate.spawn(workerFunction, credentials.toJson()); +/// +/// // Worker thread +/// void workerFunction(Map json) { +/// final credentials = DpopCredentials.fromJson(json); +/// final dpop = credentials.generateDpopToken( +/// url: 'https://alice.pod.com/data/', +/// method: 'GET', +/// ); +/// } +/// ``` +/// +/// ## Complete Documentation +/// +/// For detailed security guidelines, usage patterns, and best practices, see: +/// **[doc/dpop_worker_threads.md](../../doc/dpop_worker_threads.md)** +/// +/// The documentation covers: +/// - Security model and trust boundaries +/// - Safe vs. unsafe usage patterns +/// - Complete examples for isolates, compute(), and web workers +/// - Thread safety considerations +class DpopCredentials { + /// RSA public key in PEM format + final String publicKey; + + /// RSA private key in PEM format + /// + /// **Warning**: This is sensitive cryptographic material. Handle with care. + final String privateKey; + + /// Public key in JSON Web Key (JWK) format + final Map publicKeyJwk; + + /// OAuth2 access token for the authenticated user + /// + /// **Warning**: This is a bearer token that grants access to resources. + final String accessToken; + + const DpopCredentials({ + required this.publicKey, + required this.privateKey, + required this.publicKeyJwk, + required this.accessToken, + }); + + /// Serializes the credentials to JSON for transfer to workers. + /// + /// The resulting map can be: + /// - Converted to JSON string via `jsonEncode(credentials.toJson())` + /// - Sent directly via isolate SendPort + /// - Posted to web workers via postMessage + Map toJson() => { + 'publicKey': publicKey, + 'privateKey': privateKey, + 'publicKeyJwk': publicKeyJwk, + 'accessToken': accessToken, + }; + + /// Deserializes credentials from JSON received from the main thread. + /// + /// Example: + /// ```dart + /// // From JSON string + /// final credentials = DpopCredentials.fromJson(jsonDecode(jsonString)); + /// + /// // From map received via isolate + /// final credentials = DpopCredentials.fromJson(messageFromMain); + /// ``` + factory DpopCredentials.fromJson(Map json) => + DpopCredentials( + publicKey: json['publicKey'] as String, + privateKey: json['privateKey'] as String, + publicKeyJwk: json['publicKeyJwk'] as Map, + accessToken: json['accessToken'] as String, + ); + + /// Generates a DPoP token using these credentials. + /// + /// This method can be called from any thread (main thread, isolate, or web worker) + /// without requiring access to a [SolidAuth] instance. It's designed for use cases where + /// DPoP token generation needs to happen on a worker thread for performance reasons. + /// + /// ## Parameters + /// + /// - [url]: The complete URL of the API endpoint being accessed + /// - [method]: The HTTP method ('GET', 'POST', 'PUT', 'DELETE', etc.) + /// + /// ## Return Value + /// + /// Returns a [DPoP] object containing both the DPoP proof token and access token. + /// + /// ## Example + /// + /// ```dart + /// // In a worker/isolate + /// void workerFunction(Map credentialsJson) { + /// final credentials = DpopCredentials.fromJson(credentialsJson); + /// final dpop = credentials.generateDpopToken( + /// url: 'https://alice.pod.com/data/file.txt', + /// method: 'GET', + /// ); + /// + /// // Use in HTTP request + /// final response = await http.get( + /// Uri.parse('https://alice.pod.com/data/file.txt'), + /// headers: dpop.httpHeaders(), + /// ); + /// } + /// ``` + /// + /// ## Security + /// + /// - Each DPoP token is bound to the specific URL and HTTP method + /// - Tokens include a unique nonce and timestamp + /// - Tokens should be generated fresh for each request + /// - The private key never leaves the credentials object + DPoP generateDpopToken({ + required String url, + required String method, + }) { + final rsaKeyPair = KeyPair(publicKey, privateKey); + final dpopToken = solid_auth_client.genDpopToken( + url, + rsaKeyPair, + publicKeyJwk, + method, + ); + + return DPoP(dpopToken: dpopToken, accessToken: accessToken); + } +} + /// Contains DPoP token and access token for authenticated API requests to Solid servers. /// /// DPoP (Demonstration of Proof-of-Possession) is a security mechanism required @@ -238,6 +388,10 @@ Future> _getIssuersDefault(String webIdOrIssuer) async { } } +/// Internal storage for RSA key pair used in DPoP token generation. +/// +/// This class is not intended for direct use. Instead, use [DpopCredentials] +/// to safely transfer credentials to worker threads/isolates. class _RsaInfo { final String pubKey; final String privKey; @@ -248,6 +402,12 @@ class _RsaInfo { required this.privKey, required this.pubKeyJwk, }); + + Map toJson() => { + 'pubKey': pubKey, + 'privKey': privKey, + 'pubKeyJwk': pubKeyJwk, + }; } /// Advanced configuration settings for the OIDC authentication flow in Solid applications. @@ -1187,6 +1347,79 @@ class SolidOidcUserManager { return DPoP(dpopToken: dpopToken, accessToken: accessToken); } + /// Exports DPoP credentials for use in worker threads/isolates. + /// + /// This method extracts the necessary cryptographic material and tokens + /// to allow DPoP token generation in a separate thread without requiring + /// the full [SolidOidcUserManager] instance. + /// + /// ## Use Cases + /// + /// - Offloading DPoP token generation to a worker thread for performance + /// - Generating multiple DPoP tokens in parallel in separate isolates + /// - Separating authentication from request processing in worker architecture + /// + /// ## Security Considerations + /// + /// The returned [DpopCredentials] contain sensitive data: + /// - RSA private key for DPoP signing + /// - OAuth2 access token granting resource access + /// + /// **Best Practices:** + /// - Only export when actually needed for worker processing + /// - Send only to trusted worker code within your application + /// - Generate fresh credentials for each worker task + /// - Never persist exported credentials to disk + /// - Dispose of credentials immediately after use + /// + /// ## Example + /// + /// ```dart + /// // Main thread + /// final manager = SolidOidcUserManager(/* ... */); + /// await manager.init(); + /// await manager.loginAuthorizationCodeFlow(); + /// + /// // Export for worker + /// final credentials = manager.exportDpopCredentials(); + /// + /// // Send to isolate + /// await Isolate.spawn(workerFunction, credentials.toJson()); + /// + /// // Worker thread + /// void workerFunction(Map credentialsJson) { + /// final credentials = DpopCredentials.fromJson(credentialsJson); + /// final dpop = credentials.generateDpopToken( + /// url: 'https://alice.pod.com/data/', + /// method: 'GET', + /// ); + /// // Use dpop for request... + /// } + /// ``` + /// + /// ## Throws + /// + /// Throws [Exception] if: + /// - No user is currently authenticated + /// - Access token is not available + /// - RSA key pair is not initialized + DpopCredentials exportDpopCredentials() { + if (_manager?.currentUser?.token.accessToken == null) { + throw Exception('No access token available. User must be authenticated.'); + } + if (_rsaInfo == null) { + throw Exception( + 'RSA key pair not initialized. User must be authenticated.'); + } + + return DpopCredentials( + publicKey: _rsaInfo!.pubKey, + privateKey: _rsaInfo!.privKey, + publicKeyJwk: Map.from(_rsaInfo!.pubKeyJwk), + accessToken: _manager!.currentUser!.token.accessToken!, + ); + } + /// Logs out the current user and clears all authentication data. /// /// This method performs a complete logout process: diff --git a/lib/src/solid_auth.dart b/lib/src/solid_auth.dart index 13a1102..5c3fac5 100644 --- a/lib/src/solid_auth.dart +++ b/lib/src/solid_auth.dart @@ -6,7 +6,7 @@ import 'package:oidc/oidc.dart'; import 'package:oidc_default_store/oidc_default_store.dart'; import 'package:solid_auth/src/oidc/solid_oidc_user_manager.dart'; export 'package:solid_auth/src/oidc/solid_oidc_user_manager.dart' - show DPoP, UserAndWebId; + show DPoP, UserAndWebId, DpopCredentials; /// The default refresh behavior: refresh tokens 1 minute before they expire. /// @@ -967,6 +967,73 @@ class SolidAuth { return _manager!.genDpopToken(url, method); } + /// Exports DPoP credentials for use in worker threads/isolates. + /// + /// This method enables DPoP token generation on worker threads by extracting + /// the necessary cryptographic material and access token. The returned + /// credentials can be safely transferred to Dart isolates or web workers. + /// + /// ## ⚠️ Contains Sensitive Material + /// + /// The exported credentials include your **RSA private key** and **access token**. + /// These are safe to transfer within your app's process (isolates, web workers) + /// but must NEVER be sent over networks, stored to disk, or logged. + /// + /// ## Quick Example + /// + /// ```dart + /// // Export credentials + /// final credentials = solidAuth.exportDpopCredentials(); + /// + /// // Send to isolate + /// await Isolate.spawn(workerFunction, credentials.toJson()); + /// + /// // Worker generates DPoP token + /// void workerFunction(Map json) { + /// final credentials = DpopCredentials.fromJson(json); + /// final dpop = credentials.generateDpopToken( + /// url: 'https://alice.pod.com/data/', + /// method: 'GET', + /// ); + /// } + /// ``` + /// + /// ## When to Use This + /// + /// Use this method when: + /// - DPoP token generation is a performance bottleneck + /// - You need to generate multiple tokens in parallel + /// - Your architecture separates authentication from request processing + /// + /// For most applications, the simpler [genDpopToken] method is sufficient: + /// ```dart + /// final dpop = solidAuth.genDpopToken(url, method); // Simpler approach + /// ``` + /// + /// ## Complete Documentation + /// + /// For detailed security guidelines, usage patterns, and examples, see: + /// **[doc/dpop_worker_threads.md](../doc/dpop_worker_threads.md)** + /// + /// The documentation includes: + /// - Comprehensive security model explanation + /// - Safe vs. unsafe usage patterns + /// - Complete examples for isolates, compute(), and web workers + /// - Performance considerations and best practices + /// + /// ## Throws + /// + /// Throws [Exception] if: + /// - No user is currently authenticated + /// - Access token is unavailable or expired + /// - RSA key pair is not initialized + DpopCredentials exportDpopCredentials() { + if (_manager == null) { + throw Exception('SolidAuth not initialized. Call authenticate() first.'); + } + return _manager!.exportDpopCredentials(); + } + /// Checks if a user is currently authenticated. /// /// This is a synchronous check of the current authentication state. diff --git a/test/dpop_credentials_test.dart b/test/dpop_credentials_test.dart new file mode 100644 index 0000000..0e3ce5f --- /dev/null +++ b/test/dpop_credentials_test.dart @@ -0,0 +1,125 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:solid_auth/src/oidc/solid_oidc_user_manager.dart'; + +void main() { + group('DpopCredentials', () { + late DpopCredentials testCredentials; + + setUp(() { + testCredentials = const DpopCredentials( + publicKey: + '-----BEGIN PUBLIC KEY-----\ntest_public_key\n-----END PUBLIC KEY-----', + privateKey: + '-----BEGIN PRIVATE KEY-----\ntest_private_key\n-----END PRIVATE KEY-----', + publicKeyJwk: { + 'kty': 'RSA', + 'n': 'test_modulus', + 'e': 'AQAB', + 'alg': 'RS256', + }, + accessToken: 'test_access_token_12345', + ); + }); + + test('should serialize to JSON correctly', () { + final json = testCredentials.toJson(); + + expect(json['publicKey'], contains('BEGIN PUBLIC KEY')); + expect(json['privateKey'], contains('BEGIN PRIVATE KEY')); + expect(json['publicKeyJwk'], isA>()); + expect(json['accessToken'], equals('test_access_token_12345')); + }); + + test('should deserialize from JSON correctly', () { + final json = testCredentials.toJson(); + final deserialized = DpopCredentials.fromJson(json); + + expect(deserialized.publicKey, equals(testCredentials.publicKey)); + expect(deserialized.privateKey, equals(testCredentials.privateKey)); + expect(deserialized.publicKeyJwk, equals(testCredentials.publicKeyJwk)); + expect(deserialized.accessToken, equals(testCredentials.accessToken)); + }); + + test('should be immutable', () { + // Modifying the original JWK map should not affect the credentials + final jwkMap = {'kty': 'RSA', 'n': 'test', 'e': 'AQAB'}; + final credentials = DpopCredentials( + publicKey: 'pub', + privateKey: 'priv', + publicKeyJwk: Map.from(jwkMap), + accessToken: 'token', + ); + + jwkMap['modified'] = 'value'; + + // The original map is modified + expect(jwkMap.containsKey('modified'), isTrue); + + // But the credentials' JWK is a different instance + // (Note: DpopCredentials doesn't deep-copy the map, so this tests + // that we created a new map instance via Map.from) + expect(identical(credentials.publicKeyJwk, jwkMap), isFalse); + }); + + test('should round-trip through JSON serialization', () { + final json1 = testCredentials.toJson(); + final intermediate = DpopCredentials.fromJson(json1); + final json2 = intermediate.toJson(); + + expect(json2, equals(json1)); + }); + + test('generateDpopToken should throw on invalid keys', () { + // Note: This test verifies that the method properly validates + // cryptographic keys. Invalid test keys should throw an error. + + expect( + () => testCredentials.generateDpopToken( + url: 'https://example.com/resource', + method: 'GET', + ), + // This will throw because test keys are invalid + throwsA(anything), + ); + }); + + test('should handle complex JWK structures', () { + final complexCredentials = DpopCredentials( + publicKey: 'pub', + privateKey: 'priv', + publicKeyJwk: { + 'kty': 'RSA', + 'n': 'very_long_modulus_value', + 'e': 'AQAB', + 'alg': 'RS256', + 'use': 'sig', + 'kid': 'key-id-123', + }, + accessToken: 'token', + ); + + final json = complexCredentials.toJson(); + final deserialized = DpopCredentials.fromJson(json); + + expect(deserialized.publicKeyJwk['kid'], equals('key-id-123')); + expect(deserialized.publicKeyJwk['use'], equals('sig')); + }); + }); + + group('DpopCredentials Security', () { + test('should contain sensitive data warning in documentation', () { + // This is a documentation test to ensure developers are aware + // of security implications + const credentials = DpopCredentials( + publicKey: 'pub', + privateKey: 'priv', + publicKeyJwk: {'kty': 'RSA'}, + accessToken: 'token', + ); + + // Verify that the class exists and has the expected fields + expect(credentials.privateKey, isNotEmpty); + expect(credentials.accessToken, isNotEmpty); + }); + }); +} From 8258c6d87a368162e8ccb1e72422176edbaf0bc2 Mon Sep 17 00:00:00 2001 From: Klas Kalass Date: Sat, 1 Nov 2025 19:38:52 +0100 Subject: [PATCH 10/11] refactor: eliminate fast_rsa dependency from serializable types PROBLEM: DPoP credentials needed to be transferred to web workers and isolates for performance-optimized token generation. However, using fast_rsa's KeyPair class in our serializable DpopCredentials prevented this because fast_rsa types depend on platform-specific code (platform channels on native, WebAssembly on web) that is unavailable in worker contexts. The root cause was subtle but simple: we never actually needed to *call* fast_rsa methods in workers - we only needed to *use* the KeyPair type to store PEM strings. The dependency on the fast_rsa.KeyPair type itself was the blocker. SOLUTION: Introduce a clean abstraction layer that isolates fast_rsa to key generation: 1. Created platform-agnostic types in rsa_api.dart: - KeyPair: Simple PEM string container (no platform dependencies) - GeneratedRsaKeyPair: Complete generation result with JWK - RsaCrypto: Abstract interface for key generation 2. Implemented fast_rsa adapter in rsa_fast.dart: - RsaCryptoImpl wraps fast_rsa for all platforms - Converts fast_rsa.KeyPair to our platform-agnostic KeyPair - Maintains excellent performance (native code + WebAssembly) 3. Created singleton in rsa_impl.dart: - Provides single point of access via 'rsa' constant - Ready for platform-specific implementations if needed 4. Updated all consumers to use new types: - DpopCredentials now uses KeyPair instead of fast_rsa.KeyPair - SolidOidcUserManager adapted to new API - Removed genRsaKeyPair() helper (now rsa.generate()) BENEFITS: - DPoP credentials can now be safely serialized and transferred to workers - Clear separation between interface and implementation - Preserves fast_rsa performance on all platforms - No behavioral changes to existing functionality - Simpler than initially anticipated - no need for web-specific implementation KEY INSIGHT: The abstraction isn't about replacing fast_rsa - it's about making the *results* of fast_rsa operations portable across execution contexts by using simple, serializable types (strings) instead of platform-specific classes. --- example/pubspec.lock | 24 ++-- lib/src/oidc/solid_oidc_user_manager.dart | 14 +-- lib/src/rsa/rsa_api.dart | 141 ++++++++++++++++++++++ lib/src/rsa/rsa_fast.dart | 64 ++++++++++ lib/src/rsa/rsa_impl.dart | 44 +++++++ lib/src/solid_auth_client.dart | 21 +--- 6 files changed, 269 insertions(+), 39 deletions(-) create mode 100644 lib/src/rsa/rsa_api.dart create mode 100644 lib/src/rsa/rsa_fast.dart create mode 100644 lib/src/rsa/rsa_impl.dart diff --git a/example/pubspec.lock b/example/pubspec.lock index 12802c8..d9902a2 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -109,10 +109,10 @@ packages: dependency: transitive description: name: fast_rsa - sha256: e410cf3bc1e5034a79ff11a1d5a0b9928e4d5c3f9e861b0b7d3a1b16833585a5 + sha256: "3332ab9023288310ceaf990e4567e68adff6c2ef5cbe4a742866754016f0b943" url: "https://pub.dev" source: hosted - version: "3.8.5" + version: "3.8.6" ffi: dependency: transitive description: @@ -291,26 +291,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" logging: dependency: transitive description: @@ -696,10 +696,10 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.6" typed_data: dependency: transitive description: @@ -800,10 +800,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: diff --git a/lib/src/oidc/solid_oidc_user_manager.dart b/lib/src/oidc/solid_oidc_user_manager.dart index aa4e629..0e26354 100644 --- a/lib/src/oidc/solid_oidc_user_manager.dart +++ b/lib/src/oidc/solid_oidc_user_manager.dart @@ -1,11 +1,11 @@ import 'dart:convert'; - -import 'package:fast_rsa/fast_rsa.dart'; import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; import 'package:oidc/oidc.dart'; +import 'package:solid_auth/src/rsa/rsa_api.dart'; import 'package:solid_auth/src/solid_auth_client.dart' as solid_auth_client; import 'package:solid_auth/src/solid_auth_issuer.dart' as solid_auth_issuer; +import 'package:solid_auth/src/rsa/rsa_impl.dart'; final _log = Logger("solid_authentication_oidc"); @@ -1160,12 +1160,12 @@ class SolidOidcUserManager { } Future _generateAndPersistRsaKeyPair() async { - final rsaInfo = await solid_auth_client.genRsaKeyPair(); - final rsa = rsaInfo['rsa'] as KeyPair; + final rsaInfo = await rsa.generate(); + final keyPair = rsaInfo.rsaKeyPair; _rsaInfo = _RsaInfo( - pubKey: rsa.publicKey, - privKey: rsa.privateKey, - pubKeyJwk: rsaInfo['pubKeyJwk'], + pubKey: keyPair.publicKey, + privKey: keyPair.privateKey, + pubKeyJwk: rsaInfo.publicKeyJwk, ); await _persistRsaInfo(); _log.info('DPoP RSA key pair generated and persisted'); diff --git a/lib/src/rsa/rsa_api.dart b/lib/src/rsa/rsa_api.dart new file mode 100644 index 0000000..538fc9e --- /dev/null +++ b/lib/src/rsa/rsa_api.dart @@ -0,0 +1,141 @@ +/// Platform-agnostic RSA key pair representation. +/// +/// This class provides a simple, serializable representation of an RSA key pair +/// using PEM-encoded strings. Unlike platform-specific implementations (e.g., +/// `fast_rsa.KeyPair`), this class can be safely used across all platforms +/// including web workers and isolates. +/// +/// ## Usage +/// +/// ```dart +/// final keyPair = KeyPair( +/// '-----BEGIN PUBLIC KEY-----...', +/// '-----BEGIN PRIVATE KEY-----...', +/// ); +/// ``` +/// +/// ## Security +/// +/// The private key should be treated as sensitive data and handled securely: +/// - Never log or display private keys +/// - Store securely using platform-appropriate secure storage +/// - Transfer only within trusted boundaries (same application) +class KeyPair { + /// RSA public key in PEM format (PKCS#8). + final String publicKey; + + /// RSA private key in PEM format (PKCS#8). + /// + /// **Warning**: This contains sensitive cryptographic material. + final String privateKey; + + /// Creates a key pair from PEM-encoded strings. + const KeyPair(this.publicKey, this.privateKey); +} + +/// Result of RSA key pair generation, including both PEM and JWK representations. +/// +/// This class encapsulates the complete result of generating an RSA key pair, +/// providing both the key pair itself and the public key in JWK format for use +/// in DPoP token headers. +/// +/// ## Usage +/// +/// ```dart +/// final result = await rsa.generate(2048); +/// final pem = result.rsaKeyPair.publicKey; +/// final jwk = result.publicKeyJwk; // For DPoP token headers +/// ``` +class GeneratedRsaKeyPair { + /// The generated RSA key pair in PEM format. + final KeyPair rsaKeyPair; + + /// The public key in JSON Web Key (JWK) format. + /// + /// This format is required for DPoP token headers according to + /// [RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449). + /// The JWK includes the `alg: "RS256"` parameter. + final dynamic publicKeyJwk; + + /// Creates a generation result with both PEM and JWK representations. + const GeneratedRsaKeyPair({ + required this.rsaKeyPair, + required this.publicKeyJwk, + }); +} + +/// Abstract interface for RSA cryptographic operations. +/// +/// This interface abstracts RSA key generation to allow platform-specific +/// implementations. Currently uses `fast_rsa` on all platforms for optimal +/// performance. +/// +/// ## Why This Abstraction? +/// +/// This abstraction was introduced to solve a specific problem: `fast_rsa.KeyPair` +/// could not be used in serializable types like `DpopCredentials` because it +/// depends on Flutter platform channels which are unavailable in web workers. +/// +/// By introducing our own platform-agnostic `KeyPair` class, we can: +/// - Use `fast_rsa` for key generation (excellent performance) +/// - Pass the resulting keys safely across isolate/worker boundaries +/// - Serialize credentials without platform-specific dependencies +/// +/// ## Implementation Strategy +/// +/// All platforms currently use `fast_rsa` for key generation, because key +/// generation typically is done on the main thread where platform channels are +/// available. If you do need key generation in a web worker or isolate in the future, +/// a different implementation can be provided here and used via conditional imports. +/// +/// ## Usage +/// +/// ```dart +/// import 'package:solid_auth/src/rsa/rsa_impl.dart'; +/// +/// // Use the singleton instance +/// final result = await rsa.generate(2048); +/// final keyPair = result.rsaKeyPair; +/// final jwk = result.publicKeyJwk; +/// ``` +/// +/// ## Custom Implementation +/// +/// To provide a custom implementation: +/// +/// ```dart +/// class CustomRsaCrypto implements RsaCrypto { +/// @override +/// Future generate([int bits = 2048]) async { +/// // Custom key generation logic +/// return GeneratedRsaKeyPair( +/// rsaKeyPair: KeyPair(publicPem, privatePem), +/// publicKeyJwk: jwkMap, +/// ); +/// } +/// } +/// ``` +abstract class RsaCrypto { + /// Generates an RSA key pair with the specified bit length. + /// + /// ## Parameters + /// + /// - [bits]: The key size in bits. Common values: + /// - 2048: Standard security, good performance (default) + /// - 4096: Higher security, slower performance + /// + /// ## Return Value + /// + /// Returns a [GeneratedRsaKeyPair] containing: + /// - The key pair in PEM format (PKCS#8) + /// - The public key in JWK format with `alg: "RS256"` + /// + /// ## Example + /// + /// ```dart + /// final result = await rsa.generate(2048); + /// print('Public key: ${result.rsaKeyPair.publicKey}'); + /// print('JWK algorithm: ${result.publicKeyJwk['alg']}'); + /// ``` + Future generate([int bits = 2048]); +} diff --git a/lib/src/rsa/rsa_fast.dart b/lib/src/rsa/rsa_fast.dart new file mode 100644 index 0000000..edbcb48 --- /dev/null +++ b/lib/src/rsa/rsa_fast.dart @@ -0,0 +1,64 @@ +import 'rsa_api.dart'; +import 'package:fast_rsa/fast_rsa.dart' as fast; + +/// Native platform implementation of RSA crypto operations using fast_rsa. +/// +/// This implementation provides high-performance RSA key generation by leveraging +/// the `fast_rsa` package. +/// +/// ## Platform Support +/// +/// This implementation is used on: +/// - iOS +/// - Android +/// - macOS +/// - Linux +/// - Windows +/// - Web +/// +/// ## Performance +/// +/// `fast_rsa` provides significantly better performance than pure Dart +/// implementations due to its use of native cryptographic libraries. +/// Key generation for 2048-bit keys typically completes in under 100ms. +/// +/// ## Key Generation vs. Key Usage +/// +/// While `fast_rsa` itself relies on Flutter platform channels (native) or +/// WebAssembly (web), the _resulting_ keys are just PEM strings. By converting +/// `fast_rsa.KeyPair` to our platform-agnostic `KeyPair` class, we enable: +/// +/// - Key generation anywhere `fast_rsa` is available +/// - Key usage (signing) in workers/isolates using our existing JWT infrastructure +/// - Credential serialization without platform-specific dependencies +/// +/// ## Implementation Details +/// +/// The implementation performs the following steps: +/// 1. Generates an RSA key pair using `fast_rsa` +/// 2. Converts the fast_rsa-specific KeyPair to our platform-agnostic KeyPair +/// 3. Converts the public key to JWK format +/// 4. Adds the required `alg: "RS256"` parameter to the JWK +/// +/// This ensures compatibility with DPoP token requirements while maintaining +/// the ability to pass keys across platform boundaries. +class RsaCryptoImpl implements RsaCrypto { + /// Creates a new instance of the fast_rsa implementation. + const RsaCryptoImpl(); + + @override + Future generate([int bits = 2048]) async { + // Generate key pair using fast_rsa's native implementation + final keyPair = await fast.RSA.generate(bits); + + // Convert public key to JWK format for DPoP token headers + final publicKeyJwk = + await fast.RSA.convertPublicKeyToJWK(keyPair.publicKey); + + // Return our platform-agnostic types with required algorithm parameter + return GeneratedRsaKeyPair( + rsaKeyPair: KeyPair(keyPair.publicKey, keyPair.privateKey), + publicKeyJwk: {...publicKeyJwk, 'alg': 'RS256'}, + ); + } +} diff --git a/lib/src/rsa/rsa_impl.dart b/lib/src/rsa/rsa_impl.dart new file mode 100644 index 0000000..09471a5 --- /dev/null +++ b/lib/src/rsa/rsa_impl.dart @@ -0,0 +1,44 @@ +import 'rsa_api.dart'; +// You can use conditional imports if you have different implementations like below +// import 'rsa_fast.dart' if (dart.library.html) 'rsa_web.dart'; +import 'rsa_fast.dart'; + +/// Singleton instance of the RSA crypto implementation. +/// +/// This constant provides access to RSA key generation functionality using +/// `fast_rsa` for optimal performance on all platforms: +/// +/// ## Usage +/// +/// ```dart +/// import 'package:solid_auth/src/rsa/rsa_impl.dart'; +/// +/// // Generate a key pair +/// final result = await rsa.generate(2048); +/// +/// // Access the PEM-encoded keys +/// final publicPem = result.rsaKeyPair.publicKey; +/// final privatePem = result.rsaKeyPair.privateKey; +/// +/// // Access the JWK for DPoP headers +/// final jwk = result.publicKeyJwk; +/// ``` +/// +/// ## Why This Abstraction? +/// +/// The abstraction layer solves a critical problem: `fast_rsa.KeyPair` could +/// not be used in serializable types (like `DpopCredentials`) because it +/// depends on platform-specific code that is unavailable in web workers and +/// isolates. +/// +/// By introducing our own platform-agnostic `KeyPair` class, we can: +/// +/// 1. Use `fast_rsa` for optimal key generation performance +/// 2. Pass key pairs safely across isolate/worker boundaries +/// 3. Serialize credentials without platform-specific type dependencies +/// +/// The key insight: we never needed to _call_ `fast_rsa` in workers - we just +/// needed to _use_ the KeyPair type. By creating our own simple KeyPair class +/// and separating KeyPair type from key generation implementation, +/// the problem disappeared. +const RsaCrypto rsa = RsaCryptoImpl(); diff --git a/lib/src/solid_auth_client.dart b/lib/src/solid_auth_client.dart index dfd8ccb..eb4f66b 100644 --- a/lib/src/solid_auth_client.dart +++ b/lib/src/solid_auth_client.dart @@ -1,26 +1,7 @@ -import 'dart:async'; - -import 'package:fast_rsa/fast_rsa.dart'; import 'package:solid_auth/src/jwt/dart_jsonwebtoken.dart'; +import 'package:solid_auth/src/rsa/rsa_api.dart'; import 'package:uuid/uuid.dart'; -/// Generate RSA key pair for the authentication -Future genRsaKeyPair() async { - /// Generate a key pair - var rsaKeyPair = await RSA.generate(2048); - - /// JWK conversion of private and public keys - var publicKeyJwk = await RSA.convertPublicKeyToJWK(rsaKeyPair.publicKey); - var privateKeyJwk = await RSA.convertPrivateKeyToJWK(rsaKeyPair.privateKey); - - publicKeyJwk['alg'] = "RS256"; - return { - 'rsa': rsaKeyPair, - 'privKeyJwk': privateKeyJwk, - 'pubKeyJwk': publicKeyJwk - }; -} - /// Generate dPoP token for the authentication String genDpopToken(String endPointUrl, KeyPair rsaKeyPair, dynamic publicKeyJwk, String httpMethod) { From 8b158b39f01c83b2d82bc0a9acd245810671f8c9 Mon Sep 17 00:00:00 2001 From: Klas Kalass Date: Sun, 2 Nov 2025 08:36:18 +0100 Subject: [PATCH 11/11] refactor: create Flutter-free entry point for worker threads Architectural Improvements: ========================== Split the library into two entry points to enable DPoP token generation in Dart isolates and web workers without Flutter dependencies: 1. package:solid_auth/solid_auth.dart (Main entry point) - Full Flutter-enabled authentication library - SolidAuth class with UI integration - OIDC flow management with browser redirects - Session persistence using platform storage - Exports: SolidAuth, UserAndWebId, DpopCredentials, DPoP 2. package:solid_auth/worker.dart (Worker entry point - NEW) - Pure Dart, zero Flutter dependencies - Minimal API for DPoP token generation - Safe for use in isolates and web workers - Exports: DpopCredentials, DPoP, KeyPair File Structure Changes: ====================== Moved/Created: - lib/worker.dart (NEW) - Flutter-free public API - lib/src/gen_dpop_token.dart (MOVED from solid_auth_client.dart) - lib/src/oidc/dpop_credentials.dart (MOVED from solid_oidc_user_manager.dart) - example/lib/dpop_worker_example.dart (MOVED from example/) Deleted: - lib/src/solid_auth_client.dart (split into gen_dpop_token.dart + imports) - example/dpop_worker_example.dart (moved to example/lib/) Modified: - lib/src/oidc/solid_oidc_user_manager.dart - Removed DpopCredentials and DPoP classes (moved to dpop_credentials.dart) - Updated imports to use gen_dpop_token.dart - lib/src/solid_auth.dart - Updated exports to reflect new file structure - test/dpop_credentials_test.dart - Updated import to use new dpop_credentials.dart location Benefits: ======== Performance: - Offload cryptographic operations from main UI thread - Generate multiple DPoP tokens in parallel - Better responsiveness for high-throughput scenarios Architecture: - Clear separation of concerns (Flutter vs. pure Dart) - Enables testing without Flutter test harness - Potential reuse in non-Flutter Dart projects Developer Experience: - Explicit import paths prevent accidental Flutter deps in workers - Comprehensive documentation in worker.dart - Complete working examples Documentation: ============= Added comprehensive documentation covering: - worker.dart: 90+ lines explaining architecture and usage - gen_dpop_token.dart: RFC references and internal API docs - README.md: Complete worker thread section with examples - example/lib/dpop_worker_example.dart: Full working patterns Security Model: ============== Maintains existing security posture: - Credentials safe for intra-process transfer only - No persistent storage of serialized credentials - Fresh token generation per request - Private keys never leave credential objects Testing: ======= All existing tests pass: - test/dpop_credentials_test.dart: 7/7 tests passing - No behavioral changes to public API - Example code analyzes without issues --- README.md | 167 +++++++++++++++ example/{ => lib}/dpop_worker_example.dart | 6 +- lib/src/gen_dpop_token.dart | 81 ++++++++ lib/src/oidc/dpop_credentials.dart | 227 ++++++++++++++++++++ lib/src/oidc/solid_oidc_user_manager.dart | 228 +-------------------- lib/src/solid_auth.dart | 5 +- lib/src/solid_auth_client.dart | 37 ---- lib/worker.dart | 108 ++++++++++ test/dpop_credentials_test.dart | 2 +- 9 files changed, 595 insertions(+), 266 deletions(-) rename example/{ => lib}/dpop_worker_example.dart (92%) create mode 100644 lib/src/gen_dpop_token.dart create mode 100644 lib/src/oidc/dpop_credentials.dart delete mode 100644 lib/src/solid_auth_client.dart create mode 100644 lib/worker.dart diff --git a/README.md b/README.md index 263fe0e..38a0c58 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,173 @@ await solidAuth.logout(); await solidAuth.dispose(); ``` +## ⚡ Advanced: DPoP Token Generation in Worker Threads + +For performance-critical applications that need to generate many DPoP tokens without blocking the UI, `solid_auth` provides a Flutter-free entry point for use in Dart isolates and web workers. + +### Why Use Worker Threads? + +- **Non-blocking UI**: Offload cryptographic operations from the main thread +- **Parallel Processing**: Generate multiple DPoP tokens concurrently +- **Better Performance**: Utilize multiple CPU cores for token generation +- **Scalability**: Handle high-throughput API scenarios + +### Architecture Overview + +``` +Main Thread (Flutter) Worker Thread (Pure Dart) +───────────────────── ───────────────────────── +import 'solid_auth.dart' import 'solid_auth/worker.dart' + +SolidAuth DpopCredentials +├─ authenticate() ────────────> (serialize) +├─ exportDpopCredentials() ├─ fromJson() +└─ (Flutter/OIDC flow) └─ generateDpopToken() +``` + +### Basic Usage + +```dart +// worker.dart - Pure Dart worker thread (NO Flutter imports!) +import 'dart:isolate'; +import 'package:solid_auth/worker.dart'; // ← Flutter-free entry point + +void workerEntryPoint(Map message) { + final credentials = DpopCredentials.fromJson(message['credentials']); + + final dpop = credentials.generateDpopToken( + url: message['url'] as String, + method: message['method'] as String, + ); + + // Send result back to main thread + final sendPort = message['sendPort'] as SendPort; + sendPort.send({ + 'dpopToken': dpop.dpopToken, + 'accessToken': dpop.accessToken, + }); +} +``` + +```dart +// main.dart - Main thread with Flutter +import 'package:solid_auth/solid_auth.dart'; + +Future generateInWorker(String url, String method) async { + // Export credentials from authenticated session + final credentials = solidAuth.exportDpopCredentials(); + + // Spawn worker + final receivePort = ReceivePort(); + await Isolate.spawn(workerEntryPoint, { + 'credentials': credentials.toJson(), + 'url': url, + 'method': method, + 'sendPort': receivePort.sendPort, + }); + + // Wait for result + final response = await receivePort.first as Map; + receivePort.close(); + + return DPoP( + dpopToken: response['dpopToken'] as String, + accessToken: response['accessToken'] as String, + ); +} +``` + +### Parallel Token Generation + +Generate multiple DPoP tokens in parallel for better performance: + +```dart +import 'package:flutter/foundation.dart'; // for compute() + +// Define top-level function for compute() +Map _generateDpop(Map params) { + final credentials = DpopCredentials.fromJson(params['credentials']); + final dpop = credentials.generateDpopToken( + url: params['url'] as String, + method: params['method'] as String, + ); + return { + 'dpopToken': dpop.dpopToken, + 'accessToken': dpop.accessToken, + }; +} + +// Generate tokens in parallel +Future> generateMultipleTokens( + List urls, + String method, +) async { + final credentials = solidAuth.exportDpopCredentials(); + + return Future.wait( + urls.map((url) async { + final result = await compute(_generateDpop, { + 'credentials': credentials.toJson(), + 'url': url, + 'method': method, + }); + return DPoP( + dpopToken: result['dpopToken']!, + accessToken: result['accessToken']!, + ); + }), + ); +} +``` + +### Important: Flutter-Free Entry Point + +The `package:solid_auth/worker.dart` library is specifically designed to work without Flutter: + +```dart +// ✅ Correct - Use in worker threads +import 'package:solid_auth/worker.dart'; + +// ❌ Wrong - Has Flutter dependencies +import 'package:solid_auth/solid_auth.dart'; +``` + +**What's in `worker.dart`:** +- `DpopCredentials` - Serializable credentials +- `DPoP` - Token result container +- `KeyPair` - Platform-agnostic RSA keys + +**What's NOT in `worker.dart`:** +- `SolidAuth` - Main authentication class (requires Flutter) +- OIDC flow management +- UI components +- Platform-specific storage + +### Security Considerations for Workers + +✅ **Safe:** +- Passing credentials to isolates in the same process +- Using `DpopCredentials.toJson()` for serialization +- Generating fresh tokens for each request + +❌ **Unsafe:** +- Persisting serialized credentials to disk +- Sending credentials over the network +- Logging credentials in plaintext +- Sharing credentials between processes + +### Complete Example + +See [example/lib/dpop_worker_example.dart](example/lib/dpop_worker_example.dart) for a complete working example demonstrating: +- Worker thread setup with proper message passing +- Error handling in workers +- Parallel token generation +- Integration with the main authentication flow + +### Further Documentation + +For comprehensive information about worker thread patterns, security model, and best practices, see [doc/dpop_worker_threads.md](doc/dpop_worker_threads.md). + ## 🔐 Client Configuration Guide ### Required Scopes diff --git a/example/dpop_worker_example.dart b/example/lib/dpop_worker_example.dart similarity index 92% rename from example/dpop_worker_example.dart rename to example/lib/dpop_worker_example.dart index 945d5c9..a63cca3 100644 --- a/example/dpop_worker_example.dart +++ b/example/lib/dpop_worker_example.dart @@ -9,7 +9,11 @@ /// has been successfully completed on the main thread. import 'dart:isolate'; -import 'package:solid_auth/solid_auth.dart'; +// For the "main" side of this example +import 'package:solid_auth/solid_auth.dart' show SolidAuth; +// In the real pure worker.dart file, you must not import solid_auth.dart directly +// to avoid flutter dependencies. Instead, import only the necessary parts: +import 'package:solid_auth/worker.dart'; // Simple message passing between main and worker class _WorkerMessage { diff --git a/lib/src/gen_dpop_token.dart b/lib/src/gen_dpop_token.dart new file mode 100644 index 0000000..bd4a05c --- /dev/null +++ b/lib/src/gen_dpop_token.dart @@ -0,0 +1,81 @@ +/// Internal DPoP token generation utilities. +/// +/// This module provides the core cryptographic logic for generating +/// DPoP (Demonstration of Proof-of-Possession) tokens as specified in +/// [RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449). +/// +/// ## Architecture Note +/// +/// This file is intentionally free of Flutter dependencies to enable: +/// - Use in worker threads/isolates via `package:solid_auth/worker.dart` +/// - Pure Dart testing without Flutter test harness +/// - Potential reuse in non-Flutter Dart projects +/// +/// ## Internal API +/// +/// This is not part of the public API. End users should use: +/// - `SolidAuth.genDpopToken()` in the main thread +/// - `DpopCredentials.generateDpopToken()` in worker threads +library solid_auth.src.gen_dpop_token; + +import 'package:solid_auth/src/jwt/dart_jsonwebtoken.dart'; +import 'package:solid_auth/src/rsa/rsa_api.dart'; +import 'package:uuid/uuid.dart'; + +/// Generates a DPoP token for authenticated API requests. +/// +/// Creates a signed JWT that proves possession of the private key corresponding +/// to the public key presented during OIDC authentication. +/// +/// ## Parameters +/// +/// - [endPointUrl]: The complete URL being accessed (e.g., 'https://alice.pod.com/data/') +/// - [rsaKeyPair]: Platform-agnostic RSA key pair with PEM-encoded keys +/// - [publicKeyJwk]: Public key in JWK format for the JWT header +/// - [httpMethod]: HTTP method being used (e.g., 'GET', 'POST', 'PUT', 'DELETE') +/// +/// ## Return Value +/// +/// Returns a signed JWT string in the format specified by RFC 9449: +/// - Header: `{"alg":"RS256","typ":"dpop+jwt","jwk":{...}}` +/// - Payload: `{"htu":"","htm":"","jti":"","iat":}` +/// - Signature: RS256 signature using the private key +/// +/// ## Specification References +/// +/// - [RFC 9449: OAuth 2.0 Demonstrating Proof of Possession](https://datatracker.ietf.org/doc/html/rfc9449) +/// - [RFC 4122: UUID Generation](https://datatracker.ietf.org/doc/html/rfc4122) (for jti claim) +/// - [RFC 7519: JSON Web Token](https://datatracker.ietf.org/doc/html/rfc7519) (JWT structure) +/// - [Solid-OIDC Primer](https://solid.github.io/solid-oidc/primer/#authorization-code-pkce-flow) +String genDpopToken(String endPointUrl, KeyPair rsaKeyPair, + dynamic publicKeyJwk, String httpMethod) { + /// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop-03 + /// Unique identifier for DPoP proof JWT + /// Here we are using a version 4 UUID according to https://datatracker.ietf.org/doc/html/rfc4122 + var uuid = const Uuid(); + final String tokenId = uuid.v4(); + + /// Initialising token head and body (payload) + /// https://solid.github.io/solid-oidc/primer/#authorization-code-pkce-flow + /// https://datatracker.ietf.org/doc/html/rfc7519 + var tokenHead = {"alg": "RS256", "typ": "dpop+jwt", "jwk": publicKeyJwk}; + + var tokenBody = { + "htu": endPointUrl, + "htm": httpMethod, + "jti": tokenId, + "iat": (DateTime.now().millisecondsSinceEpoch / 1000).round() + }; + + /// Create a json web token + final jwt = JWT( + tokenBody, + header: tokenHead, + ); + + /// Sign the JWT using private key + var dpopToken = jwt.sign(RSAPrivateKey(rsaKeyPair.privateKey), + algorithm: JWTAlgorithm.RS256); + + return dpopToken; +} diff --git a/lib/src/oidc/dpop_credentials.dart b/lib/src/oidc/dpop_credentials.dart new file mode 100644 index 0000000..dd0e809 --- /dev/null +++ b/lib/src/oidc/dpop_credentials.dart @@ -0,0 +1,227 @@ +import 'package:solid_auth/src/rsa/rsa_api.dart' show KeyPair; +import 'package:solid_auth/src/gen_dpop_token.dart' as solid_auth_client; + +/// Serializable credentials for generating DPoP tokens in worker threads/isolates. +/// +/// This class contains all the necessary data to generate DPoP tokens without +/// requiring access to the full [SolidAuth] instance. It's designed to be +/// safely transferred to Dart isolates or web workers. +/// +/// ## ⚠️ Contains Sensitive Cryptographic Material +/// +/// This class holds your **RSA private key** and **OAuth2 access token**. +/// These credentials enable secure intra-process transfer to worker threads +/// while maintaining the security boundary of your application. +/// +/// ## Quick Example +/// +/// ```dart +/// // Main thread +/// final credentials = solidAuth.exportDpopCredentials(); +/// await Isolate.spawn(workerFunction, credentials.toJson()); +/// +/// // Worker thread +/// void workerFunction(Map json) { +/// final credentials = DpopCredentials.fromJson(json); +/// final dpop = credentials.generateDpopToken( +/// url: 'https://alice.pod.com/data/', +/// method: 'GET', +/// ); +/// } +/// ``` +/// +/// ## Complete Documentation +/// +/// For detailed security guidelines, usage patterns, and best practices, see: +/// **[doc/dpop_worker_threads.md](../../doc/dpop_worker_threads.md)** +/// +/// The documentation covers: +/// - Security model and trust boundaries +/// - Safe vs. unsafe usage patterns +/// - Complete examples for isolates, compute(), and web workers +/// - Thread safety considerations +class DpopCredentials { + /// RSA public key in PEM format + final String publicKey; + + /// RSA private key in PEM format + /// + /// **Warning**: This is sensitive cryptographic material. Handle with care. + final String privateKey; + + /// Public key in JSON Web Key (JWK) format + final Map publicKeyJwk; + + /// OAuth2 access token for the authenticated user + /// + /// **Warning**: This is a bearer token that grants access to resources. + final String accessToken; + + const DpopCredentials({ + required this.publicKey, + required this.privateKey, + required this.publicKeyJwk, + required this.accessToken, + }); + + /// Serializes the credentials to JSON for transfer to workers. + /// + /// The resulting map can be: + /// - Converted to JSON string via `jsonEncode(credentials.toJson())` + /// - Sent directly via isolate SendPort + /// - Posted to web workers via postMessage + Map toJson() => { + 'publicKey': publicKey, + 'privateKey': privateKey, + 'publicKeyJwk': publicKeyJwk, + 'accessToken': accessToken, + }; + + /// Deserializes credentials from JSON received from the main thread. + /// + /// Example: + /// ```dart + /// // From JSON string + /// final credentials = DpopCredentials.fromJson(jsonDecode(jsonString)); + /// + /// // From map received via isolate + /// final credentials = DpopCredentials.fromJson(messageFromMain); + /// ``` + factory DpopCredentials.fromJson(Map json) => + DpopCredentials( + publicKey: json['publicKey'] as String, + privateKey: json['privateKey'] as String, + publicKeyJwk: json['publicKeyJwk'] as Map, + accessToken: json['accessToken'] as String, + ); + + /// Generates a DPoP token using these credentials. + /// + /// This method can be called from any thread (main thread, isolate, or web worker) + /// without requiring access to a [SolidAuth] instance. It's designed for use cases where + /// DPoP token generation needs to happen on a worker thread for performance reasons. + /// + /// ## Parameters + /// + /// - [url]: The complete URL of the API endpoint being accessed + /// - [method]: The HTTP method ('GET', 'POST', 'PUT', 'DELETE', etc.) + /// + /// ## Return Value + /// + /// Returns a [DPoP] object containing both the DPoP proof token and access token. + /// + /// ## Example + /// + /// ```dart + /// // In a worker/isolate + /// void workerFunction(Map credentialsJson) { + /// final credentials = DpopCredentials.fromJson(credentialsJson); + /// final dpop = credentials.generateDpopToken( + /// url: 'https://alice.pod.com/data/file.txt', + /// method: 'GET', + /// ); + /// + /// // Use in HTTP request + /// final response = await http.get( + /// Uri.parse('https://alice.pod.com/data/file.txt'), + /// headers: dpop.httpHeaders(), + /// ); + /// } + /// ``` + /// + /// ## Security + /// + /// - Each DPoP token is bound to the specific URL and HTTP method + /// - Tokens include a unique nonce and timestamp + /// - Tokens should be generated fresh for each request + /// - The private key never leaves the credentials object + DPoP generateDpopToken({ + required String url, + required String method, + }) { + final rsaKeyPair = KeyPair(publicKey, privateKey); + final dpopToken = solid_auth_client.genDpopToken( + url, + rsaKeyPair, + publicKeyJwk, + method, + ); + + return DPoP(dpopToken: dpopToken, accessToken: accessToken); + } +} + +/// Contains DPoP token and access token for authenticated API requests to Solid servers. +/// +/// DPoP (Demonstration of Proof-of-Possession) is a security mechanism required +/// by Solid servers to prove that the client making an API request is the same +/// client to which the access token was issued. This prevents token theft and replay attacks. +/// +/// ## Usage +/// +/// Typically obtained from [SolidAuth.genDpopToken] and used to make authenticated +/// requests to Solid pod resources. +/// +/// ```dart +/// final dpop = solidAuth.genDpopToken('https://alice.pod.com/data/', 'GET'); +/// +/// // Use convenience method with additional headers +/// final response = await http.get( +/// Uri.parse('https://alice.pod.com/data/'), +/// headers: { +/// ...dpop.httpHeaders(), +/// 'Accept': 'text/turtle', +/// }, +/// ); +/// +/// // Or construct headers manually +/// final response = await http.get( +/// Uri.parse('https://alice.pod.com/data/'), +/// headers: { +/// 'Authorization': 'DPoP ${dpop.accessToken}', +/// 'DPoP': dpop.dpopToken, +/// 'Accept': 'text/turtle', +/// }, +/// ); +/// ``` +class DPoP { + /// The DPoP JWT token that proves possession of the access token. + /// + /// This is a signed JWT that includes: + /// - The HTTP method and URL being accessed + /// - A unique nonce to prevent replay attacks + /// - A timestamp showing when the token was created + /// - The public key corresponding to the private key used for signing + final String dpopToken; + + /// The OAuth2 access token for the authenticated user. + /// + /// This token grants access to resources but must be accompanied by the + /// [dpopToken] to prove possession when making requests to Solid servers. + final String accessToken; + + DPoP({required this.dpopToken, required this.accessToken}); + + /// Returns HTTP headers formatted for Solid API requests. + /// + /// This is the recommended way to use DPoP tokens with HTTP clients. + /// The returned map contains: + /// - `Authorization`: 'DPoP {accessToken}' + /// - `DPoP`: The DPoP JWT token + /// + /// ## Example + /// ```dart + /// final dpop = solidAuth.genDpopToken('https://alice.pod.com/data/', 'GET'); + /// final response = await http.get( + /// Uri.parse('https://alice.pod.com/data/'), + /// headers: { + /// ...dpop.httpHeaders(), + /// 'Accept': 'text/turtle', + /// }, + /// ); + /// ``` + Map httpHeaders() => { + 'Authorization': 'DPoP $accessToken', + 'DPoP': dpopToken, + }; +} diff --git a/lib/src/oidc/solid_oidc_user_manager.dart b/lib/src/oidc/solid_oidc_user_manager.dart index 0e26354..b01f517 100644 --- a/lib/src/oidc/solid_oidc_user_manager.dart +++ b/lib/src/oidc/solid_oidc_user_manager.dart @@ -3,237 +3,13 @@ import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; import 'package:oidc/oidc.dart'; import 'package:solid_auth/src/rsa/rsa_api.dart'; -import 'package:solid_auth/src/solid_auth_client.dart' as solid_auth_client; +import 'package:solid_auth/src/gen_dpop_token.dart' as solid_auth_client; import 'package:solid_auth/src/solid_auth_issuer.dart' as solid_auth_issuer; import 'package:solid_auth/src/rsa/rsa_impl.dart'; +import 'dpop_credentials.dart'; final _log = Logger("solid_authentication_oidc"); -/// Serializable credentials for generating DPoP tokens in worker threads/isolates. -/// -/// This class contains all the necessary data to generate DPoP tokens without -/// requiring access to the full [SolidAuth] instance. It's designed to be -/// safely transferred to Dart isolates or web workers. -/// -/// ## ⚠️ Contains Sensitive Cryptographic Material -/// -/// This class holds your **RSA private key** and **OAuth2 access token**. -/// These credentials enable secure intra-process transfer to worker threads -/// while maintaining the security boundary of your application. -/// -/// ## Quick Example -/// -/// ```dart -/// // Main thread -/// final credentials = solidAuth.exportDpopCredentials(); -/// await Isolate.spawn(workerFunction, credentials.toJson()); -/// -/// // Worker thread -/// void workerFunction(Map json) { -/// final credentials = DpopCredentials.fromJson(json); -/// final dpop = credentials.generateDpopToken( -/// url: 'https://alice.pod.com/data/', -/// method: 'GET', -/// ); -/// } -/// ``` -/// -/// ## Complete Documentation -/// -/// For detailed security guidelines, usage patterns, and best practices, see: -/// **[doc/dpop_worker_threads.md](../../doc/dpop_worker_threads.md)** -/// -/// The documentation covers: -/// - Security model and trust boundaries -/// - Safe vs. unsafe usage patterns -/// - Complete examples for isolates, compute(), and web workers -/// - Thread safety considerations -class DpopCredentials { - /// RSA public key in PEM format - final String publicKey; - - /// RSA private key in PEM format - /// - /// **Warning**: This is sensitive cryptographic material. Handle with care. - final String privateKey; - - /// Public key in JSON Web Key (JWK) format - final Map publicKeyJwk; - - /// OAuth2 access token for the authenticated user - /// - /// **Warning**: This is a bearer token that grants access to resources. - final String accessToken; - - const DpopCredentials({ - required this.publicKey, - required this.privateKey, - required this.publicKeyJwk, - required this.accessToken, - }); - - /// Serializes the credentials to JSON for transfer to workers. - /// - /// The resulting map can be: - /// - Converted to JSON string via `jsonEncode(credentials.toJson())` - /// - Sent directly via isolate SendPort - /// - Posted to web workers via postMessage - Map toJson() => { - 'publicKey': publicKey, - 'privateKey': privateKey, - 'publicKeyJwk': publicKeyJwk, - 'accessToken': accessToken, - }; - - /// Deserializes credentials from JSON received from the main thread. - /// - /// Example: - /// ```dart - /// // From JSON string - /// final credentials = DpopCredentials.fromJson(jsonDecode(jsonString)); - /// - /// // From map received via isolate - /// final credentials = DpopCredentials.fromJson(messageFromMain); - /// ``` - factory DpopCredentials.fromJson(Map json) => - DpopCredentials( - publicKey: json['publicKey'] as String, - privateKey: json['privateKey'] as String, - publicKeyJwk: json['publicKeyJwk'] as Map, - accessToken: json['accessToken'] as String, - ); - - /// Generates a DPoP token using these credentials. - /// - /// This method can be called from any thread (main thread, isolate, or web worker) - /// without requiring access to a [SolidAuth] instance. It's designed for use cases where - /// DPoP token generation needs to happen on a worker thread for performance reasons. - /// - /// ## Parameters - /// - /// - [url]: The complete URL of the API endpoint being accessed - /// - [method]: The HTTP method ('GET', 'POST', 'PUT', 'DELETE', etc.) - /// - /// ## Return Value - /// - /// Returns a [DPoP] object containing both the DPoP proof token and access token. - /// - /// ## Example - /// - /// ```dart - /// // In a worker/isolate - /// void workerFunction(Map credentialsJson) { - /// final credentials = DpopCredentials.fromJson(credentialsJson); - /// final dpop = credentials.generateDpopToken( - /// url: 'https://alice.pod.com/data/file.txt', - /// method: 'GET', - /// ); - /// - /// // Use in HTTP request - /// final response = await http.get( - /// Uri.parse('https://alice.pod.com/data/file.txt'), - /// headers: dpop.httpHeaders(), - /// ); - /// } - /// ``` - /// - /// ## Security - /// - /// - Each DPoP token is bound to the specific URL and HTTP method - /// - Tokens include a unique nonce and timestamp - /// - Tokens should be generated fresh for each request - /// - The private key never leaves the credentials object - DPoP generateDpopToken({ - required String url, - required String method, - }) { - final rsaKeyPair = KeyPair(publicKey, privateKey); - final dpopToken = solid_auth_client.genDpopToken( - url, - rsaKeyPair, - publicKeyJwk, - method, - ); - - return DPoP(dpopToken: dpopToken, accessToken: accessToken); - } -} - -/// Contains DPoP token and access token for authenticated API requests to Solid servers. -/// -/// DPoP (Demonstration of Proof-of-Possession) is a security mechanism required -/// by Solid servers to prove that the client making an API request is the same -/// client to which the access token was issued. This prevents token theft and replay attacks. -/// -/// ## Usage -/// -/// Typically obtained from [SolidAuth.genDpopToken] and used to make authenticated -/// requests to Solid pod resources. -/// -/// ```dart -/// final dpop = solidAuth.genDpopToken('https://alice.pod.com/data/', 'GET'); -/// -/// // Use convenience method with additional headers -/// final response = await http.get( -/// Uri.parse('https://alice.pod.com/data/'), -/// headers: { -/// ...dpop.httpHeaders(), -/// 'Accept': 'text/turtle', -/// }, -/// ); -/// -/// // Or construct headers manually -/// final response = await http.get( -/// Uri.parse('https://alice.pod.com/data/'), -/// headers: { -/// 'Authorization': 'DPoP ${dpop.accessToken}', -/// 'DPoP': dpop.dpopToken, -/// 'Accept': 'text/turtle', -/// }, -/// ); -/// ``` -class DPoP { - /// The DPoP JWT token that proves possession of the access token. - /// - /// This is a signed JWT that includes: - /// - The HTTP method and URL being accessed - /// - A unique nonce to prevent replay attacks - /// - A timestamp showing when the token was created - /// - The public key corresponding to the private key used for signing - final String dpopToken; - - /// The OAuth2 access token for the authenticated user. - /// - /// This token grants access to resources but must be accompanied by the - /// [dpopToken] to prove possession when making requests to Solid servers. - final String accessToken; - - DPoP({required this.dpopToken, required this.accessToken}); - - /// Returns HTTP headers formatted for Solid API requests. - /// - /// This is the recommended way to use DPoP tokens with HTTP clients. - /// The returned map contains: - /// - `Authorization`: 'DPoP {accessToken}' - /// - `DPoP`: The DPoP JWT token - /// - /// ## Example - /// ```dart - /// final dpop = solidAuth.genDpopToken('https://alice.pod.com/data/', 'GET'); - /// final response = await http.get( - /// Uri.parse('https://alice.pod.com/data/'), - /// headers: { - /// ...dpop.httpHeaders(), - /// 'Accept': 'text/turtle', - /// }, - /// ); - /// ``` - Map httpHeaders() => { - 'Authorization': 'DPoP $accessToken', - 'DPoP': dpopToken, - }; -} - /// Contains the authentication result with both OIDC user data and validated WebID. /// /// This class is returned by [SolidAuth.authenticate] and contains all the diff --git a/lib/src/solid_auth.dart b/lib/src/solid_auth.dart index 5c3fac5..ee04add 100644 --- a/lib/src/solid_auth.dart +++ b/lib/src/solid_auth.dart @@ -4,9 +4,12 @@ import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; import 'package:oidc/oidc.dart'; import 'package:oidc_default_store/oidc_default_store.dart'; +import 'package:solid_auth/src/oidc/dpop_credentials.dart'; import 'package:solid_auth/src/oidc/solid_oidc_user_manager.dart'; export 'package:solid_auth/src/oidc/solid_oidc_user_manager.dart' - show DPoP, UserAndWebId, DpopCredentials; + show UserAndWebId; +export 'package:solid_auth/src/oidc/dpop_credentials.dart' + show DpopCredentials, DPoP; /// The default refresh behavior: refresh tokens 1 minute before they expire. /// diff --git a/lib/src/solid_auth_client.dart b/lib/src/solid_auth_client.dart deleted file mode 100644 index eb4f66b..0000000 --- a/lib/src/solid_auth_client.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:solid_auth/src/jwt/dart_jsonwebtoken.dart'; -import 'package:solid_auth/src/rsa/rsa_api.dart'; -import 'package:uuid/uuid.dart'; - -/// Generate dPoP token for the authentication -String genDpopToken(String endPointUrl, KeyPair rsaKeyPair, - dynamic publicKeyJwk, String httpMethod) { - /// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop-03 - /// Unique identifier for DPoP proof JWT - /// Here we are using a version 4 UUID according to https://datatracker.ietf.org/doc/html/rfc4122 - var uuid = const Uuid(); - final String tokenId = uuid.v4(); - - /// Initialising token head and body (payload) - /// https://solid.github.io/solid-oidc/primer/#authorization-code-pkce-flow - /// https://datatracker.ietf.org/doc/html/rfc7519 - var tokenHead = {"alg": "RS256", "typ": "dpop+jwt", "jwk": publicKeyJwk}; - - var tokenBody = { - "htu": endPointUrl, - "htm": httpMethod, - "jti": tokenId, - "iat": (DateTime.now().millisecondsSinceEpoch / 1000).round() - }; - - /// Create a json web token - final jwt = JWT( - tokenBody, - header: tokenHead, - ); - - /// Sign the JWT using private key - var dpopToken = jwt.sign(RSAPrivateKey(rsaKeyPair.privateKey), - algorithm: JWTAlgorithm.RS256); - - return dpopToken; -} diff --git a/lib/worker.dart b/lib/worker.dart new file mode 100644 index 0000000..4cdfeeb --- /dev/null +++ b/lib/worker.dart @@ -0,0 +1,108 @@ +/// Flutter-free entry point for DPoP token generation in worker threads/isolates. +/// +/// This library provides a minimal, Flutter-independent API for generating DPoP +/// tokens from serialized credentials. It's specifically designed for use in +/// Dart isolates and web workers where Flutter dependencies are not available. +/// +/// ## Purpose +/// +/// The main `solid_auth` library depends on Flutter for UI components and +/// platform-specific storage. Worker threads and isolates cannot access Flutter +/// APIs, so this library provides a separate entry point that: +/// +/// - Contains **zero Flutter dependencies** +/// - Exports only what's needed for DPoP token generation +/// - Can be safely imported in isolate entry points +/// - Uses only pure Dart and platform-independent cryptography +/// +/// ## Usage Pattern +/// +/// ```dart +/// // Main thread (can use full solid_auth library) +/// import 'package:solid_auth/solid_auth.dart'; +/// +/// final solidAuth = SolidAuth(...); +/// await solidAuth.init(); +/// await solidAuth.authenticate('https://alice.pod.com/profile/card#me'); +/// +/// // Export credentials for worker +/// final credentials = solidAuth.exportDpopCredentials(); +/// +/// // Spawn worker with serialized credentials +/// await Isolate.spawn(workerEntryPoint, credentials.toJson()); +/// ``` +/// +/// ```dart +/// // Worker thread (uses Flutter-free worker library) +/// import 'package:solid_auth/worker.dart'; +/// +/// void workerEntryPoint(Map credentialsJson) { +/// // Deserialize credentials +/// final credentials = DpopCredentials.fromJson(credentialsJson); +/// +/// // Generate DPoP tokens (no Flutter APIs needed) +/// final dpop = credentials.generateDpopToken( +/// url: 'https://alice.pod.com/data/', +/// method: 'GET', +/// ); +/// +/// // Use dpop.httpHeaders() for authenticated requests +/// } +/// ``` +/// +/// ## What's Exported +/// +/// - [DpopCredentials]: Serializable credentials container +/// - [DPoP]: Result object containing DPoP token and access token +/// - [KeyPair]: Platform-agnostic RSA key pair representation +/// +/// ## What's NOT Exported +/// +/// - [SolidAuth]: Main authentication class (requires Flutter) +/// - OIDC flow management (requires Flutter for browser redirects) +/// - Session persistence (requires Flutter for platform storage) +/// - Any UI or platform-specific components +/// +/// ## Architecture +/// +/// ``` +/// ┌─────────────────────────────────────────┐ +/// │ Main Thread (Flutter) │ +/// │ import 'package:solid_auth/solid_auth' │ +/// │ │ +/// │ - SolidAuth (authentication) │ +/// │ - Browser redirects │ +/// │ - Session management │ +/// │ - exportDpopCredentials() │ +/// └─────────────────────────────────────────┘ +/// │ +/// │ Serialize credentials +/// │ (DpopCredentials.toJson()) +/// ▼ +/// ┌─────────────────────────────────────────┐ +/// │ Worker Thread (Pure Dart) │ +/// │ import 'package:solid_auth/worker' │ +/// │ │ +/// │ - DpopCredentials.fromJson() │ +/// │ - generateDpopToken() │ +/// │ - No Flutter dependencies │ +/// └─────────────────────────────────────────┘ +/// ``` +/// +/// ## Security Considerations +/// +/// Credentials contain sensitive cryptographic material: +/// - RSA private key +/// - OAuth2 access token +/// +/// While safe for intra-process transfer (isolates/workers), never: +/// - Serialize credentials to persistent storage +/// - Send credentials over the network +/// - Log credentials in plaintext +/// +/// See [doc/dpop_worker_threads.md](../doc/dpop_worker_threads.md) for +/// comprehensive security guidelines. +library solid_auth.worker; + +export 'src/rsa/rsa_api.dart' show KeyPair; +export 'src/oidc/dpop_credentials.dart' show DpopCredentials, DPoP; diff --git a/test/dpop_credentials_test.dart b/test/dpop_credentials_test.dart index 0e3ce5f..d441d0b 100644 --- a/test/dpop_credentials_test.dart +++ b/test/dpop_credentials_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:solid_auth/src/oidc/solid_oidc_user_manager.dart'; +import 'package:solid_auth/src/oidc/dpop_credentials.dart'; void main() { group('DpopCredentials', () {